"""Файл результата в 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