arm-core/api/tests/test_mapping_golden.py
Andrey Epifancev 33dbe54e9c test(golden): 60.111/60.112 из синтетики s46 — в KNOWN_OUT_OF_PLAN (#193)
Синтетика s46 (fixtures 530ede1, tabular-parser#71, AURORA-1714) — субсчета 60.111/60.112, которых нет в плане
счетов seed; test_all_golden_snapshots_mapped на main падал с «новые коды вне плана счетов».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:12:46 +04:00

225 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 (известные коды вне плана 1С — `KNOWN_OUT_OF_PLAN`).
Число снимков проверяется снизу (`MIN_SNAPSHOTS`), а не на равенство (issue #33): новые снимки fixtures не ломают тест,
а проходят те же инварианты; пропажа снимков по-прежнему ловится.
"""
import json
import time
import uuid
from collections import Counter, 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 DocumentType, 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
from tests.fixtures_helpers import golden_result
GOLDEN_DIR = Path("golden") / "expected_osv_parse"
# результатов ОСВ в fixtures на 07.10.2026: rb 42, schastl 30, osv-dkb 22 (пакет ДКБ), synthetic 19 — всего 113
MIN_SNAPSHOTS = 113
# коды строк-счетов, которых нет в плане счетов seed (выгрузка 1С, issue #23): account_id у них NULL, корень счёта
# известен. Новый код вне плана валит тест — сигнал о дыре в плане счетов (follow-up arm-core #28)
KNOWN_OUT_OF_PLAN = frozenset(
{
"60.АВ", # synthetic/s09_subaccount_names
"76.07.4", # osv-dkb/npz
"76.09.1", # osv-dkb/nkz (76.09.1–76.09.4)
"76.09.2",
"76.09.3",
"76.09.4",
"76.77", # osv-dkb/primanych, osv-dkb/tsimlyansky (76.77, 76.77.1, .2, .5)
"76.77.1",
"76.77.2",
"76.77.5",
"76.АФ", # osv-dkb/primanych
"76.Н.1", # synthetic/s42_subaccounts_totals_reversed (tabular-parser#59)
"60.111", # synthetic/s46_counterparty_code_blocks (tabular-parser#71, AURORA-1714): 60.111, 60.112
"60.112",
}
)
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 _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]}")
if (entry.counterparty_id is None) != (entry.counterparty_match_kind is None):
problems.append(f"{where}: counterparty_id {entry.counterparty_id}, kind {entry.counterparty_match_kind}")
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 _form_problems(session: AsyncSession, file_id: uuid.UUID, raw: dict[str, Any], name: str) -> list[str]:
"""Формы смешанных снимков (s12 и др.): по документу на каждую форму ББ/ОФР снимка (повтор формы хранится, P3-4),
`DISCLOSURE`/`OTHER` — по одному документу на тип."""
types = Counter(
document["document_type"] for document in raw["documents"] if document["document_type"] != DocumentType.OSV
)
stored = Counter(
(
await session.execute(
select(Document.document_type).where(
Document.stored_file_id == file_id, Document.document_type != DocumentType.OSV
)
)
).scalars()
)
merged = (DocumentType.DISCLOSURE, DocumentType.OTHER)
def expected(kind: str) -> int:
return min(types[kind], 1) if kind in merged else types[kind]
return [
f"{name}: документов {kind} {stored.get(kind, 0)}, ожидалось {expected(kind)}"
for kind in sorted({*types, *stored})
if stored.get(kind, 0) != expected(kind)
]
async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: Path) -> None: # noqa: PLR0914
"""«Все golden-снимки парсера» (не меньше 113) маппятся в БД, инварианты спеки на каждом документе, ≤ 60 с."""
session = seeded
snapshots = _snapshots(fixtures_dir)
groups = Counter(path.relative_to(fixtures_dir / GOLDEN_DIR).parts[0] for path in snapshots)
by_group = dict(sorted(groups.items()))
assert len(snapshots) >= MIN_SNAPSHOTS, f"снимков {len(snapshots)} < {MIN_SNAPSHOTS}: {by_group}"
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))
}
assert chart, "план счетов seed пуст — сверка account_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 = golden_result(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 = with_account = with_kind = 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(
# формы ББ/ОФР смешанных снимков (s12) маппятся с Среза 3b — здесь сверяются только листы ОСВ
select(Document).where(
Document.stored_file_id == file_id, Document.document_type == DocumentType.OSV
)
)
).scalars()
}
problems.extend(await _form_problems(session, file_id, raw, name))
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)
with_account += sum(1 for entry in entries if entry.account_id is not None)
with_kind += sum(1 for entry in entries if entry.counterparty_match_kind is not None)
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 == []
# план счетов seed = выгрузка 1С (issue #23): вне плана — только известные коды (s09 и субсчета пакета ДКБ)
unknown = sorted(missing_codes - KNOWN_OUT_OF_PLAN)
assert not unknown, f"новые коды вне плана счетов: {unknown}"
assert with_kind > 0 # есть сопоставленные строки: проверка «kind ⇔ counterparty_id» не вырождена
assert with_account > 0 # строки со счётом плана счетов есть — сверка account_id не вырождена
assert mapping_seconds <= TIME_BUDGET_SECONDS