feat(statements): проверки §7 в ядре — перенос tabular-parser, словарь statement_checks.yaml (D-4, D-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 11:01:11 +04:00
parent 590d4ebc09
commit 6eb708f760
3 changed files with 360 additions and 0 deletions

View File

@ -0,0 +1,29 @@
# КОПИЯ tabular-parser/api/rules/statement_checks.yaml (Срез 3b, design D-4): меняется в обоих репозиториях одновременно.
# Межрепозиторного теста идентичности нет сознательно (решение №10); расхождение копий — известный риск (03-architecture).
# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь.
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в
# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7).
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление.
# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа
# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус:
# уходит в soft_relations_failed с предупреждением «в пределах округления».
rounding_tolerance_percent: 0.001
subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду
relations:
- {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true}
- {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true}
- {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true}
- {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true}
- {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true}
- {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true}
- {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true}
- {form: BALANCE, relation: "1600 = 1700", strict: true}
- {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]}
- {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]}
- {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]}
- {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]}
- {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false}
- {form: INCOME, relation: "2410 = 2411 + 2412", strict: false}
- {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false}

View File

@ -0,0 +1,222 @@
"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5).
Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в
колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по
модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше
`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок =
четырёхзначный родитель» — мягкая. Результат не хранится (DBML).
"""
import functools
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal, cast
import yaml
__all__ = [
"CROSS_FORM_RELATION",
"CheckRules",
"ChecksReport",
"FailedCheck",
"RelationRule",
"cross_form_check",
"load_check_rules",
"run_checks",
]
Form = Literal["BALANCE", "INCOME"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml"
CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400"
# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser —
# statement_headers.yaml)
CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)}
_ZERO = Decimal("0")
_HUNDRED = Decimal("100")
_PARENT_LEN = 4
_GROUP_LEN = 3
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$")
_TERM = re.compile(r"([+\-])?\s*(\d{4,5})")
@dataclass(frozen=True)
class RelationRule:
form: Form
relation: str
target: str
terms: tuple[tuple[int, str], ...]
strict: bool
abs_terms: frozenset[str] = frozenset()
@dataclass(frozen=True)
class CheckRules:
tolerance: Decimal
rounding_tolerance_percent: Decimal
subline_check: bool
relations: tuple[RelationRule, ...]
def relations_for(self, form: str) -> tuple[RelationRule, ...]:
return tuple(rule for rule in self.relations if rule.form == form)
@dataclass(frozen=True)
class FailedCheck:
column_index: int
relation: str
expected: Decimal
actual: Decimal
@dataclass(frozen=True)
class ChecksReport:
failed: tuple[FailedCheck, ...]
soft_failed: tuple[FailedCheck, ...]
def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule:
found = _RELATION.match(relation)
if found is None:
raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …»")
terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs")))
unknown = set(abs_terms) - {code for _, code in terms}
if unknown:
raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»")
return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms))
@functools.lru_cache(maxsize=4)
def load_check_rules(path: Path | None = None) -> CheckRules:
"""Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла."""
source = path or RULES_PATH
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
relations = tuple(
_parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ()))
for item in data["relations"]
)
for rule in relations:
if rule.form not in CODE_RANGES:
raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME")
return CheckRules(
tolerance=Decimal(str(data["tolerance"])),
rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))),
subline_check=bool(data.get("subline_check", True)),
relations=relations,
)
except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc:
raise ValueError(f"словарь проверок {source.name}: {exc}") from exc
def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal:
total = _ZERO
for sign, code in rule.terms:
value = amounts.get(code, _ZERO)
if absolute and code in rule.abs_terms:
value = abs(value)
total += sign * value
return total
def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]:
expected = amounts.get(rule.target, _ZERO)
plain = _rhs(amounts, rule, absolute=False)
if abs(expected - plain) <= tolerance:
return True, expected, plain
if rule.abs_terms:
with_abs = _rhs(amounts, rule, absolute=True)
if abs(expected - with_abs) <= tolerance:
return True, expected, with_abs
if abs(expected - with_abs) < abs(expected - plain):
return False, expected, with_abs
return False, expected, plain
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по
порядку строк, M-9 2b)."""
low, high = CODE_RANGES[form]
parents = {code for code in codes if len(code) == _PARENT_LEN}
children: dict[str, list[str]] = {}
last_parent: str | None = None
for code in codes:
if len(code) == _PARENT_LEN:
last_parent = code
continue
if len(code) != _PARENT_LEN + 1:
continue
if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]:
parent = last_parent
elif code[:_PARENT_LEN] in parents:
parent = code[:_PARENT_LEN]
else:
continue
children.setdefault(parent, []).append(code)
rules: list[RelationRule] = []
for parent, kids in children.items():
if parent.isdigit() and low <= int(parent) <= high:
relation = f"{parent} = {' + '.join(kids)}"
rules.append(
RelationRule(
cast(Form, form),
relation,
parent,
tuple((1, kid) for kid in kids),
False,
)
)
return rules
def run_checks(
form: str,
lines: Sequence[tuple[str, Mapping[int, Decimal | None]]],
column_indexes: Sequence[int],
rules: CheckRules,
) -> ChecksReport:
"""Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка:
сумма})` по порядку строк."""
explicit = list(rules.relations_for(form))
sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else []
hard: list[FailedCheck] = []
soft: list[FailedCheck] = []
for column_index in column_indexes:
amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines}
for rule in [*explicit, *sublines]:
if rule not in sublines and rule.target not in amounts:
continue
# Для явных правил (не подстроки): пропустить, если целевой есть, но ни одного члена нет
if rule not in sublines:
term_codes = {code for _, code in rule.terms}
if not any(code in amounts for code in term_codes):
continue
holds, expected, actual = _holds(amounts, rule, rules.tolerance)
if holds:
continue
failed = FailedCheck(column_index, rule.relation, expected, actual)
scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)])
rounding = scale * rules.rounding_tolerance_percent / _HUNDRED
if rule.strict and abs(expected - actual) > rounding:
hard.append(failed)
else:
soft.append(failed)
return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft))
def cross_form_check(
balance_1370: tuple[Decimal | None, Decimal | None],
income_2400: Decimal | None,
tolerance: Decimal,
) -> FailedCheck | None:
"""Межформенная §7 (мягкая, D-5): `1370(отчётная дата) − 1370(начало года) = 2400(период)`;
неполные данные — None."""
current, year_start = balance_1370
if current is None or year_start is None or income_2400 is None:
return None
delta = current - year_start
if abs(delta - income_2400) <= tolerance:
return None
return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400)

View File

@ -0,0 +1,109 @@
"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser."""
from decimal import Decimal
import pytest
from src.domain.statement_checks import (
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
)
D = Decimal
RULES = load_check_rules()
def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]:
return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items]
def test_rules_loaded_from_copy() -> None:
assert RULES.tolerance == D("1")
assert RULES.rounding_tolerance_percent == D("0.001")
relations = [rule.relation for rule in RULES.relations_for("BALANCE")]
assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations
income = {rule.relation: rule for rule in RULES.relations_for("INCOME")}
assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"})
assert income["2400 = 2300 - 2410 + 2420"].strict is False
def test_hard_violation() -> None:
report = run_checks(
"BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES
)
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed
def test_relation_skipped_without_target() -> None:
report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES)
assert report == ChecksReport(failed=(), soft_failed=())
def test_tolerance_one_unit() -> None:
report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
def test_abs_terms_minus_sign() -> None:
report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES)
assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed)
def test_rounding_goes_soft() -> None:
# расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления»
report = run_checks(
"BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES
)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed)
def test_soft_relation_not_hard() -> None:
report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES)
assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed)
assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed)
def test_sublines_soft() -> None:
report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES)
assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed)
assert report.failed == ()
def test_per_column_and_null_as_zero() -> None:
report = run_checks(
"BALANCE",
_lines(("1100", {0: "300", 1: None}), ("1200", {0: "600", 1: "5"}), ("1600", {0: "900", 1: "5"})),
[0, 1],
RULES,
)
assert report.failed == ()
@pytest.mark.parametrize(
("b1370", "i2400", "expected"),
[
((D("500"), D("300")), D("200"), None),
(
(D("500"), D("300")),
D("150"),
FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150")),
),
((None, D("300")), D("200"), None),
((D("500"), D("300")), None, None),
],
)
def test_cross_form(
b1370: tuple[Decimal | None, Decimal | None], i2400: Decimal | None, expected: FailedCheck | None
) -> None:
assert cross_form_check(b1370, i2400, D("1")) == expected
def test_broken_rules_file(tmp_path) -> None:
bad = tmp_path / "statement_checks.yaml"
bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8")
with pytest.raises(ValueError, match="statement_checks.yaml"):
load_check_rules(bad)