2026-10-19 13:18:23 +04:00

260 lines
15 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С показывает счёт свёрнутым
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы.
Счета без сальдо по аналитике (90, 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.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 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` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не
проверяется.
Родитель счёта без сальдо по аналитике (`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], 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 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], secondary: Sequence[Pattern] = ()) -> str:
total = _total(rows, secondary)
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,
*,
secondary_totals: Sequence[Pattern] = (),
) -> tuple[OsvChecks, list[OsvNote]]:
"""Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось).
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
`secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они (#59).
"""
identity = balance_identity(rows, exempt)
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