- копия схем и примеров контракта 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
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
"""Контракт 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)
|