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>
176 lines
9.6 KiB
Python
176 lines
9.6 KiB
Python
"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`."""
|
||
|
||
import json
|
||
import os
|
||
import uuid
|
||
from collections.abc import Callable
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
if TYPE_CHECKING:
|
||
from src.contract.result import RecognitionResult
|
||
from src.contract.result_v2 import RecognitionResultV2
|
||
from src.models.directories import LegalEntity
|
||
from src.models.enums import RecognitionStatus
|
||
from src.models.files import Document
|
||
|
||
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||
GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse"
|
||
OCR_EXPECTED = Path("ocr") / "expected_normalize"
|
||
|
||
|
||
def resolve_fixtures_dir() -> Path:
|
||
"""Каталог fixtures: `ARM_KA_FIXTURES`, иначе соседний `../../fixtures` относительно `arm-core/api`.
|
||
|
||
Не проверяет существование: это делает вызывающий код (пропуск теста с причиной).
|
||
"""
|
||
explicit = os.environ.get(FIXTURES_ENV)
|
||
if explicit:
|
||
return Path(explicit)
|
||
return Path(__file__).resolve().parents[3] / "fixtures"
|
||
|
||
|
||
def fixtures_skip_reason() -> str | None:
|
||
"""Причина пропуска тестов с маркером `fixtures` или `None`, если каталог доступен."""
|
||
path = resolve_fixtures_dir()
|
||
if path.is_dir():
|
||
return None
|
||
return (
|
||
f"ARM_KA_FIXTURES недоступен: нет каталога {path} "
|
||
f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)"
|
||
)
|
||
|
||
|
||
def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
|
||
"""Эталон нормализатора scan-gateway (`ocr/expected_normalize/<name>.json`) как файл результата: модель — по MAJOR
|
||
`schema_version` эталона (без поля — v1.2, эталоны до контракта 2.0).
|
||
"""
|
||
from src.contract.versions import parse_result # noqa: PLC0415 - цикл с conftest
|
||
|
||
data = json.loads(path.read_text("utf-8"))
|
||
version = data.get("schema_version", "1.2")
|
||
payload: dict[str, object] = {
|
||
"schema_version": version,
|
||
"job_id": str(job_id),
|
||
"file_id": str(file_id),
|
||
"service": _golden_service({"name": "scan-gateway"}, version),
|
||
"status": data["status"],
|
||
"documents": data["documents"],
|
||
}
|
||
if data.get("error") is not None:
|
||
payload["error"] = data["error"]
|
||
if data.get("metrics") is not None:
|
||
payload["metrics"] = data["metrics"]
|
||
return parse_result(json.dumps(payload).encode())
|
||
|
||
|
||
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult | RecognitionResultV2":
|
||
"""Golden-снимок парсера как файл результата (модель — по MAJOR `schema_version`): подставляет `job_id`/`file_id`
|
||
и версию сервиса."""
|
||
from src.contract.versions import parse_result # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
|
||
|
||
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"]}
|
||
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 - все входы именованные
|
||
session: AsyncSession,
|
||
*,
|
||
entity: "LegalEntity",
|
||
path: Path,
|
||
uploaded_by: uuid.UUID | None,
|
||
result_builder: Callable[..., "RecognitionResult"] | None = None,
|
||
job_status: "RecognitionStatus | None" = None,
|
||
) -> "list[Document]":
|
||
"""Golden-снимок `path` → файл ЮЛ `entity` и его документы в БД теста (`flush`, без commit).
|
||
|
||
Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и
|
||
настоящим `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы
|
||
до маппинга). Возвращает документы файла отсортированные по (document_type, osv_account_code).
|
||
`result_builder` — функция создания RecognitionResult (по умолчанию golden_result); `job_status` — статус
|
||
текущего задания (по умолчанию `done`; `needs_check` — как после результата с `status = needs_check`).
|
||
"""
|
||
# ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл)
|
||
from src.models.accounting import ChartOfAccount # noqa: PLC0415
|
||
from src.models.enums import FileStatus, RecognitionStatus # noqa: PLC0415
|
||
from src.models.files import Document # noqa: PLC0415
|
||
from src.repositories.counterparties import CounterpartyIndex # noqa: PLC0415
|
||
from src.services.result_mapping import map_file # noqa: PLC0415
|
||
from tests.factories import make_stored_file # noqa: PLC0415
|
||
|
||
file, job = await make_stored_file(
|
||
session,
|
||
entity=entity,
|
||
status=FileStatus.PROCESSING,
|
||
job_status=job_status or RecognitionStatus.done,
|
||
original_name=path.name,
|
||
uploaded_by=uploaded_by,
|
||
)
|
||
chart = {
|
||
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
|
||
}
|
||
index = await CounterpartyIndex.load(session)
|
||
builder = result_builder or golden_result
|
||
result = builder(path, job_id=job.id, file_id=file.id)
|
||
await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC))
|
||
documents = await session.execute(
|
||
select(Document)
|
||
.where(Document.stored_file_id == file.id)
|
||
.order_by(Document.document_type, Document.osv_account_code)
|
||
)
|
||
return list(documents.scalars())
|