diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 3d7eb37..188f991 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -99,8 +99,11 @@ class ConsolidationPeriodOut(BaseModel): class ChecklistCellOut(BaseModel): - """Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" + """Ячейка чек-листа — самоописывающая (front-api-gaps D-2): правило ячейки (`document_type`, `account` — счёт ОСВ, + `None` у ББ/ОФР), статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" + document_type: DocumentType + account: str | None status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8) missing_quarters: list[PeriodQuarter] document_ids: list[uuid.UUID] diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index dcfc544..4406591 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -192,7 +192,7 @@ class ConsolidationService: legal_entity_id=entity.id, legal_entity_name=entity.name, legal_entity_inn=entity.inn, - cells=[self._cell_out(cell) for cell in cells], + cells=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells, strict=True)], ) for entity, cells in rows ], @@ -236,9 +236,14 @@ class ConsolidationService: return rules, rows @staticmethod - def _cell_out(cell: checklist_domain.Cell) -> ChecklistCellOut: + def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut: + """Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`.""" return ChecklistCellOut( - status=cell.status, missing_quarters=list(cell.missing_quarters), document_ids=list(cell.document_ids) + document_type=rule.document_type, + account=rule.account, + status=cell.status, + missing_quarters=list(cell.missing_quarters), + document_ids=list(cell.document_ids), ) async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard: @@ -385,7 +390,7 @@ class ConsolidationService: locks = await repo.active_locks(self._session, [version.id]) history = await repo.history(self._session, version.id) brief = self._brief(version, locks.get(version.id), len(members)) - _, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members]) + rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members]) return VersionCard( **brief.model_dump(), consolidation_id=consolidation.id, @@ -404,7 +409,9 @@ class ConsolidationService: name=entity.name, inn=entity.inn, is_head_entity=member.is_head_entity, - checklist=[self._cell_out(cell) for cell in cells[position][1]], + checklist=[ + self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True) + ], ) for position, (member, entity) in enumerate(members) ], diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index b69ed3e..93261b1 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -83,8 +83,20 @@ async def test_checklist_osv_60_confirmed_osv_90_missing(auth_client: AuthClient assert body["rows"][1]["legal_entity_inn"] == second.inn cells = _cells(body, "ООО А") assert len(cells) == len(EXPECTED_RULES) - assert cells[OSV_60] == {"status": "CONFIRMED", "missing_quarters": [], "document_ids": [str(i) for i in documents]} - assert cells[OSV_90] == {"status": "MISSING", "missing_quarters": [], "document_ids": []} + assert cells[OSV_60] == { + "document_type": "OSV", + "account": "60", + "status": "CONFIRMED", + "missing_quarters": [], + "document_ids": [str(i) for i in documents], + } + assert cells[OSV_90] == { + "document_type": "OSV", + "account": "90", + "status": "MISSING", + "missing_quarters": [], + "document_ids": [], + } assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"} @@ -107,6 +119,8 @@ async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientF first_cells = _cells(body, "ООО А") assert first_cells[OSV_60] == { + "document_type": "OSV", + "account": "60", "status": "MISSING", "missing_quarters": ["Q2"], "document_ids": [str(first_q1), str(third_q3)], @@ -130,6 +144,25 @@ async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClient assert card["members"][0]["id"] == str(first.id) +@pytest.mark.integration +async def test_card_cells_are_self_describing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """front-api-gaps D-2: ячейка несёт правило — фронт не сопоставляет ячейки с `rules` по индексу.""" + version, _ = await _bound_world(seeded) + client = await auth_client(KA_RF) + + checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + card = (await client.get(f"/consolidations/versions/{version.id}")).json() + + expected = [(rule["document_type"], rule["account"]) for rule in checklist["rules"]] + assert expected == EXPECTED_RULES + for member in card["members"]: + assert [(cell["document_type"], cell["account"]) for cell in member["checklist"]] == expected + for row in checklist["rows"]: + assert [(cell["document_type"], cell["account"]) for cell in row["cells"]] == expected + balance = next(cell for cell in card["members"][0]["checklist"] if cell["document_type"] == "BALANCE") + assert balance["account"] is None + + async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]: version, entities = await _world(session) for quarter in (Q1, Q2, Q3): diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index c7f7d31..d159e6c 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -20,8 +20,20 @@ def test_render() -> None: legal_entity_name='ООО "Альфа; Бета"', legal_entity_inn="7700000000", cells=[ - ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]), - ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]), + ChecklistCellOut( + document_type=DocumentType.BALANCE, + account=None, + status=CellStatus.CONFIRMED, + missing_quarters=[], + document_ids=[], + ), + ChecklistCellOut( + document_type=DocumentType.OSV, + account="60", + status=CellStatus.MISSING, + missing_quarters=[PeriodQuarter.Q2], + document_ids=[], + ), ], ) ], @@ -34,7 +46,9 @@ def test_render() -> None: def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut: - return ChecklistCellOut(status=status, missing_quarters=list(quarters), document_ids=[]) + return ChecklistCellOut( + document_type=DocumentType.OSV, account="60", status=status, missing_quarters=list(quarters), document_ids=[] + ) def test_missing_quarters_listed_in_order() -> None: diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index f5ecd88..690586d 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -251,3 +251,9 @@ def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str, create = schema["components"]["schemas"]["ConsolidationCreate"] assert "legal_entity_ids" in create["properties"] assert "legal_entity_ids" not in create.get("required", []) + + +def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None: + """front-api-gaps D-2: ячейка чек-листа самоописывающая.""" + cell = schema["components"]["schemas"]["ChecklistCellOut"] + assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"]) diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 1e52a44..a5416cc 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -10,7 +10,7 @@ from pydantic import ValidationError from src.domain.checklist import CellStatus, EntityFilesState from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType -from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter +from src.models.enums import CounterpartyMatchKind, DocumentType, FileStatus, PeriodQuarter from src.schemas import consolidations as schemas from src.schemas.consolidations import ( MAX_MEMBERS, @@ -123,10 +123,36 @@ def test_entity_files_state_is_frozen() -> None: def test_checklist_cell_status_is_the_domain_enum() -> None: """Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются.""" for status in CellStatus: - cell = ChecklistCellOut(status=status.value, missing_quarters=[], document_ids=[]) # type: ignore[arg-type] + cell = ChecklistCellOut( + document_type=DocumentType.OSV, + account="60", + status=status.value, # type: ignore[arg-type] + missing_quarters=[], + document_ids=[], + ) assert cell.status is status with pytest.raises(ValidationError): - ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] + ChecklistCellOut( + document_type=DocumentType.OSV, + account="60", + status="UNKNOWN", # type: ignore[arg-type] + missing_quarters=[], + document_ids=[], + ) + + +def test_checklist_cell_requires_its_rule() -> None: + """D-2: `document_type` обязателен, `account` — обязателен, но может быть `None` (ББ/ОФР).""" + with pytest.raises(ValidationError): + ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[], document_ids=[]) # type: ignore[call-arg] + cell = ChecklistCellOut( + document_type=DocumentType.BALANCE, + account=None, + status=CellStatus.MISSING, + missing_quarters=[], + document_ids=[], + ) + assert cell.account is None def test_counterparty_type_is_the_domain_enum() -> None: