feat: словари нормализации (§7, периоды, заголовки, единицы, коды, предупреждения) и загрузчик

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:17:18 +04:00
parent 03a7ae1a59
commit 25b8bc4abf
12 changed files with 1158 additions and 1 deletions

72
arm_statements/issues.py Normal file
View File

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

509
arm_statements/loader.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
- '"'
- '«'

View File

@ -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>\d{1,2})[.\-/ ](?P<m>\d{1,2})[.\-/ ](?P<y>\d{4}|\d{2})'
- kind: day_month_year # «31 декабря 2025 г.», «на 30 сентября 2025 года»
pattern: '(?P<d>\d{1,2})\s+(?P<mw>[а-яё]{3,8})\s+(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
- kind: quarter # «I квартал 2026», «1-й кв. 2026», «первый квартал»
pattern: '(?P<q>I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:й|ый|ого|го)?\s*кв\w*\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
- kind: half # «1 полугодие 2026», «первое полугодие», «1 п/г 2026»
pattern: '(?P<q>I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:е|ое|го)?\s*(?:полугоди\w*|п/г)\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
- kind: months # «9 месяцев 2025», «9 мес. 2025»
pattern: '(?P<n>\d{1,2})\s*мес\w*\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
- kind: month_range # «январь - сентябрь 2025»: последний месяц
pattern: '[а-яё]{3,8}\s*[-–—]\s*(?P<mw>[а-яё]{3,8})\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
- kind: year # «за 2025 г.», «2025 год», «2025г»
pattern: '(?:за\s*)?(?:отч[её]тный\s*)?(?P<y>\d{4})\s*(?:г\b|г\.|r\b|r\.|год)'

View File

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

View File

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

View File

@ -1 +1,10 @@
"""Общие фикстуры тестов пакета (фикстура `rules` появится в задаче 3)."""
"""Общие фикстуры тестов пакета."""
import pytest
from arm_statements.loader import Rules, default_rules
@pytest.fixture
def rules() -> Rules:
return default_rules()

View File

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