diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index c235249..3d7eb37 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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): diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 01e54aa..72846c0 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -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 diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 8bc64f6..7d8c7f5 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -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) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 90d2758..f5ecd88 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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", []) diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 6cdbdf6..1e52a44 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -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