test(fixtures): обобщение тестов и golden на osv-dkb/** (многолистовые книги, ожидаемые needs_check, период каталога)

- test_osv_dkb.py: на каждом из 25 файлов пакета ДКБ — статусы листов по манифесту osv_dkb_expectations.yaml (данные, не
  if по имени файла), период по каталогу (2025-Q3, 2024-10_2025-09), структура строк, контракт, время разбора.
- golden: osv-dkb/** до порога GOLDEN_MAX_BYTES = 1 МиБ (22 из 25 файлов; три файла Агрокомплекса 9–14 МБ — без снимков);
  golden_update, golden_helpers (подкаталог osv-dkb/), conftest (dkb_file, dkb_golden_file), хелперы.
- dkb_helpers: expected_period, манифест; юнит-тесты без каталога fixtures.

Stacked on #15. Closes #10

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 13:23:34 +04:00
parent a592c68509
commit f6e8da9cf6
11 changed files with 355 additions and 18 deletions

View File

@ -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` — только по контрольным суммам, структуру под сходимость не

View File

@ -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:

View File

@ -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():

92
api/tests/dkb_helpers.py Normal file
View File

@ -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<year>\d{4})-(?P<quarter>Q[1-4])$")
RANGE_DIR = re.compile(r"^(?P<start_year>\d{4})-(?P<start_month>\d{2})_(?P<end_year>\d{4})-(?P<end_month>\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)

View File

@ -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()

138
api/tests/fixtures/test_osv_dkb.py vendored Normal file
View File

@ -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)

View File

@ -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` не
считаются); отсортированы."""

View File

@ -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:

View File

@ -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]}

View File

@ -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") == []

View File

@ -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"
)