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:
parent
a71bef237e
commit
b447a09982
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", [])
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user