feat(consolidations): ячейки чек-листа с document_type и account своего правила (#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:49:28 +04:00
parent 61bbe78ce6
commit 678cc4566f
6 changed files with 103 additions and 14 deletions

View File

@ -99,8 +99,11 @@ class ConsolidationPeriodOut(BaseModel):
class ChecklistCellOut(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) status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8)
missing_quarters: list[PeriodQuarter] missing_quarters: list[PeriodQuarter]
document_ids: list[uuid.UUID] document_ids: list[uuid.UUID]

View File

@ -192,7 +192,7 @@ class ConsolidationService:
legal_entity_id=entity.id, legal_entity_id=entity.id,
legal_entity_name=entity.name, legal_entity_name=entity.name,
legal_entity_inn=entity.inn, 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 for entity, cells in rows
], ],
@ -236,9 +236,14 @@ class ConsolidationService:
return rules, rows return rules, rows
@staticmethod @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( 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: 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]) locks = await repo.active_locks(self._session, [version.id])
history = await repo.history(self._session, version.id) history = await repo.history(self._session, version.id)
brief = self._brief(version, locks.get(version.id), len(members)) 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( return VersionCard(
**brief.model_dump(), **brief.model_dump(),
consolidation_id=consolidation.id, consolidation_id=consolidation.id,
@ -404,7 +409,9 @@ class ConsolidationService:
name=entity.name, name=entity.name,
inn=entity.inn, inn=entity.inn,
is_head_entity=member.is_head_entity, 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) for position, (member, entity) in enumerate(members)
], ],

View File

@ -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 assert body["rows"][1]["legal_entity_inn"] == second.inn
cells = _cells(body, "ООО А") cells = _cells(body, "ООО А")
assert len(cells) == len(EXPECTED_RULES) 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_60] == {
assert cells[OSV_90] == {"status": "MISSING", "missing_quarters": [], "document_ids": []} "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"} 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, "ООО А") first_cells = _cells(body, "ООО А")
assert first_cells[OSV_60] == { assert first_cells[OSV_60] == {
"document_type": "OSV",
"account": "60",
"status": "MISSING", "status": "MISSING",
"missing_quarters": ["Q2"], "missing_quarters": ["Q2"],
"document_ids": [str(first_q1), str(third_q3)], "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) 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]]: async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]:
version, entities = await _world(session) version, entities = await _world(session)
for quarter in (Q1, Q2, Q3): for quarter in (Q1, Q2, Q3):

View File

@ -20,8 +20,20 @@ def test_render() -> None:
legal_entity_name='ООО "Альфа; Бета"', legal_entity_name='ООО "Альфа; Бета"',
legal_entity_inn="7700000000", legal_entity_inn="7700000000",
cells=[ cells=[
ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]), ChecklistCellOut(
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]), 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: 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: def test_missing_quarters_listed_in_order() -> None:

View File

@ -251,3 +251,9 @@ def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str,
create = schema["components"]["schemas"]["ConsolidationCreate"] create = schema["components"]["schemas"]["ConsolidationCreate"]
assert "legal_entity_ids" in create["properties"] assert "legal_entity_ids" in create["properties"]
assert "legal_entity_ids" not in create.get("required", []) 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"])

View File

@ -10,7 +10,7 @@ from pydantic import ValidationError
from src.domain.checklist import CellStatus, EntityFilesState from src.domain.checklist import CellStatus, EntityFilesState
from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType 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 import consolidations as schemas
from src.schemas.consolidations import ( from src.schemas.consolidations import (
MAX_MEMBERS, MAX_MEMBERS,
@ -123,10 +123,36 @@ def test_entity_files_state_is_frozen() -> None:
def test_checklist_cell_status_is_the_domain_enum() -> None: def test_checklist_cell_status_is_the_domain_enum() -> None:
"""Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются.""" """Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются."""
for status in CellStatus: 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 assert cell.status is status
with pytest.raises(ValidationError): 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: def test_counterparty_type_is_the_domain_enum() -> None: