From 6eb708f76095376484b9945e40fe773f17d9a4b2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:01:11 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=C2=A77=20=D0=B2=20=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=20tabular-parser,=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=8C=20statement=5Fchecks.yaml=20(D-4,=20D-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/rules/statement_checks.yaml | 29 +++ api/src/domain/statement_checks.py | 222 ++++++++++++++++++++++ api/tests/test_domain_statement_checks.py | 109 +++++++++++ 3 files changed, 360 insertions(+) create mode 100644 api/rules/statement_checks.yaml create mode 100644 api/src/domain/statement_checks.py create mode 100644 api/tests/test_domain_statement_checks.py diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml new file mode 100644 index 0000000..317466c --- /dev/null +++ b/api/rules/statement_checks.yaml @@ -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} diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py new file mode 100644 index 0000000..a580eb5 --- /dev/null +++ b/api/src/domain/statement_checks.py @@ -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\d{4})\s*=\s*(?P\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) diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py new file mode 100644 index 0000000..682cd24 --- /dev/null +++ b/api/tests/test_domain_statement_checks.py @@ -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)