Andrey Epifancev c780dfd858 fix(statements): округление итогов не портит статус (OQ-138), слабые продолжения только сразу после формы и той же формы, подстроки по порядку строк
- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 %
  от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не
  меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done.
- N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа;
  присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser.
- M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230).
- N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name.
- T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке.
make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden).

Refs #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:14:21 +04:00

303 lines
13 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]