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:
parent
b62cec83ba
commit
cd4220efc6
161
api/tests/test_mapping_golden.py
Normal file
161
api/tests/test_mapping_golden.py
Normal 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
|
||||
@ -44,7 +44,7 @@
|
||||
- [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит
|
||||
`FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два
|
||||
разных счёта не заменяют друг друга, `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`).
|
||||
Время: ≤ 60 с на все 82.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user