- 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
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""Соотношения §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]
|