diff --git a/CLAUDE.md b/CLAUDE.md index 35aafd6..7bd5de3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,9 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( новый формат = новая фикстура + синоним, не новый `if`. ## Тесты -- property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`; +- property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`; для пакета ДКБ + `../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест + `tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`); - снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»; - синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level). @@ -68,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 91 файл: 72 реальных + 19 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 113 файлов: 72 реальных + 22 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 19 синтетических) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/api/scripts/golden_update.py b/api/scripts/golden_update.py index b66b0e4..67658a1 100644 --- a/api/scripts/golden_update.py +++ b/api/scripts/golden_update.py @@ -20,6 +20,7 @@ sys.path[:0] = [str(API_ROOT), str(API_ROOT / "tests")] from fixtures_helpers import ( # noqa: E402 fixtures_skip_reason, + osv_dkb_golden_files, osv_fixture_files, resolve_fixtures_dir, synthetic_files, @@ -43,7 +44,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(reason, file=sys.stderr) return EXIT_NO_FIXTURES root = resolve_fixtures_dir() - files = [*osv_fixture_files(root), *synthetic_files(root)] + files = [*osv_fixture_files(root), *osv_dkb_golden_files(root), *synthetic_files(root)] report = sync_snapshots(root, files, get_parser(), write=not options.check) for diff in report.diffs: diff --git a/api/tests/conftest.py b/api/tests/conftest.py index d7a0b88..2344d5b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -7,6 +7,8 @@ from pathlib import Path import pytest from fixtures_helpers import ( fixtures_skip_reason, + osv_dkb_files, + osv_dkb_golden_files, osv_fixture_files, resolve_fixtures_dir, synthetic_files, @@ -76,9 +78,12 @@ def fixtures_dir() -> Path: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Параметры `osv_file` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip.""" + """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и + `synthetic_file` (синтетика); без каталога — один параметр со skip.""" sources = { "osv_file": (osv_fixture_files, fixtures_skip_reason), + "dkb_file": (osv_dkb_files, fixtures_skip_reason), + "dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason), "synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()), } for argname, (listing, skip_reason) in sources.items(): diff --git a/api/tests/dkb_helpers.py b/api/tests/dkb_helpers.py new file mode 100644 index 0000000..921d1ac --- /dev/null +++ b/api/tests/dkb_helpers.py @@ -0,0 +1,92 @@ +"""Ожидания для пакета ДКБ `fixtures/osv-dkb/**`: период каталога и манифест ожидаемых нарушений контрольных сумм. + +Каталог пакета назван по периоду шапки документа (не по имени файла): `2025-Q3` — «9 месяцев 2025 г.», +`2024-10_2025-09` — скользящие 12 месяцев «Октябрь 2024 г. – Сентябрь 2025 г.». Ожидаемые статусы — данные +(`osv_dkb_expectations.yaml`), а не условия по имени файла: всё, чего в манифесте нет, должно быть `done` без +нарушений §1.4. +""" + +import re +from dataclasses import dataclass +from functools import cache +from pathlib import Path + +import yaml + +from src.contract import OsvDocument +from src.contract.result import OsvChecks + +__all__ = [ + "CheckKind", + "ExpectedPeriod", + "expected_period", + "expected_violations", + "load_expectations", + "violated_kinds", +] + +CheckKind = str +EXPECTATIONS_FILE = Path(__file__).with_name("osv_dkb_expectations.yaml") +QUARTER_DIR = re.compile(r"^(?P\d{4})-(?PQ[1-4])$") +RANGE_DIR = re.compile(r"^(?P\d{4})-(?P\d{2})_(?P\d{4})-(?P\d{2})$") +MONTHS_IN_YEAR = 12 + + +@dataclass(frozen=True) +class ExpectedPeriod: + """Период документа по каталогу: год и квартал (`None` — не определён), скользящий ли это диапазон.""" + + year: int + quarter: str | None + rolling: bool = False + + +def expected_period(directory_name: str) -> ExpectedPeriod: + """`2025-Q3` -> (2025, Q3); `2024-10_2025-09` -> год конца 2025, квартал не определён, скользящий период + (не с начала года или не ровно квартал: парсер не выдаёт его ни за квартальный, ни за нарастающий).""" + quarter = QUARTER_DIR.fullmatch(directory_name) + if quarter is not None: + return ExpectedPeriod(int(quarter.group("year")), quarter.group("quarter")) + span = RANGE_DIR.fullmatch(directory_name) + if span is None: + raise ValueError(f"имя каталога периода не распознано: {directory_name!r}") + months = ( + (int(span.group("end_year")) - int(span.group("start_year"))) * MONTHS_IN_YEAR + + int(span.group("end_month")) + - int(span.group("start_month")) + + 1 + ) + if months <= 0: + raise ValueError(f"конец периода раньше начала: {directory_name!r}") + return ExpectedPeriod(int(span.group("end_year")), None, rolling=True) + + +@cache +def load_expectations() -> dict[str, dict[str, list[str]]]: + """`{путь файла относительно osv-dkb: {locator: [виды нарушений]}}` из манифеста; у каждой записи есть причина.""" + raw = yaml.safe_load(EXPECTATIONS_FILE.read_text(encoding="utf-8")) or {} + found: dict[str, dict[str, list[str]]] = {} + for path, entry in (raw.get("needs_check") or {}).items(): + if not entry.get("reason"): + raise ValueError(f"{EXPECTATIONS_FILE.name}: у {path!r} нет причины (`reason`)") + found[path] = {locator: sorted(kinds) for locator, kinds in entry["sheets"].items()} + return found + + +def expected_violations(relative_path: str, locator: str) -> list[CheckKind]: + """Ожидаемые виды нарушений листа; пусто — лист должен сойтись.""" + return load_expectations().get(relative_path, {}).get(locator, []) + + +def violated_kinds(document: OsvDocument) -> list[CheckKind]: + """Виды нарушений листа: `total_row` («Итого» ≠ корень), `balance_identity`, `parent_sum` (отсортированы).""" + checks: OsvChecks | None = document.checks + assert checks is not None, document.locator + kinds: list[CheckKind] = [] + if not checks.total_row_matches_root: + kinds.append("total_row") + if checks.balance_identity_violations: + kinds.append("balance_identity") + if checks.parent_sum_violations: + kinds.append("parent_sum") + return sorted(kinds) diff --git a/api/tests/fixtures/test_golden.py b/api/tests/fixtures/test_golden.py index 5ed3f9a..937742e 100644 --- a/api/tests/fixtures/test_golden.py +++ b/api/tests/fixtures/test_golden.py @@ -1,5 +1,6 @@ -"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**` и синтетики равен -снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева `osv/`), без volatile-полей. +"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**`, синтетики и файлов +`fixtures/osv-dkb/**` до `GOLDEN_MAX_BYTES` равен снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева +`osv/`, пакет ДКБ — в подкаталоге `osv-dkb/`), без volatile-полей. Снимок — регрессия, а не оракул (оракулы — ручная сверка «Итого» и property-тест). Обновление — `make golden-update`, отдельным коммитом в репозиторий fixtures с просмотром diff. @@ -24,17 +25,21 @@ def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, pa check_snapshot(fixtures_dir, osv_file, parser) +def test_dkb_file_matches_golden_snapshot(fixtures_dir: Path, dkb_golden_file: Path, parser: Parser) -> None: + """Пакет ДКБ: снимки только у файлов не больше `GOLDEN_MAX_BYTES` (большие — property-тесты `test_osv_dkb.py`).""" + check_snapshot(fixtures_dir, dkb_golden_file, parser) + + def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_file: Path, parser: Parser) -> None: check_snapshot(fixtures_dir, synthetic_file, parser) def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None: """Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет.""" - from fixtures_helpers import osv_fixture_files, synthetic_files # noqa: PLC0415 - только для этого теста + from fixtures_helpers import osv_dkb_golden_files, osv_fixture_files, synthetic_files # noqa: PLC0415 - один тест - expected = { - golden_path(fixtures_dir, file) for file in [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)] - } + sources = [*osv_fixture_files(fixtures_dir), *osv_dkb_golden_files(fixtures_dir), *synthetic_files(fixtures_dir)] + expected = {golden_path(fixtures_dir, file) for file in sources} directory = fixtures_dir / "golden" / "expected_osv_parse" present = set(directory.rglob("*.json")) if directory.is_dir() else set() diff --git a/api/tests/fixtures/test_osv_dkb.py b/api/tests/fixtures/test_osv_dkb.py new file mode 100644 index 0000000..421f75b --- /dev/null +++ b/api/tests/fixtures/test_osv_dkb.py @@ -0,0 +1,138 @@ +"""Property-тесты пакета ДКБ `../fixtures/osv-dkb/**` (issue #10): многолистовые книги, статусы `needs_check` как +ожидаемые данные, период каталога, файлы 9–14 МБ без golden. + +Инварианты проверяются на каждом файле независимо от снимков. Ожидаемые нарушения контрольных сумм — манифест +`osv_dkb_expectations.yaml` (`dkb_helpers`), а не условия по имени файла: чего в манифесте нет, должно сойтись. +Период документа — по имени каталога (`2025-Q3`, `2024-10_2025-09`). +""" + +import time +from pathlib import Path +from typing import cast + +import pytest +from dkb_helpers import expected_period, expected_violations, load_expectations, violated_kinds +from fixtures_helpers import GOLDEN_MAX_BYTES, OSV_EXTENSIONS, osv_dkb_files, osv_dkb_golden_files +from golden_helpers import golden_path +from result_contract import assert_valid_result + +from src.contract import FileType, OsvDocument, ResultStatus +from src.parser import ParseOutcome, get_parser + +pytestmark = pytest.mark.fixtures + +EXPECTED_FILES = 25 # пакет ДКБ 06.10.2026 без отчётов «Анализ счета»: 22 файла до порога снимка + 3 большие +EXPECTED_WITHOUT_GOLDEN = 3 # Агрокомплекс 62, 76, 76 (скользящий): 9–14 МБ +MAX_PARSE_SECONDS_SMALL = 5 +MAX_PARSE_SECONDS_LARGE = 30 +ROLLING_WARNING = "не с начала года" + + +def _relative(fixtures_dir: Path, file: Path) -> str: + return file.relative_to(fixtures_dir / "osv-dkb").as_posix() + + +def _parse(file: Path) -> tuple[ParseOutcome, float]: + started = time.monotonic() + outcome = get_parser().parse(file.read_bytes(), cast(FileType, file.suffix.lstrip(".").upper())) + return outcome, time.monotonic() - started + + +def _documents(outcome: ParseOutcome) -> list[OsvDocument]: + assert outcome.error is None, outcome.error + assert outcome.documents + assert all(isinstance(document, OsvDocument) for document in outcome.documents) + return cast(list[OsvDocument], list(outcome.documents)) + + +def test_dkb_package_is_complete(fixtures_dir: Path) -> None: + files = osv_dkb_files(fixtures_dir) + + assert len(files) == EXPECTED_FILES + assert all(path.suffix.lower() in OSV_EXTENSIONS for path in files) + assert len(files) - len(osv_dkb_golden_files(fixtures_dir)) == EXPECTED_WITHOUT_GOLDEN + + +def test_expectations_point_to_existing_files_and_sheets(fixtures_dir: Path) -> None: + existing = {_relative(fixtures_dir, path) for path in osv_dkb_files(fixtures_dir)} + + assert set(load_expectations()) <= existing, sorted(set(load_expectations()) - existing) + + +def test_files_over_threshold_have_no_golden_snapshot(fixtures_dir: Path) -> None: + """Большие файлы (десятки МБ JSON) снимка не имеют — только property-тесты; снимок у остальных обязателен.""" + for path in osv_dkb_files(fixtures_dir): + snapshot = golden_path(fixtures_dir, path) + assert snapshot.is_file() == (path.stat().st_size <= GOLDEN_MAX_BYTES), path.name + + +def _check_statuses(relative: str, outcome: ParseOutcome, documents: list[OsvDocument]) -> None: + locators = [document.locator for document in documents] + assert len(set(locators)) == len(locators) + assert all(locator.startswith("sheet:") for locator in locators) + # Статус результата — по нарушениям листов; ожидаемые нарушения — из манифеста + found = {document.locator: violated_kinds(document) for document in documents} + assert found == {locator: expected_violations(relative, locator) for locator in locators}, found + expected_status = ResultStatus.NEEDS_CHECK if any(found.values()) else ResultStatus.DONE + assert outcome.status is expected_status, outcome.metrics + assert (relative in load_expectations()) == (expected_status is ResultStatus.NEEDS_CHECK) + + +def _check_period(directory: str, outcome: ParseOutcome, documents: list[OsvDocument]) -> None: + expected = expected_period(directory) + for document in documents: + header = document.header + assert header.period_year == expected.year, document.locator + assert header.period_quarter == expected.quarter, document.locator + has_rolling_warning = any( + ROLLING_WARNING in warning for warning in (outcome.metrics.warnings or []) if outcome.metrics + ) + assert has_rolling_warning is expected.rolling, document.locator + if expected.rolling: + assert header.is_cumulative is None + else: + assert header.is_cumulative is not None + + +def _check_structure(outcome: ParseOutcome, documents: list[OsvDocument]) -> None: + total_data_rows = 0 + for document in documents: + rows = document.rows + header = document.header + assert header.organization_name and header.account_code_raw and header.data_type, document.locator + by_number = {row.row_number: row for row in rows} + assert len(by_number) == len(rows) # номера строк уникальны + assert [row.row_number for row in rows] == sorted(by_number) + totals = [row for row in rows if row.group_type == "TOTAL"] + assert totals and all(row.level == 0 and row.parent_row_number is None for row in totals), document.locator + for row in rows: + if row.parent_row_number is None: + assert row.level == 0 + continue + parent = by_number[row.parent_row_number] + assert parent.row_number < row.row_number + assert parent.level < row.level + assert parent.group_type != "TOTAL" + if row.group_type == "ACCOUNT": + assert row.account_code_raw + accounts = [row for row in rows if row.group_type == "ACCOUNT"] + assert accounts, document.locator + assert all( + row.account_code_raw and row.account_code_raw.startswith(header.account_code_raw) for row in accounts + ) + total_data_rows += len(rows) - len(totals) + assert outcome.metrics is not None + assert outcome.metrics.rows == total_data_rows + + +def test_dkb_file_invariants(fixtures_dir: Path, dkb_file: Path) -> None: + """Один разбор файла — все инварианты: статусы листов по манифесту, период каталога, структура строк, контракт.""" + outcome, seconds = _parse(dkb_file) + documents = _documents(outcome) + + _check_statuses(_relative(fixtures_dir, dkb_file), outcome, documents) + _check_period(dkb_file.parent.name, outcome, documents) + _check_structure(outcome, documents) + limit = MAX_PARSE_SECONDS_LARGE if dkb_file.stat().st_size > GOLDEN_MAX_BYTES else MAX_PARSE_SECONDS_SMALL + assert seconds < limit, f"{dkb_file.name}: {seconds:.2f} с" + assert_valid_result(outcome) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 4b86b87..d991a4c 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -7,6 +7,9 @@ FIXTURES_ENV = "ARM_KA_FIXTURES" OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) _SYNTHETIC = "synthetic" _OSV_DKB = "osv-dkb" +# Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк) +# снимок — десятки МБ JSON, для них остаются property-тесты инвариантов +GOLDEN_MAX_BYTES = 1024 * 1024 _ANALYSIS_REPORTS = "analysis-reports" # Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта) _IGNORED_PREFIXES = (".", "~$") @@ -65,6 +68,11 @@ def osv_dkb_files(root: Path) -> list[Path]: return sorted(path for path in found if reports not in path.parents) +def osv_dkb_golden_files(root: Path) -> list[Path]: + """Файлы пакета ДКБ, для которых хранится golden-снимок: размер не больше `GOLDEN_MAX_BYTES`.""" + return [path for path in osv_dkb_files(root) if path.stat().st_size <= GOLDEN_MAX_BYTES] + + def synthetic_files(root: Path) -> list[Path]: """Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не считаются); отсортированы.""" diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py index 2d48721..e08ff07 100644 --- a/api/tests/golden_helpers.py +++ b/api/tests/golden_helpers.py @@ -40,8 +40,10 @@ __all__ = [ SNAPSHOT_DIR = "expected_osv_parse" GOLDEN_DIR = "golden" OSV_DIR = "osv" +OSV_DKB_DIR = "osv-dkb" SYNTHETIC_DIR = "synthetic" REAL_PART = "real" +DKB_PART = "dkb" JSON_INDENT = 2 # Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms")) @@ -63,9 +65,18 @@ def normalize_result(result: dict[str, Any]) -> dict[str, Any]: return normalized +def _mirrored(fixtures_root: Path, file: Path) -> Path: + """Путь файла в зеркале снимков: относительно `osv/`, а для пакета ДКБ — `osv-dkb/<путь внутри osv-dkb>`.""" + dkb = fixtures_root / OSV_DKB_DIR + if dkb in file.parents: + return Path(OSV_DKB_DIR) / file.relative_to(dkb) + return file.relative_to(fixtures_root / OSV_DIR) + + def golden_path(fixtures_root: Path, file: Path) -> Path: - """`golden/expected_osv_parse/<путь файла относительно osv/ без расширения>.json` (зеркало дерева `osv/`).""" - relative = file.relative_to(fixtures_root / OSV_DIR) + """`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ — + в подкаталоге `osv-dkb/`.""" + relative = _mirrored(fixtures_root, file) return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json" @@ -125,15 +136,16 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, * """Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние. Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`. - Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные - файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без - синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3). + Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`), `osv-dkb/` (пакет ДКБ, + только файлы до `GOLDEN_MAX_BYTES`) и остальные (реальные файлы `osv/`). Если в `files` нет ни одного файла части + (каталог отсутствует или пуст — например, fixtures без синтетики), её снимки не лишние: они попадают в + `report.protected` и не удаляются (T12.3). Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный. """ report = SyncReport() expected: set[Path] = set() files = list(files) - present_parts = {_source_part(file.relative_to(fixtures_root / OSV_DIR)) for file in files} + present_parts = {_source_part(_mirrored(fixtures_root, file)) for file in files} for file in files: target = golden_path(fixtures_root, file) expected.add(target) @@ -166,8 +178,11 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, * def _source_part(relative: Path) -> str: - """Часть дерева по пути относительно `osv/` (или каталога снимков): `synthetic` или `real`.""" - return SYNTHETIC_DIR if relative.parts and relative.parts[0] == SYNTHETIC_DIR else REAL_PART + """Часть дерева по пути в зеркале (относительно `osv/` или каталога снимков): `synthetic`, `dkb` или `real`.""" + first = relative.parts[0] if relative.parts else "" + if first == SYNTHETIC_DIR: + return SYNTHETIC_DIR + return DKB_PART if first == OSV_DKB_DIR else REAL_PART def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None: diff --git a/api/tests/osv_dkb_expectations.yaml b/api/tests/osv_dkb_expectations.yaml new file mode 100644 index 0000000..7cde46d --- /dev/null +++ b/api/tests/osv_dkb_expectations.yaml @@ -0,0 +1,24 @@ +# Ожидаемые нарушения контрольных сумм §1.4 в пакете ДКБ (fixtures/osv-dkb/**), по листам. +# Ключ — путь файла относительно osv-dkb; значения листа — виды нарушений: total_row («Итого» ≠ корень / Σ уровня 0), +# balance_identity (начало + обороты ≠ конец в строке), parent_sum (родитель ≠ Σ детей). +# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора +# (иначе чинить парсер): запись удаляется, когда вопрос закрыт. +needs_check: + "tsimlyansky/2025-Q3/91_ 9мес.2025.xls": + reason: "OQ-132: аналитика счёта 91 несёт только обороты, сальдо закрыто на 91.09" + sheets: {"sheet:Лист_1": [balance_identity, parent_sum]} + "primanych/2025-Q3/Оборотно-сальдовая ведомость по счету 91 за 9 месяцев 2025 г. ООО Приманыческий.xls": + reason: "OQ-132: то же, счёт 91" + sheets: {"sheet:Лист_1": [balance_identity, parent_sum]} + "nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls": + reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся" + sheets: {"sheet:58": [parent_sum]} + "agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls": + reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся" + sheets: {"sheet:TDSheet": [balance_identity]} + "agrokompleks/2025-Q3/ОСВ_сч76_2025_9мес.xls": + reason: "аналитика магазинов 76.11 и 76.41.1 без сальдо на конец; пустые названия контрагентов («не задано»)" + sheets: {"sheet:TDSheet": [balance_identity, parent_sum]} + "agrokompleks/2024-10_2025-09/ОСВ_сч76_01.10.2024-30.09.2025.xls": + reason: "то же, что у 76 за 9 месяцев" + sheets: {"sheet:TDSheet": [balance_identity, parent_sum]} diff --git a/api/tests/unit/test_dkb_helpers.py b/api/tests/unit/test_dkb_helpers.py new file mode 100644 index 0000000..96db9f0 --- /dev/null +++ b/api/tests/unit/test_dkb_helpers.py @@ -0,0 +1,35 @@ +"""Ожидания пакета ДКБ: период по каталогу и манифест нарушений (без каталога fixtures).""" + +import pytest +from dkb_helpers import ExpectedPeriod, expected_period, expected_violations, load_expectations + + +@pytest.mark.parametrize( + ("directory", "expected"), + [ + ("2025-Q3", ExpectedPeriod(2025, "Q3")), + ("2024-Q1", ExpectedPeriod(2024, "Q1")), + ("2024-10_2025-09", ExpectedPeriod(2025, None, rolling=True)), + ], +) +def test_period_by_catalog_name(directory: str, expected: ExpectedPeriod) -> None: + assert expected_period(directory) == expected + + +@pytest.mark.parametrize("directory", ["2025", "2025-FY", "2025-09_2024-10", "x"]) +def test_unknown_catalog_name_is_an_error(directory: str) -> None: + with pytest.raises(ValueError, match="каталога периода|конец периода"): + expected_period(directory) + + +def test_expectations_have_reasons_and_known_kinds() -> None: + kinds = {"total_row", "balance_identity", "parent_sum"} + for path, sheets in load_expectations().items(): + assert sheets, path + for locator, found in sheets.items(): + assert locator.startswith("sheet:"), (path, locator) + assert found and set(found) <= kinds, (path, locator, found) + + +def test_unlisted_sheet_is_expected_to_converge() -> None: + assert expected_violations("nm/2025-Q3/нет такого.xlsx", "sheet:60") == [] diff --git a/api/tests/unit/test_golden_helpers.py b/api/tests/unit/test_golden_helpers.py index 0f0c4ab..9e50e57 100644 --- a/api/tests/unit/test_golden_helpers.py +++ b/api/tests/unit/test_golden_helpers.py @@ -75,3 +75,15 @@ def test_unified_diff_names_files_and_shows_changed_lines() -> None: assert "-b" in diff assert "+c" in diff assert unified_diff("a\n", "a\n", name="x") == "" + + +def test_dkb_snapshot_path_mirrors_osv_dkb_under_its_own_subdirectory(tmp_path: Path) -> None: + file = tmp_path / "osv-dkb" / "nm" / "2025-Q3" / "НМ ОСВ 63.xlsx" + + assert golden_path(tmp_path, file) == ( + tmp_path / "golden" / SNAPSHOT_DIR / "osv-dkb" / "nm" / "2025-Q3" / "НМ ОСВ 63.json" + ) + assert ( + golden_path(tmp_path, tmp_path / "osv" / "rb" / "a.xlsx") + == tmp_path / "golden" / SNAPSHOT_DIR / "rb" / "a.json" + )