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:
parent
590d4ebc09
commit
6eb708f760
29
api/rules/statement_checks.yaml
Normal file
29
api/rules/statement_checks.yaml
Normal 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}
|
||||
222
api/src/domain/statement_checks.py
Normal file
222
api/src/domain/statement_checks.py
Normal 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)
|
||||
109
api/tests/test_domain_statement_checks.py
Normal file
109
api/tests/test_domain_statement_checks.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user