У 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09), поэтому каждая ОСВ 91 получала needs_check. Список счетов — данные api/rules/osv_checks.yaml (accounts_without_analytic_balance: 91, 99; 90 не входит). - тождество строки на таких счетах не проверяется; - «сумма детей = родитель» сохраняется: обороты всегда, сальдо — только у родителя с дочерними субсчетами (корень -> субсчета); у родителя с одной аналитикой сальдо не сверяется; - «Итого» сверяется как обычно; - строка относится к счёту списка по коду ближайшего счёта ветки (91, 91.01; не 910). Тесты: unit checks/rules, манифест osv_dkb_expectations (две ОСВ 91 теперь done). README, CLAUDE.md, openspec/specs/osv-parsing, api/rules/README.md обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
242 lines
14 KiB
Python
242 lines
14 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`; строки CURRENCY/QUANTITY («Вал.», «Кол.»)
|
||
в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) —
|
||
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
|
||
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
|
||
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
|
||
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
|
||
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
|
||
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
|
||
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
|
||
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
|
||
|
||
Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только
|
||
обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2
|
||
обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь
|
||
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
|
||
|
||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
||
"""
|
||
|
||
from collections import defaultdict
|
||
from collections.abc import Iterable, Sequence
|
||
from decimal import Decimal
|
||
|
||
from src.contract.result import OsvChecks
|
||
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
||
from src.parser.rules import OsvCheckRules
|
||
|
||
__all__ = [
|
||
"TOLERANCE",
|
||
"balance_identity",
|
||
"parent_sums",
|
||
"run_checks",
|
||
"total_matches_root",
|
||
]
|
||
|
||
TOLERANCE = Decimal("0.01")
|
||
# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
|
||
WARNING_ROWS_SHOWN = 20
|
||
_ZERO = Decimal(0)
|
||
# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят
|
||
_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"})
|
||
_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.data_type in _INDICATOR_DATA_TYPES
|
||
|
||
|
||
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 balance_identity(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».
|
||
|
||
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются.
|
||
"""
|
||
violations: list[int] = []
|
||
for item in rows:
|
||
if _no_analytic_balance(item, exempt):
|
||
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]) -> bool:
|
||
"""Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто."""
|
||
if not _differs(parent, children):
|
||
return True
|
||
is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
|
||
return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
|
||
|
||
|
||
def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal], *, with_balance: bool = True) -> bool:
|
||
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных)."""
|
||
return (
|
||
(not with_balance or _balance_matches(parent[_OPENING], children[_OPENING]))
|
||
and not _differs(parent[_TURNOVER], children[_TURNOVER])
|
||
and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING]))
|
||
)
|
||
|
||
|
||
def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
||
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
||
|
||
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
|
||
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
|
||
проверяется.
|
||
|
||
Родитель счёта без сальдо по аналитике (`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))
|
||
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
|
||
if item.group_type == "ACCOUNT":
|
||
has_subaccounts.add(item.parent)
|
||
return [
|
||
row
|
||
for row in by_row
|
||
if row in totals
|
||
and not _sums_match(
|
||
_values(by_row[row]),
|
||
totals[row],
|
||
with_balance=row in has_subaccounts or not _no_analytic_balance(by_row[row], exempt),
|
||
)
|
||
]
|
||
|
||
|
||
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
||
"""Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит)."""
|
||
return next((item for item in rows if item.group_type == "TOTAL"), 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]) -> bool:
|
||
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
|
||
|
||
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма
|
||
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
|
||
"""
|
||
total = _total(rows)
|
||
if total is None:
|
||
return False
|
||
root = _root(rows)
|
||
if root is None:
|
||
return all(abs(value) <= TOLERANCE for value in _values(total))
|
||
if not _differs(_values(total), _values(root)):
|
||
return True
|
||
roots = _level_zero(rows)
|
||
# Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски, что у родителя и детей (R-21): 1С показывает «Итого»
|
||
# свёрнутым сальдо, а субсчета — развёрнутым. Обороты по-прежнему по колонкам.
|
||
return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots))
|
||
|
||
|
||
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]) -> str:
|
||
total = _total(rows)
|
||
root = _root(rows)
|
||
if total is None:
|
||
return TOTAL_NOT_FOUND_WARNING
|
||
if root is None:
|
||
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) -> tuple[OsvChecks, list[str]]:
|
||
"""Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось).
|
||
|
||
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
|
||
"""
|
||
identity = balance_identity(rows, exempt)
|
||
parents = parent_sums(rows, exempt)
|
||
matches = total_matches_root(rows)
|
||
warnings: list[str] = []
|
||
if identity:
|
||
warnings.append(
|
||
f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
|
||
)
|
||
if parents:
|
||
warnings.append(f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}")
|
||
if not matches:
|
||
warnings.append(_total_warning(rows))
|
||
checks = OsvChecks(
|
||
total_row_matches_root=matches,
|
||
balance_identity_violations=identity,
|
||
parent_sum_violations=parents,
|
||
)
|
||
return checks, warnings
|