Синтетика 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>
225 lines
13 KiB
Python
225 lines
13 KiB
Python
"""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
|