test: golden fixtures контракта 2.0 в тестах ядра (#144)

fixtures #26/#27 (19.10.2026) перевели снимки форм и OCR на контракт 2.0,
а загрузчики тестов читали их моделью 1.x: 164 падения на main.

- golden_result/ocr_result: модель по MAJOR schema_version, для 2.0 —
  service.statements_version копии пакета ядра;
- golden_result_v1: снимок 2.0 → 1.2 (строки с кодом, колонки с 0, без
  null в шапке) для модулей приёма 1.x — test_mapping_statements и
  test_api_statements; путь 2.0 покрывают *_v2;
- make_entries_from_result — parse_result; ожидания fixtures_marker под
  эталоны 2.0; удаление файла считает замечания сервиса снимка;
- 76.Н.1 (synthetic s42, tabular-parser#59) — в KNOWN_OUT_OF_PLAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-20 21:34:20 +04:00
parent e6e4cd877b
commit 7dd7d5dd40
7 changed files with 91 additions and 25 deletions

View File

@ -28,7 +28,8 @@ from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.contract import request_subject from src.contract import request_subject
from src.contract.result import OsvDocument, RecognitionResult from src.contract.result import OsvDocument
from src.contract.versions import parse_result
from src.domain.consolidation_periods import reporting_date from src.domain.consolidation_periods import reporting_date
from src.domain.osv_mapping import build_entries, document_fields from src.domain.osv_mapping import build_entries, document_fields
from src.domain.policy import FINISHED_STATUSES from src.domain.policy import FINISHED_STATUSES
@ -349,7 +350,7 @@ async def make_entries_from_result(
assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах
payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)} payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)}
payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})} payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})}
result = RecognitionResult.model_validate(payload) result = parse_result(json.dumps(payload).encode()) # модель — по MAJOR `schema_version` (снимки 1.x и 2.0)
sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)] sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)]
chart = { chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))

View File

