"""Схемы 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 ROUND_HALF_UP, Decimal from typing import Literal 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, EntityType, PeriodQuarter, StatementType, ) YEAR_MIN = 2000 YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 MAX_MEMBERS = 500 # M-6: периметр версии (ЮЛ одной ГК), с запасом на порядок выше реальных групп MAX_STATUS_COMMENT = 1000 # как причина перехвата MAX_OSV_QUERY = 500 # как NAME_MAX_LENGTH справочников CENT = Decimal("0.01") class RefOut(BaseModel): """Ссылка на объект справочника: идентификатор и название (ГК, филиал, ГСК/ГСЗ).""" id: uuid.UUID name: str class ConsolidationCreate(BaseModel): """Тело `POST /consolidations`: ГК, ГСК, период и необязательный выбранный периметр (модалка [42], front-api-gaps D-1). `legal_entity_ids` нет или `null` — периметр из текущих членов ГСК; список — ровно эти ЮЛ ГК (дубли схлопывает сервис). """ company_group_id: uuid.UUID related_group_id: uuid.UUID year: int = Field(ge=YEAR_MIN, le=YEAR_MAX) quarter: PeriodQuarter legal_entity_ids: list[uuid.UUID] | None = Field(default=None, max_length=MAX_MEMBERS) 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): """Ячейка чек-листа — самоописывающая (front-api-gaps D-2): правило ячейки (`document_type`, `account` — счёт ОСВ, `None` у ББ/ОФР), статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" document_type: DocumentType account: str | None status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8) missing_quarters: list[PeriodQuarter] document_ids: list[uuid.UUID] class MemberOut(BaseModel): """ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках).""" id: uuid.UUID name: str inn: str entity_type: EntityType | None # DBML: `legal_entity.entity_type` nullable (I-1) is_head_entity: bool checklist: list[ChecklistCellOut] osv_accounts: list[str] 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] = Field(max_length=MAX_MEMBERS) class StatusIn(BaseModel): """Тело `POST …/status`: ручной переход (`CANCELLED` — комментарий обязателен; возврат в `INITIATED`).""" to_status: ConsolidationStatus comment: str | None = Field(default=None, max_length=MAX_STATUS_COMMENT) 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_entity_inn: str | None # ИНН сопоставленного ЮЛ справочника; `counterparty_inn` — ИНН из файла 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 = Field(default=None, max_length=MAX_OSV_QUERY) 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 = null` — строка в своде не участвует (пара с пробелом или документ не выбран источником), сопоставление при этом записано. `alias_moved_from` — прежний владелец перенесённого алиаса (первый по времени добавления). """ row: ConsolidatedRowOut | None propagated: int alias_created: bool alias_moved_from: uuid.UUID | None class IntercompanyMatrixRuleSideOut(BaseModel): """Счета одной стороны правила intercompany-matrix (шахматки) до раскрытия иерархии.""" accounts: list[uuid.UUID] excluded: list[uuid.UUID] class EliminationRuleOut(BaseModel): """Правило элиминации из БД; ключ — тип отчётности, версия и номер.""" id: uuid.UUID rule_no: int version_no: int statement_type: StatementType side_1: Literal["D", "K"] measure: Literal["closing", "turnover"] company_1: IntercompanyMatrixRuleSideOut company_2: IntercompanyMatrixRuleSideOut class EliminationRulesOut(BaseModel): """Правила и основание выбора их версии.""" version_no: int | None is_version_fixed: bool rules: list[EliminationRuleOut] class IntercompanyMatrixManualIn(BaseModel): """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" debit_account_ids: list[uuid.UUID] = Field(min_length=1) credit_account_ids: list[uuid.UUID] = Field(min_length=1) class IntercompanyMatrixCellOut(BaseModel): """Суммы и расхождение для пары компаний A и Б.""" company_a_id: uuid.UUID company_b_id: uuid.UUID amount_a: Decimal amount_b: Decimal variance_amount: Decimal variance_percent: Decimal | None exceeds_threshold: bool one_sided: bool skipped_osv_entry_count: int = Field( ge=0, description="Число исходных строк ОСВ с account_id=null у обеих компаний пары; в суммы они не вошли", ) osv_entry_ids_debit: list[uuid.UUID] osv_entry_ids_credit: list[uuid.UUID] @field_validator("amount_a", "amount_b", "variance_amount", "variance_percent") @classmethod def _two_decimals(cls, value: Decimal | None) -> Decimal | None: """Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null.""" return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) class IntercompanyMatrixCompanyOut(BaseModel): """Компания на обеих осях шахматки.""" id: uuid.UUID name: str class IntercompanyMatrixOut(BaseModel): """Компании задают порядок обеих осей; диагональные ячейки отсутствуют.""" companies: list[IntercompanyMatrixCompanyOut] cells: list[IntercompanyMatrixCellOut] class IntercompanyResultAmountOut(BaseModel): """Сумма статьи у компании по всем остальным компаниям периметра.""" company_id: uuid.UUID amount: Decimal class IntercompanyResultArticleOut(BaseModel): """Статья ВГО и её суммы по компаниям и группе.""" code: str name: str amounts: list[IntercompanyResultAmountOut] group_total: Decimal class IntercompanyResultTableOut(BaseModel): """Фиксированная результирующая таблица ВГО выбранной версии.""" companies: list[IntercompanyMatrixCompanyOut] articles: list[IntercompanyResultArticleOut]