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