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