arm-core/api/tests/test_domain_statement_checks.py

274 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Проверки §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"), ("1600", "1100", "1200")) 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"), ("1600", "1100", "1200"))
]
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"), ("1600", "1100", "1200"))
]
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"), ("1370", "2400")),
),
((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 =")] != []