diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index afc6741..a5d9d08 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -543,6 +543,24 @@ class StatementCheckRules(_Model): def relations_for(self, form: str) -> list[RelationRule]: return [rule for rule in self.relations if rule.form == form] + @model_validator(mode="after") + def _check_explicit_rules_have_four_digit_codes(self) -> "StatementCheckRules": + """Явные правила из yaml содержат только четырёхзначные коды; пятизначные — только в подстроках.""" + parent_len = 4 # четырёхзначные коды для явных правил + for rule in self.relations: + target, terms = _parse_relation(rule.relation) + # Цель всегда четырёхзначная (проверяется в _RELATION) + if len(target) != parent_len: + raise ValueError(f"явное правило «{rule.relation}»: цель должна быть четырёхзначной") + # Члены правой части должны быть четырёхзначными + for _, code in terms: + if len(code) != parent_len: + raise ValueError( + f"явное правило «{rule.relation}»: код «{code}» должен быть четырёхзначным; " + f"пятизначные коды допустимы только в сгенерированных подстроках" + ) + return self + @dataclass(frozen=True) class Rules: diff --git a/api/src/parser/statements/checks.py b/api/src/parser/statements/checks.py index 9db06ff..9c741e8 100644 --- a/api/src/parser/statements/checks.py +++ b/api/src/parser/statements/checks.py @@ -1,6 +1,9 @@ -"""Соотношения §7 по колонкам (design D-7): правила — `statement_checks.yaml`; отсутствующий код — 0; правая часть -считается как записано и с членами `abs_terms` по модулю; допуск `tolerance`. Жёсткие нарушения → `relations_failed`, -мягкие и «сумма подстрок = родитель» → `soft_relations_failed`; всё дублируется предупреждениями. +"""Проверка соотношений §7 по колонкам (design D-7, спека D-7). + +Правила из `statement_checks.yaml`; отсутствующий код = 0. Правая часть вычисляется как записано +и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `tolerance` для всех правил. +Жёсткие нарушения → `relations_failed`, мягкие → `soft_relations_failed`. +Правило проверяется, если целевой код в данных (подстроки всегда). """ from collections.abc import Sequence @@ -36,17 +39,20 @@ def _rhs(amounts: dict[str, Decimal], rule: RelationRule, *, absolute: bool) -> def _holds( - amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal, is_subline: bool = False + amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal ) -> tuple[bool, Decimal, Decimal]: + """Проверка соотношения в пределах допуска. + + Вычисляет правую часть два раза: как записано и с abs() для членов в abs_terms. + Правило выполняется, если хотя бы один вариант в пределах tolerance. + """ expected = amounts.get(rule.target, _ZERO) plain = _rhs(amounts, rule, absolute=False) - # Subline checks (sums of sub-codes) use strict equality without tolerance - check_tolerance = _ZERO if is_subline else tolerance - if abs(expected - plain) <= check_tolerance: + 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) <= check_tolerance: + if abs(expected - with_abs) <= tolerance: return True, expected, with_abs return False, expected, plain @@ -77,12 +83,12 @@ def run_statement_checks( warnings: list[str] = [] for column_index, amounts in enumerate(_amounts(lines, column_count)): for rule in relations: - # Skip explicit rules (from config) where the target code is not in the data - # but always check generated subline rules if they were generated + # Явные правила из конфига пропускаются, если цель отсутствует в данных колонки. + # Сгенерированные подстроки всегда проверяются (если они сгенерированы, то цель есть). is_subline = rule in subline_rules if not is_subline and rule.target not in amounts: continue - holds, expected, actual = _holds(amounts, rule, config.tolerance, is_subline=is_subline) + holds, expected, actual = _holds(amounts, rule, config.tolerance) if holds: continue failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual) diff --git a/api/tests/unit/parser/statements/test_checks.py b/api/tests/unit/parser/statements/test_checks.py index adcfa41..f1f9559 100644 --- a/api/tests/unit/parser/statements/test_checks.py +++ b/api/tests/unit/parser/statements/test_checks.py @@ -68,12 +68,16 @@ def test_broken_total_reported_per_column(rules: Rules) -> None: def test_tolerance_one_unit(rules: Rules) -> None: + """Допуск ±1: 1600 = 151 (ожидание), 1100 + 1200 = 100 + 50 = 150 (расчёт), разница = 1.""" lines = [ - line("1100", "100"), - line("1200", "50"), - line("1600", "151"), - line("1700", "151"), - line("1300", "151"), + line("1150", "100"), # компонента 1100 + line("1100", "100"), # 1100 = 1105 + … + 1150 + … = 100 + line("1210", "50"), # компонента 1200 + line("1200", "50"), # 1200 = 1210 + … = 50 + line("1600", "151"), # 1600 = 1100 + 1200 = 150 (разница 1 в допуске) + line("1310", "151"), # компонента 1300 + line("1300", "151"), # 1300 = 1310 + … = 151 + line("1700", "151"), # 1700 = 1300 + … = 151 ] checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) @@ -82,12 +86,24 @@ def test_tolerance_one_unit(rules: Rules) -> None: def test_relation_holds_with_abs_terms(rules: Rules) -> None: - """Review Focus 3: 1С пишет расходы со знаком минус, 66н — в скобках; оба варианта сходятся.""" - one_c = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")] + """Review Focus 3: 1С пишет расходы со знаком минус, 66н — в скобках; оба варианта сходятся. + + Цепочка: 2100 = 2110 - 2120; 2200 = 2100 - 2210 - 2220; 2300 = 2200 + … + В обоих вариантах (1С: минус; 66н: скобки) chain сходится с abs_terms. + """ + one_c = [ + line("2110", "1000"), + line("2120", "-600"), + line("2100", "400"), # 2100 = 2110 - 2120 = 1000 - (-600) = 1600 (plain) | 1000 - abs(-600) = 400 (abs) + line("2200", "400"), # 2200 = 2100 - 2210 - 2220 = 400 - 0 - 0 = 400 (plain) | abs + line("2300", "400"), # 2300 = 2200 + 2310 + … = 400 + 0 + … = 400 + ] brackets = [ line("2110", "1000"), line("2120", "600"), - line("2100", "400"), + line("2100", "400"), # 2100 = 2110 - 2120 = 1000 - 600 = 400 + line("2200", "400"), # 2200 = 2100 - 2210 - 2220 = 400 - 0 - 0 = 400 + line("2300", "400"), # 2300 = 2200 + … = 400 ] assert run_statement_checks(one_c, 1, "INCOME", rules)[0].relations_failed == [] @@ -95,24 +111,41 @@ def test_relation_holds_with_abs_terms(rules: Rules) -> None: def test_soft_relation_and_sublines(rules: Rules) -> None: - lines = [ + """Мягкие соотношения и генерируемые подстроки. + + Для INCOME: 2410 = 2411 + 2412 мягкое (strict=false) → -100 ≠ -110 (разница 10 > допуска 1) → нарушение. + Для BALANCE: две проверки подстрок: + - 1110 = 11101 + 11102 с разницей 2 (10 vs 8) → нарушение + - 1110 = 11101 + 11102 с разницей 1 (10 vs 9) → без нарушения (в допуске) + """ + # Case 1: нарушение подстроки (разница > допуска) + lines_case1 = [ line("2400", "-100"), line("2410", "-100"), line("2411", "-80"), - line("2412", "-30"), + line("2412", "-30"), # -80 + -30 = -110, но 2410 = -100, разница 10 > допуска 1 line("1110", "10"), line("11101", "6"), - line("11102", "3"), + line("11102", "2"), # 6 + 2 = 8, но 1110 = 10, разница 2 > допуска 1 ] - checks, warnings = run_statement_checks(lines, 1, "INCOME", rules) - balance, _ = run_statement_checks(lines, 1, "BALANCE", rules) + checks, warnings = run_statement_checks(lines_case1, 1, "INCOME", rules) + balance, _ = run_statement_checks(lines_case1, 1, "BALANCE", rules) assert [item.relation for item in checks.soft_relations_failed] == ["2410 = 2411 + 2412"] assert checks.relations_failed == [] assert [item.relation for item in balance.soft_relations_failed] == ["1110 = 11101 + 11102"] assert any("мягкое" in warning for warning in warnings) + # Case 2: подстрока в пределах допуска (разница = 1) + lines_case2 = [ + line("1110", "10"), + line("11101", "6"), + line("11102", "3"), # 6 + 3 = 9, но 1110 = 10, разница 1 = допуска → нет нарушения + ] + balance2, _ = run_statement_checks(lines_case2, 1, "BALANCE", rules) + assert balance2.soft_relations_failed == [] + def test_null_values_count_as_zero_and_empty_form_passes(rules: Rules) -> None: lines = [line("1100", None), line("1200", None), line("1600", None)] diff --git a/api/tests/unit/parser/test_statement_rules.py b/api/tests/unit/parser/test_statement_rules.py index 91b6f61..7918d11 100644 --- a/api/tests/unit/parser/test_statement_rules.py +++ b/api/tests/unit/parser/test_statement_rules.py @@ -125,3 +125,15 @@ def test_bad_relation_rejected(tmp_path: Path) -> None: (tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8") with pytest.raises(RuntimeError, match="statement_checks.yaml"): load_rules(tmp_path) + + +def test_explicit_rules_reject_five_digit_codes(tmp_path: Path) -> None: + """R-9: явные правила должны содержать только четырёхзначные коды; пятизначные только в подстроках.""" + for name in DEFAULT_RULES_DIR.glob("*.yaml"): + (tmp_path / name.name).write_bytes(name.read_bytes()) + broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8")) + # Пытаемся добавить правило с пятизначным кодом на правой части + broken["relations"].append({"form": "BALANCE", "relation": "1100 = 11101 + 1105", "strict": True}) + (tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8") + with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"): + load_rules(tmp_path)