arm-core/api/src/schemas/statements.py
Andrey Epifancev 37e7f40890 feat(statements): PATCH создаёт строку и ячейку по line_code (#80)
CellPatch адресует ячейку ровно одним из line_id / line_code. Строка справочника
редакции, которой нет в форме, создаётся при первой непустой правке
(line_code_original_value = NULL — признак «не из источника»); отсутствующая
ячейка существующей строки тоже создаётся. Адресация (колонка, строка, код)
проверяется целиком до первой вставки, две ячейки новой строки делят одну
строку, повтор line_code после создания даёт 422 UNKNOWN_CELL. Аудит — одно
RECOGNIZED_DATA_EDIT на запрос, у созданной ячейки before = null.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:03:59 +04:00

336 lines
12 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.

"""Схемы формы ББ/ОФР для экрана «Проверка распознавания» (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)