diff --git a/arm_statements/issues.py b/arm_statements/issues.py new file mode 100644 index 0000000..4f54512 --- /dev/null +++ b/arm_statements/issues.py @@ -0,0 +1,72 @@ +"""Предупреждения нормализации: перечень кодов и фабрика с уровнем из словаря.""" + +from collections.abc import Sequence +from enum import StrEnum +from typing import TYPE_CHECKING + +from arm_statements.model import StatementWarning + +if TYPE_CHECKING: + from arm_statements.loader import Rules + + +class WarningCode(StrEnum): + """Коды предупреждений: по члену на код `rules/statement_warnings.yaml` (имя = значение).""" + + JSON_FAILED = "JSON_FAILED" + TABLE_PARSE_ERROR = "TABLE_PARSE_ERROR" + TABLE_LOST = "TABLE_LOST" + TABLE_SKIPPED = "TABLE_SKIPPED" + CODE_COLUMN_NOT_FOUND = "CODE_COLUMN_NOT_FOUND" + SEGMENT_WITHOUT_CODES = "SEGMENT_WITHOUT_CODES" + SHEET_NOT_JOINED = "SHEET_NOT_JOINED" + CONTINUATION_TYPE_CONFLICT = "CONTINUATION_TYPE_CONFLICT" + SEGMENT_COLUMNS_MISMATCH = "SEGMENT_COLUMNS_MISMATCH" + COLUMN_WITHOUT_DATE = "COLUMN_WITHOUT_DATE" + COLUMN_DATE_UNCERTAIN = "COLUMN_DATE_UNCERTAIN" + HEADER_ROW_NOT_FOUND = "HEADER_ROW_NOT_FOUND" + TYPE_CONFLICT = "TYPE_CONFLICT" + NO_ORGANIZATION_NO_INN = "NO_ORGANIZATION_NO_INN" + PERIOD_UNKNOWN = "PERIOD_UNKNOWN" + REPORT_DATE_UNCERTAIN = "REPORT_DATE_UNCERTAIN" + XLSB_SCALE_SUSPECT = "XLSB_SCALE_SUSPECT" + INN_NOT_FOUND = "INN_NOT_FOUND" + INN_UNCERTAIN = "INN_UNCERTAIN" + ORGANIZATION_NOT_FOUND = "ORGANIZATION_NOT_FOUND" + ORGANIZATION_UNCERTAIN = "ORGANIZATION_UNCERTAIN" + REPORT_DATE_UNPARSED = "REPORT_DATE_UNPARSED" + UNITS_NOT_FOUND = "UNITS_NOT_FOUND" + FILE_TYPE_MISMATCH = "FILE_TYPE_MISMATCH" + XLS_NO_CODEPAGE = "XLS_NO_CODEPAGE" + COLUMN_SKIPPED = "COLUMN_SKIPPED" + FRACTIONAL_IN_THOUSANDS = "FRACTIONAL_IN_THOUSANDS" + XLSB_NO_FORMATS = "XLSB_NO_FORMATS" + NUMBER_FORMAT_UNPARSED = "NUMBER_FORMAT_UNPARSED" + VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" + SHEET_OTHER = "SHEET_OTHER" + ROW_SPLIT = "ROW_SPLIT" + + +def make_warning( # noqa: PLR0913 -- сигнатура задана публичным API пакета + code: str, + message: str, + rules: "Rules", + *, + line_refs: Sequence[str] = (), + column_index: int | None = None, + field: str | None = None, + locator: str | None = None, +) -> StatementWarning: + """Предупреждение с уровнем из словаря; неизвестный код — `KeyError` (выдуманный код не создаётся).""" + rule = rules.warnings.get(code) + if rule is None: + raise KeyError(f"код предупреждения {code} не задан в statement_warnings.yaml") + return StatementWarning( + code=str(code), + severity=rule.severity, + message=message, + line_refs=tuple(line_refs), + column_index=column_index, + field=field, + locator=locator, + ) diff --git a/arm_statements/loader.py b/arm_statements/loader.py new file mode 100644 index 0000000..7cfbe34 --- /dev/null +++ b/arm_statements/loader.py @@ -0,0 +1,509 @@ +"""Загрузчик словарей `rules/*.yaml` в типизированный `Rules` с проверкой при загрузке.""" + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum +from functools import cache +from pathlib import Path +from typing import Literal, TypeVar + +import yaml + +from arm_statements.issues import WarningCode +from arm_statements.model import DocumentType, Edition, FormVariant, PeriodKind, Severity + +LABEL_KEYS: tuple[str, ...] = ( + "organization", + "inn", + "okpo", + "okopf", + "okfs", + "okopf_okfs", + "okei", + "units", + "address", + "date", +) + +_CHECKS = "statement_checks.yaml" +_PERIODS = "statement_periods.yaml" +_HEADERS = "statement_headers.yaml" +_UNITS = "statement_units.yaml" +_RANGES = "statement_code_ranges.yaml" +_ORDERS = "statement_code_orders.yaml" +_WARNINGS = "statement_warnings.yaml" +_LOOKALIKES = "ocr_lookalikes.yaml" + +_RELATION = re.compile(r"^(\d{4}) = (\d{4})((?: [+-] \d{4})*)$") +_RELATION_TERM = re.compile(r"([+-]) (\d{4})") +_PERIOD_GROUPS = frozenset({"d", "m", "mw", "y", "q", "n"}) +_ABSOLUTE_KINDS = frozenset({"range_end", "day_month_year", "quarter", "half", "months", "month_range", "year"}) +_MONTH_MAX = 12 +_PERCENT_MAX = 100 + +_E = TypeVar("_E", bound=StrEnum) + + +class RulesError(ValueError): + """Словарь не прошёл проверку (в сообщении — файл и ключ).""" + + +class CodeSection(StrEnum): + BALANCE = "BALANCE" + INCOME = "INCOME" + CAPITAL = "CAPITAL" + CASH_FLOW = "CASH_FLOW" + NOTES = "NOTES" + TARGET_USE = "TARGET_USE" + + +@dataclass(frozen=True, slots=True) +class CodeRange: + section: CodeSection + low: int + high: int + + +@dataclass(frozen=True, slots=True) +class CodeRanges: + ranges: tuple[CodeRange, ...] + min_share: Decimal + min_codes: int + min_codes_continuation: int + + +@dataclass(frozen=True, slots=True) +class RelationRule: + form: DocumentType + variant: FormVariant + relation: str + target: str + terms: tuple[tuple[int, str], ...] + strict: bool + abs_terms: frozenset[str] + + +@dataclass(frozen=True, slots=True) +class CheckRules: + tolerance: Decimal + rounding_tolerance_percent: Decimal + subline_check: bool + relations: tuple[RelationRule, ...] + + def relations_for(self, form: DocumentType, variant: FormVariant) -> tuple[RelationRule, ...]: + return tuple(r for r in self.relations if r.form == form and r.variant == variant) + + def abs_codes(self, form: DocumentType, variant: FormVariant) -> frozenset[str]: + found: set[str] = set() + for rule in self.relations_for(form, variant): + found |= rule.abs_terms + return frozenset(found) + + +@dataclass(frozen=True, slots=True) +class PeriodPattern: + kind: str + pattern: re.Pattern[str] + + +@dataclass(frozen=True, slots=True) +class PeriodRules: + relative: tuple[tuple[PeriodKind, re.Pattern[str]], ...] + absolute: tuple[PeriodPattern, ...] + months: tuple[tuple[str, int], ...] + min_year: int + max_year: int + max_text_length: int + + +@dataclass(frozen=True, slots=True) +class HeaderRules: + form_titles: Mapping[DocumentType, tuple[re.Pattern[str], ...]] + okud: Mapping[DocumentType, tuple[re.Pattern[str], ...]] + okud_code: re.Pattern[str] + continuation: tuple[re.Pattern[str], ...] + code_caption: re.Pattern[str] + name_caption: re.Pattern[str] + explanations_caption: re.Pattern[str] + column_exclusions: tuple[re.Pattern[str], ...] + labels: Mapping[str, tuple[re.Pattern[str], ...]] + auditor: tuple[re.Pattern[str], ...] + legal_form: tuple[re.Pattern[str], ...] + + +@dataclass(frozen=True, slots=True) +class UnitRules: + spellings: tuple[tuple[re.Pattern[str], str], ...] + okei: Mapping[str, str] + thousands: str + fractional_share_percent: int + + +@dataclass(frozen=True, slots=True) +class CodeOrders: + editions: tuple[Edition, ...] + sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] + + def sequence(self, edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]: + return self.sequences.get((edition, form, variant), ()) + + +@dataclass(frozen=True, slots=True) +class WarningRule: + code: str + severity: Severity + scope: Literal["document", "file"] + + +@dataclass(frozen=True, slots=True) +class Rules: + checks: CheckRules + periods: PeriodRules + headers: HeaderRules + units: UnitRules + code_ranges: CodeRanges + code_orders: CodeOrders + lookalikes: Mapping[str, str] + warnings: Mapping[str, WarningRule] + + +# --- проверяющие помощники: Any из YAML дальше них не уходит --- + + +def _fail(file: str, key: str, message: str) -> RulesError: + return RulesError(f"{file}: {key}: {message}") + + +def _read(directory: Path, name: str) -> dict[str, object]: + path = directory / name + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise _fail(name, "<файл>", f"не удалось прочитать: {exc}") from exc + if not isinstance(data, dict): + raise _fail(name, "<корень>", "ожидается словарь") + return {str(k): v for k, v in data.items()} + + +def _map(file: str, key: str, node: object) -> dict[str, object]: + if not isinstance(node, dict): + raise _fail(file, key, "ожидается словарь") + return {str(k): v for k, v in node.items()} + + +def _seq(file: str, key: str, node: object) -> list[object]: + if not isinstance(node, list): + raise _fail(file, key, "ожидается список") + return list(node) + + +def _get(file: str, data: Mapping[str, object], key: str) -> object: + if key not in data: + raise _fail(file, key, "ключ отсутствует") + return data[key] + + +def _str(file: str, key: str, node: object) -> str: + if not isinstance(node, str): + raise _fail(file, key, "ожидается строка") + return node + + +def _int(file: str, key: str, node: object) -> int: + if isinstance(node, bool) or not isinstance(node, int): + raise _fail(file, key, "ожидается целое число") + return node + + +def _bool(file: str, key: str, node: object) -> bool: + if not isinstance(node, bool): + raise _fail(file, key, "ожидается true/false") + return node + + +def _number(file: str, key: str, node: object) -> Decimal: + if isinstance(node, bool) or not isinstance(node, int | float): + raise _fail(file, key, "ожидается число") + return Decimal(str(node)) + + +def _rx(file: str, key: str, node: object) -> re.Pattern[str]: + text = _str(file, key, node) + try: + return re.compile(text, re.IGNORECASE) + except re.error as exc: + raise _fail(file, key, f"регулярка не компилируется: {exc}") from exc + + +def _rx_list(file: str, key: str, node: object) -> tuple[re.Pattern[str], ...]: + return tuple(_rx(file, f"{key}[{i}]", item) for i, item in enumerate(_seq(file, key, node))) + + +def _enum(file: str, key: str, enum_type: type[_E], node: object) -> _E: + try: + return enum_type(_str(file, key, node)) + except ValueError as exc: + raise _fail(file, key, f"недопустимое значение {node!r}") from exc + + +def _doc_type_patterns(file: str, key: str, node: object) -> dict[DocumentType, tuple[re.Pattern[str], ...]]: + return { + _enum(file, f"{key}.{name}", DocumentType, name): _rx_list(file, f"{key}.{name}", value) + for name, value in _map(file, key, node).items() + } + + +# --- разделы --- + + +def _parse_relation(file: str, key: str, text: str) -> tuple[str, tuple[tuple[int, str], ...]]: + match = _RELATION.match(text) + if match is None: + raise _fail(file, key, f"соотношение {text!r} не вида «<код> = <код> [+|-] <код> …»") + terms = [(1, match.group(2))] + terms += [(1 if sign == "+" else -1, code) for sign, code in _RELATION_TERM.findall(match.group(3))] + return match.group(1), tuple(terms) + + +def _load_checks(directory: Path) -> CheckRules: + file = _CHECKS + data = _read(directory, file) + relations: list[RelationRule] = [] + for i, item in enumerate(_seq(file, "relations", _get(file, data, "relations"))): + key = f"relations[{i}]" + entry = _map(file, key, item) + form = _enum(file, f"{key}.form", DocumentType, _get(file, entry, "form")) + variant = _enum(file, f"{key}.variant", FormVariant, _get(file, entry, "variant")) + text = _str(file, f"{key}.relation", _get(file, entry, "relation")) + target, terms = _parse_relation(file, f"{key}.relation", text) + raw_abs = _seq(file, f"{key}.abs_terms", entry.get("abs_terms", [])) + abs_terms = frozenset(_str(file, f"{key}.abs_terms", code) for code in raw_abs) + if not abs_terms <= {code for _, code in terms}: + raise _fail(file, f"{key}.abs_terms", "коды должны быть членами правой части соотношения") + relations.append( + RelationRule( + form=form, + variant=variant, + relation=text, + target=target, + terms=terms, + strict=_bool(file, f"{key}.strict", _get(file, entry, "strict")), + abs_terms=abs_terms, + ) + ) + return CheckRules( + tolerance=_number(file, "tolerance", _get(file, data, "tolerance")), + rounding_tolerance_percent=_number( + file, "rounding_tolerance_percent", _get(file, data, "rounding_tolerance_percent") + ), + subline_check=_bool(file, "subline_check", _get(file, data, "subline_check")), + relations=tuple(relations), + ) + + +def _load_periods(directory: Path) -> PeriodRules: + file = _PERIODS + data = _read(directory, file) + relative: list[tuple[PeriodKind, re.Pattern[str]]] = [] + for i, item in enumerate(_seq(file, "relative", _get(file, data, "relative"))): + key = f"relative[{i}]" + entry = _map(file, key, item) + kind = _enum(file, f"{key}.kind", PeriodKind, _get(file, entry, "kind")) + relative.append((kind, _rx(file, f"{key}.pattern", _get(file, entry, "pattern")))) + absolute: list[PeriodPattern] = [] + for i, item in enumerate(_seq(file, "absolute", _get(file, data, "absolute"))): + key = f"absolute[{i}]" + entry = _map(file, key, item) + abs_kind = _str(file, f"{key}.kind", _get(file, entry, "kind")) + if abs_kind not in _ABSOLUTE_KINDS: + raise _fail(file, f"{key}.kind", f"недопустимый вид {abs_kind!r}") + pattern = _rx(file, f"{key}.pattern", _get(file, entry, "pattern")) + if not set(pattern.groupindex) <= _PERIOD_GROUPS: + raise _fail(file, f"{key}.pattern", f"допустимые имена групп: {sorted(_PERIOD_GROUPS)}") + absolute.append(PeriodPattern(kind, pattern)) + months: list[tuple[str, int]] = [] + for stem, number in _map(file, "months", _get(file, data, "months")).items(): + month = _int(file, f"months.{stem}", number) + if not 1 <= month <= _MONTH_MAX: + raise _fail(file, f"months.{stem}", "номер месяца вне 1..12") + months.append((stem, month)) + years = _map(file, "years", _get(file, data, "years")) + min_year = _int(file, "years.min", _get(file, years, "min")) + max_year = _int(file, "years.max", _get(file, years, "max")) + if min_year > max_year: + raise _fail(file, "years", "min больше max") + return PeriodRules( + relative=tuple(relative), + absolute=tuple(absolute), + months=tuple(months), + min_year=min_year, + max_year=max_year, + max_text_length=_int(file, "max_text_length", _get(file, data, "max_text_length")), + ) + + +def _load_headers(directory: Path) -> HeaderRules: + file = _HEADERS + data = _read(directory, file) + captions = _map(file, "captions", _get(file, data, "captions")) + labels_raw = _map(file, "labels", _get(file, data, "labels")) + if tuple(labels_raw) != LABEL_KEYS: + raise _fail(file, "labels", f"ключи должны быть ровно {LABEL_KEYS}, получены {tuple(labels_raw)}") + return HeaderRules( + form_titles=_doc_type_patterns(file, "form_titles", _get(file, data, "form_titles")), + okud=_doc_type_patterns(file, "okud", _get(file, data, "okud")), + okud_code=_rx(file, "okud_code", _get(file, data, "okud_code")), + continuation=_rx_list(file, "continuation", _get(file, data, "continuation")), + code_caption=_rx(file, "captions.code", _get(file, captions, "code")), + name_caption=_rx(file, "captions.name", _get(file, captions, "name")), + explanations_caption=_rx(file, "captions.explanations", _get(file, captions, "explanations")), + column_exclusions=_rx_list(file, "captions.exclusions", _get(file, captions, "exclusions")), + labels={name: _rx_list(file, f"labels.{name}", value) for name, value in labels_raw.items()}, + auditor=_rx_list(file, "auditor", _get(file, data, "auditor")), + legal_form=_rx_list(file, "legal_form", _get(file, data, "legal_form")), + ) + + +def _load_units(directory: Path) -> UnitRules: + file = _UNITS + data = _read(directory, file) + spellings: list[tuple[re.Pattern[str], str]] = [] + canonical_all: set[str] = set() + for i, item in enumerate(_seq(file, "units", _get(file, data, "units"))): + key = f"units[{i}]" + entry = _map(file, key, item) + canonical = _str(file, f"{key}.canonical", _get(file, entry, "canonical")) + canonical_all.add(canonical) + spellings.extend((rx, canonical) for rx in _rx_list(file, f"{key}.patterns", _get(file, entry, "patterns"))) + okei = { + code: _str(file, f"okei.{code}", value) for code, value in _map(file, "okei", _get(file, data, "okei")).items() + } + for code, canonical in okei.items(): + if canonical not in canonical_all: + raise _fail(file, f"okei.{code}", f"единица {canonical!r} не среди canonical") + thousands = _str(file, "thousands", _get(file, data, "thousands")) + if thousands not in canonical_all: + raise _fail(file, "thousands", f"единица {thousands!r} не среди canonical") + percent = _int(file, "fractional_share_percent", _get(file, data, "fractional_share_percent")) + if not 1 <= percent <= _PERCENT_MAX: + raise _fail(file, "fractional_share_percent", "ожидается 1..100") + return UnitRules(tuple(spellings), okei, thousands, percent) + + +def _load_ranges(directory: Path) -> CodeRanges: + file = _RANGES + data = _read(directory, file) + ranges: list[CodeRange] = [] + for name, bounds in _map(file, "sections", _get(file, data, "sections")).items(): + section = _enum(file, f"sections.{name}", CodeSection, name) + pair = _seq(file, f"sections.{name}", bounds) + if len(pair) != len(("low", "high")): + raise _fail(file, f"sections.{name}", "ожидается [low, high]") + low, high = (_int(file, f"sections.{name}", v) for v in pair) + if low > high: + raise _fail(file, f"sections.{name}", "low больше high") + ranges.append(CodeRange(section, low, high)) + min_codes = _int(file, "min_codes", _get(file, data, "min_codes")) + continuation = _int(file, "min_codes_continuation", _get(file, data, "min_codes_continuation")) + if not 1 <= continuation <= min_codes: + raise _fail(file, "min_codes_continuation", "должно быть 1 <= min_codes_continuation <= min_codes") + return CodeRanges( + ranges=tuple(ranges), + min_share=_number(file, "min_share", _get(file, data, "min_share")), + min_codes=min_codes, + min_codes_continuation=continuation, + ) + + +def _load_orders(directory: Path) -> CodeOrders: + file = _ORDERS + data = _read(directory, file) + if _get(file, data, "version") != 1: + raise _fail(file, "version", "поддерживается только версия 1") + repeats = _map(file, "repeats", _get(file, data, "repeats")) + editions_raw = _map(file, "editions", _get(file, data, "editions")) + editions: list[Edition] = [] + sequences: dict[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] = {} + for ed_name, forms_node in editions_raw.items(): + edition = _enum(file, f"editions.{ed_name}", Edition, ed_name) + editions.append(edition) + forms = _map(file, f"editions.{ed_name}", forms_node) + edition_repeats = _map(file, f"repeats.{ed_name}", repeats.get(ed_name, {})) + for form_name, variants_node in forms.items(): + form = _enum(file, f"editions.{ed_name}.{form_name}", DocumentType, form_name) + repeat_codes = [ + _str(file, f"repeats.{ed_name}.{form_name}", c) + for c in _seq(file, f"repeats.{ed_name}.{form_name}", edition_repeats.get(form_name, [])) + ] + variants = _map(file, f"editions.{ed_name}.{form_name}", variants_node) + for var_name, codes_node in variants.items(): + key = f"editions.{ed_name}.{form_name}.{var_name}" + variant = _enum(file, key, FormVariant, var_name) + codes = tuple(_str(file, key, c) for c in _seq(file, key, codes_node)) + for code in codes: + if not re.fullmatch(r"\d{4}", code): + raise _fail(file, key, f"код {code!r} не из четырёх цифр") + _check_repeat_counts(file, key, variant, codes, repeat_codes) + sequences[(edition, form, variant)] = codes + return CodeOrders(tuple(editions), sequences) + + +def _check_repeat_counts(file: str, key: str, variant: FormVariant, codes: tuple[str, ...], repeats: list[str]) -> None: + expected = {code: 1 + repeats.count(code) for code in set(codes) | set(repeats)} + for code, limit in sorted(expected.items()): + actual = codes.count(code) + if variant is FormVariant.FULL and actual != limit: + raise _fail(file, key, f"код {code} встречается {actual} раз, ожидается {limit} (1 + repeats)") + if variant is FormVariant.SIMPLIFIED and actual > limit: + raise _fail(file, key, f"код {code} встречается {actual} раз, допустимо не более {limit}") + + +def _load_warnings(directory: Path) -> dict[str, WarningRule]: + file = _WARNINGS + data = _read(directory, file) + warnings: dict[str, WarningRule] = {} + for code, node in _map(file, "warnings", _get(file, data, "warnings")).items(): + entry = _map(file, f"warnings.{code}", node) + severity = _enum(file, f"warnings.{code}.severity", Severity, _get(file, entry, "severity")) + scope = _str(file, f"warnings.{code}.scope", _get(file, entry, "scope")) + if scope == "document": + warnings[code] = WarningRule(code, severity, "document") + elif scope == "file": + warnings[code] = WarningRule(code, severity, "file") + else: + raise _fail(file, f"warnings.{code}.scope", f"недопустимая область {scope!r}") + if set(warnings) != {c.value for c in WarningCode}: + diff = sorted(set(warnings) ^ {c.value for c in WarningCode}) + raise _fail(file, "warnings", f"коды словаря и WarningCode расходятся: {diff}") + return warnings + + +def _load_lookalikes(directory: Path) -> dict[str, str]: + file = _LOOKALIKES + data = _read(directory, file) + return { + char: _str(file, f"lookalikes.{char}", value) + for char, value in _map(file, "lookalikes", _get(file, data, "lookalikes")).items() + } + + +def load_rules(directory: Path | None = None) -> Rules: + """Прочитать и проверить словари каталога (по умолчанию — `rules/` пакета).""" + base = directory if directory is not None else Path(__file__).parent / "rules" + return Rules( + checks=_load_checks(base), + periods=_load_periods(base), + headers=_load_headers(base), + units=_load_units(base), + code_ranges=_load_ranges(base), + code_orders=_load_orders(base), + lookalikes=_load_lookalikes(base), + warnings=_load_warnings(base), + ) + + +@cache +def default_rules() -> Rules: + return load_rules() diff --git a/arm_statements/rules/ocr_lookalikes.yaml b/arm_statements/rules/ocr_lookalikes.yaml new file mode 100644 index 0000000..486a8a9 --- /dev/null +++ b/arm_statements/rules/ocr_lookalikes.yaml @@ -0,0 +1,16 @@ +# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов. +lookalikes: + "О": "0" + "о": "0" + "O": "0" + "o": "0" + "З": "3" + "з": "3" + "б": "6" + "S": "5" + "s": "5" + "I": "1" + "l": "1" + "|": "1" + "і": "1" + "Ч": "4" diff --git a/arm_statements/rules/statement_checks.yaml b/arm_statements/rules/statement_checks.yaml new file mode 100644 index 0000000..8466325 --- /dev/null +++ b/arm_statements/rules/statement_checks.yaml @@ -0,0 +1,29 @@ +# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. +# variant: full — полный набор; simplified — упрощённый набор (спека «Шаг 7», §7 «Упрощённый набор»). +# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check +# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — +# в скобках: правая часть считается и как записано, и с этими членами по модулю. +tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) +# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит). +rounding_tolerance_percent: 0.001 +subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду +relations: + - {form: BALANCE, variant: full, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} + - {form: BALANCE, variant: full, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true} + - {form: BALANCE, variant: full, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true} + - {form: BALANCE, variant: full, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true} + - {form: BALANCE, variant: full, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true} + - {form: BALANCE, variant: full, relation: "1600 = 1100 + 1200", strict: true} + - {form: BALANCE, variant: full, relation: "1700 = 1300 + 1400 + 1500", strict: true} + - {form: BALANCE, variant: full, relation: "1600 = 1700", strict: true} + - {form: INCOME, variant: full, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]} + - {form: INCOME, variant: full, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]} + - {form: INCOME, variant: full, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]} + - {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]} + - {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false} + - {form: INCOME, variant: full, relation: "2410 = 2411 + 2412", strict: false} + - {form: INCOME, variant: full, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false} + - {form: BALANCE, variant: simplified, relation: "1600 = 1150 + 1170 + 1210 + 1230 + 1250", strict: true} + - {form: BALANCE, variant: simplified, relation: "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550", strict: true} + - {form: BALANCE, variant: simplified, relation: "1600 = 1700", strict: true} + - {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"]} diff --git a/arm_statements/rules/statement_code_orders.yaml b/arm_statements/rules/statement_code_orders.yaml new file mode 100644 index 0000000..de9bce1 --- /dev/null +++ b/arm_statements/rules/statement_code_orders.yaml @@ -0,0 +1,194 @@ +# Упорядоченные перечни кодов строк форм ББ/ОФР по редакциям и вариантам — данные для структурного подтверждения +# неуверенного кода (05-domain-rules.md §11.9 «Код строки»; change unified-statement-normalization, K-5, Р-20). +# +# Что это: порядок кодов бланка (итоги после слагаемых: 1110 … 1190, 1100), без наименований (Р-3: «код → наименование» +# знает только ядро, arm-core/api/rules/statement_lines.yaml). Только четырёхзначные коды: пятизначная подстрока +# подтверждается через родителя (§11.9). Перечень — объединение кодов шаблонов банка (словарь имён ядра, +# fixtures/statements-xlsx/templates/) и кодов печатных форм 1С и сканов, найденных в fixtures (источник — комментарий +# у кода). Код без образца в fixtures сюда не добавляется: кандидаты — OQ-168. +# +# Где живёт: этот файл — источник; в пакете arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml), +# идентичность проверяет sync-statements --check (как копии схем контракта в сервисах). Ядро берёт порядок и состав +# редакций из копии пакета; sync-statements --check сверяет, что все коды словаря имён ядра есть в перечне своей +# редакции. Правка перечня — PR сюда, затем в arm-statements, затем sync-statements в потребителях («сначала ядро»). +# +# Как читается (§11.9): четырёхзначные коды строк ok документа — последовательность; для каждой редакции берётся +# наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (form_variant); выбирается +# редакция с более длинной (при равенстве — по эффективной дате: fsbu4 после 2024-12-31). Повтор кода допустим только +# там, где он есть в перечне (repeats): тогда структура подтверждает обе копии; повтор вне перечня с неуверенной копией — +# needs_review у неуверенной копии (Р-16, Р-20). +# +# Проверка формы файла — scripts/validate_contracts.py (make validate-contracts). +version: 1 + +# Законные повторы кода в одном перечне (Р-20). Ключ — редакция и форма; значение — коды, которые встречаются в +# перечне больше одного раза: в варианте full код стоит ровно 1 + (число его вхождений в этот список) раз, в simplified — +# не чаще (сейчас повторов в simplified нет; validate_contracts.py проверяет оба правила). +repeats: + 66n: + # раздел III для некоммерческих организаций («Целевое финансирование») печатается после раздела III коммерческих + # (fixtures/ocr/aep_3.json, 31.12.2024: 1310 «Паевой фонд», 1320 «Целевой капитал», 1350 «Целевые средства», + # 1360 «Фонд недвижимого и особо ценного движимого имущества», 1370 «Резервный и иные целевые фонды») + BALANCE: ["1310", "1320", "1350", "1360", "1370", "1300"] + INCOME: [] + fsbu4: + # печатная форма 1С по ФСБУ 4/2023 (fixtures/ocr/aep_6.json, «БО 1 кв 2025.json», «Бухгалтерская отчетность + # 1 кв.25.json»): 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде» (AURORA-1431) + BALANCE: ["1220", "1520"] + # 2410 «Налог при упрощенной системе налогообложения» после 2412 (fixtures/ocr/aep_6.json, OQ-152) + INCOME: ["2410"] + +editions: + 66n: + BALANCE: + full: + - "1110" + - "1120" + - "1130" + - "1140" + - "1150" + - "1160" + - "1170" + - "1180" + - "1190" + - "1100" + - "1210" + - "1220" + - "1230" + - "1231" # код клиента: unagrande-group/2024-FY/ББ. Лист 1 .xls + - "1240" + - "1250" + - "1260" + - "1200" + - "1600" + - "1310" + - "1320" + - "1340" + - "1350" + - "1360" + - "1370" + - "1300" + - "1310" # раздел III НКО (aep_3), см. repeats + - "1320" + - "1350" + - "1360" + - "1370" + - "1300" + - "1410" + - "1420" + - "1430" + - "1450" + - "1400" + - "1510" + - "1520" + - "1530" + - "1540" + - "1550" + - "1500" + - "1700" + # упрощённая форма, приказ 66н прил. 5 — по бланку, образца в fixtures нет (OQ-166) + simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510", + "1520", "1550", "1700"] + INCOME: + full: + - "2110" + - "2120" + - "2100" + - "2210" + - "2220" + - "2200" + - "2310" + - "2320" + - "2330" + - "2340" + - "2350" + - "2300" + - "2410" + - "2411" + - "2412" + - "2460" + - "2400" + - "2510" + - "2520" + - "2530" # печатная форма 1С: unagrande-co/2024-FY/ОФР. Лист 2 .xls, mezheninovskaya/2025-Q3 + - "2500" + - "2900" + - "2910" + - "2930" # печатная форма 1С: fixtures/ocr/aep_3.json (после 2900) + # упрощённая форма, приказ 66н прил. 5 — по бланку, образца нет (OQ-166) + simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"] + fsbu4: + BALANCE: + full: + - "1105" # печатная форма 1С: npz/2025-Q3, fixtures/ocr/aep_6.json, «БО 1 кв 2025.json» + - "1110" + - "1130" # печатная форма 1С: npz/2025-Q3, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json» + - "1140" # то же и aep_6.json + - "1150" + - "1160" + - "1170" + - "1180" + - "1190" + - "1100" + - "1210" + - "1215" + - "1220" + - "1220" # «НДС, начисленный по отгрузке», см. repeats + - "1230" + - "1240" + - "1250" + - "1260" + - "1200" + - "1600" + - "1310" + - "1320" + - "1340" + - "1350" + - "1360" + - "1370" + - "1300" + - "1410" + - "1420" + - "1430" + - "1450" + - "1400" + - "1510" + - "1520" + - "1520" # «Обязательство по аренде», см. repeats + - "1530" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025 + - "1540" + - "1550" + - "1500" + - "1700" + # упрощённая форма по ФСБУ 4/2023 — состав принят как у 66н до образца (OQ-166, OQ-168) + simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510", + "1520", "1550", "1700"] + INCOME: + full: + - "2110" + - "2120" + - "2100" + - "2210" + - "2220" + - "2200" + - "2310" + - "2320" + - "2330" + - "2340" + - "2350" + - "2300" + - "2410" + - "2411" + - "2412" + - "2410" # «Налог при упрощенной системе налогообложения», см. repeats + - "2420" + - "2460" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025 + - "2400" + - "2510" + - "2520" + - "2530" + - "2500" + - "2900" + - "2910" + # упрощённая форма по ФСБУ 4/2023 — как у 66н до образца (OQ-166, OQ-168) + simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"] diff --git a/arm_statements/rules/statement_code_ranges.yaml b/arm_statements/rules/statement_code_ranges.yaml new file mode 100644 index 0000000..bf994db --- /dev/null +++ b/arm_statements/rules/statement_code_ranges.yaml @@ -0,0 +1,13 @@ +# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в столбце, +# минимум кодов, чтобы столбец считался столбцом кодов. +sections: + BALANCE: [1100, 1799] + INCOME: [2100, 2999] + CAPITAL: [3100, 3699] + CASH_FLOW: [4100, 4499] + NOTES: [5000, 5999] + TARGET_USE: [6100, 6499] +min_share: 0.6 +min_codes: 3 +# слабое продолжение (отрезок формы без шапки, лист-продолжение без маркера): хватает одного кода +min_codes_continuation: 1 diff --git a/arm_statements/rules/statement_headers.yaml b/arm_statements/rules/statement_headers.yaml new file mode 100644 index 0000000..2bc897b --- /dev/null +++ b/arm_statements/rules/statement_headers.yaml @@ -0,0 +1,78 @@ +# Словарь форм ББ/ОФР (05-domain-rules.md §12): заголовки форм и ОКУД, маркер продолжения, подписи колонок, метки шапки. +# Шаблоны ищутся (re.search) без учёта регистра. Текст перед поиском: normalize_label (пунктуация сохраняется) для +# form_titles, okud, okud_code, continuation, labels, auditor, legal_form; normalize_caption (пунктуация -> пробел) +# для captions. Новый синоним = строка в YAML. + +# Заголовок формы -> тип документа. Титул «Бухгалтерская отчетность» (НПЗ) типом не является и здесь не перечислен. +form_titles: + BALANCE: + - 'бухгалтерский\s+баланс' + INCOME: + - 'отчет\s+о\s+финансовых\s+результатах' + - 'отчет\s+о\s+прибылях\s+и\s+убытках' + DISCLOSURE: + - 'пояснени\w*\s+к\s+бухгалтерскому\s+балансу' + - '^пояснени' + - '^расшифровк' + - 'отчет\s+об\s+изменениях\s+капитала' + - 'отчет\s+о\s+движении\s+денежных\s+средств' + - 'отчет\s+о\s+целевом\s+использовании' + +# Код ОКУД -> тип (ячейка «Форма по ОКУД 0710001» или сам код). Прочие формы 0710xxx — расшифровки +okud: + BALANCE: + - '\b0710001\b' + INCOME: + - '\b0710002\b' + DISCLOSURE: + - '\b07100(0(3|4|5|6|7|8|9)|(1|2|3|4|5|6|7|8|9)\d)\b' + +# Сам код ОКУД формы внутри ячейки («Форма по ОКУД 0710001» -> «0710001») +okud_code: '0710\d{3}' + +# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2» +continuation: + - '\bформа\s+0710\d{3}\s+с\.?\s*\d+' + +# Подписи колонок заголовка таблицы (ячейка целиком; исключения — подстроки) +captions: + code: '^код(ы|\s+строки|\s+показателя)?$' + name: '^наименование' + explanations: '^пояснени' + exclusions: ['изменени', 'отклонени', '%', 'темп', 'доля', 'примечани'] + +# Метки шапки документа (значение — правее в той же строке либо в следующей строке) +labels: + organization: + - '^организация\b' + inn: + - '^инн\b' + - 'идентификационный\s+номер\s+налогоплательщика' + okpo: + - 'по\s+окпо' + okopf: + - 'по\s+окопф\b(?!\s*/)' + okfs: + - 'по\s+окфс' + okopf_okfs: + - 'по\s+окопф\s*/\s*окфс' + okei: + - 'по\s+океи' + units: + - 'единица\s+измерения' + address: + - 'местонахождение\s*\(адрес\)' + - 'адрес\s+в\s+пределах\s+места\s+нахождения' + - '^адрес\b' + date: + - '^(отчетная\s+)?дата\b' + +# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации +auditor: + - 'аудитор' + +# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ) +legal_form: + - '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b' + - '"' + - '«' diff --git a/arm_statements/rules/statement_periods.yaml b/arm_statements/rules/statement_periods.yaml new file mode 100644 index 0000000..4adfad2 --- /dev/null +++ b/arm_statements/rules/statement_periods.yaml @@ -0,0 +1,44 @@ +# Периоды форм ББ/ОФР: подпись колонки или подзаголовок формы -> дата (конец периода). §11.3, §12 п. 4. +# Регулярки ищутся без учёта регистра. Относительные подписи применяются к подписи после normalize_caption. +max_text_length: 200 # длиннее — не дата (защита от backtracking) +years: {min: 1990, max: 2100} # одинокий год вне диапазона датой не считается + +# Основа названия месяца -> номер (сопоставляется по началу слова) +months: + январ: 1 + феврал: 2 + март: 3 + апрел: 4 + ма: 5 + июн: 6 + июл: 7 + август: 8 + сентябр: 9 + октябр: 10 + ноябр: 11 + декабр: 12 + +# Относительные подписи колонок (по порядку проверки) +relative: + - {kind: "relative:prev_prev_year_end", pattern: 'предшествующ\w*\s+предыдущ'} + - {kind: "relative:prev_year_end", pattern: 'декабр\w*\s+предыдущ'} + - {kind: "relative:prev_period", pattern: 'аналогичн\w*\s+период\w*\s+предыдущ'} + - {kind: "relative:current", pattern: 'отч[её]тн\w*\s+(дат|период)'} + +# Абсолютные формы по приоритету (§11.3 п. 3). Именованные группы: d (день), m (месяц числом), mw (месяц словом), +# y (год, 2 или 4 цифры), q (номер квартала/полугодия словом, римской или арабской цифрой), n (число месяцев). +absolute: + - kind: range_end # «с 01.01.2025 по 30.09.2025»: конец диапазона + pattern: 'по\s*(?P\d{1,2})[.\-/ ](?P\d{1,2})[.\-/ ](?P\d{4}|\d{2})' + - kind: day_month_year # «31 декабря 2025 г.», «на 30 сентября 2025 года» + pattern: '(?P\d{1,2})\s+(?P[а-яё]{3,8})\s+(?P\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?' + - kind: quarter # «I квартал 2026», «1-й кв. 2026», «первый квартал» + pattern: '(?PI{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:й|ый|ого|го)?\s*кв\w*\.?\s*(?P\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?' + - kind: half # «1 полугодие 2026», «первое полугодие», «1 п/г 2026» + pattern: '(?PI{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:е|ое|го)?\s*(?:полугоди\w*|п/г)\.?\s*(?P\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?' + - kind: months # «9 месяцев 2025», «9 мес. 2025» + pattern: '(?P\d{1,2})\s*мес\w*\.?\s*(?P\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?' + - kind: month_range # «январь - сентябрь 2025»: последний месяц + pattern: '[а-яё]{3,8}\s*[-–—]\s*(?P[а-яё]{3,8})\s*(?P\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?' + - kind: year # «за 2025 г.», «2025 год», «2025г» + pattern: '(?:за\s*)?(?:отч[её]тный\s*)?(?P\d{4})\s*(?:г\b|г\.|r\b|r\.|год)' diff --git a/arm_statements/rules/statement_units.yaml b/arm_statements/rules/statement_units.yaml new file mode 100644 index 0000000..dd2c217 --- /dev/null +++ b/arm_statements/rules/statement_units.yaml @@ -0,0 +1,16 @@ +# Единицы измерения форм ББ/ОФР. Подпись проверяется по порядку: первая совпавшая запись определяет единицу. +units: + - canonical: "тыс. руб." + patterns: ['тыс\w*\.?\s*руб', 'тысяч\w*\s+рубл'] + - canonical: "млн руб." + patterns: ['млн\w*\.?\s*руб', 'миллион\w*\s+рубл'] + - canonical: "руб." + patterns: ['руб'] +# Код ОКЕИ -> каноническая единица +okei: + "384": "тыс. руб." + "383": "руб." + "385": "млн руб." +thousands: "тыс. руб." # единица, для которой проверяется доля дробных значений +# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100) +fractional_share_percent: 10 diff --git a/arm_statements/rules/statement_warnings.yaml b/arm_statements/rules/statement_warnings.yaml new file mode 100644 index 0000000..d81a271 --- /dev/null +++ b/arm_statements/rules/statement_warnings.yaml @@ -0,0 +1,36 @@ +# Предупреждения нормализации: код -> уровень и область (спека «Шаг 8 — предупреждения»). +# scope: document — предупреждение документа; file — предупреждение файла (metrics.warnings). +# Множество кодов равно перечислению WarningCode (arm_statements/issues.py); код добавляется в обоих местах. +warnings: + JSON_FAILED: {severity: error, scope: file} + TABLE_PARSE_ERROR: {severity: error, scope: file} + TABLE_LOST: {severity: error, scope: file} + TABLE_SKIPPED: {severity: info, scope: file} + CODE_COLUMN_NOT_FOUND: {severity: error, scope: document} + SEGMENT_WITHOUT_CODES: {severity: error, scope: document} + SHEET_NOT_JOINED: {severity: error, scope: document} + CONTINUATION_TYPE_CONFLICT: {severity: error, scope: document} + SEGMENT_COLUMNS_MISMATCH: {severity: error, scope: document} + COLUMN_WITHOUT_DATE: {severity: error, scope: document} + COLUMN_DATE_UNCERTAIN: {severity: error, scope: document} + HEADER_ROW_NOT_FOUND: {severity: error, scope: document} + TYPE_CONFLICT: {severity: error, scope: document} + NO_ORGANIZATION_NO_INN: {severity: error, scope: document} + PERIOD_UNKNOWN: {severity: error, scope: document} + REPORT_DATE_UNCERTAIN: {severity: error, scope: document} + XLSB_SCALE_SUSPECT: {severity: error, scope: document} + INN_NOT_FOUND: {severity: info, scope: document} + INN_UNCERTAIN: {severity: info, scope: document} + ORGANIZATION_NOT_FOUND: {severity: info, scope: document} + ORGANIZATION_UNCERTAIN: {severity: info, scope: document} + REPORT_DATE_UNPARSED: {severity: info, scope: document} + UNITS_NOT_FOUND: {severity: info, scope: document} + FILE_TYPE_MISMATCH: {severity: info, scope: file} + XLS_NO_CODEPAGE: {severity: info, scope: file} + COLUMN_SKIPPED: {severity: info, scope: document} + FRACTIONAL_IN_THOUSANDS: {severity: info, scope: document} + XLSB_NO_FORMATS: {severity: info, scope: document} + NUMBER_FORMAT_UNPARSED: {severity: info, scope: document} + VALUE_OUT_OF_RANGE: {severity: info, scope: document} + SHEET_OTHER: {severity: info, scope: file} + ROW_SPLIT: {severity: info, scope: document} diff --git a/arm_statements/tests/conftest.py b/arm_statements/tests/conftest.py index c3a717e..6e1508e 100644 --- a/arm_statements/tests/conftest.py +++ b/arm_statements/tests/conftest.py @@ -1 +1,10 @@ -"""Общие фикстуры тестов пакета (фикстура `rules` появится в задаче 3).""" +"""Общие фикстуры тестов пакета.""" + +import pytest + +from arm_statements.loader import Rules, default_rules + + +@pytest.fixture +def rules() -> Rules: + return default_rules() diff --git a/arm_statements/tests/test_loader.py b/arm_statements/tests/test_loader.py new file mode 100644 index 0000000..84a9e5e --- /dev/null +++ b/arm_statements/tests/test_loader.py @@ -0,0 +1,141 @@ +import re +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from arm_statements.issues import WarningCode, make_warning +from arm_statements.loader import LABEL_KEYS, Rules, RulesError, load_rules +from arm_statements.model import DocumentType, Edition, FormVariant, Severity + +RULES_DIR = Path(__file__).parent.parent / "rules" + +SPEC_WARNINGS = { + "JSON_FAILED": ("error", "file"), + "TABLE_PARSE_ERROR": ("error", "file"), + "TABLE_LOST": ("error", "file"), + "TABLE_SKIPPED": ("info", "file"), + "CODE_COLUMN_NOT_FOUND": ("error", "document"), + "SEGMENT_WITHOUT_CODES": ("error", "document"), + "SHEET_NOT_JOINED": ("error", "document"), + "CONTINUATION_TYPE_CONFLICT": ("error", "document"), + "SEGMENT_COLUMNS_MISMATCH": ("error", "document"), + "COLUMN_WITHOUT_DATE": ("error", "document"), + "COLUMN_DATE_UNCERTAIN": ("error", "document"), + "HEADER_ROW_NOT_FOUND": ("error", "document"), + "TYPE_CONFLICT": ("error", "document"), + "NO_ORGANIZATION_NO_INN": ("error", "document"), + "PERIOD_UNKNOWN": ("error", "document"), + "REPORT_DATE_UNCERTAIN": ("error", "document"), + "XLSB_SCALE_SUSPECT": ("error", "document"), + "INN_NOT_FOUND": ("info", "document"), + "INN_UNCERTAIN": ("info", "document"), + "ORGANIZATION_NOT_FOUND": ("info", "document"), + "ORGANIZATION_UNCERTAIN": ("info", "document"), + "REPORT_DATE_UNPARSED": ("info", "document"), + "UNITS_NOT_FOUND": ("info", "document"), + "FILE_TYPE_MISMATCH": ("info", "file"), + "COLUMN_SKIPPED": ("info", "document"), + "FRACTIONAL_IN_THOUSANDS": ("info", "document"), + "XLSB_NO_FORMATS": ("info", "document"), + "NUMBER_FORMAT_UNPARSED": ("info", "document"), + "VALUE_OUT_OF_RANGE": ("info", "document"), + "SHEET_OTHER": ("info", "file"), + "ROW_SPLIT": ("info", "document"), + "XLS_NO_CODEPAGE": ("info", "file"), +} + + +def test_warning_table_matches_spec(rules: Rules) -> None: + assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS + + +def test_simplified_income_relation_is_soft(rules: Rules) -> None: + (rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED) + assert rule.target == "2400" and not rule.strict + assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"}) + assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset( + {"2120", "2210", "2220", "2330", "2350", "2410"} + ) + + +def test_code_orders_with_repeats(rules: Rules) -> None: + fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL) + assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu + ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL) + assert "1120" in ras and ras.index("1110") < ras.index("1100") + assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4) + assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == () + + +def test_public_rule_types(rules: Rules) -> None: + assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1") + assert all(isinstance(e, Edition) for e in rules.code_orders.editions) + fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED) + assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu) + assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3) + assert tuple(rules.headers.labels) == LABEL_KEYS + + +def test_warning_code_enum_matches_dictionary(rules: Rules) -> None: + assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS) + assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE" + + +def test_no_code_to_name_mapping_anywhere() -> None: + code = re.compile(r"^\d{4,5}$") + + def walk(node: object) -> None: + if isinstance(node, dict): + for key, value in node.items(): + assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value)) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + for path in RULES_DIR.glob("*.yaml"): + walk(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +def test_broken_repeat_count_rejected(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + orders = tmp_path / "statement_code_orders.yaml" + orders.write_text( + orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'), encoding="utf-8" + ) + with pytest.raises(RulesError, match="statement_code_orders"): + load_rules(tmp_path) + + +def test_unknown_severity_rejected(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + target = tmp_path / "statement_warnings.yaml" + target.write_text( + target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1), encoding="utf-8" + ) + with pytest.raises(RulesError, match="statement_warnings"): + load_rules(tmp_path) + + +def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None: + warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5) + assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5) + assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR + + +def test_make_warning_unknown_code_raises(rules: Rules) -> None: + with pytest.raises(KeyError, match="NOT_A_CODE"): + make_warning("NOT_A_CODE", "x", rules) + + +def test_label_keys_are_checked(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + target = tmp_path / "statement_headers.yaml" + target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8") + with pytest.raises(RulesError, match="statement_headers"): + load_rules(tmp_path)