feat: перевод документа и предупреждений в форму контракта v2.0, вход статуса из документа контракта

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 14:06:55 +04:00
parent 9e28213ce6
commit 3ee74c6907
3 changed files with 255 additions and 2 deletions

View File

@ -1,12 +1,12 @@
"""Общий код нормализации форм ББ/ОФР. """Общий код нормализации форм ББ/ОФР.
Публичный API — только имена `__all__` (раздел «Публичный API пакета» плана 1). Имена задачи 19 Публичный API — только имена `__all__` (раздел «Публичный API пакета» плана 1). Контрактные имена — `contract.py`.
(`to_contract`, `warning_to_contract`, `status_input_from_contract`) добавляются ими.
""" """
from arm_statements.amounts import parse_amount from arm_statements.amounts import parse_amount
from arm_statements.codes import parse_code, sheet_joins from arm_statements.codes import parse_code, sheet_joins
from arm_statements.columns import amount_columns from arm_statements.columns import amount_columns
from arm_statements.contract import status_input_from_contract, to_contract, warning_to_contract
from arm_statements.dates import parse_date, resolve_relative from arm_statements.dates import parse_date, resolve_relative
from arm_statements.header import continuation_type, is_continuation, parse_header, type_hint from arm_statements.header import continuation_type, is_continuation, parse_header, type_hint
from arm_statements.issues import WarningCode, make_warning from arm_statements.issues import WarningCode, make_warning
@ -130,6 +130,9 @@ __all__ = [
"sheet_joins", "sheet_joins",
"statements_version", "statements_version",
"status_input", "status_input",
"status_input_from_contract",
"to_contract",
"to_jsonable", "to_jsonable",
"type_hint", "type_hint",
"warning_to_contract",
] ]

151
arm_statements/contract.py Normal file
View File

@ -0,0 +1,151 @@
"""Перевод выхода пакета в форму контракта v2.0 и вход статуса из документа контракта.
Один перевод на оба сервиса (P1-26): хосты свой `contract_map` не пишут. Схему пакет не читает —
форма держится тестами и разовой сверкой со `statementDocument` схемы v2.0.
"""
from collections.abc import Mapping
from decimal import Decimal
from typing import Any
from arm_statements.model import (
HEADER_FIELDS,
DocumentType,
FailedRelation,
LineStatus,
PeriodKind,
Severity,
StatementDocument,
StatementHeader,
StatementWarning,
)
from arm_statements.status import StatusInput, StatusLine, StatusValue
def _money(value: Decimal | None) -> str | None:
return None if value is None else format(value, "f")
def warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any]:
"""`warning` схемы; при `file_level` — `fileWarning` (с `locator`). Ключи без значения опускаются."""
data: dict[str, Any] = {
"code": warning.code,
"severity": str(warning.severity),
"message": warning.message,
}
if warning.line_refs:
data["line_refs"] = list(warning.line_refs)
if warning.column_index is not None:
data["column_index"] = warning.column_index
if warning.field is not None:
data["field"] = warning.field
if file_level and warning.locator is not None:
data["locator"] = warning.locator
return data
def _header(header: StatementHeader) -> dict[str, Any]:
data: dict[str, Any] = {"form_name": header.form_name}
for name in HEADER_FIELDS:
field = getattr(header, name)
value = field.value
data[name] = value.isoformat() if name == "report_date" and value is not None else value
data[f"{name}_text"] = field.text
data[f"{name}_confidence"] = field.confidence
data["needs_review_fields"] = list(header.needs_review_fields)
return data
def _relation(relation: FailedRelation) -> dict[str, Any]:
if relation.column_index is None:
raise ValueError(f"у соотношения {relation.relation!r} нет column_index: у сервиса номер колонки есть всегда")
return {
"column_index": relation.column_index,
"relation": relation.relation,
"codes": list(relation.codes),
"expected": _money(relation.expected),
"actual": _money(relation.actual),
}
def to_contract(document: StatementDocument) -> dict[str, Any]:
"""`statementDocument` схемы v2.0 как json-совместимый `dict`."""
data: dict[str, Any] = {
"document_type": str(document.document_type),
"form_variant": None if document.form_variant is None else str(document.form_variant),
"locator": document.locator,
"status": str(document.status),
"warnings": [warning_to_contract(w) for w in document.warnings],
"header": _header(document.header),
"columns": [
{
"column_index": c.column_index,
"label": c.label,
"label_confidence": c.label_confidence,
"period_date": None if c.period_date is None else c.period_date.isoformat(),
"period_kind": str(c.period_kind),
}
for c in document.columns
],
"lines": [
{
"row_index": ln.row_index,
"ref": ln.ref,
"line_status": str(ln.line_status),
"line_code": ln.line_code,
"line_code_text": ln.line_code_text,
"line_code_confidence": ln.line_code_confidence,
"line_name": ln.line_name,
"line_name_confidence": ln.line_name_confidence,
"parent_ref": ln.parent_ref,
"needs_review": ln.needs_review,
"values": [
{
"column_index": v.column_index,
"amount": _money(v.amount),
"string_value": v.string_value,
"text": v.text,
"confidence": v.confidence,
"is_recognized": v.is_recognized,
"needs_review": v.needs_review,
}
for v in ln.values
],
}
for ln in document.lines
],
}
if document.checks is not None:
data["checks"] = {
"relations_failed": [_relation(r) for r in document.checks.relations_failed],
"soft_relations_failed": [_relation(r) for r in document.checks.soft_relations_failed],
}
return data
def status_input_from_contract(document: Mapping[str, Any]) -> StatusInput:
"""Вход функции статуса из `statementDocument` контракта v2.0 (то же правило, что `status_input`).
Схему не проверяет: битый документ — `KeyError` / `ValueError`. Отсутствующий необязательный ключ и `null` равны.
"""
header = document["header"]
lines = document["lines"]
columns = document["columns"]
period_known = header.get("report_date") is not None or any(
c["period_kind"] == PeriodKind.ABSOLUTE and c.get("period_date") for c in columns
)
checks = document.get("checks")
return StatusInput(
document_type=DocumentType(document["document_type"]),
layout_found=len(lines) > 0,
has_organization_or_inn=bool(header.get("organization_name") or header.get("inn")),
period_known=period_known,
needs_review_fields=tuple(header.get("needs_review_fields") or ()),
lines=tuple(StatusLine(LineStatus(ln["line_status"]), bool(ln["needs_review"])) for ln in lines),
values=tuple(
StatusValue(LineStatus(ln["line_status"]), bool(v["needs_review"])) for ln in lines for v in ln["values"]
),
hard_failures=0 if checks is None else len(checks["relations_failed"]),
accepted=False,
warning_severities=tuple(Severity(w["severity"]) for w in document["warnings"]),
)

