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:
parent
4cc08cc3de
commit
f46cf2fec5
@ -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)
|
||||
|
||||
31
api/src/api/endpoints/consolidations/checklist.py
Normal file
31
api/src/api/endpoints/consolidations/checklist.py
Normal 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)
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
153
api/tests/test_api_consolidation_checklist.py
Normal file
153
api/tests/test_api_consolidation_checklist.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
244
api/tests/test_domain_checklist.py
Normal file
244
api/tests/test_domain_checklist.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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` читается обычным запросом."""
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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): чек-лист комплекта документов по правилам подхода`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 | нулевая |
|
||||
|
||||
@ -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. Сводная ОСВ
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user