fix(task-11): R-6…R-9 — цель в данных, общий допуск, валидация 4-цифр, тесты

R-6: проверка только если цель присутствует (в колонке)
R-8: убран zero tolerance для подстрок, общий tolerance
R-7: тесты с полными данными (подкомпоненты)
R-9: валидация — явные правила 4-цифр, подстроки 5-цифр
R-7: усиление abs_terms теста (цепочка 2100→2200→2300)

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 17:40:02 +04:00
parent c5aa02c8ec
commit 72e565621c
4 changed files with 93 additions and 24 deletions

View File

@ -543,6 +543,24 @@ class StatementCheckRules(_Model):
def relations_for(self, form: str) -> list[RelationRule]:
return [rule for rule in self.relations if rule.form == form]
@model_validator(mode="after")
def _check_explicit_rules_have_four_digit_codes(self) -> "StatementCheckRules":
"""Явные правила из yaml содержат только четырёхзначные коды; пятизначные — только в подстроках."""
parent_len = 4 # четырёхзначные коды для явных правил
for rule in self.relations:
target, terms = _parse_relation(rule.relation)
# Цель всегда четырёхзначная (проверяется в _RELATION)
if len(target) != parent_len:
raise ValueError(f"явное правило «{rule.relation}»: цель должна быть четырёхзначной")
# Члены правой части должны быть четырёхзначными
for _, code in terms:
if len(code) != parent_len:
raise ValueError(
f"явное правило «{rule.relation}»: код «{code}» должен быть четырёхзначным; "
f"пятизначные коды допустимы только в сгенерированных подстроках"
)
return self
@dataclass(frozen=True)
class Rules:

View File

@ -1,6 +1,9 @@
"""Соотношения §7 по колонкам (design D-7): правила — `statement_checks.yaml`; отсутствующий код — 0; правая часть
считается как записано и с членами `abs_terms` по модулю; допуск `tolerance`. Жёсткие нарушения → `relations_failed`,
мягкие и «сумма подстрок = родитель» → `soft_relations_failed`; всё дублируется предупреждениями.
"""Проверка соотношений §7 по колонкам (design D-7, спека D-7).
Правила из `statement_checks.yaml`; отсутствующий код = 0. Правая часть вычисляется как записано
и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `tolerance` для всех правил.
Жёсткие нарушения → `relations_failed`, мягкие → `soft_relations_failed`.
Правило проверяется, если целевой код в данных (подстроки всегда).
"""
from collections.abc import Sequence
@ -36,17 +39,20 @@ def _rhs(amounts: dict[str, Decimal], rule: RelationRule, *, absolute: bool) ->
def _holds(
amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal, is_subline: bool = False
amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal
) -> tuple[bool, Decimal, Decimal]:
"""Проверка соотношения в пределах допуска.
Вычисляет правую часть два раза: как записано и с abs() для членов в abs_terms.
Правило выполняется, если хотя бы один вариант в пределах tolerance.
"""
expected = amounts.get(rule.target, _ZERO)
plain = _rhs(amounts, rule, absolute=False)
# Subline checks (sums of sub-codes) use strict equality without tolerance
check_tolerance = _ZERO if is_subline else tolerance
if abs(expected - plain) <= check_tolerance:
if abs(expected - plain) <= tolerance:
return True, expected, plain
if rule.abs_terms:
with_abs = _rhs(amounts, rule, absolute=True)
if abs(expected - with_abs) <= check_tolerance:
if abs(expected - with_abs) <= tolerance:
return True, expected, with_abs
return False, expected, plain
@ -77,12 +83,12 @@ def run_statement_checks(
warnings: list[str] = []
for column_index, amounts in enumerate(_amounts(lines, column_count)):
for rule in relations:
# Skip explicit rules (from config) where the target code is not in the data
# but always check generated subline rules if they were generated
# Явные правила из конфига пропускаются, если цель отсутствует в данных колонки.
# Сгенерированные подстроки всегда проверяются (если они сгенерированы, то цель есть).
is_subline = rule in subline_rules
if not is_subline and rule.target not in amounts:
continue
holds, expected, actual = _holds(amounts, rule, config.tolerance, is_subline=is_subline)
holds, expected, actual = _holds(amounts, rule, config.tolerance)
if holds:
continue
failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual)

View File

@ -68,12 +68,16 @@ def test_broken_total_reported_per_column(rules: Rules) -> None:
def test_tolerance_one_unit(rules: Rules) -> None:
"""Допуск ±1: 1600 = 151 (ожидание), 1100 + 1200 = 100 + 50 = 150 (расчёт), разница = 1."""
lines = [
line("1100", "100"),
line("1200", "50"),
line("1600", "151"),
line("1700", "151"),
line("1300", "151"),
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)
@ -82,12 +86,24 @@ def test_tolerance_one_unit(rules: Rules) -> None:
def test_relation_holds_with_abs_terms(rules: Rules) -> None:
"""Review Focus 3: 1С пишет расходы со знаком минус, 66н — в скобках; оба варианта сходятся."""
one_c = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")]
"""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"), # 2100 = 2110 - 2120 = 1000 - (-600) = 1600 (plain) | 1000 - abs(-600) = 400 (abs)
line("2200", "400"), # 2200 = 2100 - 2210 - 2220 = 400 - 0 - 0 = 400 (plain) | abs
line("2300", "400"), # 2300 = 2200 + 2310 + … = 400 + 0 + … = 400
]
brackets = [
line("2110", "1000"),
line("2120", "600"),
line("2100", "400"),
line("2100", "400"), # 2100 = 2110 - 2120 = 1000 - 600 = 400
line("2200", "400"), # 2200 = 2100 - 2210 - 2220 = 400 - 0 - 0 = 400
line("2300", "400"), # 2300 = 2200 + … = 400
]
assert run_statement_checks(one_c, 1, "INCOME", rules)[0].relations_failed == []
@ -95,24 +111,41 @@ def test_relation_holds_with_abs_terms(rules: Rules) -> None:
def test_soft_relation_and_sublines(rules: Rules) -> None:
lines = [
"""Мягкие соотношения и генерируемые подстроки.
Для 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"),
line("2412", "-30"), # -80 + -30 = -110, но 2410 = -100, разница 10 > допуска 1
line("1110", "10"),
line("11101", "6"),
line("11102", "3"),
line("11102", "2"), # 6 + 2 = 8, но 1110 = 10, разница 2 > допуска 1
]
checks, warnings = run_statement_checks(lines, 1, "INCOME", rules)
balance, _ = run_statement_checks(lines, 1, "BALANCE", rules)
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)]

View File

@ -125,3 +125,15 @@ def test_bad_relation_rejected(tmp_path: Path) -> None:
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="statement_checks.yaml"):
load_rules(tmp_path)
def test_explicit_rules_reject_five_digit_codes(tmp_path: Path) -> None:
"""R-9: явные правила должны содержать только четырёхзначные коды; пятизначные только в подстроках."""
for name in DEFAULT_RULES_DIR.glob("*.yaml"):
(tmp_path / name.name).write_bytes(name.read_bytes())
broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8"))
# Пытаемся добавить правило с пятизначным кодом на правой части
broken["relations"].append({"form": "BALANCE", "relation": "1100 = 11101 + 1105", "strict": True})
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"):
load_rules(tmp_path)