feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов

PUT /consolidations/versions/{id}/members: замена состава ЮЛ версии, проверка принадлежности ГК
(ENTITY_NOT_IN_GROUP), минимум два ЮЛ, diff с отвязкой документов ушедших (unbind_entity) и подхватом
добавленных (bind_confirmed, заглушка до Task 6), аудит MEMBERSHIP_CHANGE. Общий пролог
«lock → видимость → редактируемость» вынесен в services/consolidation_guard.py (T4.2). Решения #25–#29.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 04:34:52 +04:00
parent e317fe59dd
commit 90b7bd5c16
13 changed files with 685 additions and 29 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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 = (

View File

@ -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 атрибута нет

View File

@ -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

View File

@ -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,

View File

@ -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",

View File

@ -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]:

View File

@ -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)]

View File

@ -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 быть не должно

View File

@ -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): редактирование периметра версии с подхватом и отвязкой документов`.
---

View File

@ -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) |

View File

@ -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. Привязка документов и чек-лист