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

View File

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

View File

@ -3,6 +3,7 @@
`CONSOLIDATION_CREATE`; design D-15, D-16)."""
import uuid
from collections.abc import Sequence
from typing import Any
import pytest
@ -18,7 +19,7 @@ from src.models.consolidation import (
ConsolidationStatusHistory,
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 tests.conftest import AuthClientFactory
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}
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:
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"]]
@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
async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
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:
missing.append(f"{method.upper()} {path}")
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)
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:
assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004
assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT