Andrey Epifancev 133b31a1f3 fix(osv): нетто-признак только цепочкой нетто-строк, непрерывность периодов, спека и CLAUDE.md (AURORA-1863, AURORA-1823, #78, ревью)
I-2: в netted попадает только строка, сама сошедшаяся с детьми лишь по нетто; сошедшаяся по колонкам признак не
передаёт. I-3: сальдо конца периода = сальдо начала следующего (по колонкам либо по нетто), разрыв — нарушение.
M-6: сценарии спеки (защиты вложения по суммам, цепочка нетто-строк, непрерывность периодов); CLAUDE.md и докстринг
checks.py ссылаются на решения владельца 28.10.2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:56:18 +04:00

385 lines
24 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.

"""Контрольные суммы документа ОСВ (`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