test(mapping): golden-прогон всех снимков парсера в БД (82 результата)

`tests/test_mapping_golden.py` (integration + fixtures, рулинг R-1, tasks 3.4): все
`../fixtures/golden/expected_osv_parse/**/*.json` → файл ЮЛ в PROCESSING с заданием `done`
→ `map_file` в откатываемой транзакции с seed. Инварианты спеки на каждом документе: строк =
строк снимка без TOTAL, у строк уровня > 0 есть родитель, шесть сумм равны снимку, `account_id`
= план счетов seed для нормализованного кода, вне плана — NULL. 82 снимка, 85 документов,
13510 строк, маппинг ~4 с (бюджет 60 с); 19 кодов строк-счетов вне плана seed.

Путь теста в tasks.md 3.4 приведён к рулингу R-1 (`tests/test_mapping_golden.py`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 23:08:25 +04:00
parent b62cec83ba
commit cd4220efc6
2 changed files with 162 additions and 1 deletions

View File

@ -0,0 +1,161 @@
"""Golden-прогон маппинга: все снимки парсера `../fixtures/golden/expected_osv_parse/**` в БД (spec osv-result-mapping
«Все golden-снимки парсера»; рулинг R-1; tasks 3.4).
Каждый снимок — файл ЮЛ в `PROCESSING` с текущим заданием `done` и `map_file` в одной откатываемой транзакции теста
(`seeded`: план счетов и справочники seed). Цикл маппинга и S3 здесь не нужны: чтение результата и выбор кандидата
проверяет `test_result_mapping.py`, а golden-прогон проверяет сам маппинг на реальных данных. Все снимки маппятся для
одного ЮЛ — файлы с одинаковым счётом и периодом по пути заменяют друг друга (автозамена), документы и строки
заменённых файлов сохраняются, поэтому инварианты проверяются для каждого файла.
Инварианты: строк `osv_entry` = строк результата без `TOTAL`; у каждой строки уровня > 0 есть родитель (строка того же
документа с `row_number = parent_row_number`); шесть сумм равны снимку; `account_id` = строка плана счетов seed для
нормализованного кода, а для кодов вне плана — NULL (часть кодов golden в плане seed отсутствует — это норма).
"""
import json
import time
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.contract.result import RecognitionResult
from src.domain.osv_mapping import normalize_account_code
from src.models.accounting import ChartOfAccount
from src.models.enums import FileStatus, RecognitionStatus
from src.models.files import Document, StoredFile
from src.models.osv import OsvEntry
from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import map_file
from tests.factories import make_legal_entity, make_stored_file
GOLDEN_DIR = Path("golden") / "expected_osv_parse"
EXPECTED_SNAPSHOTS = 82 # результатов ОСВ в fixtures (rb, schastl, synthetic)
TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты
SUM_FIELDS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
TOTAL = "TOTAL"
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
def _snapshots(fixtures_dir: Path) -> list[Path]:
return sorted((fixtures_dir / GOLDEN_DIR).rglob("*.json"))
def _payload(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult:
data = json.loads(path.read_text("utf-8"))
# у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна)
data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
def _decimal(value: str | None) -> Decimal | None:
return Decimal(value) if value is not None else None
def _check_sheet( # noqa: PLR0912 - все инварианты одного листа в одном месте
name: str, sheet: dict[str, Any], entries: list[OsvEntry], chart: dict[str, uuid.UUID]
) -> tuple[list[str], set[str]]:
"""Нарушения инвариантов листа и нормализованные коды строк-счетов, которых нет в плане счетов."""
problems: list[str] = []
missing: set[str] = set()
rows = [row for row in sheet["rows"] if row["group_type"] != TOTAL]
if len(entries) != len(rows):
problems.append(f"{name}: строк osv_entry {len(entries)}, в снимке без TOTAL {len(rows)}")
by_row = {entry.row_number: entry for entry in entries}
by_id = {entry.id: entry for entry in entries}
for row in rows:
entry = by_row.get(row["row_number"])
if entry is None:
problems.append(f"{name}: нет строки {row['row_number']}")
continue
where = f"{name} строка {row['row_number']}"
if entry.level > 0:
parent = by_id.get(entry.parent_entry_id) if entry.parent_entry_id is not None else None
if parent is None or parent.row_number != row["parent_row_number"]:
problems.append(f"{where}: уровень {entry.level} без родителя {row['parent_row_number']}")
for field in SUM_FIELDS:
if getattr(entry, field) != _decimal(row[field]):
problems.append(f"{where}: {field} {getattr(entry, field)} != {row[field]}")
code = row["account_code_raw"]
expected = chart.get(normalize_account_code(code)) if code else None
if entry.account_id != expected:
problems.append(f"{where}: account_id {entry.account_id}, ожидался {expected} для кода {code!r}")
if row["group_type"] == "ACCOUNT" and code and expected is None:
missing.add(normalize_account_code(code))
return problems, missing
async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: Path) -> None: # noqa: PLR0914
"""«Все golden-снимки парсера»: 82 результата маппятся в БД, инварианты спеки на каждом документе, ≤ 60 с."""
session = seeded
snapshots = _snapshots(fixtures_dir)
assert len(snapshots) == EXPECTED_SNAPSHOTS
owner = await make_legal_entity(session, name="АО «Русский бисквит»", inn="3528052718")
chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
}
index = await CounterpartyIndex.load(session)
mapped: list[tuple[Path, uuid.UUID, RecognitionResult]] = []
mapping_seconds = 0.0
for path in snapshots:
file, job = await make_stored_file(
session,
entity=owner,
status=FileStatus.PROCESSING,
job_status=RecognitionStatus.done,
original_name=path.name,
)
result = _payload(path, job_id=job.id, file_id=file.id)
started = time.perf_counter()
await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC))
mapping_seconds += time.perf_counter() - started
mapped.append((path, file.id, result))
problems: list[str] = []
missing_codes: set[str] = set()
documents_total = entries_total = 0
for path, file_id, _ in mapped:
name = str(path.relative_to(fixtures_dir / GOLDEN_DIR))
raw = json.loads(path.read_text("utf-8"))
documents = {
document.locator: document
for document in (
await session.execute(select(Document).where(Document.stored_file_id == file_id))
).scalars()
}
sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"]
if set(documents) != {sheet["locator"] for sheet in sheets}:
problems.append(f"{name}: документы {sorted(documents)} не совпадают с листами снимка")
continue
entries_by_document: dict[uuid.UUID, list[OsvEntry]] = defaultdict(list)
rows = await session.execute(
select(OsvEntry).where(OsvEntry.document_id.in_([document.id for document in documents.values()]))
)
for entry in rows.scalars():
entries_by_document[entry.document_id].append(entry)
for sheet in sheets:
entries = entries_by_document[documents[sheet["locator"]].id]
sheet_problems, sheet_missing = _check_sheet(f"{name} {sheet['locator']}", sheet, entries, chart)
problems.extend(sheet_problems)
missing_codes |= sheet_missing
documents_total += 1
entries_total += len(entries)
file_status = (await session.execute(select(StoredFile.status).where(StoredFile.id == file_id))).scalar_one()
if file_status not in {FileStatus.PROCESSED, FileStatus.SUPERSEDED}:
problems.append(f"{name}: статус файла {file_status}")
print( # noqa: T201 - сводка прогона в отчёт задачи
f"\nGOLDEN: снимков {len(mapped)}, документов {documents_total}, строк {entries_total}, "
f"маппинг {mapping_seconds:.2f} с; кодов строк-счетов вне плана счетов seed: {len(missing_codes)} "
f"{sorted(missing_codes)}"
)
assert problems == []
assert mapping_seconds <= TIME_BUDGET_SECONDS

View File

@ -44,7 +44,7 @@
- [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит - [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит
`FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два `FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два
разных счёта не заменяют друг друга, `include_superseded`. разных счёта не заменяют друг друга, `include_superseded`.
- [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` - [x] 3.4 **Golden-прогон.** `tests/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json`
→ файл + задание `done` + маппинг через `map_file`; инварианты спеки (строки без TOTAL, родители, суммы, `account_id`). → файл + задание `done` + маппинг через `map_file`; инварианты спеки (строки без TOTAL, родители, суммы, `account_id`).
Время: ≤ 60 с на все 82. Время: ≤ 60 с на все 82.