arm-tabular-parser/api/tests/unit/test_contract_v20.py
Andrey Epifancev 71f596cdb7 feat: формы через arm_statements, контракт результата и конверта v2.0, статус и OSV_* у ОСВ (#54)
- копия схем и примеров контракта v2.0 (запрос остаётся v1.0), модели result/common/message/request;
  ResultWarning/FileWarning, статус и предупреждения у документов, строки forms v2.0, service.statements_version
- statements/pipeline.py: кандидаты адаптера -> normalize_candidate -> to_contract / warning_to_contract
- BookParser: статус книги через envelope_status, SHEET_OTHER / FILE_TYPE_MISMATCH / XLS_NO_CODEPAGE как предупреждения
  файла, OSV_* и OSV_XLSB_NO_OUTLINE в документах ОСВ, тексты иерархии по спеке
- воркер, app и CLI получают statements_version из require_statements_version()
- старые модели документа формы вынесены в statements/legacy_models.py до удаления старого конвейера (задача 9)
- OpenSpec: MODIFIED «Парсер — заменяемый интерфейс» в дельте tabular-recognition-service

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:52:52 +04:00

186 lines
7.3 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.

"""Контракт v2.0 (RSHB/arm-ka-docs#116): модели результата и конверта v2, запрос остаётся v1."""
import datetime
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from src.contract import CONTRACT_VERSION, RecognitionRequest, RecognitionResult
from src.contract.common import REQUEST_SCHEMA_VERSION_PATTERN, SCHEMA_VERSION_PATTERN
from src.contract.result import (
FailedRelation,
OsvHeader,
StatementChecks,
StatementDocument,
StatementLine,
)
EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples"
RESULT_EXAMPLES = (
"result-osv-minimal",
"result-osv-needs-check",
"result-statement-excel",
"result-statement-minimal",
"result-error",
)
def _load(name: str) -> dict[str, Any]:
data = json.loads((EXAMPLES_DIR / f"{name}.json").read_text(encoding="utf-8"))
assert isinstance(data, dict)
return data
def test_docs_examples_validate_with_models() -> None:
for name in RESULT_EXAMPLES:
payload = _load(name)
assert RecognitionResult.model_validate(payload).to_wire() == payload
def test_request_stays_v1_and_result_is_v2() -> None:
assert CONTRACT_VERSION == "2.0"
assert SCHEMA_VERSION_PATTERN == r"^2\.\d+$"
assert REQUEST_SCHEMA_VERSION_PATTERN == r"^1\.\d+$"
request = RecognitionRequest.model_validate(_load("request-tabular"))
assert request.schema_version == "1.0"
with pytest.raises(ValidationError):
RecognitionRequest.model_validate({**_load("request-tabular"), "schema_version": "2.0"})
def test_statement_without_statements_version_is_rejected() -> None:
payload = _load("result-statement-excel")
del payload["service"]["statements_version"]
with pytest.raises(ValidationError, match="statements_version"):
RecognitionResult.model_validate(payload)
def test_osv_only_result_does_not_need_statements_version() -> None:
payload = _load("result-osv-minimal")
assert "statements_version" not in payload["service"]
RecognitionResult.model_validate(payload)
def test_unreadable_line_with_code_is_rejected() -> None:
"""Спека «Строка без кода с кодом в поле»: `line_code` не `null` ⇔ `line_status = ok`."""
line = _load("result-statement-minimal")["documents"][0]["lines"][2]
assert line["line_status"] == "unreadable" and line["line_code"] is None
line["line_code"] = "1240"
with pytest.raises(ValidationError, match="line_code"):
StatementLine.model_validate(line)
ok_line = {**line, "line_status": "ok", "line_code": None}
with pytest.raises(ValidationError, match="line_code"):
StatementLine.model_validate(ok_line)
def test_detail_line_requires_parent_ref() -> None:
line = _load("result-statement-excel")["documents"][1]["lines"][1]
assert line["line_status"] == "detail"
del line["parent_ref"]
with pytest.raises(ValidationError, match="parent_ref"):
StatementLine.model_validate(line)
def test_checks_required_for_balance_with_lines_and_forbidden_without() -> None:
document = _load("result-statement-minimal")["documents"][0]
without_checks = {k: v for k, v in document.items() if k != "checks"}
with pytest.raises(ValidationError, match="checks"):
StatementDocument.model_validate(without_checks)
empty = {**document, "lines": [], "checks": {"relations_failed": [], "soft_relations_failed": []}}
with pytest.raises(ValidationError, match="checks"):
StatementDocument.model_validate(empty)
disclosure = {**document, "document_type": "DISCLOSURE", "form_variant": None, "lines": []}
disclosure.pop("checks")
StatementDocument.model_validate(disclosure)
def test_form_variant_is_required_and_other_type_is_gone() -> None:
document = _load("result-statement-minimal")["documents"][0]
with pytest.raises(ValidationError):
StatementDocument.model_validate({k: v for k, v in document.items() if k != "form_variant"})
with pytest.raises(ValidationError):
StatementDocument.model_validate({**document, "document_type": "OTHER"})
def test_osv_document_requires_status_and_warnings() -> None:
payload = _load("result-osv-minimal")
for key in ("status", "warnings"):
broken = json.loads(json.dumps(payload))
del broken["documents"][0][key]
with pytest.raises(ValidationError):
RecognitionResult.model_validate(broken)
def test_failed_relation_has_codes_and_nullable_amounts() -> None:
relation = FailedRelation.model_validate(
{
"column_index": 3,
"relation": "2200 = 2100 - 2210 - 2220",
"codes": ["2200", "2100", "2210", "2220"],
"expected": None,
"actual": "400.00",
}
)
assert relation.expected is None and relation.actual == Decimal("400.00")
with pytest.raises(ValidationError):
FailedRelation.model_validate(
{"column_index": 3, "relation": "x", "codes": [], "expected": None, "actual": None}
)
with pytest.raises(ValidationError):
FailedRelation.model_validate(
{"column_index": 3, "relation": "x", "codes": ["12"], "expected": None, "actual": None}
)
def test_statement_checks_have_no_duplicate_line_codes() -> None:
checks = StatementChecks.model_validate({"relations_failed": [], "soft_relations_failed": []})
assert "duplicate_line_codes" not in StatementChecks.model_fields
assert checks.to_wire() == {"relations_failed": [], "soft_relations_failed": []}
def test_warning_code_is_free_text_by_pattern_and_severity_is_closed() -> None:
payload = _load("result-statement-minimal")
payload["documents"][0]["warnings"][0]["code"] = "NEW_CODE_2_1"
RecognitionResult.model_validate(payload)
payload["documents"][0]["warnings"][0]["code"] = "lowercase"
with pytest.raises(ValidationError):
RecognitionResult.model_validate(payload)
payload["documents"][0]["warnings"][0].update(code="OK_CODE", severity="warning")
with pytest.raises(ValidationError):
RecognitionResult.model_validate(payload)
def test_metrics_warnings_are_structures() -> None:
payload = _load("result-osv-minimal")
payload["metrics"] = {"warnings": ["строка"]}
with pytest.raises(ValidationError):
RecognitionResult.model_validate(payload)
payload["metrics"]["warnings"] = [
{"code": "SHEET_OTHER", "severity": "info", "message": "лист", "locator": "sheet:Прочее"}
]
assert RecognitionResult.model_validate(payload).to_wire()["metrics"]["warnings"][0]["locator"] == "sheet:Прочее"
def test_osv_header_period_bounds_are_optional_dates() -> None:
header = OsvHeader(
organization_name="ООО",
account_code_raw="60",
period_text="1 квартал 2025 г.",
)
assert header.period_start is None and header.is_from_year_start is None
filled = OsvHeader.model_validate(
{
"organization_name": "ООО",
"account_code_raw": "60",
"period_text": "x",
"period_start": "2024-10-01",
"period_end": "2025-09-30",
"is_from_year_start": False,
}
)
assert filled.period_start == datetime.date(2024, 10, 1)
assert filled.period_end == datetime.date(2025, 9, 30)