diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py new file mode 100644 index 0000000..de49dfb --- /dev/null +++ b/api/tests/test_mapping_golden.py @@ -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 diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index eb32646..109cf81 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -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.