260 lines
15 KiB
Python
260 lines
15 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С показывает счёт свёрнутым
|
||
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
|
||
разными сторонами сальдо);
|
||
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
|