406 lines
15 KiB
Python
406 lines
15 KiB
Python
"""Схемы 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<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_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]
|