"""Проверки §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, subline_parents, ) 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_target_present_no_terms_hard_failure() -> None: # Спека: отсутствующий код = 0; если цель присутствует но членов нет, это нарушение # Правило 1600 = 1100 + 1200, в колонке только 1600=1000 → 1600 ≠ 0, жёсткое нарушение report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) assert any( item.relation == "1600 = 1100 + 1200" and item.expected == D("1000") and item.actual == D("0") for item in report.failed ) def test_target_without_self_sum_hard_failure() -> None: # Спека: пропуск только если цель отсутствует; если цель есть, правило проверяется # Правило 1600 = 1700, в колонке только 1600=1000 (нет 1300, 1400, 1500) → нарушение report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) assert any( item.relation == "1600 = 1700" and item.expected == D("1000") and item.actual == D("0") for item in report.failed ) def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]: return [item for item in items if item.relation == relation] def test_relation_checked_per_column_and_none_is_zero() -> None: # колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0) lines = _lines( ("1100", {0: "300", 1: "300", 2: "300"}), ("1200", {0: "600", 1: "600", 2: None}), ("1600", {0: "900", 1: "1000", 2: "300"}), ) report = run_checks("BALANCE", lines, [0, 1, 2], RULES) assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900")) ] assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] def test_tolerance_exceeded_by_one_unit_is_violation() -> None: # допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"})) report = run_checks("BALANCE", lines, [0], RULES) assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900")) ] def test_tolerance_boundary_is_inclusive() -> None: lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})) report = run_checks("BALANCE", lines, [0], RULES) assert _by_relation(report.failed, "1600 = 1100 + 1200") == [] assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None: # 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES) assert _by_relation(holds.failed, "2100 = 2110 - 2120") == [] broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES) failed = _by_relation(broken.failed, "2100 = 2110 - 2120") assert len(failed) == 1 assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40")) def test_subline_attached_to_last_parent_by_row_order() -> None: # M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) report = run_checks("BALANCE", lines, [0], RULES) assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [ "1230 = 12310 + 12320" ] def test_subline_prefers_last_parent_over_prefix() -> None: # 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) report = run_checks("BALANCE", lines, [0], RULES) relations = {item.relation for item in report.soft_failed} assert "1230 = 12310 + 12320" in relations assert not any(relation.startswith("1231 =") for relation in relations) def test_rounding_threshold_boundary() -> None: # порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100 assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100") halves = {0: "4999950"} at_threshold = run_checks( "BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES ) assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == [] assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [ D("100") ] above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES) assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")] assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == [] @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) def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None: """M-1 (откат): цель присутствует, если строка есть в форме; пустая ячейка (`None`) = 0 — как в tabular-parser (`_amounts`, null -> 0): оба производителя отдают значение на каждую колонку каждой кодированной строки, пустая или нераспознанная ячейка итога при ненулевых слагаемых — жёсткое нарушение (а не пропуск правила).""" terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"})) report = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) failed = _by_relation(report.failed, "1600 = 1100 + 1200") assert [(item.column_index, item.expected, item.actual) for item in failed] == [ (0, D("0"), D("900")), (1, D("0"), D("900")), ] def test_subline_parents_nearest_parent_above_with_same_group() -> None: # 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231 codes = ["1231", "1230", "12310", "12320"] assert subline_parents(codes) == {"12310": "1230", "12320": "1230"} def test_subline_parents_fallback_to_four_digit_prefix() -> None: # выше подстроки нет родителя той же группы — родитель по первым четырём цифрам, если такая строка есть assert subline_parents(["11101", "1110"]) == {"11101": "1110"} assert subline_parents(["1100", "1110", "1120", "11101"]) == {"11101": "1110"} def test_subline_parents_orphans_and_non_sublines_are_absent() -> None: assert subline_parents(["1100", "11101", "1700", "123"]) == {} assert subline_parents([]) == {} def test_subline_parents_follow_given_order() -> None: # порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"} assert subline_parents(["1231", "1230", "12310"]) == {"12310": "1230"} def test_run_checks_and_subline_parents_agree() -> None: # проверка «сумма подстрок = родитель» строится по тем же родителям, что и `subline_parents` codes = ["1231", "1230", "12310", "12320", "1110", "11101"] amounts = {"11101": "5", "1230": "10"} report = run_checks("BALANCE", _lines(*((code, {0: amounts.get(code, "1")}) for code in codes)), [0], RULES) parents = subline_parents(codes) expected = { f"{parent} = {' + '.join(kid for kid, owner in parents.items() if owner == parent)}" for parent in set(parents.values()) } assert {item.relation for item in report.soft_failed} == expected def test_subline_check_skipped_when_all_sublines_empty_in_column() -> None: # AURORA-1527: детализации нет (все подстроки пусты) — «1150 = 11501 + …» по колонке не проверяется lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: None}), ("11502", {0: None, 1: None})) assert _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") == [] def test_subline_check_is_per_column() -> None: # в колонке 0 подстроки пусты (не проверяется), в колонке 1 заполнены и не сходятся (нарушение) lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: "10"}), ("11502", {0: None, 1: None})) failed = _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, D("90"), D("10"))] def test_subline_zero_is_a_value() -> None: # ноль — значение: подстроки с нулями проверяются как раньше lines = _lines(("1150", {0: "90"}), ("11501", {0: "0"}), ("11502", {0: None})) assert len(_by_relation(run_checks("BALANCE", lines, [0], RULES).soft_failed, "1150 = 11501 + 11502")) == 1 def test_empty_sublines_do_not_affect_other_relations() -> None: # прочие соотношения §7 не затронуты: пустая цель 1200 при ненулевых слагаемых — по-прежнему жёсткое нарушение lines = _lines(("1210", {0: "5"}), ("12101", {0: None}), ("1200", {0: None}), ("1600", {0: "5"})) report = run_checks("BALANCE", lines, [0], RULES) assert [f.relation for f in report.failed if f.relation.startswith("1200 =")] != []