View File

@ -0,0 +1,99 @@
import json
from dataclasses import replace
from decimal import Decimal as D
import pytest
from arm_statements import (
DocStatus,
DocumentType,
document_status,
make_warning,
normalize_candidate,
status_input,
status_input_from_contract,
to_contract,
warning_to_contract,
)
from arm_statements.loader import Rules
from arm_statements.model import FailedRelation, StatementChecks, StatementDocument
from arm_statements.tests.builders import hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
FULL_HEADER = hdr(
organization_name="ООО «Пример»", inn=("7700000000", 80.0), report_date="30.09.2025", units="тыс. руб."
)
HEADER_KEYS = {"form_name", "needs_review_fields"} | {
f"{name}{suffix}"
for name in ("organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address")
for suffix in ("", "_text", "_confidence")
}
def balance(rules: Rules, *rows: list[str]) -> StatementDocument:
cand = ocr([HEAD, *rows], header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс")
doc = normalize_candidate(cand, rules).document
assert doc is not None
return doc
def test_document_in_contract_form(rules: Rules) -> None:
doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"])
data = json.loads(json.dumps(to_contract(doc), ensure_ascii=False))
assert set(data["header"]) == HEADER_KEYS
assert (data["header"]["form_name"], data["header"]["inn"], data["header"]["inn_confidence"]) == (
"Бухгалтерский баланс",
"7700000000",
80.0,
)
assert data["header"]["report_date"] == "2025-09-30" and data["header"]["okei"] is None
assert data["lines"][0]["values"][0]["amount"] == "60.00"
assert {ln["line_status"] for ln in data["lines"]} >= {"ok"}
assert "column_key" not in json.dumps(data) and "row:" not in json.dumps(data["header"])
assert "locator" not in json.dumps(data["warnings"])
assert set(data["checks"]) == {"relations_failed", "soft_relations_failed"}
def test_empty_target_is_null_in_contract(rules: Rules) -> None:
doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["Итого I", "1100", "40"])
failed = to_contract(doc)["checks"]["relations_failed"]
target = next(f for f in failed if f["relation"] == "1600 = 1100 + 1200")
assert (target["expected"], target["actual"], target["column_index"]) == (None, "100.00", 2)
def test_no_lines_no_checks_key(rules: Rules) -> None:
doc = normalize_candidate(
ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), rules
).document
assert doc is not None and "checks" not in to_contract(doc) and to_contract(doc)["lines"] == []
def test_relation_without_column_index_is_error(rules: Rules) -> None:
doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"])
bad = FailedRelation(None, "c1", "1600 = 1700", ("1600", "1700"), D("1"), None)
with pytest.raises(ValueError, match="column_index"):
to_contract(replace(doc, checks=StatementChecks((bad,), ())))
def test_warning_keys(rules: Rules) -> None:
w = make_warning("ROW_SPLIT", "разлеплено", rules, line_refs=("row:3#1", "row:3#2"), locator="file:a.json")
assert warning_to_contract(w) == {
"code": "ROW_SPLIT",
"severity": "info",
"message": "разлеплено",
"line_refs": ["row:3#1", "row:3#2"],
}
lost = make_warning("TABLE_LOST", "таблица", rules, locator="file:a.json;table:1")
assert warning_to_contract(lost, file_level=True) == {
"code": "TABLE_LOST",
"severity": "error",
"message": "таблица",
"locator": "file:a.json;table:1",
}
def test_status_input_roundtrip(rules: Rules) -> None:
doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"])
data = status_input_from_contract(json.loads(json.dumps(to_contract(doc), ensure_ascii=False)))
assert data == status_input(doc)
assert document_status(data) is doc.status is DocStatus.NEEDS_CHECK