feat(consolidation): чек-лист комплекта документов по правилам подхода

Домен `checklist` (Rule, CellStatus, Cell, order_rules, build) — чистые функции без ORM; ячейка (ЮЛ, правило):
CONFIRMED по привязанным документам (ОСВ: нарастающий за квартал версии либо квартальные Q1..Qn, пробел →
missing_quarters; ББ/ОФР: документ за квартал версии), NEEDS_CHECK/RECOGNIZED по неподтверждённому подходящему,
PROCESSING, ERROR, иначе MISSING; правило ОСВ закрывает только точный код счёта. Репозиторий: rules_for,
unconfirmed_documents. Сервис: checklist(), ячейки в карточке версии. Маршрут GET /consolidations/versions/{id}/checklist.
Схема использует CellStatus домена (T2.8). Решения №44-49 в design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 05:50:48 +04:00
parent 4cc08cc3de
commit f46cf2fec5
15 changed files with 854 additions and 20 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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
]

View File

@ -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())

View File

@ -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]

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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` читается обычным запросом."""

View File

@ -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]

View File

@ -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): чек-лист комплекта документов по правилам подхода`.
---

View File

@ -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 | нулевая |

View File

@ -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. Сводная ОСВ