diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index c23ab35..413e891 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import locks, periods, status, versions +from src.api.endpoints.consolidations import locks, members, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) router.include_router(versions.router) router.include_router(locks.router) router.include_router(status.router) +router.include_router(members.router) diff --git a/api/src/api/endpoints/consolidations/members.py b/api/src/api/endpoints/consolidations/members.py new file mode 100644 index 0000000..8f14963 --- /dev/null +++ b/api/src/api/endpoints/consolidations/members.py @@ -0,0 +1,38 @@ +"""`PUT /consolidations/versions/{id}/members` — периметр версии консолидации (spec consolidations-api «Периметр +версии»; directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-8, D-9).""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import MembersIn, VersionCard + +router = APIRouter(prefix="/consolidations/versions") + +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "ENTITY_NOT_IN_GROUP (legal_entity_ids), PERIMETER_TOO_SMALL или ошибка валидации тела" + }, +} + + +@router.put( + "/{version_id}/members", + response_model=VersionCard, + dependencies=EDIT, + responses=RESPONSES, + summary="Заменить периметр версии", +) +async def set_members(version_id: uuid.UUID, data: MembersIn, service: ConsolidationServiceDep) -> VersionCard: + """Новый состав ЮЛ версии (все — из ГК консолидации, не меньше двух; нужна своя блокировка).""" + return await service.set_members(version_id, data) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 7f19cf9..daa2527 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -182,6 +182,16 @@ async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[Co return [(row[0], row[1]) for row in await session.execute(stmt)] +async def entities_in_group( + session: AsyncSession, group_id: uuid.UUID, entity_ids: Collection[uuid.UUID] +) -> set[uuid.UUID]: + """Из `entity_ids` — те ЮЛ, что существуют и принадлежат ГК `group_id` (`legal_entity.company_group_id`).""" + if not entity_ids: + return set() + stmt = select(LegalEntity.id).where(LegalEntity.id.in_(entity_ids), LegalEntity.company_group_id == group_id) + return set((await session.execute(stmt)).scalars()) + + async def bound_documents(session: AsyncSession, version_id: uuid.UUID) -> list[Document]: """Документы, привязанные к версии (`consolidation_document`).""" stmt = ( diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 68d63fe..63f7d23 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -2,16 +2,21 @@ ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок -файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызов из -`services.consolidations.ConsolidationService.create` сохраняет. +файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызовы из +`services.consolidations.ConsolidationService` (`create`, `set_members`) сохраняет. + +`unbind_entity` (Task 5) — прямое удаление привязок ЮЛ к версии; Task 6 владеет функцией и вправе переписать тело +(имя и сигнатура — прежние): вызывается из `set_members` для ЮЛ, ушедших из периметра. """ import uuid from collections.abc import Sequence +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.consolidation import ConsolidationVersion +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.files import Document async def bind_confirmed( @@ -21,3 +26,19 @@ async def bind_confirmed( ) -> int: """Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок.""" return 0 + + +async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_id: uuid.UUID) -> int: + """Снимает все привязки документов ЮЛ `entity_id` к версии `version_id`; возвращает число снятых. + + Task 5: простое прямое удаление `consolidation_document` по `document.legal_entity_id`. Принадлежит Task 6 + (отвязка по правилам D-5, порядок блокировок); версия должна быть под `FOR UPDATE` вызывающего. + """ + owned = select(Document.id).where(Document.legal_entity_id == entity_id) + result = await session.execute( + delete(ConsolidationDocument).where( + ConsolidationDocument.consolidation_version_id == version_id, + ConsolidationDocument.document_id.in_(owned), + ) + ) + return int(result.rowcount) # type: ignore[attr-defined] # CursorResult у DML; у базового Result атрибута нет diff --git a/api/src/services/consolidation_guard.py b/api/src/services/consolidation_guard.py new file mode 100644 index 0000000..cdaa927 --- /dev/null +++ b/api/src/services/consolidation_guard.py @@ -0,0 +1,40 @@ +"""Общий пролог операций над версией консолидации: блокировка строки версии и видимость (design D-8, D-9). + +Один порядок для всех операций, меняющих версию: `consolidation_version FOR UPDATE` → видимость (ГК консолидации в +области пользователя; чужая и несуществующая версии — один и тот же 404 `VERSION_NOT_FOUND`) → для правок — +активная блокировка и `ensure_version_editable` (409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`). +Версию блокирует и правка, и перехват, и подтверждение документа (R-2), поэтому проверка статуса и владельца +блокировки не может устареть до конца транзакции. Операции, которым редактируемость не нужна (взять в работу, снять +блокировку), берут только `lock_visible_version`. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import NotFoundError +from src.domain.policy import VisibilityScope, ensure_version_editable +from src.models.consolidation import Consolidation, ConsolidationVersion +from src.repositories import consolidations as repo + +VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей + + +async def lock_visible_version( + session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID +) -> tuple[ConsolidationVersion, Consolidation]: + """Версия под `FOR UPDATE` и её консолидация, если ГК видна пользователю; иначе 404 `VERSION_NOT_FOUND`.""" + version = await repo.lock_version(session, version_id) + found = None if version is None else await repo.get_version(session, scope, version_id) + if version is None or found is None: + raise NotFoundError(VERSION_NOT_FOUND) + return version, found[1] + + +async def lock_editable_version( + session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID, user_id: uuid.UUID +) -> tuple[ConsolidationVersion, Consolidation]: + """`lock_visible_version` + `ensure_version_editable` по активной блокировке: версию вправе править `user_id`.""" + version, consolidation = await lock_visible_version(session, scope, version_id) + ensure_version_editable(version, await repo.active_lock(session, version.id), user_id) + return version, consolidation diff --git a/api/src/services/consolidation_locks.py b/api/src/services/consolidation_locks.py index 5465be1..77ad80c 100644 --- a/api/src/services/consolidation_locks.py +++ b/api/src/services/consolidation_locks.py @@ -17,15 +17,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.constraint_conflicts import constraint_name, resolve_unique_conflict -from src.common.exceptions import ConflictError, NotFoundError +from src.common.exceptions import ConflictError from src.domain.policy import VisibilityScope from src.models.access import AppUser -from src.models.consolidation import ConsolidationLock, ConsolidationVersion +from src.models.consolidation import ConsolidationLock from src.models.enums import AuditAction, AuditSection, ConsolidationStatus from src.repositories import consolidations as repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import LockIn, LockOut -from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, lock_out +from src.services.consolidation_guard import lock_visible_version +from src.services.consolidations import OBJECT_TYPE, lock_out ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active" @@ -70,7 +71,7 @@ class ConsolidationLockService: непустой причиной — прежняя снимается, новая с `taken_over_from`/`takeover_reason`, аудит перехвата. Причина при свободной версии не сохраняется (перехватывать не у кого). """ - version = await self._locked_visible_version(version_id) + version, _ = await lock_visible_version(self._session, self._scope, version_id) if version.status == ConsolidationStatus.CANCELLED: raise ConflictError("VERSION_NOT_EDITABLE", "Аннулированную версию нельзя взять в работу") user = self._principal.user @@ -112,7 +113,7 @@ class ConsolidationLockService: async def release(self, version_id: uuid.UUID) -> None: """Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409 `NOT_LOCK_OWNER`; активной нет — ничего не делает (идемпотентно).""" - version = await self._locked_visible_version(version_id) + version, _ = await lock_visible_version(self._session, self._scope, version_id) current = await repo.active_lock(self._session, version.id) if current is None: return @@ -134,13 +135,6 @@ class ConsolidationLockService: after_value={"locked_by": user.id, "released": True}, ) - async def _locked_visible_version(self, version_id: uuid.UUID) -> ConsolidationVersion: - """Версия под `FOR UPDATE` (D-9), если её ГК видна; иначе 404 — одинаковый для чужой и несуществующей.""" - version = await repo.lock_version(self._session, version_id) - if version is None or await repo.get_version(self._session, self._scope, version_id) is None: - raise NotFoundError(VERSION_NOT_FOUND) - return version - async def _insert( self, version_id: uuid.UUID, diff --git a/api/src/services/consolidation_status.py b/api/src/services/consolidation_status.py index 97e9a0a..ca9bec0 100644 --- a/api/src/services/consolidation_status.py +++ b/api/src/services/consolidation_status.py @@ -13,16 +13,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now -from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError -from src.domain.policy import VisibilityScope, ensure_version_editable +from src.common.exceptions import ConflictError, DomainValidationError +from src.domain.policy import VisibilityScope from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion from src.models.enums import AuditAction, AuditSection, ConsolidationStatus -from src.repositories import consolidations as repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import StatusIn, VersionCard from src.services import consolidation_locks from src.services.audit import AuditService -from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, ConsolidationService +from src.services.consolidation_guard import lock_editable_version +from src.services.consolidations import OBJECT_TYPE, ConsolidationService _CANCELLED = ConsolidationStatus.CANCELLED _INITIATED = ConsolidationStatus.INITIATED @@ -111,11 +111,8 @@ class ConsolidationStatusService: с `from_status`, `to_status`; повтор того же статуса — тоже) → `CANCELLED` без комментария — 422 `COMMENT_REQUIRED`. Комментарий из одних пробелов считается пустым. """ - version = await repo.lock_version(self._session, version_id) - if version is None or await repo.get_version(self._session, self._scope, version_id) is None: - raise NotFoundError(VERSION_NOT_FOUND) user = self._principal.user - ensure_version_editable(version, await repo.active_lock(self._session, version.id), user.id) + version, _ = await lock_editable_version(self._session, self._scope, version_id, user.id) if data.to_status not in MANUAL_TRANSITIONS.get(version.status, frozenset()): raise ConflictError( "TRANSITION_NOT_ALLOWED", diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 7e022cd..1deca69 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -44,15 +44,16 @@ from src.schemas.consolidations import ( HistoryOut, LockOut, MemberOut, + MembersIn, RefOut, VersionBrief, VersionCard, ) from src.services import consolidation_documents from src.services.company_groups import COMPANY_GROUP_NOT_FOUND +from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version from src.services.related_groups import RELATED_GROUP_NOT_FOUND -VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей OBJECT_TYPE = "consolidation_version" MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) FIRST_VERSION_NO = 1 @@ -155,6 +156,61 @@ class ConsolidationService: raise NotFoundError(VERSION_NOT_FOUND) return await self._card(*found) + async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard: + """Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии. + + Порядок: `FOR UPDATE` версии → видимость (404) → редактируемость и своя блокировка (409, D-8, D-9) → дубли + схлопываются → все ЮЛ существуют и принадлежат ГК консолидации (422 `ENTITY_NOT_IN_GROUP`, + `legal_entity_ids` — в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`) → diff: ушедшие + теряют `consolidation_member` и привязки документов (`unbind_entity`), пришедшие получают член + (`is_head_entity = false`) и подхват подтверждённых документов (`bind_confirmed`); оставшиеся не трогаются → + `MEMBERSHIP_CHANGE` с полными отсортированными списками ЮЛ до/после. Состав не изменился — 200 без записи и + без события. `related_group_member` не меняется никогда. + """ + user = self._principal.user + version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id) + requested = list(dict.fromkeys(data.legal_entity_ids)) + valid = await repo.entities_in_group(self._session, consolidation.company_group_id, requested) + foreign = [entity_id for entity_id in requested if entity_id not in valid] + if foreign: + raise DomainValidationError( + "ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign + ) + if len(requested) < MIN_PERIMETER: + raise DomainValidationError( + "PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}" + ) + + current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)} + added = [entity_id for entity_id in requested if entity_id not in current] + removed = [entity_id for entity_id in current if entity_id not in set(requested)] + if not added and not removed: + return await self._card(version, consolidation) + + for entity_id in removed: + await self._session.delete(current[entity_id]) + self._session.add_all( + ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False) + for entity_id in added + ) + version.updated_by = user.id + version.updated_at = utc_now() + await self._session.flush() + for entity_id in removed: + await consolidation_documents.unbind_entity(self._session, version_id=version.id, entity_id=entity_id) + if added: + await consolidation_documents.bind_confirmed(self._session, version, added) + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + before_value={"legal_entity_ids": sorted(current)}, + after_value={"legal_entity_ids": sorted(requested)}, + ) + return await self._card(version, consolidation) + async def _insert( self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity] ) -> tuple[Consolidation, ConsolidationVersion]: diff --git a/api/tests/test_api_consolidation_members.py b/api/tests/test_api_consolidation_members.py new file mode 100644 index 0000000..c258bf8 --- /dev/null +++ b/api/tests/test_api_consolidation_members.py @@ -0,0 +1,493 @@ +"""Периметр версии: `PUT /consolidations/versions/{id}/members` (spec consolidations-api «Периметр версии»; +directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-5, D-8, D-9). + +Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`) до Task 6 — заглушка: здесь проверяется ВЫЗОВ +(шпион на функцию модуля), а настоящие привязки появятся с реализацией и ячейкой `CONFIRMED` (Task 6, Task 8). +Отвязка документов удалённых ЮЛ (`unbind_entity`) работает уже сейчас — прямое удаление `consolidation_document`. +""" + +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 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, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int: + calls.append((bound_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_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, bound_version: ConsolidationVersion, 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` (Task 6 заменит тело, имя и сигнатура — прежние) возвращает число снятых привязок.""" + 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)] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index a2b81ec..e240f13 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -57,6 +57,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}": {"get"}, "/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"}, "/api/v1/consolidations/versions/{version_id}/status": {"post"}, + "/api/v1/consolidations/versions/{version_id}/members": {"put"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 1861668..971ddc1 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -119,9 +119,9 @@ - `ConsolidationService.set_members(version_id, data: MembersIn) -> VersionCard`: `lock_version` → видимость → `ensure_version_editable` → уникальные id → все ЮЛ с `company_group_id == consolidation.company_group_id` (иначе 422 `ENTITY_NOT_IN_GROUP`, `extra.legal_entity_ids`) → ≥ 2 → diff: удалить `ConsolidationMember` ушедших + `consolidation_documents.unbind_entity(version, entity_id)` (Task 6; до него — удаление `consolidation_document` по `document.legal_entity_id` прямо здесь), добавить новых + `bind_confirmed` → `audit(MEMBERSHIP_CHANGE, before = {legal_entity_ids: старые}, after = {legal_entity_ids: новые})`. - Маршрут: `PUT …/members` (200, карточка). -- [ ] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился). -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`. +- [x] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился). +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 985ce6f..c8f6429 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -209,3 +209,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) | | 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ | | 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая | +| 25 | 2026-10-06 | `PUT …/members` с составом, равным текущему, отвечает 200 с карточкой, ничего не пишет (ни членов, ни `updated_at`) и НЕ пишет `MEMBERSHIP_CHANGE` | событие «периметр изменён» без изменения вводит в заблуждение и засоряет журнал; идемпотентный повтор запроса (двойной клик, ретрай) безопасен | в журнале нет следа повторного сохранения того же состава (оно ничего не меняло) | +| 26 | 2026-10-06 | `ENTITY_NOT_IN_GROUP` покрывает три случая: ЮЛ другой ГК, ЮЛ без ГК и несуществующий id; `legal_entity_ids` — в порядке запроса. Проверка идёт ДО проверки размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`, не `PERIMETER_TOO_SMALL`); дубли схлопываются ДО подсчёта размера (`[a, a]` → `PERIMETER_TOO_SMALL`) | спека: «все ЮЛ SHALL принадлежать ГК» — несуществующее ЮЛ ей не принадлежит; существование отдельным кодом не различаем, чтобы не раскрывать чужие id (как 404 вместо 403) | клиент не отличает «нет такого ЮЛ» от «ЮЛ чужой ГК» | +| 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая | +| 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) | +| 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index eee2af5..e99661f 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -26,7 +26,7 @@ возврат в `INITIATED`, `TRANSITION_NOT_ALLOWED`, `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`. - [x] 2.3 **Блокировка.** `services/consolidation_locks.py:take/release`, `POST/DELETE …/lock`, `IntegrityError` → `LOCKED_BY_OTHER`. Тесты: сценарии «Взять в работу», гонка двух `POST` (`test_api_constraint_races`), `CANCELLED` снимает блокировку. -- [ ] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии». +- [x] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии». ## 3. Привязка документов и чек-лист