I-2: в netted попадает только строка, сама сошедшаяся с детьми лишь по нетто; сошедшаяся по колонкам признак не передаёт. I-3: сальдо конца периода = сальдо начала следующего (по колонкам либо по нетто), разрыв — нарушение. M-6: сценарии спеки (защиты вложения по суммам, цепочка нетто-строк, непрерывность периодов); CLAUDE.md и докстринг checks.py ссылаются на решения владельца 28.10.2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
24 KiB
Python
385 lines
24 KiB
Python
"""Контрольные суммы документа ОСВ (`05-domain-rules.md` §1.4; spec «Контрольные суммы определяют статус»).
|
||
|
||
Три проверки над классифицированными строками одного листа (суммы — `Decimal`, `None` = 0, допуск ±0.01):
|
||
|
||
1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit =
|
||
closing_debit − closing_credit`;
|
||
2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки-показатели (`indicator`: «Вал.», «Кол.»,
|
||
НУ и прочие метки колонки «Показатели») в суммирование не входят и сами родителями не проверяются.
|
||
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
|
||
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
|
||
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
|
||
разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть
|
||
«нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С
|
||
схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх
|
||
только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель
|
||
без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка
|
||
«Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало первого периода,
|
||
сальдо конца = конец последнего, а конец каждого периода = начало следующего (по колонкам либо по нетто; сальдо
|
||
месяцев — не слагаемые; AURORA-1823); смешанные дети — обычная сумма;
|
||
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
|
||
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
|
||
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда
|
||
корень и «Итого развернутое» развёрнуты, а «Итого» свёрнута, AURORA-1708); корня нет
|
||
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
|
||
результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы.
|
||
|
||
Счета без сальдо по аналитике (90, 91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут
|
||
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2
|
||
обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь
|
||
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
|
||
|
||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||
подгоняет (решения владельца 28.10.2026: узкое нетто для нетто-ребёнка, AURORA-1863, выше; уточнение иерархии по
|
||
отступам с защитами, AURORA-1858, `hierarchy.strategy_indent`, `05-domain-rules.md` §1.3). Номера строк — в
|
||
`OsvChecks`, причины — тексты предупреждений.
|
||
"""
|
||
|
||
from collections import defaultdict
|
||
from collections.abc import Iterable, Sequence
|
||
from decimal import Decimal
|
||
from itertools import pairwise
|
||
|
||
from src.contract.result import OsvChecks
|
||
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
||
from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote
|
||
from src.parser.rules import OsvCheckRules, Pattern
|
||
|
||
__all__ = [
|
||
"TOLERANCE",
|
||
"balance_identity",
|
||
"parent_sums",
|
||
"run_checks",
|
||
"total_matches_root",
|
||
]
|
||
|
||
TOLERANCE = Decimal("0.01")
|
||
# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
|
||
WARNING_ROWS_SHOWN = 20
|
||
_ZERO = Decimal(0)
|
||
_ROOT_LEVEL = 0
|
||
# Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом
|
||
_OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6)
|
||
|
||
TOTAL_NOT_FOUND_WARNING = "строка «Итого» не найдена"
|
||
|
||
|
||
def _values(item: ClassifiedRow) -> tuple[Decimal, ...]:
|
||
return tuple(_ZERO if value is None else value for value in item.body.sums)
|
||
|
||
|
||
def _net(pair: Sequence[Decimal]) -> Decimal:
|
||
debit, credit = pair
|
||
return debit - credit
|
||
|
||
|
||
def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
|
||
return any(abs(a - b) > TOLERANCE for a, b in zip(left, right, strict=True))
|
||
|
||
|
||
def _is_indicator(item: ClassifiedRow) -> bool:
|
||
return item.indicator
|
||
|
||
|
||
def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool:
|
||
"""Строка относится к счёту без сальдо по аналитике (код ближайшего счёта ветки; у «Итого» кода нет)."""
|
||
return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw)
|
||
|
||
|
||
def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bool:
|
||
return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary)
|
||
|
||
|
||
def _has_turnovers(item: ClassifiedRow) -> bool:
|
||
"""В строке есть хотя бы одно значение оборотов за период (дебет или кредит; пусто = нет, 0 — значение)."""
|
||
return any(value is not None for value in item.body.sums[_TURNOVER])
|
||
|
||
|
||
def balance_identity(
|
||
rows: Sequence[ClassifiedRow],
|
||
exempt: OsvCheckRules | None = None,
|
||
secondary_totals: Sequence[Pattern] = (),
|
||
) -> list[int]:
|
||
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».
|
||
|
||
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка
|
||
(`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не
|
||
единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами
|
||
или единственная — проверяется как обычная строка. Итоговые строки-показатели («НУ»), идущие блоком сразу за
|
||
пропущенной второй итоговой строкой, пропускаются на тех же условиях (R-21); в счётчик TOTAL показатели не входят.
|
||
"""
|
||
violations: list[int] = []
|
||
totals = sum(1 for item in rows if item.group_type == "TOTAL" and not item.indicator)
|
||
in_skipped_block = False
|
||
for item in rows:
|
||
if item.group_type == "TOTAL" and item.indicator:
|
||
if in_skipped_block and not _has_turnovers(item):
|
||
continue
|
||
else:
|
||
in_skipped_block = False
|
||
if _no_analytic_balance(item, exempt):
|
||
continue
|
||
if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item):
|
||
in_skipped_block = True
|
||
continue
|
||
sums = _values(item)
|
||
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
|
||
if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
|
||
violations.append(item.body.row)
|
||
return violations
|
||
|
||
|
||
def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal], *, net_child: bool = False) -> bool:
|
||
"""Пара «дебет, кредит» сальдо: равна по колонкам или по нетто — при свёрнутом сальдо родителя либо когда среди
|
||
его детей есть свёрнутый в нетто (`net_child`, AURORA-1863)."""
|
||
if not _differs(parent, children):
|
||
return True
|
||
is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
|
||
return (is_collapsed or net_child) and abs(_net(parent) - _net(children)) <= TOLERANCE
|
||
|
||
|
||
def _sums_match(
|
||
parent: Sequence[Decimal],
|
||
children: Sequence[Decimal],
|
||
*,
|
||
with_balance: bool = True,
|
||
net_child: bool = False,
|
||
) -> bool:
|
||
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных). `net_child` —
|
||
среди детей есть нетто-ребёнок: сальдо (начало и конец) сверяется и по нетто."""
|
||
return (
|
||
(not with_balance or _balance_matches(parent[_OPENING], children[_OPENING], net_child=net_child))
|
||
and not _differs(parent[_TURNOVER], children[_TURNOVER])
|
||
and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING], net_child=net_child))
|
||
)
|
||
|
||
|
||
def _periods_continuous(periods: Sequence[ClassifiedRow]) -> bool:
|
||
"""Сальдо конца каждого периода = сальдо начала следующей строки периода (по колонкам либо по нетто)."""
|
||
return all(
|
||
_balance_matches(closing, opening) or _balance_matches(opening, closing)
|
||
for closing, opening in (
|
||
(_values(left)[_CLOSING], _values(right)[_OPENING]) for left, right in pairwise(periods)
|
||
)
|
||
)
|
||
|
||
|
||
def _period_chain(periods: Sequence[ClassifiedRow]) -> list[Decimal]:
|
||
"""Суммы родителя, все дети которого — периоды («Обороты за <месяц>»): обороты — Σ оборотов периодов, сальдо
|
||
начала — начало первого периода, конца — конец последнего (в порядке строк): сальдо периодов — цепочка."""
|
||
first, last = _values(periods[0]), _values(periods[-1])
|
||
turnover = _sum_of(periods)[_TURNOVER]
|
||
return [*first[_OPENING], *turnover, *last[_CLOSING]]
|
||
|
||
|
||
def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
||
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
||
|
||
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
|
||
Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не
|
||
проверяется.
|
||
|
||
Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по
|
||
оборотам: сальдо его аналитики пусто по определению, а сальдо закрыто на субсчёт закрытия.
|
||
"""
|
||
by_row = {item.body.row: item for item in rows}
|
||
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
||
children_of: dict[int, list[int]] = defaultdict(list)
|
||
periods_of: dict[int, list[ClassifiedRow]] = defaultdict(list)
|
||
has_subaccounts: set[int] = set()
|
||
for item in rows:
|
||
if item.parent is None or _is_indicator(item):
|
||
continue
|
||
parent = by_row.get(item.parent)
|
||
if parent is None or _is_indicator(parent) or parent.data_type != item.data_type:
|
||
continue
|
||
accumulated = totals[item.parent]
|
||
for index, value in enumerate(_values(item)):
|
||
accumulated[index] += value
|
||
children_of[item.parent].append(item.body.row)
|
||
if item.is_period:
|
||
periods_of[item.parent].append(item)
|
||
if item.group_type == "ACCOUNT":
|
||
has_subaccounts.add(item.parent)
|
||
violations: set[int] = set()
|
||
# Нетто-строки: строка сама сошлась с детьми по сальдо (начало или конец) лишь по нетто, по колонкам не сошлось.
|
||
# Цепочка нетто-строк непрерывна: строка, сошедшаяся по колонкам, признак вверх не передаёт. Нетто допускается на
|
||
# обеих сторонах сальдо, а не только на той, где ребёнок свёрнут: схлопнутую пару 1С не показывает ни в начале, ни
|
||
# в конце (на реальных файлах родитель расходится с детьми симметрично на обеих сторонах). Потомки идут по большим
|
||
# номерам строк, поэтому родители обходятся от больших номеров к меньшим.
|
||
netted: set[int] = set()
|
||
for row in sorted(totals, reverse=True):
|
||
item = by_row[row]
|
||
with_balance = row in has_subaccounts or not _no_analytic_balance(item, exempt)
|
||
net_child = any(child in netted for child in children_of[row])
|
||
children = totals[row]
|
||
chained = bool(periods_of[row]) and len(periods_of[row]) == len(children_of[row])
|
||
if chained:
|
||
children = _period_chain(periods_of[row])
|
||
if (chained and with_balance and not _periods_continuous(periods_of[row])) or not _sums_match(
|
||
_values(item), children, with_balance=with_balance, net_child=net_child
|
||
):
|
||
violations.add(row)
|
||
elif with_balance and (
|
||
_differs(_values(item)[_OPENING], children[_OPENING])
|
||
or _differs(_values(item)[_CLOSING], children[_CLOSING])
|
||
):
|
||
netted.add(row)
|
||
return [row for row in by_row if row in violations]
|
||
|
||
|
||
def _total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> ClassifiedRow | None:
|
||
"""Основная «Итого» листа — первая строка TOTAL без признака показателя, чей текст не матчит вторые итоговые.
|
||
|
||
Строки-показатели (НУ, Кол., Вал. под «Итого») отсекаются первыми (#58). Вторая итоговая строка («Итого
|
||
развернутое», `secondary`) в сверку не входит, даже если стоит перед «Итого» (#59); если других TOTAL нет —
|
||
служит единственной итоговой.
|
||
"""
|
||
totals = [item for item in rows if item.group_type == "TOTAL" and not item.indicator]
|
||
main = next((item for item in totals if not any(p.search(item.body.text) for p in secondary)), None)
|
||
return main or next(iter(totals), None)
|
||
|
||
|
||
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
||
return next(
|
||
(item for item in rows if item.group_type == "ACCOUNT" and item.level == _ROOT_LEVEL and item.parent is None),
|
||
None,
|
||
)
|
||
|
||
|
||
def _level_zero(rows: Sequence[ClassifiedRow]) -> list[ClassifiedRow]:
|
||
"""Строки уровня 0 без родителя, кроме «Итого» и строк-показателей, в порядке листа."""
|
||
return [
|
||
item
|
||
for item in rows
|
||
if item.level == _ROOT_LEVEL and item.parent is None and item.group_type != "TOTAL" and not _is_indicator(item)
|
||
]
|
||
|
||
|
||
def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
|
||
totals = [_ZERO] * len(SUM_FIELDS)
|
||
for item in rows:
|
||
for index, value in enumerate(_values(item)):
|
||
totals[index] += value
|
||
return tuple(totals)
|
||
|
||
|
||
def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool:
|
||
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
|
||
|
||
Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский
|
||
список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или
|
||
нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов,
|
||
«Итого» равно их сумме.
|
||
"""
|
||
total = _total(rows, secondary_totals)
|
||
if total is None:
|
||
return False
|
||
root = _root(rows)
|
||
if root is None:
|
||
roots = _level_zero(rows)
|
||
if roots:
|
||
return _sums_match(_values(total), _sum_of(roots))
|
||
return all(abs(value) <= TOLERANCE for value in _values(total))
|
||
if _matches_single_root(rows, total, root, secondary_totals):
|
||
return True
|
||
roots = _level_zero(rows)
|
||
# Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски (R-21)
|
||
return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots))
|
||
|
||
|
||
def _matches_single_root(
|
||
rows: Sequence[ClassifiedRow], total: ClassifiedRow, root: ClassifiedRow, secondary: Sequence[Pattern]
|
||
) -> bool:
|
||
"""«Итого» = корень: по колонкам; корень — «ребёнок» итоговой строки, те же допуски, что у родителя и детей
|
||
(R-21, AURORA-1708): 1С показывает «Итого» свёрнутым сальдо, а счёт (и «Итого развернутое») — развёрнутым, поэтому
|
||
при свёрнутом сальдо «Итого» оно сверяется по нетто. «Итого развернутое» = корню по колонкам — основная «Итого»
|
||
сверяется с корнем по нетто сальдо и оборотам по колонкам."""
|
||
if not _differs(_values(total), _values(root)) or _sums_match(_values(total), _values(root)):
|
||
return True
|
||
expanded = _expanded_total(rows, secondary)
|
||
return (
|
||
expanded is not None
|
||
and not _differs(_values(expanded), _values(root))
|
||
and _net_matches(_values(total), _values(root))
|
||
)
|
||
|
||
|
||
def _expanded_total(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern]) -> ClassifiedRow | None:
|
||
"""Вторая итоговая строка («Итого развернутое»): первая TOTAL не-показатель, чей текст матчит `secondary`."""
|
||
return next(
|
||
(
|
||
item
|
||
for item in rows
|
||
if item.group_type == "TOTAL" and not item.indicator and any(p.search(item.body.text) for p in secondary)
|
||
),
|
||
None,
|
||
)
|
||
|
||
|
||
def _net_matches(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
|
||
"""Обороты по колонкам, сальдо начала и конца по нетто «дебет − кредит» (±0.01)."""
|
||
return (
|
||
abs(_net(left[_OPENING]) - _net(right[_OPENING])) <= TOLERANCE
|
||
and not _differs(left[_TURNOVER], right[_TURNOVER])
|
||
and abs(_net(left[_CLOSING]) - _net(right[_CLOSING])) <= TOLERANCE
|
||
)
|
||
|
||
|
||
def _rows_text(rows: Iterable[int]) -> str:
|
||
numbers = list(rows)
|
||
shown = ", ".join(str(number) for number in numbers[:WARNING_ROWS_SHOWN])
|
||
if len(numbers) > WARNING_ROWS_SHOWN:
|
||
return f"{shown} … (всего {len(numbers)})"
|
||
return shown
|
||
|
||
|
||
def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = ()) -> str:
|
||
total = _total(rows, secondary)
|
||
root = _root(rows)
|
||
if total is None:
|
||
return TOTAL_NOT_FOUND_WARNING
|
||
if root is None:
|
||
level_zero = _level_zero(rows)
|
||
if level_zero:
|
||
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ сумме {len(level_zero)} строк уровня 0"
|
||
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
|
||
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
|
||
level_zero = _level_zero(rows)
|
||
if len(level_zero) > 1:
|
||
return f"{message} и ≠ сумме {len(level_zero)} строк уровня 0"
|
||
return message
|
||
|
||
|
||
def run_checks(
|
||
rows: Sequence[ClassifiedRow],
|
||
exempt: OsvCheckRules | None = None,
|
||
*,
|
||
secondary_totals: Sequence[Pattern] = (),
|
||
) -> tuple[OsvChecks, list[OsvNote]]:
|
||
"""Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
|
||
|
||
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
|
||
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они; тождество на них
|
||
пропускается только без оборотов, когда есть другая «Итого» (#59, R-19).
|
||
"""
|
||
identity = balance_identity(rows, exempt, secondary_totals)
|
||
parents = parent_sums(rows, exempt)
|
||
matches = total_matches_root(rows, secondary_totals)
|
||
warnings: list[OsvNote] = []
|
||
if identity:
|
||
text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
|
||
warnings.append(OsvNote(OSV_BALANCE_IDENTITY, text))
|
||
if parents:
|
||
text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}"
|
||
warnings.append(OsvNote(OSV_PARENT_SUM, text))
|
||
if not matches:
|
||
warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows, secondary_totals)))
|
||
checks = OsvChecks(
|
||
total_row_matches_root=matches,
|
||
balance_identity_violations=identity,
|
||
parent_sum_violations=parents,
|
||
)
|
||
return checks, warnings
|