@ -6,13 +6,14 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from src.contract.result import RecognitionResult from src.contract.result import RecognitionResult
from src.contract.result_v2 import RecognitionResultV2
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import RecognitionStatus from src.models.enums import RecognitionStatus
from src.models.files import Document from src.models.files import Document
@ -44,18 +45,19 @@ def fixtures_skip_reason() -> str | None:
) )
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) """Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как файл результата: модель — по MAJOR
как `RecognitionResult` контракта v1.2. `schema_version` эталона (без поля — v1.2, эталоны до контракта 2.0).
""" """
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest from src.contract.versions import parse_result # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8")) data = json.loads(path.read_text("utf-8"))
version = data.get("schema_version", "1.2")
payload: dict[str, object] = { payload: dict[str, object] = {
"schema_version": "1.2", "schema_version": version,
"job_id": str(job_id), "job_id": str(job_id),
"file_id": str(file_id), "file_id": str(file_id),
"service": {"name": "scan-gateway", "version": "golden"}, "service": _golden_service({"name": "scan-gateway"}, version),
"status": data["status"], "status": data["status"],
"documents": data["documents"], "documents": data["documents"],
} }
@ -63,18 +65,68 @@ def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Recogni
payload["error"] = data["error"] payload["error"] = data["error"]
if data.get("metrics") is not None: if data.get("metrics") is not None:
payload["metrics"] = data["metrics"] payload["metrics"] = data["metrics"]
return RecognitionResult.model_validate(payload) return parse_result(json.dumps(payload).encode())
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
"""Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса.""" """Golden-снимок парсера как файл результата (модель — по MAJOR `schema_version`): подставляет `job_id`/`file_id`
from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest и версию сервиса."""
from src.contract.versions import parse_result # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
data = json.loads(path.read_text("utf-8")) data = json.loads(path.read_text("utf-8"))
data["service"] = _golden_service(data["service"], data["schema_version"])
return parse_result(json.dumps(data | {"job_id": str(job_id), "file_id": str(file_id)}).encode())
def golden_result_v1(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
"""Golden-снимок как результат контракта 1.2 — для тестов маппинга и API формы на пути `1.x` (ядро принимает оба
MAJOR). Снимок 2.0 сводится к 1.2: строки без кода отброшены (в 1.x `line_code` обязателен), колонки формы
перенумерованы по порядку с 0, как отдавал сервис 1.x (в 2.0 `column_index` — номер столбца листа), предупреждения
файла — текстом; поля, которых нет в 1.x, модель отбрасывает сама."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest
data = json.loads(path.read_text("utf-8"))
if data["schema_version"].startswith("2."):
data["schema_version"] = "1.2"
metrics = data.get("metrics")
if metrics and metrics.get("warnings") is not None:
metrics["warnings"] = [warning["message"] for warning in metrics["warnings"]]
for document in data["documents"]:
if "lines" in document:
_statement_as_v1(document)
data["service"] = {"version": "golden", **data["service"]} data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
def _statement_as_v1(document: dict[str, Any]) -> None:
"""Форма снимка 2.0 → 1.2 на месте: строки с кодом, колонки 0…n-1 в порядке первого появления `column_index`
(повтор номера остаётся повтором), `null` в шапке — отсутствие поля (в 1.x часть полей шапки не допускает null)."""
index: dict[int, int] = {}
for column in document["columns"]:
index.setdefault(column["column_index"], len(index))
document["header"] = {key: value for key, value in document["header"].items() if value is not None}
for column in document["columns"]:
column["column_index"] = index[column["column_index"]]
document["lines"] = [line for line in document["lines"] if line.get("line_code")]
for line in document["lines"]:
line["values"] = [value | {"column_index": index[value["column_index"]]} for value in line["values"]]
checks = document.get("checks") or {}
for key in ("relations_failed", "soft_relations_failed"):
checks[key] = [item | {"column_index": index[item["column_index"]]} for item in checks.get(key) or ()]
def _golden_service(service: dict[str, object], schema_version: str) -> dict[str, object]:
"""`service` снимка: версия сервиса `golden`, у контракта 2.0 — ещё `statements_version` копии пакета ядра (в
снимках версии вырезаны, а при формах поле обязательно; совпадение с ядром — как у сервиса той же сборки)."""
from src.contract.versions import MAJOR_V2, major # noqa: PLC0415 - цикл с conftest
from src.services.statements_engine import default_engine # noqa: PLC0415
result: dict[str, object] = {"version": "golden", **service}
if major(schema_version) == MAJOR_V2:
result.setdefault("statements_version", default_engine().version)
return result
async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные
session: AsyncSession, session: AsyncSession,
*, *,

View File

@ -169,8 +169,9 @@ async def test_delete_file_with_statement_form(
file_id = await _form_file_with_comment(seeded, fixtures_dir) file_id = await _form_file_with_comment(seeded, fixtures_dir)
document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars()) document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars())
assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1 assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1
issues = await _count(seeded, DocumentIssue) # предупреждения сервиса из снимка 2.0
await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом await make_document_issue(seeded, document_id=document_ids[0]) # v5.19: document_issue уходит вместе с файлом
assert await _count(seeded, DocumentIssue) == 1 assert await _count(seeded, DocumentIssue) == issues + 1
if superseded: if superseded:
await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED)) await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED))
await seeded.flush() await seeded.flush()

View File

@ -2,6 +2,7 @@
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" «Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
import datetime import datetime
import functools
import json import json
import uuid import uuid
from decimal import Decimal from decimal import Decimal
@ -51,10 +52,14 @@ from tests.factories import (
make_stored_file, make_stored_file,
) )
from tests.fake_engine import FakeEngine from tests.fake_engine import FakeEngine
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures] pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
# формы — из снимков, сведённых к 1.2 (модуль писался под приём `1.x`); правка формы 2.0 — `test_statements_v2_edit.py`
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
KA_RF = "ka_rf@armka.local" KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал" ROSTOV = "Ростовский региональный филиал"

View File

@ -46,7 +46,7 @@ def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tm
@pytest.mark.fixtures @pytest.mark.fixtures
def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None: def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
"""ocr_result читает эталон нормализатора и создаёт RecognitionResult """ocr_result читает эталон нормализатора (контракт 2.0) и создаёт файл результата
с правильным порядком документов. с правильным порядком документов.
""" """
job_id, file_id = uuid.uuid4(), uuid.uuid4() job_id, file_id = uuid.uuid4(), uuid.uuid4()
@ -54,18 +54,19 @@ def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
assert result.job_id == job_id and result.file_id == file_id assert result.job_id == job_id and result.file_id == file_id
assert result.service.name == "scan-gateway" assert result.service.name == "scan-gateway"
assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"] assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"]
assert result.status == "needs_check" and result.schema_version == "1.2" assert result.status == "needs_check" and result.schema_version == "2.0"
assert result.error is None assert result.error is None
assert result.metrics is not None and result.metrics.rows == 60 # noqa: PLR2004 - эталон aep_6 assert result.metrics is not None and result.metrics.rows == 88 # noqa: PLR2004 - эталон aep_6
@pytest.mark.fixtures @pytest.mark.fixtures
def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None: def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None:
"""Эталон с ошибкой: код и сообщение переносятся в результат, метрик нет.""" """Эталон с ошибкой: код и сообщение переносятся в результат, в метриках — предупреждение файла уровня error."""
result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4()) result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4())
assert result.status == "error" and result.documents == [] assert result.status == "error" and result.documents == []
assert result.error is not None and result.error.code == "INVALID_DOCUMENT" assert result.error is not None and result.error.code == "INVALID_DOCUMENT"
assert result.metrics is None assert result.metrics is not None and result.metrics.warnings is not None
assert [(w.code, w.severity) for w in result.metrics.warnings] == [("JSON_FAILED", "error")]
def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None: def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None:

View File

@ -57,6 +57,7 @@ KNOWN_OUT_OF_PLAN = frozenset(
"76.77.2", "76.77.2",
"76.77.5", "76.77.5",
"76.АФ", # osv-dkb/primanych "76.АФ", # osv-dkb/primanych
"76.Н.1", # synthetic/s42_subaccounts_totals_reversed (tabular-parser#59)
} }
) )
TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты

View File

@ -1,6 +1,7 @@
"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и """Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и
прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures.""" прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures."""
import functools
import json import json
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
@ -35,10 +36,14 @@ from src.models.statements import (
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file
from tests.factories import make_document_issue, make_legal_entity from tests.factories import make_document_issue, make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result_v1, ocr_result
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures] pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
# модуль проверяет маппинг форм `1.x` (Срез 3b): снимки 2.0 сводятся к 1.2; путь 2.0 — `test_mapping_statements_v2.py`
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3" NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic" SYNTH = GOLDEN_STATEMENTS / "synthetic"
@ -312,9 +317,9 @@ async def test_duplicate_column_index_does_not_fail_mapping(
) )
) )
).all() ).all()
first = columns[0] first = columns[0] # снимок сведён к 1.2: колонки пронумерованы с 0, повтор номера первой колонки — тоже 0
assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns}) assert sorted(i for i, _ in stored) == list(range(len({c["column_index"] for c in columns})))
assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored assert (0, first["label"]) in stored and (0, "дубль") not in stored
file = await seeded.get(StoredFile, income.stored_file_id) file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None and file.status is FileStatus.PROCESSED assert file is not None and file.status is FileStatus.PROCESSED
assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income)) assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income))
@ -354,7 +359,7 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture
file.status = FileStatus.PROCESSING file.status = FileStatus.PROCESSING
await seeded.flush() await seeded.flush()
chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))} chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))}
result = golden_result(path, job_id=job.id, file_id=file.id) result = golden_result_v1(path, job_id=job.id, file_id=file.id)
await map_file( await map_file(
seeded, seeded,