arm-core/api/src/schemas/consolidations.py

406 lines
15 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 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]