feat(statements): соотношения §7 из словаря — жёсткие/мягкие, abs_terms, допуск, подстроки
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
aad6310b97
commit
7bcd324194
@ -85,8 +85,8 @@ _INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
|
|||||||
# Что нормализация убирает из текста: таких символов в паттерне быть не может
|
# Что нормализация убирает из текста: таких символов в паттерне быть не может
|
||||||
_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]")
|
_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]")
|
||||||
# Регулярные выражения для парсинга соотношений форм
|
# Регулярные выражения для парсинга соотношений форм
|
||||||
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4}(\s*[+\-]\s*\d{4})*)\s*$")
|
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$")
|
||||||
_TERM = re.compile(r"([+\-])?\s*(\d{4})")
|
_TERM = re.compile(r"([+\-])?\s*(\d{4,5})")
|
||||||
|
|
||||||
Value = TypeVar("Value")
|
Value = TypeVar("Value")
|
||||||
|
|
||||||
@ -527,7 +527,10 @@ class RelationRule(_Model):
|
|||||||
def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]:
|
def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]:
|
||||||
found = _RELATION.match(relation)
|
found = _RELATION.match(relation)
|
||||||
if found is None:
|
if found is None:
|
||||||
raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных кодов")
|
msg = (
|
||||||
|
f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных или пятизначных кодов"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs")))
|
terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs")))
|
||||||
return found.group("target"), terms
|
return found.group("target"), terms
|
||||||
|
|
||||||
|
|||||||
99
api/src/parser/statements/checks.py
Normal file
99
api/src/parser/statements/checks.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""Соотношения §7 по колонкам (design D-7): правила — `statement_checks.yaml`; отсутствующий код — 0; правая часть
|
||||||
|
считается как записано и с членами `abs_terms` по модулю; допуск `tolerance`. Жёсткие нарушения → `relations_failed`,
|
||||||
|
мягкие и «сумма подстрок = родитель» → `soft_relations_failed`; всё дублируется предупреждениями.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from src.contract.result import FailedRelation, StatementChecks, StatementLine
|
||||||
|
from src.parser.rules import FormType, RelationRule, Rules
|
||||||
|
|
||||||
|
__all__ = ["run_statement_checks"]
|
||||||
|
|
||||||
|
_ZERO = Decimal("0")
|
||||||
|
_PARENT_LEN = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _amounts(lines: Sequence[StatementLine], column_count: int) -> list[dict[str, Decimal]]:
|
||||||
|
"""Код → сумма по каждой колонке (null → 0)."""
|
||||||
|
columns: list[dict[str, Decimal]] = [{} for _ in range(column_count)]
|
||||||
|
for item in lines:
|
||||||
|
for value in item.values:
|
||||||
|
if 0 <= value.column_index < column_count:
|
||||||
|
columns[value.column_index][item.line_code] = value.amount if value.amount is not None else _ZERO
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def _rhs(amounts: dict[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal:
|
||||||
|
total = _ZERO
|
||||||
|
for sign, code in rule.terms:
|
||||||
|
value = amounts.get(code, _ZERO)
|
||||||
|
if absolute and code in rule.abs_terms:
|
||||||
|
value = abs(value)
|
||||||
|
total += sign * value
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _holds(
|
||||||
|
amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal, is_subline: bool = False
|
||||||
|
) -> tuple[bool, Decimal, Decimal]:
|
||||||
|
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:
|
||||||
|
return True, expected, plain
|
||||||
|
if rule.abs_terms:
|
||||||
|
with_abs = _rhs(amounts, rule, absolute=True)
|
||||||
|
if abs(expected - with_abs) <= check_tolerance:
|
||||||
|
return True, expected, with_abs
|
||||||
|
return False, expected, plain
|
||||||
|
|
||||||
|
|
||||||
|
def _subline_rules(lines: Sequence[StatementLine], form: FormType, rules: Rules) -> list[RelationRule]:
|
||||||
|
"""«1110 = 11101 + 11102 + …» для каждого четырёхзначного кода формы (в диапазоне кодов этой формы), у которого
|
||||||
|
есть пятизначные подстроки."""
|
||||||
|
low, high = rules.statement_headers.code_ranges[form]
|
||||||
|
codes = [item.line_code for item in lines]
|
||||||
|
rules_out: list[RelationRule] = []
|
||||||
|
for parent in codes:
|
||||||
|
if len(parent) != _PARENT_LEN or not low <= int(parent) <= high:
|
||||||
|
continue
|
||||||
|
children = [code for code in codes if len(code) == _PARENT_LEN + 1 and code.startswith(parent)]
|
||||||
|
if children:
|
||||||
|
rules_out.append(RelationRule(form=form, relation=f"{parent} = {' + '.join(children)}", strict=False))
|
||||||
|
return rules_out
|
||||||
|
|
||||||
|
|
||||||
|
def run_statement_checks(
|
||||||
|
lines: Sequence[StatementLine], column_count: int, form: FormType, rules: Rules
|
||||||
|
) -> tuple[StatementChecks, list[str]]:
|
||||||
|
config = rules.statement_checks
|
||||||
|
relations = list(config.relations_for(form))
|
||||||
|
subline_rules = _subline_rules(lines, form, rules) if config.subline_check else []
|
||||||
|
relations.extend(subline_rules)
|
||||||
|
checks = StatementChecks()
|
||||||
|
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)
|
||||||
|
if holds:
|
||||||
|
continue
|
||||||
|
failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual)
|
||||||
|
if rule.strict:
|
||||||
|
checks.relations_failed.append(failed)
|
||||||
|
warning_msg = f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}"
|
||||||
|
warnings.append(warning_msg)
|
||||||
|
else:
|
||||||
|
checks.soft_relations_failed.append(failed)
|
||||||
|
warning_msg = (
|
||||||
|
f"мягкое соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}"
|
||||||
|
)
|
||||||
|
warnings.append(warning_msg)
|
||||||
|
return checks, warnings
|
||||||
122
api/tests/unit/parser/statements/test_checks.py
Normal file
122
api/tests/unit/parser/statements/test_checks.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""Соотношения §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:
|
||||||
|
lines = [
|
||||||
|
line("1100", "100"),
|
||||||
|
line("1200", "50"),
|
||||||
|
line("1600", "151"),
|
||||||
|
line("1700", "151"),
|
||||||
|
line("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н — в скобках; оба варианта сходятся."""
|
||||||
|
one_c = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")]
|
||||||
|
brackets = [
|
||||||
|
line("2110", "1000"),
|
||||||
|
line("2120", "600"),
|
||||||
|
line("2100", "400"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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:
|
||||||
|
lines = [
|
||||||
|
line("2400", "-100"),
|
||||||
|
line("2410", "-100"),
|
||||||
|
line("2411", "-80"),
|
||||||
|
line("2412", "-30"),
|
||||||
|
line("1110", "10"),
|
||||||
|
line("11101", "6"),
|
||||||
|
line("11102", "3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
checks, warnings = run_statement_checks(lines, 1, "INCOME", rules)
|
||||||
|
balance, _ = run_statement_checks(lines, 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)
|
||||||
|
|
||||||
|
|
||||||
|
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 == []
|
||||||
Loading…
x
Reference in New Issue
Block a user