arm-core/api/src/schemas/consolidations.py
Andrey Epifancev a2c2135811 feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами
GET /consolidations/versions/{id}/osv и /osv/summary (CONSOLIDATION_VIEW): ConsolidatedOsvService собирает свод
чистой функцией от версии (периметр, привязанные ОСВ, load_osv_rows, один CounterpartyIndex, один SELECT legal_entity)
и режет страницу в памяти; чтение любой версии, включая CANCELLED, без блокировок и аудита.
OsvFilters отделён от OsvPageParams (T2.7), суммы всегда с двумя знаками; account_sort_key устойчив к «6²»/«①» (T9.a).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 06:23:24 +04:00

286 lines
10 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()]`), `OsvPageParams` — пагинация (`limit` ≤ 2000 по умолчанию 500, как у строк
документа). Суммы свода — всегда с двумя знаками (`"0.00"`, `"30.00"`): деньги `numeric(18,2)`.
"""
import datetime
import uuid
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator
from src.domain.checklist import CellStatus
from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8)
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
CENT = Decimal("0.01")
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]
@field_validator(
"opening_debit",
"opening_credit",
"turnover_debit",
"turnover_credit",
"closing_debit",
"closing_credit",
)
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""`numeric(18,2)`: сумма склейки (`Decimal("0")` для отсутствующей строки, сумма кварталов) в JSON — всегда
`"0.00"`/`"30.00"`; значение не меняется (знаков после запятой в данных больше двух не бывает)."""
return None if value is None else value.quantize(CENT)
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
class OsvPageParams(BaseModel):
"""Пагинация `GET …/osv` из query: `Annotated[OsvPageParams, Query()]` (как `EntriesPageParams`)."""
limit: int = Field(DEFAULT_OSV_LIMIT, ge=1, le=MAX_OSV_LIMIT)
offset: int = Field(0, ge=0)
class OsvQuery(OsvFilters, OsvPageParams):
"""Query-параметры `GET …/osv` целиком: FastAPI принимает ровно одну query-модель на маршрут, поэтому фильтры и
пагинация сливаются; сервису модель передаётся и как `OsvFilters`, и как `OsvPageParams` (экспорт берёт только
`OsvFilters`)."""
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