"""Периметр версии: `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)]