diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 413e891..b8c3992 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import locks, members, periods, status, versions +from src.api.endpoints.consolidations import checklist, locks, members, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -10,3 +10,4 @@ router.include_router(versions.router) router.include_router(locks.router) router.include_router(status.router) router.include_router(members.router) +router.include_router(checklist.router) diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py new file mode 100644 index 0000000..92ab0bb --- /dev/null +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -0,0 +1,31 @@ +"""`GET /consolidations/versions/{id}/checklist` — чек-лист комплекта документов версии (spec consolidations-api +«Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404.""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import ChecklistOut + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}/checklist", + response_model=ChecklistOut, + dependencies=VIEW, + responses=NOT_FOUND, + summary="Чек-лист комплекта документов версии", +) +async def get_checklist(version_id: uuid.UUID, service: ConsolidationServiceDep) -> ChecklistOut: + """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`.""" + return await service.checklist(version_id) diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index a8e97b9..9dd9198 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -1,13 +1,50 @@ -"""Чек-лист комплекта документов версии консолидации (spec consolidations-api «Чек-лист комплекта»; §9.1). +"""Чек-лист комплекта документов версии консолидации (spec consolidations-api «Чек-лист комплекта»; §9.1; D-13). -Чистые функции без БД и ORM (design D-13). Сейчас — только состояние файлов ЮЛ, которое репозиторий отдаёт -вычислению ячейки (`PROCESSING` — есть файл в работе, `ERROR` — последний файл ЮЛ в ошибке); неподтверждённые -документы-кандидаты и сборка ячеек добавляются в задаче 3.3 (рулинг R-5). +Чистые функции без БД и ORM: документы и ЮЛ приходят структурно (Protocol'ы), состояние файлов ЮЛ — `EntityFilesState` +(`PROCESSING` — есть файл в работе, `ERROR` — последний файл ЮЛ в ошибке). Статус ячейки (ЮЛ, правило): + +1. `CONFIRMED` — пара полностью покрыта привязанными документами (ОСВ — выбор источников без пробела; ББ/ОФР — + документ за квартал версии); +2. `NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый подходящий документ того же типа и счёта (приоритет + `NEEDS_CHECK`); +3. `PROCESSING` — у ЮЛ есть файл `UPLOADED`/`PROCESSING` (по всем недостающим ячейкам, решение №6); +4. `ERROR` — последний файл ЮЛ в `ERROR`; +5. иначе `MISSING`. + +Правило ОСВ закрывается только документом с `osv_account_code`, равным `account` правила (OQ-04): субсчёт 58.03 не +закрывает 58. «Подходящесть» неподтверждённых документов (`fits`: член периметра, год, квартал) проверяет +вызывающий — в `unbound` приходят уже подходящие. """ +import enum +import re +import uuid +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass +from typing import Protocol, TypeVar -from src.models.enums import FileStatus +from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter + +__all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"] + +_QUARTERS = tuple(PeriodQuarter) +_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(_QUARTERS)} +# порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце +_TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2} +_UNORDERED = len(_TYPE_ORDER) +_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1} + + +class CellStatus(enum.StrEnum): + """Статус ячейки чек-листа (единственное определение: схема API берёт его отсюда).""" + + CONFIRMED = "CONFIRMED" + NEEDS_CHECK = "NEEDS_CHECK" + RECOGNIZED = "RECOGNIZED" + PROCESSING = "PROCESSING" + ERROR = "ERROR" + MISSING = "MISSING" @dataclass(frozen=True) @@ -17,3 +54,174 @@ class EntityFilesState: has_in_progress: bool last_file_status: FileStatus | None + + +@dataclass(frozen=True) +class Rule: + """Правило комплекта (`required_document_rule`): тип документа, код счёта ОСВ (`None` — не ОСВ), обязательность.""" + + document_type: DocumentType + account: str | None + is_mandatory: bool + + +@dataclass(frozen=True) +class Cell: + """Ячейка чек-листа: статус, недостающие кварталы квартальной ОСВ (при частичном покрытии) и привязанные + документы, покрывающие пару полностью (`CONFIRMED`) или частично (пробел).""" + + status: CellStatus + missing_quarters: tuple[PeriodQuarter, ...] = () + document_ids: tuple[uuid.UUID, ...] = () + + +class EntityLike(Protocol): + """ЮЛ периметра (структурный тип: `LegalEntity` подходит).""" + + @property + def id(self) -> uuid.UUID: ... + + +class DocumentLike(Protocol): + """Документ (структурный тип: `Document` подходит); поля — как в DBML `document`.""" + + @property + def id(self) -> uuid.UUID: ... + @property + def legal_entity_id(self) -> uuid.UUID: ... + @property + def document_type(self) -> DocumentType: ... + @property + def osv_account_code(self) -> str | None: ... + @property + def period_quarter(self) -> PeriodQuarter | None: ... + @property + def is_cumulative(self) -> bool | None: ... + @property + def status(self) -> DocumentStatus: ... + + +def _natural_key(code: str) -> tuple[tuple[int, int, str], ...]: + """Натуральный ключ кода счёта: числовые части — по значению (`01 < 58 < 58.03 < 60`), прочие — по тексту.""" + return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in re.split(r"[.\s]+", code) if part) + + +def order_rules(rules: Iterable[Rule]) -> list[Rule]: + """Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта.""" + return sorted( + rules, + key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), _natural_key(rule.account or "")), + ) + + +@dataclass(frozen=True) +class _Coverage: + """Покрытие пары привязанными документами: полное или частичное (пробел в квартальных ОСВ).""" + + complete: bool + missing_quarters: tuple[PeriodQuarter, ...] + document_ids: tuple[uuid.UUID, ...] + + +def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage: + """Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий + приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов. + + Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал, + квартальный позже версии) покрытия не дают. + + # Task 9 заменит на consolidation_periods.select_sources + """ + cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter] + if cumulative: + return _Coverage(True, (), tuple(d.id for d in cumulative)) + last = _QUARTER_ORDER[version_quarter] + needed = _QUARTERS[: last + 1] + by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list) + for document in documents: + if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter: + by_quarter[document.period_quarter].append(document) + present = [d.id for quarter in needed for d in by_quarter.get(quarter, [])] + missing = tuple(quarter for quarter in needed if quarter not in by_quarter) + if not missing: + return _Coverage(True, (), tuple(present)) + return _Coverage(False, missing if present else (), tuple(present)) + + +def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage: + if rule.document_type == DocumentType.OSV: + return _osv_coverage(version_quarter, documents) + # ББ/ОФР: документ за квартал версии (прочие типы — так же) + matched = tuple(d.id for d in documents if d.period_quarter == version_quarter) + return _Coverage(bool(matched), (), matched) + + +def _same_slot(document: DocumentLike, rule: Rule) -> bool: + return document.document_type == rule.document_type and ( + rule.document_type != DocumentType.OSV or document.osv_account_code == rule.account + ) + + +def _cell( + version_quarter: PeriodQuarter, + rule: Rule, + bound: Sequence[DocumentLike], + unbound: Sequence[DocumentLike], + state: EntityFilesState, +) -> Cell: + coverage = _coverage(version_quarter, rule, [d for d in bound if _same_slot(d, rule)]) + if coverage.complete: + return Cell(CellStatus.CONFIRMED, (), coverage.document_ids) + + def partial(status: CellStatus) -> Cell: + return Cell(status, coverage.missing_quarters, coverage.document_ids) + + candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_PRIORITY] + if candidates: + best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status]) + return partial(CellStatus(best.status.value)) + if state.has_in_progress: + return partial(CellStatus.PROCESSING) + if state.last_file_status == FileStatus.ERROR: + return partial(CellStatus.ERROR) + return partial(CellStatus.MISSING) + + +E = TypeVar("E", bound=EntityLike) + + +def build( # noqa: PLR0913 - сигнатура зафиксирована планом: правила, квартал, ЮЛ, привязанные, кандидаты, файлы + rules: Sequence[Rule], + *, + version_quarter: PeriodQuarter, + members: Sequence[E], + bound: Sequence[DocumentLike], + unbound: Sequence[DocumentLike], + files_state: Mapping[uuid.UUID, EntityFilesState], +) -> list[tuple[E, list[Cell]]]: + """Ячейки чек-листа по каждому ЮЛ `members` (порядок сохраняется) в порядке `rules` (порядок столбцов задаёт + вызывающий — `order_rules`). `bound` — документы, привязанные к версии; `unbound` — неподтверждённые подходящие + версии документы (`RECOGNIZED`/`NEEDS_CHECK`); ЮЛ без записи в `files_state` — без файлов.""" + idle = EntityFilesState(has_in_progress=False, last_file_status=None) + bound_by: dict[uuid.UUID, list[DocumentLike]] = defaultdict(list) + for document in bound: + bound_by[document.legal_entity_id].append(document) + unbound_by: dict[uuid.UUID, list[DocumentLike]] = defaultdict(list) + for document in unbound: + unbound_by[document.legal_entity_id].append(document) + return [ + ( + member, + [ + _cell( + version_quarter, + rule, + bound_by.get(member.id, []), + unbound_by.get(member.id, []), + files_state.get(member.id, idle), + ) + for rule in rules + ], + ) + for member in members + ] diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 65e44f7..da066d2 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState from src.domain.policy import VisibilityScope, company_groups_visible from src.models.access import AppUser +from src.models.accounting import ChartOfAccount +from src.models.audit_ex import RequiredDocumentRule from src.models.consolidation import ( Consolidation, ConsolidationDocument, @@ -25,13 +27,22 @@ from src.models.consolidation import ( ConsolidationVersion, ) from src.models.directories import CompanyGroup, LegalEntity -from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.enums import ( + ConsolidationApproach, + ConsolidationStatus, + DocumentStatus, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, +) from src.models.files import Document, StoredFile from src.models.osv import OsvEntry FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2) SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM) +UNCONFIRMED_STATUSES = (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK) def _visible_consolidations(scope: VisibilityScope) -> Select[tuple[Consolidation]]: @@ -289,3 +300,44 @@ async def entity_files_state( entity_id: EntityFilesState(has_in_progress=entity_id in busy, last_file_status=last_status.get(entity_id)) for entity_id in states } + + +async def rules_for( + session: AsyncSession, approach: ConsolidationApproach +) -> list[tuple[RequiredDocumentRule, str | None]]: + """Правила комплекта подхода (`required_document_rule`) и код счёта правила (`chart_of_account.code`; у ББ/ОФР — + `None`). Порядок столбцов задаёт домен (`checklist.order_rules`); здесь — детерминированный по `id`.""" + stmt = ( + select(RequiredDocumentRule, ChartOfAccount.code) + .outerjoin(ChartOfAccount, ChartOfAccount.id == RequiredDocumentRule.account_id) + .where(RequiredDocumentRule.approach == approach) + .order_by(RequiredDocumentRule.id) + ) + return [(row[0], row[1]) for row in await session.execute(stmt)] + + +async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int) -> list[Document]: + """Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED`; любых типов (R-5). + + Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`. + """ + if not entity_ids: + return [] + stmt = ( + select(Document) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id.in_(list(entity_ids)), + Document.period_year == year, + Document.status.in_(UNCONFIRMED_STATUSES), + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by( + Document.legal_entity_id, + Document.document_type, + Document.osv_account_code, + Document.period_quarter, + Document.id, + ) + ) + return list((await session.execute(stmt)).scalars()) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 9cee00a..14e8228 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -12,6 +12,7 @@ from typing import Literal from pydantic import BaseModel, Field +from src.domain.checklist import CellStatus from src.models.enums import ( ConsolidationApproach, ConsolidationScenario, @@ -28,8 +29,6 @@ MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 # признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] -# статус ячейки чек-листа (spec consolidations-api «Чек-лист комплекта») -ChecklistCellStatus = Literal["CONFIRMED", "NEEDS_CHECK", "RECOGNIZED", "PROCESSING", "ERROR", "MISSING"] class RefOut(BaseModel): @@ -93,7 +92,7 @@ class ConsolidationPeriodOut(BaseModel): class ChecklistCellOut(BaseModel): """Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" - status: ChecklistCellStatus + 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 aa75be9..5080738 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain import checklist as checklist_domain from src.domain.consolidation_periods import reporting_date from src.domain.policy import FINISHED_STATUSES, VisibilityScope from src.models.access import AppUser @@ -39,6 +40,10 @@ from src.repositories import consolidations as repo from src.repositories import directories as directories_repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import ( + ChecklistCellOut, + ChecklistOut, + ChecklistRowOut, + ChecklistRuleOut, ConsolidationCreate, ConsolidationPeriodOut, HistoryOut, @@ -161,6 +166,60 @@ class ConsolidationService: raise NotFoundError(VERSION_NOT_FOUND) return await self._card(*found) + async def checklist(self, version_id: uuid.UUID) -> ChecklistOut: + """Чек-лист комплекта версии: правила подхода (`BALANCE`, `INCOME`, ОСВ по коду счёта) и строки по ЮЛ периметра + (по имени) с ячейками в порядке правил; версия вне видимости и несуществующая — один и тот же 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + entities = [entity for _, entity in await repo.members(self._session, version.id)] + rules, rows = await self._checklist_cells(version, consolidation, entities) + return ChecklistOut( + rules=[ + ChecklistRuleOut(document_type=rule.document_type, account=rule.account, is_mandatory=rule.is_mandatory) + for rule in rules + ], + rows=[ + ChecklistRowOut( + legal_entity_id=entity.id, + legal_entity_name=entity.name, + legal_entity_inn=entity.inn, + cells=[self._cell_out(cell) for cell in cells], + ) + for entity, cells in rows + ], + ) + + async def _checklist_cells( + self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity] + ) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]: + """Правила подхода версии и ячейки по каждому ЮЛ `entities` (D-13): привязанные документы версии, + неподтверждённые подходящие (`fits`) и состояние файлов ЮЛ. Кандидаты — `repo.unconfirmed_documents`, + фильтр `fits` — здесь.""" + rules = checklist_domain.order_rules( + checklist_domain.Rule(rule.document_type, account, rule.is_mandatory) + for rule, account in await repo.rules_for(self._session, version.approach) + ) + entity_ids = [entity.id for entity in entities] + candidates = await repo.unconfirmed_documents(self._session, entity_ids, consolidation.year) + unbound = [doc for doc in candidates if consolidation_documents.fits(doc, version, consolidation, entity_ids)] + rows = checklist_domain.build( + rules, + version_quarter=consolidation.quarter, + members=entities, + bound=await repo.bound_documents(self._session, version.id), + unbound=unbound, + files_state=await repo.entity_files_state(self._session, entity_ids), + ) + return rules, rows + + @staticmethod + def _cell_out(cell: checklist_domain.Cell) -> ChecklistCellOut: + return ChecklistCellOut( + 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: """Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии. @@ -285,7 +344,7 @@ class ConsolidationService: async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard: """Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка. - Чек-лист у членов — пустой список до Task 8 (`domain.checklist.build`). + Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода. """ group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) initiator = await self._session.get(AppUser, version.initiator_id) @@ -296,6 +355,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]) return VersionCard( **brief.model_dump(), consolidation_id=consolidation.id, @@ -314,9 +374,9 @@ class ConsolidationService: name=entity.name, inn=entity.inn, is_head_entity=member.is_head_entity, - checklist=[], # Task 8: ячейки по правилам подхода + checklist=[self._cell_out(cell) for cell in cells[position][1]], ) - for member, entity in members + for position, (member, entity) in enumerate(members) ], history=[ HistoryOut( diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py new file mode 100644 index 0000000..298b56e --- /dev/null +++ b/api/tests/test_api_consolidation_checklist.py @@ -0,0 +1,153 @@ +"""API чек-листа комплекта версии (spec consolidations-api «Чек-лист комплекта»; design D-13): правила подхода BASIC +из сида, ячейки по привязанным документам, карточка с теми же ячейками, права и видимость.""" + +import uuid +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_document, + make_legal_entity, + make_stored_file, +) + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +KA_RF = "ka_rf@armka.local" +CURATOR = "curator_go@armka.local" +FOREIGN = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +EXPECTED_RULES = [ + ("BALANCE", None), + ("INCOME", None), + *[("OSV", account) for account in ("01", "58", "60", "62", "66", "67", "76", "90")], +] +OSV_60, OSV_90 = 4, 9 # позиции столбцов в `rules` + + +async def _world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]: + """Версия РБ Q3 2024 ГК Ростовского филиала с двумя ЮЛ: «А» (имя первым) и «Б».""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group: CompanyGroup = await make_company_group(session, name="ГК Русский бисквит", office=office) + entities = [await make_legal_entity(session, name=name, group=group) for name in ("ООО А", "ООО Б")] + user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation(session, group=group, year=YEAR, quarter=Q3, members=entities, initiator=user) + return version, entities + + +async def _bound_osv( + session: AsyncSession, version: ConsolidationVersion, entity: LegalEntity, account: str, quarter: PeriodQuarter +) -> uuid.UUID: + document = await make_confirmed_osv( + session, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=False, rows=[] + ) + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + return document.id + + +def _cells(body: dict[str, Any], name: str) -> list[dict[str, Any]]: + return next(row["cells"] for row in body["rows"] if row["legal_entity_name"] == name) + + +@pytest.mark.integration +async def test_checklist_osv_60_confirmed_osv_90_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, second) = await _world(seeded) + documents = [await _bound_osv(seeded, version, first, "60", quarter) for quarter in (Q1, Q2, Q3)] + client = await auth_client(KA_RF) + + response = await client.get(f"/consolidations/versions/{version.id}/checklist") + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert [(rule["document_type"], rule["account"]) for rule in body["rules"]] == EXPECTED_RULES + assert all(rule["is_mandatory"] is True for rule in body["rules"]) + assert [row["legal_entity_name"] for row in body["rows"]] == ["ООО А", "ООО Б"] + assert body["rows"][0]["legal_entity_id"] == str(first.id) + 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 {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"} + + +@pytest.mark.integration +async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, second) = await _world(seeded) + first_q1 = await _bound_osv(seeded, version, first, "60", Q1) + third_q3 = await _bound_osv(seeded, version, first, "60", Q3) + # ОСВ 62 первого ЮЛ — на проверке (не подтверждена); у второго ЮЛ файл в работе + file, job = await make_stored_file( + seeded, entity=first, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + await make_document( + seeded, file=file, job=job, account="62", year=YEAR, quarter=Q3, status=DocumentStatus.NEEDS_CHECK + ) + await make_stored_file(seeded, entity=second, status=FileStatus.PROCESSING) + client = await auth_client(KA_RF) + + body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + + first_cells = _cells(body, "ООО А") + assert first_cells[OSV_60] == { + "status": "MISSING", + "missing_quarters": ["Q2"], + "document_ids": [str(first_q1), str(third_q3)], + } + assert first_cells[OSV_60 + 1]["status"] == "NEEDS_CHECK" # 62 + assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"PROCESSING"} + + +@pytest.mark.integration +async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, _) = 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() + + assert [member["name"] for member in card["members"]] == [row["legal_entity_name"] for row in checklist["rows"]] + for member, row in zip(card["members"], checklist["rows"], strict=True): + assert member["checklist"] == row["cells"] + assert card["members"][0]["checklist"][OSV_60]["status"] == "CONFIRMED" + assert card["members"][0]["id"] == str(first.id) + + +async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]: + version, entities = await _world(session) + for quarter in (Q1, Q2, Q3): + await _bound_osv(session, version, entities[0], "60", quarter) + return version, entities + + +@pytest.mark.integration +async def test_checklist_visible_to_go_curator_hidden_from_foreign_office( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + version, _ = await _world(seeded) + curator = await auth_client(CURATOR) + foreign = await auth_client(FOREIGN) + admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет + url = f"/consolidations/versions/{version.id}/checklist" + + assert (await curator.get(url)).status_code == HTTP_OK + hidden = await foreign.get(url) + unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/checklist") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (await admin.get(url)).status_code == HTTP_FORBIDDEN diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index db24a60..8bc64f6 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -37,6 +37,7 @@ HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 +CHECKLIST_COLUMNS = 10 # ББ, ОФР и ОСВ 01, 58, 60, 62, 66, 67, 76, 90 ROSTOV = "Ростовский региональный филиал" KRASNODAR = "Краснодарский региональный филиал" KA_RF = "ka_rf@armka.local" @@ -101,7 +102,10 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s assert card["initiator_office"] == {"id": str(user.regional_office_id), "name": ROSTOV} assert [member["name"] for member in card["members"]] == ["ООО Член 0", "ООО Член 1"] assert {member["id"] for member in card["members"]} == {str(entity.id) for entity in entities} - assert all(member["is_head_entity"] is False and member["checklist"] == [] for member in card["members"]) + assert all(member["is_head_entity"] is False for member in card["members"]) + # чек-лист по правилам подхода BASIC (ББ, ОФР, ОСВ по 8 счетам): документов нет — все ячейки MISSING + expected = ["MISSING"] * CHECKLIST_COLUMNS + assert all([cell["status"] for cell in member["checklist"]] == expected for member in card["members"]) assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [(None, "INITIATED")] assert card["history"][0]["changed_by"] == str(user.id) diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py new file mode 100644 index 0000000..57c8ec6 --- /dev/null +++ b/api/tests/test_domain_checklist.py @@ -0,0 +1,244 @@ +"""Чек-лист комплекта (Срез 4, задача 3.3; D-13): чистая сборка ячеек без БД, документы — `Document` без сессии.""" + +import uuid + +import pytest + +from src.domain.checklist import Cell, CellStatus, EntityFilesState, Rule, build, order_rules +from src.models.directories import LegalEntity +from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document + +Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 +OSV_60 = Rule(DocumentType.OSV, "60", True) +OSV_58 = Rule(DocumentType.OSV, "58", True) +BALANCE = Rule(DocumentType.BALANCE, None, True) +IDLE = EntityFilesState(has_in_progress=False, last_file_status=None) + + +def _entity() -> LegalEntity: + return LegalEntity(id=uuid.uuid4(), name="ООО", inn="7700000000") + + +def _doc( # noqa: PLR0913 - тестовая фабрика документа + entity: LegalEntity, + *, + kind: DocumentType = DocumentType.OSV, + account: str | None = "60", + quarter: PeriodQuarter | None = Q3, + cumulative: bool | None = False, + status: DocumentStatus = DocumentStatus.CONFIRMED, +) -> Document: + return Document( + id=uuid.uuid4(), + legal_entity_id=entity.id, + document_type=kind, + osv_account_code=account, + period_year=2024, + period_quarter=quarter, + is_cumulative=cumulative, + status=status, + ) + + +def _cell( # noqa: PLR0913 - тестовая обёртка над `build` для одного ЮЛ и одного правила + rule: Rule, + entity: LegalEntity, + *, + version_quarter: PeriodQuarter = Q3, + bound: list[Document] | None = None, + unbound: list[Document] | None = None, + state: EntityFilesState = IDLE, +) -> Cell: + result = build( + [rule], + version_quarter=version_quarter, + members=[entity], + bound=bound or [], + unbound=unbound or [], + files_state={entity.id: state}, + ) + return result[0][1][0] + + +def test_gap_in_quarters_is_missing_with_present_documents() -> None: + entity = _entity() + first, third = _doc(entity, quarter=Q1), _doc(entity, quarter=Q3) + + cell = _cell(OSV_60, entity, bound=[third, first]) + + assert cell.status == CellStatus.MISSING + assert cell.missing_quarters == (Q2,) + assert cell.document_ids == (first.id, third.id) # по порядку кварталов + + +def test_all_quarterly_documents_confirm() -> None: + entity = _entity() + documents = [_doc(entity, quarter=quarter) for quarter in (Q1, Q2, Q3)] + + cell = _cell(OSV_60, entity, bound=documents) + + assert cell == Cell(CellStatus.CONFIRMED, (), tuple(d.id for d in documents)) + + +def test_cumulative_of_version_quarter_beats_quarterly_with_gap() -> None: + entity = _entity() + cumulative = _doc(entity, quarter=Q3, cumulative=True) + quarterly = _doc(entity, quarter=Q3, cumulative=False) + + cell = _cell(OSV_60, entity, bound=[quarterly, cumulative]) + + assert cell == Cell(CellStatus.CONFIRMED, (), (cumulative.id,)) + + +def test_cumulative_of_other_quarter_does_not_cover() -> None: + entity = _entity() + + cell = _cell(OSV_60, entity, bound=[_doc(entity, quarter=Q2, cumulative=True)]) + + assert cell == Cell(CellStatus.MISSING) + + +def test_q1_version_quarterly_q1_equals_cumulative() -> None: + entity = _entity() + document = _doc(entity, quarter=Q1, cumulative=False) + + cell = _cell(OSV_60, entity, version_quarter=Q1, bound=[document]) + + assert cell == Cell(CellStatus.CONFIRMED, (), (document.id,)) + + +def test_quarterly_later_than_version_does_not_count() -> None: + entity = _entity() + + cell = _cell(OSV_60, entity, version_quarter=Q2, bound=[_doc(entity, quarter=Q1), _doc(entity, quarter=Q4)]) + + assert cell.status == CellStatus.MISSING + assert cell.missing_quarters == (Q2,) + assert len(cell.document_ids) == 1 + + +def test_subaccount_does_not_close_account() -> None: + entity = _entity() + + cell = _cell(OSV_58, entity, bound=[_doc(entity, account="58.03", quarter=Q3, cumulative=True)]) + + assert cell == Cell(CellStatus.MISSING) + + +def test_unconfirmed_subaccount_does_not_make_needs_check() -> None: + entity = _entity() + + cell = _cell( + OSV_58, entity, unbound=[_doc(entity, account="58.03", status=DocumentStatus.NEEDS_CHECK, cumulative=True)] + ) + + assert cell.status == CellStatus.MISSING + + +def test_needs_check_has_priority_over_recognized() -> None: + entity = _entity() + recognized = _doc(entity, status=DocumentStatus.RECOGNIZED) + needs_check = _doc(entity, status=DocumentStatus.NEEDS_CHECK) + + assert _cell(OSV_60, entity, unbound=[recognized, needs_check]).status == CellStatus.NEEDS_CHECK + assert _cell(OSV_60, entity, unbound=[recognized]).status == CellStatus.RECOGNIZED + + +def test_unconfirmed_of_other_type_or_account_is_ignored() -> None: + entity = _entity() + other = [ + _doc(entity, account="62", status=DocumentStatus.NEEDS_CHECK), + _doc(entity, kind=DocumentType.BALANCE, account=None, status=DocumentStatus.NEEDS_CHECK), + ] + + assert _cell(OSV_60, entity, unbound=other).status == CellStatus.MISSING + + +def test_needs_check_keeps_partial_coverage_of_bound() -> None: + entity = _entity() + first = _doc(entity, quarter=Q1) + + cell = _cell(OSV_60, entity, bound=[first], unbound=[_doc(entity, status=DocumentStatus.NEEDS_CHECK)]) + + assert cell == Cell(CellStatus.NEEDS_CHECK, (Q2, Q3), (first.id,)) + + +def test_processing_for_any_missing_cell_when_file_in_progress() -> None: + entity = _entity() + busy = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.PROCESSING) + + assert _cell(OSV_60, entity, state=busy) == Cell(CellStatus.PROCESSING) + assert _cell(BALANCE, entity, state=busy) == Cell(CellStatus.PROCESSING) + + +def test_processing_does_not_override_confirmed_or_unconfirmed() -> None: + entity = _entity() + busy = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.PROCESSING) + document = _doc(entity, quarter=Q3, cumulative=True) + + assert _cell(OSV_60, entity, bound=[document], state=busy).status == CellStatus.CONFIRMED + unconfirmed = _doc(entity, status=DocumentStatus.RECOGNIZED) + assert _cell(OSV_60, entity, unbound=[unconfirmed], state=busy).status == CellStatus.RECOGNIZED + + +def test_error_when_last_file_failed_and_processing_wins_over_error() -> None: + entity = _entity() + failed = EntityFilesState(has_in_progress=False, last_file_status=FileStatus.ERROR) + both = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) + processed = EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED) + + assert _cell(OSV_60, entity, state=failed) == Cell(CellStatus.ERROR) + assert _cell(OSV_60, entity, state=both).status == CellStatus.PROCESSING + assert _cell(OSV_60, entity, state=processed).status == CellStatus.MISSING + + +def test_balance_of_version_quarter_is_confirmed() -> None: + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q3, cumulative=None) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.CONFIRMED, (), (balance.id,)) + + +def test_balance_of_q2_at_version_q3_is_missing() -> None: + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q2, cumulative=None) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) + + +def test_documents_of_other_entity_are_ignored() -> None: + entity, stranger = _entity(), _entity() + foreign = _doc(stranger, quarter=Q3, cumulative=True) + + assert _cell(OSV_60, entity, bound=[foreign]).status == CellStatus.MISSING + + +def test_build_keeps_members_and_rules_order_and_defaults_files_state() -> None: + first, second = _entity(), _entity() + rules = [BALANCE, OSV_58, OSV_60] + document = _doc(second, account="58", quarter=Q3, cumulative=True) + + result = build(rules, version_quarter=Q3, members=[first, second], bound=[document], unbound=[], files_state={}) + + assert [member for member, _ in result] == [first, second] + assert [cell.status for cell in result[0][1]] == [CellStatus.MISSING] * 3 + assert [cell.status for cell in result[1][1]] == [CellStatus.MISSING, CellStatus.CONFIRMED, CellStatus.MISSING] + + +def test_order_rules_balance_income_then_osv_by_natural_code() -> None: + income = Rule(DocumentType.INCOME, None, True) + osv = [Rule(DocumentType.OSV, code, True) for code in ("90", "58", "01", "76", "60", "58.03", "9")] + + ordered = order_rules([*osv, income, BALANCE]) + + assert [(r.document_type, r.account) for r in ordered] == [ + (DocumentType.BALANCE, None), + (DocumentType.INCOME, None), + *[(DocumentType.OSV, code) for code in ("01", "9", "58", "58.03", "60", "76", "90")], + ] + + +@pytest.mark.parametrize("status", list(CellStatus)) +def test_cell_status_is_str_enum(status: CellStatus) -> None: + assert status == status.value diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index e240f13..e1a78a0 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -58,6 +58,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"}, "/api/v1/consolidations/versions/{version_id}/status": {"post"}, "/api/v1/consolidations/versions/{version_id}/members": {"put"}, + "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -131,6 +132,9 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any assert {"200", "404"} <= set(card) listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"] assert {"200", "404"} <= set(listing) + checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"] + assert {"200", "404"} <= set(checklist) + assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: diff --git a/api/tests/test_repositories_consolidations.py b/api/tests/test_repositories_consolidations.py index 84e1308..600cb18 100644 --- a/api/tests/test_repositories_consolidations.py +++ b/api/tests/test_repositories_consolidations.py @@ -15,12 +15,23 @@ from src.domain.policy import VisibilityScope from src.models.access import AppUser from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice -from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.enums import ( + ConsolidationApproach, + ConsolidationStatus, + DocumentStatus, + DocumentType, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, + RecognitionStatus, +) from src.repositories import consolidations as repo from tests.factories import ( make_company_group, make_confirmed_osv, make_consolidation, + make_document, make_legal_entity, make_lock, make_regional_office, @@ -391,6 +402,57 @@ async def test_entity_files_state_in_progress_and_last_status(db_session: AsyncS assert await repo.entity_files_state(db_session, []) == {} +@pytest.mark.integration +async def test_rules_for_basic_and_extended_from_seed(seeded: AsyncSession) -> None: + basic = await repo.rules_for(seeded, ConsolidationApproach.BASIC) + extended = await repo.rules_for(seeded, ConsolidationApproach.EXTENDED) + + assert {(rule.document_type, account) for rule, account in basic} == { + (DocumentType.BALANCE, None), + (DocumentType.INCOME, None), + *[(DocumentType.OSV, code) for code in ("01", "58", "60", "62", "66", "67", "76", "90")], + } + assert all(rule.approach == ConsolidationApproach.BASIC and rule.is_mandatory for rule, _ in basic) + assert extended + assert {rule.approach for rule, _ in extended} == {ConsolidationApproach.EXTENDED} + + +@pytest.mark.integration +async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(db_session: AsyncSession) -> None: + group = await make_company_group(db_session) + entity = await make_legal_entity(db_session, group=group) + other = await make_legal_entity(db_session, group=group) + + async def doc( + owner: LegalEntity, + *, + status: DocumentStatus, + year: int = 2024, + file_status: FileStatus = FileStatus.PROCESSED, + account: str = "60", + ) -> uuid.UUID: + file, job = await make_stored_file( + db_session, entity=owner, status=file_status, job_status=RecognitionStatus.done + ) + document = await make_document( + db_session, file=file, job=job, account=account, year=year, quarter=PeriodQuarter.Q3, status=status + ) + return document.id + + needs_check = await doc(entity, status=DocumentStatus.NEEDS_CHECK) + recognized = await doc(entity, status=DocumentStatus.RECOGNIZED, account="58") + await doc(entity, status=DocumentStatus.CONFIRMED) # подтверждённый — не кандидат + await doc(entity, status=DocumentStatus.ERROR) + await doc(entity, status=DocumentStatus.NEEDS_CHECK, year=2023) + await doc(entity, status=DocumentStatus.NEEDS_CHECK, file_status=FileStatus.SUPERSEDED) + await doc(other, status=DocumentStatus.NEEDS_CHECK) + + found = await repo.unconfirmed_documents(db_session, [entity.id], 2024) + + assert sorted(d.id for d in found) == sorted([needs_check, recognized]) + assert await repo.unconfirmed_documents(db_session, [], 2024) == [] + + @pytest.mark.integration async def test_repository_returns_selectable_models(db_session: AsyncSession) -> None: """Дымовая проверка: список версий ЮЛ после `make_consolidation` читается обычным запросом.""" diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 8006eb5..92068a8 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -7,9 +7,10 @@ from decimal import Decimal import pytest from pydantic import ValidationError -from src.domain.checklist import EntityFilesState +from src.domain.checklist import CellStatus, EntityFilesState from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter from src.schemas.consolidations import ( + ChecklistCellOut, ConsolidatedRowOut, ConsolidationCreate, MapCounterpartyIn, @@ -79,3 +80,12 @@ def test_entity_files_state_is_frozen() -> None: state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) with pytest.raises(dataclasses.FrozenInstanceError): state.has_in_progress = False # type: ignore[misc] + + +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] + assert cell.status is status + with pytest.raises(ValidationError): + ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index d706f3a..06a9a3d 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -170,9 +170,9 @@ - `domain/checklist.py`: `@dataclass(frozen=True) class Rule: document_type: DocumentType; account: str | None; is_mandatory: bool`; `class CellStatus(StrEnum): CONFIRMED, NEEDS_CHECK, RECOGNIZED, PROCESSING, ERROR, MISSING`; `@dataclass(frozen=True) class Cell: status; missing_quarters: tuple[PeriodQuarter, ...]; document_ids: tuple[uuid.UUID, ...]`; `def build(rules, *, version_quarter, members: Sequence[LegalEntity], bound: Sequence[Document], unbound: Sequence[Document], files_state: Mapping[uuid.UUID, EntityFilesState]) -> list[tuple[LegalEntity, list[Cell]]]` — для ОСВ использует `consolidation_periods.select_sources` (Task 9; до него — локальная проверка «нарастающий за квартал версии или все кварталы Q1..Qn»; в Task 9 заменить на `select_sources`), для ББ/ОФР — документ за квартал версии. - Порядок правил: `BALANCE`, `INCOME`, затем ОСВ по `account` (натуральная сортировка кодов `01 < 58 < 60`). -- [ ] **Step 1: Тесты.** Домен: пробел Q2 (`MISSING`, `["Q2"]`, два `document_ids`), субсчёт 58.03 не закрывает 58, `NEEDS_CHECK` при неподтверждённом подходящем, `PROCESSING` при файле в работе, `ERROR`, ББ за квартал версии `CONFIRMED`, ББ за Q2 при версии Q3 — `MISSING`. API: `GET …/checklist` на версии РБ с тремя ОСВ 60 → ячейка ОСВ 60 `CONFIRMED`, ОСВ 90 `MISSING`; карточка содержит те же ячейки; `curator_go` читает. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): чек-лист комплекта документов по правилам подхода`. +- [x] **Step 1: Тесты.** Домен: пробел Q2 (`MISSING`, `["Q2"]`, два `document_ids`), субсчёт 58.03 не закрывает 58, `NEEDS_CHECK` при неподтверждённом подходящем, `PROCESSING` при файле в работе, `ERROR`, ББ за квартал версии `CONFIRMED`, ББ за Q2 при версии Q3 — `MISSING`. API: `GET …/checklist` на версии РБ с тремя ОСВ 60 → ячейка ОСВ 60 `CONFIRMED`, ОСВ 90 `MISSING`; карточка содержит те же ячейки; `curator_go` читает. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): чек-лист комплекта документов по правилам подхода`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 44e8358..9ee8820 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -228,3 +228,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | | 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | | 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе | +| 44 | 2026-10-06 | Статус ячейки — одно определение: `domain.checklist.CellStatus` (StrEnum); `ChecklistCellOut.status` в схеме API использует его, `Literal` `ChecklistCellStatus` удалён (T2.8) | два расходящихся определения — источник тихого рассинхрона домена и контракта | нулевая: OpenAPI-enum тот же набор значений | +| 45 | 2026-10-06 | Проверка «подходящести» неподтверждённых документов (`fits`: член периметра, год, квартал) выполняется в сервисе (`_checklist_cells`), в домен `build` приходят уже подходящие; домен сам сверяет только ЮЛ, тип, счёт и статус (`RECOGNIZED`/`NEEDS_CHECK`) | `fits` живёт в `services.consolidation_documents` (домен не импортирует сервисы и ORM); дублировать предикат в домене — риск расхождения с привязкой | если `fits` изменится — меняется и чек-лист, что желательно; вне `fits` домен кандидатов не отсеивает | +| 46 | 2026-10-06 | Выбор источников ОСВ в чек-листе — локальная функция `_osv_coverage` (нарастающий за квартал версии, иначе квартальные Q1..Qn все; нарастающий приоритетнее; пробел → `missing_quarters` + документы имеющихся кварталов), помечена `# Task 9 заменит на consolidation_periods.select_sources`; при полном отсутствии документов `missing_quarters = []` (перечисляются только при частичном покрытии, как в спеке) | `select_sources` появится в Task 9; план прямо разрешает локальную проверку до него | до Task 9 два места с правилом выбора; Task 9 удаляет локальное | +| 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) | +| 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая | +| 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 9a975c4..b8ab252 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -38,7 +38,7 @@ - [x] 3.2 **Статусы по событиям файлов.** `upload.py`: `consolidation_version_id` (проверки, переход после приёма); `result_mapping`: хук `on_documents_mapped` (NEEDS_CHECK → `DATA_CORRECTION`, `uploaded_by`, пропуск при NULL). Тесты: сценарии `primary-files` «Загрузка в контексте версии», `osv-result-mapping` «Предупреждение маппинга». -- [ ] 3.3 **Чек-лист.** `domain/checklist.py:build(rules, members, documents, files)` (чистая), `GET …/checklist`, ячейки в карточке. +- [x] 3.3 **Чек-лист.** `domain/checklist.py:build(rules, members, documents, files)` (чистая), `GET …/checklist`, ячейки в карточке. Тесты: пробел Q2, субсчёт, `PROCESSING`, `ERROR`, ББ/ОФР за квартал версии. ## 4. Сводная ОСВ