feat(consolidations): создание с выбранным периметром legal_entity_ids, общая проверка периметра (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 17:48:03 +04:00
parent a71bef237e
commit b447a09982
5 changed files with 225 additions and 31 deletions

View File

@ -42,12 +42,18 @@ class RefOut(BaseModel):
class ConsolidationCreate(BaseModel): class ConsolidationCreate(BaseModel):
"""Тело `POST /consolidations`: ГК, ГСК периметра, период.""" """Тело `POST /consolidations`: ГК, ГСК, период и необязательный выбранный периметр (модалка [42],
front-api-gaps D-1).
`legal_entity_ids` нет или `null` — периметр из текущих членов ГСК; список — ровно эти ЮЛ ГК (дубли схлопывает
сервис).
"""
company_group_id: uuid.UUID company_group_id: uuid.UUID
related_group_id: uuid.UUID related_group_id: uuid.UUID
year: int = Field(ge=YEAR_MIN, le=YEAR_MAX) year: int = Field(ge=YEAR_MIN, le=YEAR_MAX)
quarter: PeriodQuarter quarter: PeriodQuarter
legal_entity_ids: list[uuid.UUID] | None = Field(default=None, max_length=MAX_MEMBERS)
class LockOut(BaseModel): class LockOut(BaseModel):

View File

@ -3,13 +3,14 @@
audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16). audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16).
Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404 Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404
с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК, `is_head_entity = false`; с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК или выбранные ЮЛ ГК
ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя (`legal_entity_ids`, front-api-gaps D-1), `is_head_entity = false`; ГСК в `consolidation.related_group_id` не
не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя не ставится. Параллельное создание того
`IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`). же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → `IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`).
""" """
import uuid import uuid
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -87,11 +88,13 @@ class ConsolidationService:
self._principal = principal self._principal = principal
async def create(self, data: ConsolidationCreate) -> VersionCard: async def create(self, data: ConsolidationCreate) -> VersionCard:
"""Консолидация ГК за период с версией №1 (`INITIATED`), периметром из ГСК, историей и аудитом. """Консолидация ГК за период с версией №1 (`INITIATED`), периметром (ГСК или выбранные ЮЛ), историей и аудитом.
Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422 Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422
`RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет `RELATED_GROUP_MISMATCH`) → периметр: без `legal_entity_ids` — текущие члены ГСК, не меньше двух (422
(409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё. `PERIMETER_TOO_SMALL`); со списком — `_validate_perimeter` (422 `ENTITY_NOT_IN_GROUP` / `PERIMETER_TOO_SMALL`) →
периода ещё нет (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка
откатывает всё.
После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не
разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК. разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК.
""" """
@ -106,15 +109,18 @@ class ConsolidationService:
raise NotFoundError(RELATED_GROUP_NOT_FOUND) raise NotFoundError(RELATED_GROUP_NOT_FOUND)
if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id: if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id:
raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК") raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК")
entities = [entity for _, entity in await directories_repo.current_members(self._session, related.id)] if data.legal_entity_ids is None:
if len(entities) < MIN_PERIMETER: entity_ids = [entity.id for _, entity in await directories_repo.current_members(self._session, related.id)]
raise DomainValidationError( if len(entity_ids) < MIN_PERIMETER:
"PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entities)}" raise DomainValidationError(
) "PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entity_ids)}"
)
else:
entity_ids = await self._validate_perimeter(group.id, data.legal_entity_ids)
if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter): if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter):
raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана") raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана")
consolidation, version = await self._insert(group.id, data, entities) consolidation, version = await self._insert(group.id, data, entity_ids)
await self._audit.record( await self._audit.record(
action=AuditAction.CONSOLIDATION_CREATE, action=AuditAction.CONSOLIDATION_CREATE,
section=AuditSection.CONSOLIDATION, section=AuditSection.CONSOLIDATION,
@ -125,7 +131,7 @@ class ConsolidationService:
"consolidation_id": consolidation.id, "consolidation_id": consolidation.id,
"year": consolidation.year, "year": consolidation.year,
"quarter": consolidation.quarter, "quarter": consolidation.quarter,
"members": sorted(entity.id for entity in entities), "members": sorted(entity_ids),
}, },
) )
return await self._card(version, consolidation) return await self._card(version, consolidation)
@ -247,17 +253,7 @@ class ConsolidationService:
""" """
user = self._principal.user user = self._principal.user
version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id) version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id)
requested = list(dict.fromkeys(data.legal_entity_ids)) requested = await self._validate_perimeter(consolidation.company_group_id, 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)} 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] added = [entity_id for entity_id in requested if entity_id not in current]
@ -291,8 +287,27 @@ class ConsolidationService:
) )
return await self._card(version, consolidation) return await self._card(version, consolidation)
async def _validate_perimeter(
self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID]
) -> list[uuid.UUID]:
"""Периметр консолидации — одно правило для создания и `PUT …/members` (front-api-gaps D-1): дубли схлопываются
в порядке первого вхождения → все ЮЛ существуют и принадлежат ГК (422 `ENTITY_NOT_IN_GROUP`, `legal_entity_ids`
— чужие в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`)."""
requested = list(dict.fromkeys(entity_ids))
valid = await repo.entities_in_group(self._session, 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)}"
)
return requested
async def _insert( async def _insert(
self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity] self, group_id: uuid.UUID, data: ConsolidationCreate, entity_ids: list[uuid.UUID]
) -> tuple[Consolidation, ConsolidationVersion]: ) -> tuple[Consolidation, ConsolidationVersion]:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now() now = utc_now()
@ -322,8 +337,8 @@ class ConsolidationService:
self._session.add(version) self._session.add(version)
await self._session.flush() await self._session.flush()
self._session.add_all( self._session.add_all(
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False) ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False)
for entity in entities for entity_id in entity_ids
) )
self._session.add( self._session.add(
ConsolidationStatusHistory( ConsolidationStatusHistory(
@ -336,7 +351,7 @@ class ConsolidationService:
) )
await self._session.flush() await self._session.flush()
await consolidation_documents.bind_confirmed( await consolidation_documents.bind_confirmed(
self._session, version=version, consolidation=consolidation, entity_ids=[entity.id for entity in entities] self._session, version=version, consolidation=consolidation, entity_ids=entity_ids
) )
return consolidation, version return consolidation, version

View File

@ -3,6 +3,7 @@
`CONSOLIDATION_CREATE`; design D-15, D-16).""" `CONSOLIDATION_CREATE`; design D-15, D-16)."""
import uuid import uuid
from collections.abc import Sequence
from typing import Any from typing import Any
import pytest import pytest
@ -18,7 +19,7 @@ from src.models.consolidation import (
ConsolidationStatusHistory, ConsolidationStatusHistory,
ConsolidationVersion, ConsolidationVersion,
) )
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind
from tests.conftest import AuthClientFactory from tests.conftest import AuthClientFactory
from tests.factories import ( from tests.factories import (
@ -73,6 +74,18 @@ def _body(group: CompanyGroup, gsk: RelatedGroup, year: int = 2024, quarter: str
return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter} return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter}
def _with_ids(body: dict[str, Any], ids: Sequence[uuid.UUID | str]) -> dict[str, Any]:
"""Тело создания с явным периметром (модалка [42], front-api-gaps D-1)."""
return {**body, "legal_entity_ids": [str(entity_id) for entity_id in ids]}
async def _gsk_members(session: AsyncSession, gsk: RelatedGroup) -> set[uuid.UUID]:
stmt = select(RelatedGroupMember.legal_entity_id).where(
RelatedGroupMember.related_group_id == gsk.id, RelatedGroupMember.is_current.is_(True)
)
return set((await session.execute(stmt)).scalars())
async def _count(session: AsyncSession, model: type) -> int: async def _count(session: AsyncSession, model: type) -> int:
return (await session.execute(select(func.count()).select_from(model))).scalar_one() return (await session.execute(select(func.count()).select_from(model))).scalar_one()
@ -175,6 +188,149 @@ async def test_create_ignores_closed_memberships(auth_client: AuthClientFactory,
assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]] assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]]
@pytest.mark.integration
async def test_create_with_selected_perimeter_wider_than_gsk(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Выбранный периметр шире ГСК: ровно эти ЮЛ, одно событие создания, состав ГСК не меняется."""
group, gsk, entities = await _scenario(seeded)
outsider = await make_legal_entity(seeded, name="ООО Вне ГСК", group=group)
selected = [*entities, outsider]
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [e.id for e in selected]))
assert response.status_code == HTTP_CREATED, response.text
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in selected}
event = (
await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE))
).scalar_one()
assert event.after_value is not None
assert event.after_value["members"] == sorted(str(e.id) for e in selected)
membership = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
assert (await seeded.execute(membership)).scalar_one() == 0
assert await _gsk_members(seeded, gsk) == {e.id for e in entities}
assert await _count(seeded, ConsolidationLock) == 0
@pytest.mark.integration
async def test_create_gsk_of_one_with_second_entity_selected(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""ГСК из одного ЮЛ, второе отмечено вручную — периметр из двух (проверка размера — по выбранным)."""
group, gsk, (single,) = await _scenario(seeded, members=1)
second = await make_legal_entity(seeded, name="ООО Второе", group=group)
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [single.id, second.id]))
assert response.status_code == HTTP_CREATED, response.text
assert {member["id"] for member in response.json()["members"]} == {str(single.id), str(second.id)}
@pytest.mark.integration
async def test_create_with_entity_of_other_group_is_rejected(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group, gsk, entities = await _scenario(seeded)
other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV))
stranger = await make_legal_entity(seeded, name="ООО Чужое", group=other_group)
client = await auth_client(KA_RF)
body = _with_ids(_body(group, gsk), [*[e.id for e in entities], stranger.id])
response = await client.post(CONSOLIDATIONS, json=body)
assert response.status_code == HTTP_UNPROCESSABLE, response.text
detail = response.json()["detail"]
assert detail["code"] == "ENTITY_NOT_IN_GROUP"
assert detail["legal_entity_ids"] == [str(stranger.id)]
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration
async def test_create_with_unknown_entity_is_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, entities = await _scenario(seeded)
ghost = uuid.uuid4()
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [*[e.id for e in entities], ghost]))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]
@pytest.mark.integration
async def test_create_with_duplicates_collapsing_to_one_is_too_small(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group, gsk, entities = await _scenario(seeded)
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [entities[0].id, entities[0].id]))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration
async def test_create_with_empty_selected_perimeter_is_too_small(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Review Focus: `[]` — явный пустой периметр, а не «поле не передано» (ГСК из двух не подставляется)."""
group, gsk, _ = await _scenario(seeded)
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), []))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
@pytest.mark.integration
async def test_create_with_null_selected_perimeter_takes_gsk(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Решение №3: `legal_entity_ids: null` равнозначно отсутствию поля."""
group, gsk, entities = await _scenario(seeded)
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "legal_entity_ids": None})
assert response.status_code == HTTP_CREATED, response.text
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in entities}
@pytest.mark.integration
async def test_create_gsk_mismatch_reported_before_perimeter(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Порядок проверок (решение №5): ГСК раньше периметра."""
group, _, _ = await _scenario(seeded)
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ")
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsz), [uuid.uuid4()]))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH"
@pytest.mark.integration
async def test_create_selected_perimeter_longer_than_limit(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group, gsk, _ = await _scenario(seeded)
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [uuid.uuid4() for _ in range(501)]))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["detail"][0]["loc"] == ["body", "legal_entity_ids"]
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration @pytest.mark.integration
async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, _ = await _scenario(seeded) group, gsk, _ = await _scenario(seeded)

View File

@ -244,3 +244,10 @@ def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any
elif not has_bearer: elif not has_bearer:
missing.append(f"{method.upper()} {path}") missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}" assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"
def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str, Any]) -> None:
"""front-api-gaps D-1: `legal_entity_ids` в теле создания — необязательный массив."""
create = schema["components"]["schemas"]["ConsolidationCreate"]
assert "legal_entity_ids" in create["properties"]
assert "legal_entity_ids" not in create.get("required", [])

View File

@ -37,6 +37,16 @@ def test_consolidation_create_year_bounds() -> None:
ConsolidationCreate(year=year, **ids) ConsolidationCreate(year=year, **ids)
def test_consolidation_create_selected_perimeter_bounds() -> None:
"""D-1: `legal_entity_ids` необязателен (`None` — периметр из ГСК), не больше `MAX_MEMBERS`."""
ids = {"company_group_id": uuid.uuid4(), "related_group_id": uuid.uuid4(), "year": 2024, "quarter": "Q3"}
assert ConsolidationCreate(**ids).legal_entity_ids is None # type: ignore[arg-type]
selected = [uuid.uuid4() for _ in range(MAX_MEMBERS)]
assert len(ConsolidationCreate(**ids, legal_entity_ids=selected).legal_entity_ids or []) == MAX_MEMBERS # type: ignore[arg-type]
with pytest.raises(ValidationError):
ConsolidationCreate(**ids, legal_entity_ids=[*selected, uuid.uuid4()]) # type: ignore[arg-type]
def test_osv_page_params_limit_bounds() -> None: def test_osv_page_params_limit_bounds() -> None:
assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004 assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004
assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT