"""Поиск каталога реальных данных `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/.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())