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