Andrey Epifancev f017c37c51 feat(checks): «родитель = Σ подстрок» не проверяется при пустой детализации (#45, AURORA-1527)
По колонке соотношение считается, только если хотя бы одна подстрока родителя имеет значение
(amount не null; ноль — значение). Совпадает с arm-core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:10:05 +04:00

153 lines
8.6 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, спека D-7).
Правила из `statement_checks.yaml`; отсутствующий код = 0. Правая часть вычисляется как записано
и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `tolerance` для всех правил.
Жёсткие нарушения → `relations_failed`, мягкие → `soft_relations_failed`.
Правило проверяется, если целевой код в данных (подстроки всегда).
OQ-138: жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего |числа| соотношения — округление,
уходит в мягкие.
AURORA-1527: «родитель = Σ подстрок» по колонке — только если хотя бы одна подстрока в ней непуста (amount не null).
M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же первыми тремя цифрами.
"""
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")
_HUNDRED = Decimal("100")
_PARENT_LEN = 4
_GROUP_LEN = 3 # первые цифры кода, общие у строки и её подстрок (1230 → 12301…12314)
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 _filled(lines: Sequence[StatementLine], column_count: int) -> list[set[str]]:
"""Коды строк, у которых в колонке есть значение (`amount` не null; ноль — значение)."""
columns: list[set[str]] = [set() for _ in range(column_count)]
for item in lines:
for value in item.values:
if 0 <= value.column_index < column_count and value.amount is not None:
columns[value.column_index].add(item.line_code)
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) -> tuple[bool, Decimal, Decimal]:
"""Проверка соотношения в пределах допуска.
Вычисляет правую часть два раза: как записано и с abs() для членов в abs_terms.
Правило выполняется, если хотя бы один вариант в пределах tolerance. При нарушении возвращается вариант, ближайший
к левой части (при равенстве — «как записано»): аналитик видит реальную величину расхождения.
"""
expected = amounts.get(rule.target, _ZERO)
plain = _rhs(amounts, rule, absolute=False)
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) <= tolerance:
return True, expected, with_abs
if abs(expected - with_abs) < abs(expected - plain):
return False, 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]
parents = {code for code in codes if len(code) == _PARENT_LEN}
children: dict[str, list[str]] = {}
last_parent: str | None = None
for code in codes: # порядок строк документа
if len(code) == _PARENT_LEN:
last_parent = code
continue
if len(code) != _PARENT_LEN + 1:
continue
# M-9: 12310 под 1230 — по порядку строк; без подходящей строки выше — по префиксу (11101 → 1110)
if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]:
parent = last_parent
elif code[:_PARENT_LEN] in parents:
parent = code[:_PARENT_LEN]
else:
continue
children.setdefault(parent, []).append(code)
return [
RelationRule(form=form, relation=f"{parent} = {' + '.join(kids)}", strict=False)
for parent, kids in children.items()
if low <= int(parent) <= high
]
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)
hard: list[FailedRelation] = []
soft: list[FailedRelation] = []
warnings: list[str] = []
filled = _filled(lines, column_count)
for column_index, amounts in enumerate(_amounts(lines, column_count)):
for rule in relations:
# Явные правила из конфига пропускаются, если цель отсутствует в данных колонки.
# Сгенерированные подстроки всегда проверяются (если они сгенерированы, то цель есть).
is_subline = rule in subline_rules
if not is_subline and rule.target not in amounts:
continue
# AURORA-1527: детализации нет (все подстроки родителя в колонке пусты) — соотношение не проверяется
if is_subline and not any(code in filled[column_index] for _, code in rule.terms):
continue
holds, expected, actual = _holds(amounts, rule, config.tolerance)
if holds:
continue
failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual)
# база — наибольшее по модулю число соотношения: итог может быть малой разностью больших слагаемых
scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)])
rounding = scale * config.rounding_tolerance_percent / _HUNDRED
if rule.strict and abs(expected - actual) <= rounding:
soft.append(failed)
warnings.append(
f"соотношение «{rule.relation}» сошлось в пределах округления (колонка {column_index}): "
f"{expected} ≈ {actual}"
)
continue
if rule.strict:
hard.append(failed)
warning_msg = f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}"
warnings.append(warning_msg)
else:
soft.append(failed)
warning_msg = (
f"мягкое соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}"
)
warnings.append(warning_msg)
# списки задаются явно: `to_wire` сериализует только заданные поля, пустой список = «проверено, сошлось»
return StatementChecks(relations_failed=hard, soft_relations_failed=soft), warnings