Andrey Epifancev f017c37c51 feat(checks): «родитель = Σ подстрок» не проверяется при пустой детализации (#45, AURORA-1527)
По колонке соотношение считается, только если хотя бы одна подстрока родителя имеет значение
(amount не null; ноль — значение). Совпадает с arm-core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:10:05 +04:00

328 lines
15 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 как данные (design D-7; spec «Соотношения §7 — данные, проверка по колонкам»)."""
from decimal import Decimal
import pytest
from src.contract.result import StatementLine, StatementValue
from src.parser.rules import Rules, load_rules
from src.parser.statements.checks import run_statement_checks
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def line(code: str, *amounts: str | None) -> StatementLine:
values = [
StatementValue(
column_index=index,
amount=None if amount is None else Decimal(amount),
confidence=100,
is_recognized=True,
)
for index, amount in enumerate(amounts)
]
return StatementLine(
line_code=code,
line_code_confidence=100,
line_name=code,
line_name_confidence=100,
values=values,
)
def test_balance_relations_hold_with_missing_codes_as_zero(rules: Rules) -> None:
lines = [
line("1150", "100", "90"),
line("1100", "100", "90"),
line("1210", "50", "40"),
line("1200", "50", "40"),
line("1600", "150", "130"),
line("1310", "150", "130"),
line("1300", "150", "130"),
line("1700", "150", "130"),
]
checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules)
assert checks.relations_failed == []
assert checks.soft_relations_failed == []
assert warnings == []
def test_broken_total_reported_per_column(rules: Rules) -> None:
lines = [
line("1100", "100", "100"),
line("1200", "50", "50"),
line("1600", "155", "150"),
]
checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules)
failed = [(item.column_index, item.relation, item.expected, item.actual) for item in checks.relations_failed]
assert (0, "1600 = 1100 + 1200", Decimal("155"), Decimal("150")) in failed
assert not any(item.column_index == 1 and item.relation == "1600 = 1100 + 1200" for item in checks.relations_failed)
assert any("1600 = 1100 + 1200" in warning and "колонка 0" in warning for warning in warnings)
def test_tolerance_one_unit(rules: Rules) -> None:
"""Допуск ±1: 1600 = 151 (ожидание), 1100 + 1200 = 100 + 50 = 150 (расчёт), разница = 1."""
lines = [
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)
assert checks.relations_failed == []
def test_relation_holds_with_abs_terms(rules: Rules) -> None:
"""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"), # 1000 - (-600) = 1600 plain | 1000 - abs(-600) = 400 (abs) ✓
line("2210", "-50"),
line("2220", "-30"),
line("2200", "320"), # 400 - (-50) - (-30) = 480 plain | 400 - 50 - 30 = 320 (abs) ✓
line("2330", "-20"),
line("2350", "-10"),
line("2300", "290"), # 320 - (-20) - (-10) = 350 plain | 320 - 20 - 10 = 290 (abs) ✓
]
brackets = [
line("2110", "1000"),
line("2120", "600"),
line("2100", "400"), # 1000 - 600 = 400 ✓
line("2210", "50"),
line("2220", "30"),
line("2200", "320"), # 400 - 50 - 30 = 320 ✓
line("2330", "20"),
line("2350", "10"),
line("2300", "290"), # 320 - 20 - 10 = 290 ✓
]
assert run_statement_checks(one_c, 1, "INCOME", rules)[0].relations_failed == []
assert run_statement_checks(brackets, 1, "INCOME", rules)[0].relations_failed == []
def test_soft_relation_and_sublines(rules: Rules) -> None:
"""Мягкие соотношения и генерируемые подстроки.
Для 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"), # -80 + -30 = -110, но 2410 = -100, разница 10 > допуска 1
line("1110", "10"),
line("11101", "6"),
line("11102", "2"), # 6 + 2 = 8, но 1110 = 10, разница 2 > допуска 1
]
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)]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
assert checks.relations_failed == []
def test_target_absent_no_violations(rules: Rules) -> None:
"""R-6: Если цели правила нет в данных, проверка пропускается → нарушений нет."""
# INCOME: 2100, 2110, 2120 есть; 2200 НЕТУ → правило 2200 не проверяется
lines_income = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")]
checks_income, _ = run_statement_checks(lines_income, 1, "INCOME", rules)
assert checks_income.relations_failed == []
assert checks_income.soft_relations_failed == []
# BALANCE: только 1150 БЕЗ 1100 → правило 1100 не проверяется (1100 это цель правила)
lines_balance = [line("1150", "100")]
checks_balance, _ = run_statement_checks(lines_balance, 1, "BALANCE", rules)
assert checks_balance.relations_failed == []
def test_target_present_members_absent_as_zero(rules: Rules) -> None:
"""R-6: Цель есть, члены отсутствуют (= 0) → соотношение с нарушением."""
# BALANCE: 1100 = 100 БЕЗ подстатей → 1100 = 0 + 0 + … = 0 (не совпадает с 100)
lines = [line("1100", "100")]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
# Правило 1100 = 1105 + … (strict: true) должно нарушиться
failed_relations = [item.relation for item in checks.relations_failed]
assert any("1100 =" in rel for rel in failed_relations)
def _full_balance(total: str) -> list[StatementLine]:
return [
line("1150", "100"),
line("1100", "100"),
line("1210", "50"),
line("1200", "50"),
line("1600", total),
line("1310", "150"),
line("1300", "150"),
line("1700", "150"),
]
def test_failed_and_empty_relation_lists_reach_the_wire(rules: Rules) -> None:
"""Проверенный документ сериализует `relations_failed`/`soft_relations_failed` всегда: с нарушением и пустыми."""
broken, _ = run_statement_checks(_full_balance("155"), 1, "BALANCE", rules)
clean, _ = run_statement_checks(_full_balance("150"), 1, "BALANCE", rules)
wire = broken.to_wire()
assert [item["relation"] for item in wire["relations_failed"]] == ["1600 = 1100 + 1200", "1600 = 1700"]
assert wire["relations_failed"][0]["expected"] == "155"
assert wire["soft_relations_failed"] == []
assert clean.to_wire()["relations_failed"] == []
assert clean.to_wire()["soft_relations_failed"] == []
def test_failed_relation_reports_the_nearest_variant_as_actual(rules: Rules) -> None:
"""T14.I1: при нарушении `actual` — вариант (как записано / с abs_terms), ближайший к `expected`."""
lines = [line("2110", "1000"), line("2120", "-600"), line("2100", "410")] # как записано 1600, с abs 400
checks, _ = run_statement_checks(lines, 1, "INCOME", rules)
failed = {item.relation: item for item in checks.relations_failed}
assert failed["2100 = 2110 - 2120"].expected == Decimal("410")
assert failed["2100 = 2110 - 2120"].actual == Decimal("400")
def test_failed_relation_keeps_as_written_variant_on_a_tie(rules: Rules) -> None:
lines = [line("2110", "1000"), line("2120", "0"), line("2100", "900")] # оба варианта дают 1000
checks, _ = run_statement_checks(lines, 1, "INCOME", rules)
assert {item.relation: item.actual for item in checks.relations_failed}["2100 = 2110 - 2120"] == Decimal("1000")
# --- OQ-138 (решение владельца 06.10.2026): расхождение в пределах округления не портит статус ---
def test_rounding_within_relative_tolerance_is_soft_not_hard(rules: Rules) -> None:
"""Итог 2 871 145 000 при сумме слагаемых 2 871 144 583.67: 416.33 ≤ 0,001 % итога — мягкое нарушение."""
lines = [line("2110", "9033211892.66"), line("2120", "-6162067308.99"), line("2100", "2871145000")]
checks, warnings = run_statement_checks(lines, 1, "INCOME", rules)
assert checks.relations_failed == []
assert [item.relation for item in checks.soft_relations_failed] == ["2100 = 2110 - 2120"]
assert any("в пределах округления" in warning for warning in warnings)
def test_discrepancy_above_relative_tolerance_stays_hard(rules: Rules) -> None:
"""0,001 % от 1 000 000 = 10: расхождение 11 — жёсткое нарушение."""
lines = [
line("1150", "600000"),
line("1100", "600000"),
line("1210", "400011"),
line("1200", "400011"),
line("1600", "1000000"),
]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
assert "1600 = 1100 + 1200" in [item.relation for item in checks.relations_failed] # 1600 = 1700: пассива нет
def test_absolute_tolerance_still_applies_to_small_totals(rules: Rules) -> None:
"""У малых итогов действует абсолютный допуск ±1: 0,001 % от 150 меньше единицы."""
lines = [line("1150", "100"), line("1100", "100"), line("1210", "50"), line("1200", "50"), line("1600", "151")]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
relations = [item.relation for item in [*checks.relations_failed, *checks.soft_relations_failed]]
assert "1600 = 1100 + 1200" not in relations # 1600 = 1700 нарушено: пассива нет
# --- M-9: подстроки привязываются к ближайшей четырёхзначной строке выше ---
def test_sublines_from_tenth_number_belong_to_parent_above(rules: Rules) -> None:
"""У НПЗ под 1230 идут 12301…12314: 12310 начинается с «1231», но относится к 1230 по порядку строк."""
lines = [line("1230", "20"), line("12301", "1"), line("12309", "4"), line("12310", "4"), line("12314", "5")]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
assert [item.relation for item in checks.soft_relations_failed] == ["1230 = 12301 + 12309 + 12310 + 12314"]
assert checks.soft_relations_failed[0].actual == Decimal("14")
def test_rounding_base_is_the_largest_number_of_the_relation(rules: Rules) -> None:
"""ОФР Унагранде Групп 3 кв. 2025: 2200 = −1 365 000 — малая разность больших слагаемых (2220 = −393 623 601.56);
расхождение 398.44 — округление относительно слагаемых (0,0001 %), а не итога (0,03 %)."""
lines = [
line("2110", "392258982.50"),
line("2100", "392259000"),
line("2220", "-393623601.56"),
line("2200", "-1365000"),
]
checks, _ = run_statement_checks(lines, 1, "INCOME", rules)
assert checks.relations_failed == []
assert "2200 = 2100 - 2210 - 2220" in [item.relation for item in checks.soft_relations_failed]
# --- AURORA-1527: детализации нет — соотношение подстрок не проверяется ---
def test_sublines_all_empty_in_column_are_not_checked(rules: Rules) -> None:
lines = [line("1150", "90", "90"), line("11501", None, None), line("11502", None, None)]
checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules)
assert [f for f in checks.soft_relations_failed if f.relation.startswith("1150 =")] == []
assert not any("1150 =" in w for w in warnings)
def test_sublines_check_is_per_column(rules: Rules) -> None:
lines = [line("1150", "90", "90"), line("11501", None, "10"), line("11502", None, None)]
checks, _ = run_statement_checks(lines, 2, "BALANCE", rules)
failed = [f for f in checks.soft_relations_failed if f.relation == "1150 = 11501 + 11502"]
assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, Decimal("90"), Decimal("10"))]
def test_subline_zero_is_a_value(rules: Rules) -> None:
lines = [line("1150", "90"), line("11501", "0"), line("11502", None)]
checks, _ = run_statement_checks(lines, 1, "BALANCE", rules)
assert [f.relation for f in checks.soft_relations_failed if f.relation.startswith("1150 =")] == [
"1150 = 11501 + 11502"
]