"""Ожидания для пакета ДКБ `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)