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:
parent
c5aa02c8ec
commit
72e565621c
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user