"""Схемы формы ББ/ОФР для экрана «Проверка распознавания» (spec documents-api «Форма ББ/ОФР для экрана проверки»; design D-5, D-14). Суммы — `Decimal` (в JSON — строка, без `float`), как `EntryOut`. Проверки §7 в ответе — вычисленные на лету (`checks.failed` / `soft_failed` / `cross_form`), акцепт — из шапки. """ import datetime import uuid from collections.abc import Sequence from decimal import Decimal from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator from src.domain.statement_checks import ChecksReport, FailedCheck from src.domain.statement_lines import TemplateLine from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter from src.models.files import Document from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) class HeaderFieldOut(BaseModel): """Поле шапки, доступное для правки: значение, распознанное исходное и признаки проверки.""" value: str | None original_value: str | None confidence: Decimal | None = None verified: bool | None modified: bool | None class StatementHeaderOut(BaseModel): inn: HeaderFieldOut report_date: HeaderFieldOut units: HeaderFieldOut organization_name: str | None okud: str | None okpo: str | None okopf: str | None okfs: str | None address: str | None class ColumnOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID column_index: int label: str | None period_date: datetime.date | None is_current: bool | None LineOrigin = Literal["source", "template"] class ValueOut(BaseModel): id: uuid.UUID | None # None — ячейка достроена в ответе, в БД её нет column_id: uuid.UUID column_index: int amount: Decimal | None string_value: str | None confidence: Decimal | None is_recognized: bool | None is_modified: bool original_amount: Decimal | None modified_by: uuid.UUID | None modified_at: datetime.datetime | None class LineOut(BaseModel): id: uuid.UUID | None # None — строка достроена из справочника редакции, в БД её нет origin: LineOrigin line_code: str | None line_code_confidence: Decimal | None line_name: str | None line_name_confidence: Decimal | None values: list[ValueOut] class FailedCheckOut(BaseModel): """Нарушенное соотношение §7; `column_index = -1` — межформенная проверка (колонки нет).""" model_config = ConfigDict(from_attributes=True) column_index: int relation: str expected: Decimal actual: Decimal class ChecksAcceptedOut(BaseModel): by: uuid.UUID at: datetime.datetime comment: str class ChecksOut(BaseModel): failed: list[FailedCheckOut] soft_failed: list[FailedCheckOut] cross_form: FailedCheckOut | None accepted: ChecksAcceptedOut | None class StatementDocumentOut(BaseModel): id: uuid.UUID document_type: DocumentType status: DocumentStatus period_year: int | None period_quarter: PeriodQuarter | None reporting_date: datetime.date | None counterparty_check: DocumentCheckResult | None locator: str | None file_id: uuid.UUID class StatementOut(BaseModel): document: StatementDocumentOut edition: str | None header: StatementHeaderOut columns: list[ColumnOut] lines: list[LineOut] checks: ChecksOut def _failed(check: FailedCheck) -> FailedCheckOut: return FailedCheckOut( column_index=check.column_index, relation=check.relation, expected=check.expected, actual=check.actual ) def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut: return ValueOut( id=value.id, column_id=value.column_id, column_index=column_index[value.column_id], amount=value.amount, string_value=value.string_value, confidence=value.confidence, is_recognized=value.is_recognized, is_modified=value.is_modified, original_amount=value.original_amount, modified_by=value.modified_by, modified_at=value.modified_at, ) def line_origin(line: FinancialStatementLine) -> LineOrigin: """`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`).""" return "template" if line.line_code_original_value is None else "source" def _empty_value(column: FinancialStatementColumn) -> ValueOut: """Ячейка, которой нет в БД (design D-3): всё пусто, не распознана и не правлена.""" return ValueOut( id=None, column_id=column.id, column_index=column.column_index, amount=None, string_value=None, confidence=None, is_recognized=None, is_modified=False, original_amount=None, modified_by=None, modified_at=None, ) def _lines_out( columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], template: Sequence[TemplateLine], ) -> list[LineOut]: """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду.""" column_index = {column.id: column.column_index for column in columns} out: list[LineOut] = [] for line, values in lines: present = {value.column_id for value in values} cells = [_value_out(value, column_index) for value in values] cells += [_empty_value(column) for column in columns if column.id not in present] cells.sort(key=lambda cell: cell.column_index) out.append( LineOut( id=line.id, origin=line_origin(line), line_code=line.line_code, line_code_confidence=line.line_code_confidence, line_name=line.line_name, line_name_confidence=line.line_name_confidence, values=cells, ) ) codes = {line.line_code for line, _ in lines} for item in template: if item.code in codes: continue out.append( LineOut( id=None, origin="template", line_code=item.code, line_code_confidence=None, line_name=item.name, line_name_confidence=None, values=[_empty_value(column) for column in columns], ) ) out.sort(key=lambda line: (line.line_code is None, line.line_code or "")) return out def _accepted(meta: FinancialStatementMeta) -> ChecksAcceptedOut | None: if meta.checks_accepted_at is None or meta.checks_accepted_by is None: return None return ChecksAcceptedOut( by=meta.checks_accepted_by, at=meta.checks_accepted_at, comment=meta.checks_accepted_comment or "" ) def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из частей формы document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], report: ChecksReport, cross: FailedCheck | None, *, edition: str | None, template: Sequence[TemplateLine], ) -> StatementOut: """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по справочнику редакции (D-3).""" return StatementOut( document=StatementDocumentOut( id=document.id, document_type=document.document_type, status=document.status, period_year=document.period_year, period_quarter=document.period_quarter, reporting_date=document.reporting_date, counterparty_check=document.counterparty_check, locator=document.locator, file_id=document.stored_file_id, ), edition=edition, header=StatementHeaderOut( inn=HeaderFieldOut( value=meta.inn, original_value=meta.inn_original_value, confidence=meta.inn_confidence, verified=meta.inn_verified, modified=meta.inn_modified, ), report_date=HeaderFieldOut( value=meta.report_date.isoformat() if meta.report_date is not None else None, original_value=meta.report_date_original_value, verified=meta.report_date_verified, modified=meta.report_date_modified, ), units=HeaderFieldOut( value=meta.units, original_value=meta.units_original_value, verified=meta.units_verified, modified=meta.units_modified, ), organization_name=meta.organization_name, okud=meta.okud, okpo=meta.okpo, okopf=meta.okopf, okfs=meta.okfs, address=meta.address, ), columns=[ColumnOut.model_validate(column) for column in columns], lines=_lines_out(columns, lines, template), checks=ChecksOut( failed=[_failed(check) for check in report.failed], soft_failed=[_failed(check) for check in report.soft_failed], cross_form=_failed(cross) if cross is not None else None, accepted=_accepted(meta), ), ) class CellPatch(BaseModel): """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" model_config = ConfigDict(extra="forbid") line_id: uuid.UUID | None = None line_code: str | None = Field(None, max_length=10) column_id: uuid.UUID amount: Decimal | None = Field(max_digits=18, decimal_places=2) @model_validator(mode="after") def _one_address(self) -> "CellPatch": if (self.line_id is None) == (self.line_code is None): raise ValueError("укажите ровно одно из line_id и line_code") return self class HeaderPatch(BaseModel): """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 символов (пробельная строка — 422).""" model_config = ConfigDict(extra="forbid") inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)") report_date: datetime.date | None = None units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None class StatementPatch(BaseModel): model_config = ConfigDict(extra="forbid") header: HeaderPatch | None = None values: list[CellPatch] = Field(default_factory=list, max_length=5000) class ChecksAcceptIn(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) comment: str = Field(min_length=1, max_length=2000)