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