305 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.

"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v2.0.
Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров;
маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`).
Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь.
"""
from enum import StrEnum
from typing import Annotated, Literal, Self
from pydantic import Field, field_validator, model_validator
from src.contract.common import (
Confidence,
ContractDate,
ContractModel,
ContractUUID,
DataType,
DocumentWarning,
HeaderFieldName,
HeaderVariant,
IntLike,
LaxErrorCode,
Metrics,
Money,
NonEmptyStr,
OptionalNotNull,
SchemaVersion,
ServiceInfo,
)
__all__ = [
"FailedRelation",
"OsvChecks",
"OsvDocument",
"OsvGroup",
"OsvHeader",
"OsvRow",
"RecognitionResult",
"ResultError",
"ResultStatus",
"StatementChecks",
"StatementColumn",
"StatementDocument",
"StatementHeader",
"StatementLine",
"StatementValue",
]
DocumentStatus = Literal["done", "needs_check"]
class ResultStatus(StrEnum):
DONE = "done"
NEEDS_CHECK = "needs_check"
ERROR = "error"
LaxResultStatus = Annotated[ResultStatus, Field(strict=False)]
class ResultError(ContractModel):
"""`error` файла результата: в отличие от конверта, `code` и `message` необязательны."""
code: OptionalNotNull[LaxErrorCode] = None
message: OptionalNotNull[str] = None
# --- ОСВ ------------------------------------------------------------------------------------------------------------
class OsvHeader(ContractModel):
organization_name: str # как в r1 файла
organization_inn: str | None = None
account_code_raw: str # «60», «58.03»
period_text: str # «1 квартал 2025 г.»
period_year: IntLike | None = None
period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None
is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02)
period_start: ContractDate | None = None # начало периода по шапке (v1.2, OQ-131)
period_end: ContractDate | None = None
is_from_year_start: bool | None = None # period_start — 1 января; is_cumulative не переопределяет
data_type: DataType | None = None
currency: str | None = None
header_variant: OptionalNotNull[HeaderVariant] = None
class OsvGroup(ContractModel):
"""Группировка из шапки (порядок — после первого ACCOUNT)."""
type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
name: OptionalNotNull[str] = None # исходный текст из шапки
has_inn: OptionalNotNull[bool] = None
class OsvRow(ContractModel):
row_number: IntLike
level: IntLike
parent_row_number: IntLike | None = None
row_value: str
group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"]
group_name: str | None = None
account_code_raw: str | None = None # код текущей ветки, как в файле (60.1)
counterparty_name: str | None = None
counterparty_inn: str | None = None
data_type: DataType | None = None
opening_debit: Money = None
opening_credit: Money = None
turnover_debit: Money = None
turnover_credit: Money = None
closing_debit: Money = None
closing_credit: Money = None
extra: dict[str, str | None] | None = None # нераспознанные колонки таблицы: заголовок → текст ячейки (§1.2)
class OsvChecks(ContractModel):
total_row_matches_root: OptionalNotNull[bool] = None
balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing
parent_sum_violations: OptionalNotNull[list[IntLike]] = None
class OsvDocument(ContractModel):
document_type: Literal["OSV"]
locator: str # «sheet:Лист_1»
status: DocumentStatus # done — контрольные суммы §1.4 листа сошлись, иначе needs_check
warnings: list[DocumentWarning] # все уровня info, код с префиксом OSV_
header: OsvHeader
group_schema: list[OsvGroup]
columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН
rows: list[OsvRow]
checks: OptionalNotNull[OsvChecks] = None
# --- ББ / ОФР / раскрытия -------------------------------------------------------------------------------------------
PeriodKind = Literal[
"absolute",
"relative:current",
"relative:prev_year_end",
"relative:prev_prev_year_end",
"relative:prev_period",
"none",
]
LineStatus = Literal["ok", "unreadable", "missing", "detail"]
CODE_PATTERN = r"^\d{4,5}$"
INN_PATTERN = r"^(\d{10}|\d{12})$"
NonNegativeInt = Annotated[IntLike, Field(ge=0)]
class StatementHeader(ContractModel):
"""Шапка формы: значение, сырой текст и уверенность каждого поля (схема v2.0, все поля необязательны)."""
form_name: str | None = None
organization_name: str | None = None
organization_name_text: str | None = None
organization_name_confidence: Confidence = None
inn: Annotated[str, Field(pattern=INN_PATTERN)] | None = None
inn_text: str | None = None
inn_confidence: Confidence = None
report_date: ContractDate | None = None
report_date_text: str | None = None
report_date_confidence: Confidence = None
units: str | None = None # «тыс. руб.»
units_text: str | None = None
units_confidence: Confidence = None
okud: str | None = None
okud_text: str | None = None
okud_confidence: Confidence = None
okpo: str | None = None
okpo_text: str | None = None
okpo_confidence: Confidence = None
okopf: str | None = None
okopf_text: str | None = None
okopf_confidence: Confidence = None
okfs: str | None = None
okfs_text: str | None = None
okfs_confidence: Confidence = None
okei: str | None = None # код ОКЕИ единицы: 384 — тыс. руб., 383 — руб., 385 — млн руб.
okei_text: str | None = None
okei_confidence: Confidence = None
address: str | None = None
address_text: str | None = None
address_confidence: Confidence = None
needs_review_fields: OptionalNotNull[list[HeaderFieldName]] = None
@field_validator("needs_review_fields")
@classmethod
def _unique_fields(cls, value: list[HeaderFieldName] | None) -> list[HeaderFieldName] | None:
if value is not None and len(set(value)) != len(value):
raise ValueError("needs_review_fields: поля не должны повторяться (uniqueItems)")
return value
class StatementColumn(ContractModel):
column_index: NonNegativeInt # номер столбца сетки кандидата шага 1; уникален в документе
label: str # «На 30 сентября 2025 г.»
label_confidence: Confidence = None
period_date: ContractDate | None = None
period_kind: PeriodKind
class StatementValue(ContractModel):
column_index: IntLike
amount: Money
string_value: str | None = None
text: str | None
confidence: Confidence
is_recognized: bool
needs_review: bool
class StatementLine(ContractModel):
row_index: NonNegativeInt
ref: NonEmptyStr # положение в источнике; уникален в документе
line_status: LineStatus
line_code: Annotated[str, Field(pattern=CODE_PATTERN)] | None
line_code_text: str | None
line_code_confidence: Confidence = None
line_name: str | None
line_name_confidence: Confidence = None
parent_ref: str | None = None
needs_review: bool
values: list[StatementValue]
@model_validator(mode="after")
def _check_status_rules(self) -> Self:
"""Буквально из `allOf` схемы: `line_code` задан ⇔ `line_status = ok`; у `detail` обязателен `parent_ref`."""
if (self.line_status == "ok") != (self.line_code is not None):
raise ValueError("line_code задан тогда и только тогда, когда line_status = ok")
if self.line_status == "detail" and self.parent_ref is None:
raise ValueError("у строки detail обязателен parent_ref")
return self
class FailedRelation(ContractModel):
"""Нарушенное соотношение §7 по одной колонке.
`expected` — левая часть (цель), `actual` — правая по строкам ok; `null` — цели нет или все члены пусты.
"""
column_index: IntLike
relation: str # «1600 = 1100 + 1200»
codes: Annotated[list[Annotated[str, Field(pattern=CODE_PATTERN)]], Field(min_length=1)]
expected: Money
actual: Money
class StatementChecks(ContractModel):
"""Самопроверка формы общим кодом: пустые списки — «проверено, сошлось»."""
relations_failed: list[FailedRelation]
soft_relations_failed: list[FailedRelation]
class StatementDocument(ContractModel):
document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]
form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE
locator: str # «file:aep_3.json;table:0»
status: DocumentStatus
warnings: list[DocumentWarning]
header: StatementHeader
columns: list[StatementColumn]
lines: list[StatementLine]
checks: OptionalNotNull[StatementChecks] = None
@model_validator(mode="after")
def _check_checks_presence(self) -> Self:
"""`allOf` схемы: `checks` обязателен у BALANCE/INCOME со строками и запрещён у документа без строк."""
if self.lines:
if self.document_type in ("BALANCE", "INCOME") and self.checks is None:
raise ValueError(f"у {self.document_type} со строками обязателен checks")
elif self.checks is not None:
raise ValueError("у документа без строк checks не допускается")
return self
ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")]
class RecognitionResult(ContractModel):
schema_version: SchemaVersion
job_id: ContractUUID
file_id: ContractUUID
service: ServiceInfo
status: LaxResultStatus
error: ResultError | None = None
metrics: OptionalNotNull[Metrics] = None
raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json
documents: list[ResultDocument]
@model_validator(mode="after")
def _check_documents_for_success(self) -> Self:
"""`if status in (done, needs_check) then documents.minItems = 1` — буквально из схемы."""
if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents:
raise ValueError(f"при status = {self.status.value} список documents не может быть пустым")
return self
@model_validator(mode="after")
def _check_statements_version(self) -> Self:
"""Есть `StatementDocument` ⇒ `service.statements_version` задан (`allOf` схемы)."""
if any(isinstance(d, StatementDocument) for d in self.documents) and self.service.statements_version is None:
raise ValueError("при документах ББ/ОФР обязателен service.statements_version")
return self