"""Схемы 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" 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