arm-core/api/tests/test_api_consolidation_members.py
Andrey Epifancev 0e1fbd8036 fix(consolidation): ревью Task 6 — гонки автозамены с подхватом, выбор по confirmed_at, одна выборка ГК
- T6.0: тесты на двух сессиях «автозамена ∥ подхват при правке периметра» и «автозамена ∥ создание версии»:
  автозамена ждёт, снимает незафиксированную ранее привязку, unbound_version_ids — эта версия.
- T6.2: тесты выбора самого позднего confirmed_at строятся так, чтобы «наибольший id» проигрывал;
  тест равенства confirmed_at (больший id, решение №35).
- T6.3: реальный подхват через PUT …/members — версия в INITIATED, статус не меняется.
- T6.4: lock_versions_for_entity не читает ГК ЮЛ второй раз (подзапрос заменён значением).
- T6.5: design №30 — цена: подтверждения документов ЮЛ одной ГК сериализуются на advisory даже без версий в работе.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 05:11:03 +04:00

545 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Периметр версии: `PUT /consolidations/versions/{id}/members` (spec consolidations-api «Периметр версии»;
directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-5, D-8, D-9).
Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`): шпион проверяет, для кого он вызван, а
`test_add_member_really_binds_confirmed_documents` — настоящую привязку (Task 6; ячейка `CONFIRMED` — Task 8).
Отвязка документов удалённых ЮЛ — `unbind_entity`.
"""
import uuid
from collections.abc import Sequence
from typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
Consolidation,
ConsolidationDocument,
ConsolidationMember,
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroupMember
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter
from src.services import consolidation_documents
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_confirmed_osv,
make_consolidation,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
)
HTTP_OK = 200
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
ROSTOV = "Ростовский региональный филиал"
KRASNODAR = "Краснодарский региональный филиал"
KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local"
CURATOR = "curator_go@armka.local"
EXPERT = "expert@armka.local"
VERSIONS = "/consolidations/versions"
ACCOUNT = "60"
THREE = 3
async def _user(session: AsyncSession, email: str) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one()
async def _version( # noqa: PLR0913 - тестовая фабрика: состав периметра, статус, филиал и владелец блокировки
session: AsyncSession,
*,
members: int = 2,
spare: int = 1,
status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION,
office_name: str = ROSTOV,
locked_by: str | None = KA_RF,
) -> tuple[ConsolidationVersion, list[LegalEntity], list[LegalEntity], uuid.UUID]:
"""Версия ГК филиала за 2024 Q3: `members` ЮЛ в периметре и `spare` ЮЛ той же ГК вне периметра.
Возвращает версию, члены, запасные ЮЛ и id ГК; `locked_by` — владелец активной блокировки.
"""
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
inside = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)]
outside = [await make_legal_entity(session, name=f"ООО Запас {n}", group=group) for n in range(spare)]
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=PeriodQuarter.Q3,
members=inside,
initiator=await _user(session, KA_RF),
status=status,
)
if locked_by is not None:
await make_lock(session, version=version, user=await _user(session, locked_by))
return version, inside, outside, group.id
def _url(version_id: uuid.UUID) -> str:
return f"{VERSIONS}/{version_id}/members"
def _body(entities: Sequence[LegalEntity | uuid.UUID]) -> dict[str, Any]:
return {"legal_entity_ids": [str(e.id if isinstance(e, LegalEntity) else e) for e in entities]}
async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationMember.legal_entity_id).where(ConsolidationMember.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _bound_documents(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
return list((await session.execute(stmt)).scalars())
# --- добавление ----------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_add_member_updates_perimeter_and_picks_up_documents(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Добавленное ЮЛ — в периметре и в карточке; подхват подтверждённых документов вызван ровно для добавленных."""
version, inside, outside, _ = await _version(seeded)
await make_confirmed_osv(
seeded,
entity=outside[0],
account=ACCOUNT,
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
)
calls: list[tuple[uuid.UUID, list[uuid.UUID]]] = []
async def spy(
session: AsyncSession,
*,
version: ConsolidationVersion,
consolidation: Consolidation,
entity_ids: Sequence[uuid.UUID],
) -> int:
calls.append((version.id, list(entity_ids)))
return 0
monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_OK
card = response.json()
assert {m["id"] for m in card["members"]} == {str(e.id) for e in [*inside, outside[0]]}
assert card["members_count"] == THREE
assert calls == [(version.id, [outside[0].id])]
assert await _member_ids(seeded, version.id) == {e.id for e in [*inside, outside[0]]}
@pytest.mark.integration
async def test_add_member_really_binds_confirmed_documents(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Добавленное ЮЛ с подтверждённой ОСВ за квартал версии: `consolidation_document` создан без шпионов; статус
версии подхват не меняет (`INITIATED` остаётся); подтверждённая ОСВ другого года не подхватывается."""
version, inside, outside, _ = await _version(seeded, status=ConsolidationStatus.INITIATED)
fitting = await make_confirmed_osv(
seeded,
entity=outside[0],
account=ACCOUNT,
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
)
await make_confirmed_osv(
seeded,
entity=outside[0],
account=ACCOUNT,
year=2023,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_OK, response.text
assert await _bound_documents(seeded, version.id) == {fitting.id}
assert response.json()["status"] == ConsolidationStatus.INITIATED.value
@pytest.mark.integration
async def test_unchanged_members_are_not_touched_by_picking_up(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Подхват не вызывается для ЮЛ, которые уже в периметре, и не вызывается, если добавлять некого."""
version, inside, _, _ = await _version(seeded, members=THREE, spare=0)
calls: list[list[uuid.UUID]] = []
async def spy(
session: AsyncSession,
*,
version: ConsolidationVersion,
consolidation: Consolidation,
entity_ids: Sequence[uuid.UUID],
) -> int:
calls.append(list(entity_ids))
return 0
monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body(inside[:2]))
assert response.status_code == HTTP_OK
assert calls == []
@pytest.mark.integration
async def test_duplicates_are_collapsed(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, outside, _ = await _version(seeded)
client = await auth_client(KA_RF)
ids = [outside[0], inside[0], outside[0], inside[1], inside[0]]
response = await client.put(_url(version.id), json=_body(ids))
assert response.status_code == HTTP_OK
assert response.json()["members_count"] == THREE
assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id, outside[0].id}
count = await seeded.execute(
select(func.count())
.select_from(ConsolidationMember)
.where(ConsolidationMember.consolidation_version_id == version.id)
)
assert count.scalar_one() == THREE
# --- удаление ------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_removing_member_drops_its_bindings_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Удалённое ЮЛ теряет привязки документов к этой версии; документы оставшихся и других версий не затронуты."""
version, inside, _, _ = await _version(seeded, members=THREE, spare=0)
documents = {}
for entity in inside:
documents[entity.id] = await make_confirmed_osv(
seeded,
entity=entity,
account=ACCOUNT,
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
)
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=documents[entity.id].id))
other, other_entities, _, _ = await _version(seeded, spare=0, locked_by=None)
seeded.add(ConsolidationDocument(consolidation_version_id=other.id, document_id=documents[inside[2].id].id))
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body(inside[:2]))
assert response.status_code == HTTP_OK
assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id}
assert await _bound_documents(seeded, version.id) == {documents[inside[0].id].id, documents[inside[1].id].id}
assert await _bound_documents(seeded, other.id) == {documents[inside[2].id].id}
@pytest.mark.integration
async def test_unbind_entity_removes_only_that_entitys_documents_of_that_version(seeded: AsyncSession) -> None:
"""`unbind_entity` возвращает число снятых привязок."""
version, inside, _, _ = await _version(seeded, locked_by=None)
docs = []
for entity in inside:
document = await make_confirmed_osv(
seeded,
entity=entity,
account=ACCOUNT,
year=2024,
quarter=PeriodQuarter.Q3,
is_cumulative=True,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
)
docs.append(document)
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await seeded.flush()
removed = await consolidation_documents.unbind_entity(seeded, version_id=version.id, entity_id=inside[0].id)
assert removed == 1
assert await _bound_documents(seeded, version.id) == {docs[1].id}
# --- проверки ------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_entity_of_other_group_is_rejected(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, _, _ = await _version(seeded)
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
stranger_group = await make_company_group(seeded, name="ГК Чужая", office=office)
stranger = await make_legal_entity(seeded, name="ООО Чужое", group=stranger_group)
ungrouped = await make_legal_entity(seeded, name="ООО Без ГК")
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, stranger, ungrouped]))
assert response.status_code == HTTP_UNPROCESSABLE
detail = response.json()["detail"]
assert detail["code"] == "ENTITY_NOT_IN_GROUP"
assert set(detail["legal_entity_ids"]) == {str(stranger.id), str(ungrouped.id)}
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
assert await _events(seeded) == []
@pytest.mark.integration
async def test_unknown_entity_is_rejected_as_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, _, _ = await _version(seeded)
client = await auth_client(KA_RF)
ghost = uuid.uuid4()
response = await client.put(_url(version.id), json=_body([*inside, ghost]))
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]
@pytest.mark.integration
@pytest.mark.parametrize("count", [0, 1])
async def test_perimeter_smaller_than_two_is_rejected(
auth_client: AuthClientFactory, seeded: AsyncSession, count: int
) -> None:
version, inside, _, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body(inside[:count]))
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
@pytest.mark.integration
async def test_duplicates_do_not_make_a_perimeter_of_two(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Два одинаковых id — это один ЮЛ: меньше двух."""
version, inside, _, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([inside[0], inside[0]]))
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
@pytest.mark.integration
async def test_foreign_entity_is_reported_before_size(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Порядок проверок: принадлежность ГК раньше размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`)."""
version, _, _, _ = await _version(seeded)
stranger = await make_legal_entity(seeded, name="ООО Чужое")
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([stranger]))
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
# --- редактируемость, видимость, право -----------------------------------------------------------------------------
@pytest.mark.integration
async def test_members_without_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, outside, _ = await _version(seeded, locked_by=None)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
@pytest.mark.integration
async def test_members_with_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, outside, _ = await _version(seeded, locked_by=RKA)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_CONFLICT
detail = response.json()["detail"]
assert detail["code"] == "LOCKED_BY_OTHER"
assert detail["locked_by"] == str((await _user(seeded, RKA)).id)
@pytest.mark.integration
async def test_members_of_cancelled_version_is_not_editable(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
version, inside, outside, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
@pytest.mark.integration
async def test_editability_is_checked_before_body_validation(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Без блокировки даже заведомо неверный состав даёт `LOCK_REQUIRED` (порядок спеки: редактируемость раньше)."""
version, _, _, _ = await _version(seeded, locked_by=None)
stranger = await make_legal_entity(seeded, name="ООО Чужое")
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([stranger]))
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
@pytest.mark.integration
async def test_members_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, inside, outside, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None)
client = await auth_client(KA_RF)
body = _body([*inside, outside[0]])
foreign = await client.put(_url(version.id), json=body)
missing = await client.put(_url(uuid.uuid4()), json=body)
assert foreign.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
@pytest.mark.integration
@pytest.mark.parametrize("email", [CURATOR, EXPERT])
async def test_members_requires_consolidation_edit(
auth_client: AuthClientFactory, seeded: AsyncSession, email: str
) -> None:
version, inside, outside, _ = await _version(seeded)
client = await auth_client(email)
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
assert response.status_code == HTTP_FORBIDDEN
@pytest.mark.integration
async def test_members_rejects_malformed_body(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _, _, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json={"legal_entity_ids": ["не-uuid"]})
assert response.status_code == HTTP_UNPROCESSABLE
# --- аудит и независимость от ГСК ----------------------------------------------------------------------------------
@pytest.mark.integration
async def test_membership_change_is_audited_with_full_sorted_lists(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
version, inside, outside, _ = await _version(seeded, members=THREE, spare=1)
client = await auth_client(KA_RF)
new = [inside[0], inside[1], outside[0]] # inside[2] ушёл, outside[0] пришёл
response = await client.put(_url(version.id), json=_body(new))
assert response.status_code == HTTP_OK
(event,) = await _events(seeded)
assert event.section == AuditSection.CONSOLIDATION
assert event.object_type == "consolidation_version"
assert event.object_id == version.id
assert event.consolidation_version_id == version.id
assert event.user_id == (await _user(seeded, KA_RF)).id
assert event.before_value == {"legal_entity_ids": sorted(str(e.id) for e in inside)}
assert event.after_value == {"legal_entity_ids": sorted(str(e.id) for e in new)}
@pytest.mark.integration
async def test_same_perimeter_is_ok_without_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Состав не изменился (в том числе порядок id другой): 200 с карточкой, события нет (design #25)."""
version, inside, _, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body(list(reversed(inside))))
assert response.status_code == HTTP_OK
assert response.json()["members_count"] == len(inside)
assert await _events(seeded) == []
@pytest.mark.integration
async def test_kept_members_and_related_group_are_untouched(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Оставшиеся члены не пересоздаются (`is_head_entity`, `id`), `related_group_member` не меняется."""
version, inside, outside, group_id = await _version(seeded, members=THREE, spare=1)
head = (
await seeded.execute(
select(ConsolidationMember).where(
ConsolidationMember.consolidation_version_id == version.id,
ConsolidationMember.legal_entity_id == inside[0].id,
)
)
).scalar_one()
head.is_head_entity = True
group = await seeded.get(CompanyGroup, group_id)
assert group is not None
related = await make_related_group(seeded, group=group)
for entity in inside:
await make_membership(seeded, related_group=related, entity=entity)
await seeded.flush()
before = await _related_state(seeded, related.id)
kept_id = head.id
client = await auth_client(KA_RF)
response = await client.put(_url(version.id), json=_body([inside[0], inside[1], outside[0]]))
assert response.status_code == HTTP_OK
assert await _related_state(seeded, related.id) == before
kept = (
await seeded.execute(
select(ConsolidationMember)
.where(ConsolidationMember.id == kept_id)
.execution_options(populate_existing=True)
)
).scalar_one()
assert kept.is_head_entity is True
head_flags = {m["id"]: m["is_head_entity"] for m in response.json()["members"]}
assert head_flags[str(inside[0].id)] is True
assert head_flags[str(outside[0].id)] is False
async def _related_state(session: AsyncSession, related_group_id: uuid.UUID) -> list[tuple[uuid.UUID, bool, Any]]:
stmt = (
select(RelatedGroupMember.legal_entity_id, RelatedGroupMember.is_current, RelatedGroupMember.valid_to)
.where(RelatedGroupMember.related_group_id == related_group_id)
.order_by(RelatedGroupMember.legal_entity_id)
)
return [(row[0], row[1], row[2]) for row in await session.execute(stmt)]