arm-core/api/src/schemas/consolidations.py
Andrey Epifancev f46cf2fec5 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
2026-10-06 05:50:48 +04:00

261 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Схемы API консолидаций (Срез 4; spec consolidations-api, consolidated-osv; design D-16).
Суммы — `Decimal` (в JSON — строка, без `float`). `ConsolidatedRowOut` и `OsvSummaryOut` — сводная ОСВ версии
(чистая функция, в БД не хранится; DECISION-5). `OsvFilters` — query-модель фильтров `GET …/osv`
(`Annotated[OsvFilters, Query()]`); `limit` ≤ 2000 по умолчанию 500, как у строк документа.
"""
import datetime
import uuid
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
from src.domain.checklist import CellStatus
from src.models.enums import (
ConsolidationApproach,
ConsolidationScenario,
ConsolidationStatus,
CounterpartyMatchKind,
DocumentType,
PeriodQuarter,
)
YEAR_MIN = 2000
YEAR_MAX = 2100
DEFAULT_OSV_LIMIT = 500
MAX_OSV_LIMIT = 2000
MAX_TAKEOVER_REASON = 1000
# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний
CounterpartyType = Literal["GSK", "GK", "EXTERNAL"]
class RefOut(BaseModel):
"""Ссылка на объект справочника: идентификатор и название (ГК, филиал, ГСК/ГСЗ)."""
id: uuid.UUID
name: str
class ConsolidationCreate(BaseModel):
"""Тело `POST /consolidations`: ГК, ГСК периметра, период."""
company_group_id: uuid.UUID
related_group_id: uuid.UUID
year: int = Field(ge=YEAR_MIN, le=YEAR_MAX)
quarter: PeriodQuarter
class LockOut(BaseModel):
"""Активная блокировка версии: кто взял, когда, у кого перехвачена и по какой причине."""
locked_by: uuid.UUID
locked_by_name: str
locked_at: datetime.datetime
taken_over_from: uuid.UUID | None
takeover_reason: str | None
class LockIn(BaseModel):
"""Тело `POST …/lock`: причина перехвата чужой блокировки (при взятии свободной версии не нужна).
Пустая или из одних пробелов — как отсутствующая (чужая блокировка → 409 `LOCKED_BY_OTHER`).
"""
takeover_reason: str | None = Field(default=None, max_length=MAX_TAKEOVER_REASON)
class VersionBrief(BaseModel):
"""Версия в списке периодов ГК."""
id: uuid.UUID
version_no: int
status: ConsolidationStatus
scenario: ConsolidationScenario
approach: ConsolidationApproach
lock: LockOut | None
created_at: datetime.datetime | None
members_count: int
class ConsolidationPeriodOut(BaseModel):
"""Период ГК (консолидация) и её версии по убыванию `version_no`."""
consolidation_id: uuid.UUID
year: int
quarter: PeriodQuarter
reporting_date: datetime.date
versions: list[VersionBrief]
class ChecklistCellOut(BaseModel):
"""Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы."""
status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8)
missing_quarters: list[PeriodQuarter]
document_ids: list[uuid.UUID]
class MemberOut(BaseModel):
"""ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа)."""
id: uuid.UUID
name: str
inn: str
is_head_entity: bool
checklist: list[ChecklistCellOut]
class HistoryOut(BaseModel):
"""Запись маршрута согласования (`consolidation_status_history`)."""
from_status: ConsolidationStatus | None
to_status: ConsolidationStatus
changed_by: uuid.UUID
changed_at: datetime.datetime
comment: str | None
class VersionCard(VersionBrief):
"""Карточка версии: версия + консолидация, ГК, инициатор, периметр с чек-листом и история."""
consolidation_id: uuid.UUID
year: int
quarter: PeriodQuarter
reporting_date: datetime.date
company_group: RefOut
initiator: RefOut
initiator_office: RefOut
goodwill_enabled: bool
is_in_work: bool
previous_version_id: uuid.UUID | None
members: list[MemberOut]
history: list[HistoryOut]
class MembersIn(BaseModel):
"""Тело `PUT …/members`: новый периметр версии (дубли схлопываются сервисом)."""
legal_entity_ids: list[uuid.UUID]
class StatusIn(BaseModel):
"""Тело `POST …/status`: ручной переход (`CANCELLED` — комментарий обязателен; возврат в `INITIATED`)."""
to_status: ConsolidationStatus
comment: str | None = None
class ChecklistRuleOut(BaseModel):
"""Правило комплекта (`required_document_rule`): тип документа, счёт ОСВ, обязательность."""
document_type: DocumentType
account: str | None
is_mandatory: bool
class ChecklistRowOut(BaseModel):
"""Строка чек-листа: ЮЛ периметра и ячейки в порядке `rules`."""
legal_entity_id: uuid.UUID
legal_entity_name: str
legal_entity_inn: str
cells: list[ChecklistCellOut]
class ChecklistOut(BaseModel):
"""Чек-лист комплекта документов версии: правила и строки по ЮЛ периметра."""
rules: list[ChecklistRuleOut]
rows: list[ChecklistRowOut]
class ConsolidatedRowOut(BaseModel):
"""Строка сводной ОСВ версии (D-16): ЮЛ, счёт, период, контрагент (как в файле и после резолва), суммы."""
entry_ids: list[uuid.UUID]
legal_entity_id: uuid.UUID
legal_entity_name: str
legal_entity_inn: str
account_code: str | None
account_root: str | None
period: str # "<год> Q<n>"
is_cumulative: bool
row_number: int | None
counterparty_name: str | None
counterparty_inn: str | None
counterparty_id: uuid.UUID | None
counterparty_entity_name: str | None
counterparty_type: CounterpartyType
match_kind: CounterpartyMatchKind | None
match_stored: bool # `False` — сопоставление найдено дорезолвом на чтении и в `osv_entry` не записано
opening_debit: Decimal | None
opening_credit: Decimal | None
turnover_debit: Decimal | None
turnover_credit: Decimal | None
closing_debit: Decimal | None
closing_credit: Decimal | None
warnings: list[str]
class GapOut(BaseModel):
"""Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся)."""
legal_entity_id: uuid.UUID
legal_entity_name: str
account_code: str
missing_quarters: list[PeriodQuarter]
class OsvSummaryOut(BaseModel):
"""Сводка по своду версии (`GET …/osv/summary`)."""
rows_total: int
unmapped_total: int
auto_matched_total: int
documents_total: int
gaps: list[GapOut]
class OsvFilters(BaseModel):
"""Фильтры и пагинация `GET …/osv` из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый."""
legal_entity_id: uuid.UUID | None = None
account_root: str | None = None
counterparty_type: CounterpartyType | None = None
unmapped: bool | None = None
match_kind: list[CounterpartyMatchKind] | None = None
q: str | None = None
limit: int = Field(DEFAULT_OSV_LIMIT, ge=1, le=MAX_OSV_LIMIT)
offset: int = Field(0, ge=0)
class CandidateOut(BaseModel):
"""Кандидат для ручного сопоставления контрагента (`GET …/counterparty-candidates`)."""
id: uuid.UUID
inn: str
name: str
company_group: RefOut | None
gsk: RefOut | None
gsz: RefOut | None
counterparty_type: CounterpartyType
recommended: bool
class MapCounterpartyIn(BaseModel):
"""Тело `POST …/osv/entries/{entry_id}/counterparty`: ЮЛ-контрагент и перенос чужого алиаса."""
legal_entity_id: uuid.UUID
move_alias: bool = False
class MapCounterpartyOut(BaseModel):
"""Результат ручного сопоставления: обновлённая строка свода, число строк автоприменения, судьба алиаса."""
row: ConsolidatedRowOut
propagated: int
alias_created: bool
alias_moved_from: uuid.UUID | None