ОСВ: ложное «Требует проверки» — периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858) #79

Merged
aep merged 5 commits from feat/78-osv-false-check into main 2026-10-28 10:07:44 +03:00
18 changed files with 1116 additions and 67 deletions

View File

@ -79,11 +79,13 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
подгонять.
подгонять (решения владельца 28.10.2026 — AURORA-1858: уточнение иерархии по отступам А/Б/В с защитами, см.
`05-domain-rules.md` §1.3).
**Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо —
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения (решение владельца 28.10.2026, AURORA-1863: узкое нетто для
развёрнутого родителя над нетто-ребёнком — только непрерывной цепочкой нетто-строк).
**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (90, 91, 99) — данные `api/rules/osv_checks.yaml`, не код;
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и

View File

@ -20,6 +20,11 @@ mapping:
# строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18)
- ['^(в\s+)?валют', OTHER]
# Группировки-периоды: помесячные строки «Обороты за <месяц>» под контрагентом (выгрузка 1С «Субсчета + Контрагенты»,
# третья группировка шапки «Период», AURORA-1823). Тип остаётся OTHER; строки получают признак `is_period`, и сальдо
# родителя сверяется цепочкой периодов (начало первого, конец последнего), а не суммой сальдо периодов.
period_groups: ['^период$']
# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван
# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам
# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты.

View File

@ -9,7 +9,14 @@
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
разными сторонами сальдо);
разными сторонами сальдо). Нетто допустимо и у развёрнутого родителя, если среди его прямых детей есть
«нетто-ребёнок» — ребёнок, сам сошедшийся с детьми по этой стороне сальдо (начало или конец) лишь по нетто (1С
схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми, AURORA-1863); признак передаётся вверх
только непрерывной цепочкой нетто-строк: строка, сошедшаяся по колонкам, его не передаёт. Развёрнутый родитель
без нетто-ребёнка сверяется по колонкам. Родитель, все дети которого — периоды («Обороты за <месяц>», группировка
«Период», `is_period`), сверяется цепочкой: обороты = Σ оборотов периодов, сальдо начала = начало первого периода,
сальдо конца = конец последнего, а конец каждого периода = начало следующего (по колонкам либо по нетто; сальдо
месяцев — не слагаемые; AURORA-1823); смешанные дети — обычная сумма;
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2; то же для единственного корня, когда
@ -23,12 +30,15 @@
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
подгоняет (решения владельца 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
@ -119,23 +129,49 @@ def balance_identity(
return violations
def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
"""Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто."""
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 and abs(_net(parent) - _net(children)) <= TOLERANCE
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) -> bool:
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных)."""
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]))
(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]))
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): обороты — по колонкам, сальдо — по колонкам
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
@ -149,6 +185,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
"""
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):
@ -159,18 +197,36 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
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)
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),
)
]
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:

View File

@ -37,7 +37,7 @@ from src.parser.detect import (
table_kind_row,
)
from src.parser.grid import Sheet, column_letter
from src.parser.hierarchy import BodyRow, Levels
from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels
from src.parser.indicators import is_indicator_total, read_label
from src.parser.numbers import column_inn, split_inn_suffix, to_money
from src.parser.rules import GroupType, Rules
@ -64,7 +64,7 @@ ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
_SEGMENT_SEPARATOR = "."
_SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR
def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None:
@ -106,6 +106,8 @@ class ClassifiedRow:
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
# COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок
counterparty_by_account: bool = False
# Строка группировки-периода («Период» схемы шапки, `period_groups`): помесячный разрез родителя (AURORA-1823)
is_period: bool = False
# --- Строки таблицы ---
@ -308,10 +310,12 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
# любого отступа ячейки
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None
return BodyRow(
row=row,
text=text,
is_account=account is not None,
account_code=None if account is None else account.group(1),
account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
bold=bold,
indent=indent,
@ -320,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
label_col=label_col,
indicator=label.indicator,
label_data_type=label.data_type,
has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None,
)
@ -414,6 +419,7 @@ class _Kind:
counterparty_inn: str | None = None
indicator: bool = False
counterparty_by_account: bool = False
is_period: bool = False
@dataclass(frozen=True)
@ -472,7 +478,8 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
if group_type == "OTHER" and name is None and item.role == "contract":
group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`)
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, own), branch
is_period = group_type == "OTHER" and context.rules.groups.is_period_group(name)
return _Kind(group_type, name, code, own, is_period=is_period), branch
inn_col = context.header.inn_col
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
@ -565,6 +572,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
extra=_extra(sheet, item.row, extra_keys),
indicator=kind.indicator,
counterparty_by_account=kind.counterparty_by_account,
is_period=kind.is_period,
)
)
return result

View File

@ -44,7 +44,15 @@ MIN_ROWS_FOR_CASCADE = 2
TRIVIAL_STRATEGY = "trivial"
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
DEADLINE_CHECK_EVERY = 500
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
NEST_SCAN_LIMIT = 50
# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка
MIN_NESTABLE_CHILDREN = 2
# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же
ACCOUNT_SEGMENT_SEPARATOR = "."
_ZERO = Decimal(0)
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
Role = Literal["counterparty", "contract"]
@ -67,6 +75,8 @@ class BodyRow:
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
role: Role | None = None
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
@dataclass(frozen=True)
@ -129,17 +139,252 @@ def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None)
return {item.row: item.outline - base for item in rows}
def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
def strategy_indent(
rows: Sequence[BodyRow], deadline: Deadline | None = None, *, nest_by_sums: bool = True
) -> dict[int, int] | None:
"""Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа.
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных
отступов меньше двух.
Три поправки к рангам отступа (AURORA-1858), когда отступа не хватает:
- подуровень счёта на том же отступе (`68.01` → `68.01.1`) — ребёнок по коду (`_nest_subaccounts`);
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
(`_attach_closing_rows`);
- аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с
нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка,
ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_nest_by_sums`). Вызывающий
отключает её (`nest_by_sums=False`), когда схема шапки называет группы аналитики после счёта: вложение
изменило бы `group_type`/`group_name` строки.
"""
ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
if ranks is None:
return None
return {item.row: ranks[item.indent] for item in rows}
levels = {item.row: ranks[item.indent] for item in rows}
levels = _nest_subaccounts(rows, levels)
levels = _attach_closing_rows(rows, levels)
return _nest_by_sums(rows, levels, deadline) if nest_by_sums else levels
def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]:
"""Счёт, чей непосредственный родительский код (`68.01.1` → `68.01`) есть у более раннего счёта того же уровня в
том же блоке, — его ребёнок: уровень родителя + 1, поддерево (строки глубже по исходному уровню) сдвигается вместе
с ним. Так вкладываются `68.01.1` и `68.01.2` под `68.01`, стоящие на одном отступе.
Не трогаем счёт, который уже стоит под счётом с кодом своего родителя (блок «76.09» с собственной аналитикой и
«76.09.1» рядом на одном отступе). Применяется, только если все такие пары на одном исходном уровне (одном
отступе): иначе соседние корни с похожими кодами могут быть намеренными, и дерево остаётся по отступам.
"""
parent_of: dict[int, int] = {} # строка счёта -> строка счёта-родителя по коду
pair_levels: set[int] = set()
open_accounts: list[tuple[int, str]] = [] # (уровень, код) счетов-предков по текущим уровням
blocks: dict[int, dict[str, BodyRow]] = {} # уровень -> код -> последний счёт с этим кодом в текущем блоке
seen_codes: dict[int, set[str]] = {} # уровень -> коды всех встреченных счетов (блоки их забывают)
ambiguous = False
for item in rows:
if not item.is_account or item.account_code is None:
# строка не-счёт на уровне блока или выше разрывает его: счёт после неё — не ребёнок счёта до неё
for closed in [known for known in blocks if known >= levels[item.row]]:
del blocks[closed]
continue
level, code = levels[item.row], item.account_code
while open_accounts and open_accounts[-1][0] >= level:
open_accounts.pop()
for deeper in [known for known in blocks if known > level]:
del blocks[deeper]
# у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст
parent_code = code.rpartition(ACCOUNT_SEGMENT_SEPARATOR)[0]
candidate = blocks.get(level, {}).get(parent_code) if parent_code else None
under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code
if candidate is not None and not under_parent:
parent_of[item.row] = candidate.row
pair_levels.add(level)
elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())):
# родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем
# или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего
ambiguous = True
seen_codes.setdefault(level, set()).add(code)
blocks.setdefault(level, {})[code] = item
open_accounts.append((level, code))
if not parent_of or ambiguous or len(pair_levels) > 1:
return levels
result = _shift_nested(rows, levels, parent_of)
# перекладка принимается для счёта-родителя, только если после неё сумма его детей сходится с ним: иначе (файл с
# неровными отступами, где похожий код — совпадение) этот родитель остаётся как был
kids: dict[int, tuple[Decimal, ...]] = {}
by_parent = parents(rows, result)
by_row = {item.row: item for item in rows}
for item in rows:
parent = by_parent[item.row]
if parent is not None and not item.indicator:
kids[parent] = _add(kids[parent], _vector(item)) if parent in kids else _vector(item)
broken = {
parent
for parent in set(parent_of.values())
if parent in kids and _violates(_vector(by_row[parent]), kids[parent])
}
if broken:
parent_of = {row: parent for row, parent in parent_of.items() if parent not in broken}
if not parent_of:
return levels
result = _shift_nested(rows, levels, parent_of)
return result
def _shift_nested(rows: Sequence[BodyRow], levels: dict[int, int], parent_of: dict[int, int]) -> dict[int, int]:
"""Уровни, в которых счета из `parent_of` стали детьми указанных счетов (поддерево сдвинуто вместе со счётом)."""
result: dict[int, int] = {}
shifts: list[tuple[int, int]] = [] # (исходный уровень сдвинутого счёта, его сдвиг) — открытые поддеревья
for item in rows:
original = levels[item.row]
while shifts and shifts[-1][0] >= original:
shifts.pop()
level = original + (shifts[-1][1] if shifts else 0)
if item.row in parent_of:
level = result[parent_of[item.row]] + 1
shifts.append((original, level - original))
result[item.row] = level
return result
def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]:
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная
строка с суммами — не закрытие, и дерево остаётся по отступам.
"""
result = dict(levels)
previous: BodyRow | None = None
for item in rows:
if (
previous is not None
and previous.is_account
and not item.is_account
and not item.text.strip()
and any(value != _ZERO for value in _vector(item)[_TURNOVER_PAIR])
and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR])
and result[item.row] <= result[previous.row]
):
result[item.row] = result[previous.row] + 1
previous = item
return result
def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> bool:
"""«Сумма детей ≠ родитель» для решения о перестройке: по паре «дебет, кредит» (начало, обороты, конец); пара, в
которой у детей пусто (аналитика без сальдо), не сверяется, а сальдо свёрнутого родителя сверяется по нетто — как
в контрольных суммах §1.4, но мягче: окончательно решают они, а не эта функция."""
for start, is_balance in ((0, True), (2, False), (4, True)):
pair_parent, pair_children = parent[start : start + 2], children[start : start + 2]
if all(abs(value) <= SUM_TOLERANCE for value in pair_children):
continue
if _matches(pair_parent, pair_children):
continue
collapsed = any(abs(value) <= SUM_TOLERANCE for value in pair_parent)
net_equal = abs((pair_parent[0] - pair_parent[1]) - (pair_children[0] - pair_children[1])) <= SUM_TOLERANCE
if not (is_balance and collapsed and net_equal):
return True
return False
def _greedy_ends(
vectors: Sequence[tuple[Decimal, ...]],
pacer: _Pacer,
*,
scan_limit: int | None = None,
blocked: Sequence[bool] | None = None,
) -> list[int]:
"""Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает
следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм
родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца);
`blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней)."""
count = len(vectors)
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
end = [index + 1 for index in range(count)]
for index in reversed(range(count)):
pacer.tick()
target = vectors[index]
if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]):
continue
accumulated, position, steps = zero, index + 1, 0
while position < count and (scan_limit is None or steps < scan_limit):
if blocked is not None and blocked[position]:
break
pacer.tick()
accumulated = _add(accumulated, vectors[position])
position = end[position]
steps += 1
if _matches(accumulated, target):
end[index] = position
break
return end
def _depths(end: Sequence[int]) -> list[int]:
"""Глубина вложенности каждой строки по границам поддеревьев `end`."""
depths: list[int] = []
open_ends: list[int] = [] # границы поддеревьев открытых предков
for index in range(len(end)):
while open_ends and open_ends[-1] <= index:
open_ends.pop()
depths.append(len(open_ends))
if end[index] > index + 1:
open_ends.append(end[index])
return depths
def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Deadline | None) -> dict[int, int]:
"""Вложенность аналитики по суммам внутри родителя с нарушением «сумма детей = родитель» (при одном отступе
уровни аналитики неразличимы, видны только суммы: 15 = 16 + 17).
Только для родителя с нарушением, чьи дети — строки аналитики (не счета и не показатели) с суммами; перестройка
принимается, если после неё сумма прямых детей совпадает с родителем (`_violates`), иначе дерево родителя остаётся
исходным. Строки без нарушения не трогаются: равные суммы соседей (50 + 50 = 100) не делают одного ребёнком другого.
"""
pacer = _Pacer(deadline, "иерархия (indent): вложенность по суммам")
by_parent = parents(rows, levels)
kids: dict[int, list[int]] = {}
for index, item in enumerate(rows):
parent = by_parent[item.row]
if parent is not None:
kids.setdefault(parent, []).append(index)
position = {item.row: index for index, item in enumerate(rows)}
bump: dict[int, int] = {}
for parent_row, kid_indexes in kids.items():
pacer.tick()
members = [rows[index] for index in kid_indexes]
if (
len(members) < MIN_NESTABLE_CHILDREN
or any(m.is_account or m.indicator for m in members)
or len({m.indent for m in members}) > 1
):
continue
vectors = [_vector(member) for member in members]
total = vectors[0]
for vector in vectors[1:]:
total = _add(total, vector)
parent_vector = _vector(rows[position[parent_row]])
if not _violates(parent_vector, total):
continue
depths = _depths(
_greedy_ends(vectors, pacer, scan_limit=NEST_SCAN_LIMIT, blocked=[member.has_inn for member in members])
)
top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0]
if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))):
continue
for kid, depth in zip(kid_indexes, depths, strict=True):
if depth == 0:
continue
index = kid
# сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
index += 1
while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]:
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
index += 1
return {row: level + bump.get(row, 0) for row, level in levels.items()}
def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
@ -237,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) ->
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
и такой файл завершается `TIMEOUT`, а не висит.
"""
count = len(rows)
vectors = [_vector(item) for item in rows]
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
has_children = False
pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев")
for index in reversed(range(count)):
pacer.tick()
target = vectors[index]
if all(value == _ZERO for value in target):
continue
accumulated = zero
position = index + 1
while position < count:
pacer.tick()
accumulated = _add(accumulated, vectors[position])
position = end[position]
if _matches(accumulated, target):
end[index] = position
has_children = True
break
if not has_children:
end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев"))
if all(end[index] == index + 1 for index in range(len(rows))):
return None
by_row: dict[int, int] = {}
open_ends: list[int] = [] # границы поддеревьев открытых предков
for index, item in enumerate(rows):
while open_ends and open_ends[-1] <= index:
open_ends.pop()
by_row[item.row] = len(open_ends)
if end[index] > index + 1:
open_ends.append(end[index])
return by_row
return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)}
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
@ -309,10 +527,18 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
def resolve_levels(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
rows: Sequence[BodyRow],
total_row: int | None,
deadline: Deadline,
*,
header_account: bool = False,
nest_by_sums: bool = False,
) -> Levels:
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
`nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема
шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки).
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
(плоский список под счётом шапки, #59).
@ -333,7 +559,9 @@ def resolve_levels(
cascade.insert(before_sums, ("header_account", strategy_header_account))
for name, strategy in cascade:
deadline.check(f"иерархия ({name})")
found = strategy(body, deadline)
found = (
strategy_indent(body, deadline, nest_by_sums=nest_by_sums) if name == "indent" else strategy(body, deadline)
)
if found is None:
continue
by_row = {**found, **pinned}

View File

@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
def levels_with_indicators(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
rows: Sequence[BodyRow],
total_row: int | None,
deadline: Deadline,
*,
header_account: bool = False,
nest_by_sums: bool = False,
) -> Levels:
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
@ -67,7 +72,11 @@ def levels_with_indicators(
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
"""
levels = resolve_levels(
[item for item in rows if not item.indicator], total_row, deadline, header_account=header_account
[item for item in rows if not item.indicator],
total_row,
deadline,
header_account=header_account,
nest_by_sums=nest_by_sums,
)
by_row: dict[int, int] = {}
parent: dict[int, int | None] = {}

View File

@ -217,7 +217,14 @@ class OsvParser:
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
body = blocks.rows
step = "иерархия строк"
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
# Вложенность по суммам меняет group_type/group_name строки: только без названных групп после счёта
levels = levels_with_indicators(
body,
total_row,
deadline,
header_account=bool(doc.account_code_raw),
nest_by_sums=all(group.name is None for group in schema.groups[1:]),
)
step = "строки ИНН"
glue = glue_inn_rows(
body,

View File

@ -206,6 +206,12 @@ class OsvGroupRules(_Model):
default: GroupType = "OTHER"
# Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6)
counterparty_accounts: list[str] = []
# Названия группировок-периодов («Период»): строки «Обороты за <месяц>» — цепочка сальдо, не слагаемые (AURORA-1823)
period_groups: list[Pattern] = []
def is_period_group(self, name: str | None) -> bool:
"""Название группировки из схемы шапки относится к периодам."""
return name is not None and any(pattern.search(name) for pattern in self.period_groups)
@model_validator(mode="after")
def _check_counterparty_accounts(self) -> "OsvGroupRules":

View File

@ -4,9 +4,6 @@
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
needs_check:
"nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls":
reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся"
sheets: {"sheet:58": [parent_sum]}
"agrokompleks/2025-Q3/ОСВ_сч62_2025_9мес.xls":
reason: "аналитика магазинов 62.04 без сальдо на конец (тот же класс, что OQ-132); Итого и родители сходятся"
sheets: {"sheet:TDSheet": [balance_identity]}

View File

@ -5,7 +5,8 @@
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78);
описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
и zip постоянны, порядок записи фиксирован.
@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]:
return [sheet]
def _s47() -> list[SheetSpec]:
"""Иерархия по отступам там, где отступа не хватает (AURORA-1858): три листа «Субконто» без вида аналитики в шапке.
Лист «68»: подуровень «68.01.1» на том же отступе, что «68.01». Лист «90»: безымянная строка закрытия сразу за
счётом (пустая ячейка отступа не хранит). Лист «91»: аналитика на одном отступе, а вложена по суммам
(100 = 60 + 40; 30 = 20 + 10; 40 ⊃ 40)."""
head = {1: 'ООО "Октябрьское"', 3: "Период: 9 месяцев 2024 г."}
def sheet(account: str, rows: Sequence[FlatRow]) -> SheetSpec:
spec = _flat_sheet({**head, 2: f"Оборотно-сальдовая ведомость по счету {account}"}, 5, rows)
spec.name = f"{account} сч."
return spec
tax = (None, None, "2333.00", "2624.00", None, "291.00")
tax_old = (None, "15687.00", "45332.00", "45332.00", None, "15687.00")
rows_68: list[FlatRow] = [
("68.01", tax, 0),
("68.01.1", tax, 0),
("Налог (взносы): начислено / уплачено", tax, 1),
("68.10", tax_old, 0),
("Налог (взносы): начислено / уплачено", tax_old, 1),
("Итого", (None, "15687.00", "47665.00", "47956.00", None, "15978.00"), 0),
]
income = (None, None, None, "300.00", None, "300.00")
expense = (None, None, "200.00", None, "200.00", None)
closing = (None, None, "500.00", "300.00", "200.00", None)
rows_90: list[FlatRow] = [
("90.01", income, 0),
("90.01.1", income, 0),
("Аренда", (None, None, None, "200.00", None, None), 1),
("Прочие", (None, None, None, "100.00", None, None), 1),
("90.07", expense, 0),
("90.07.1", expense, 0),
("Расходы по аренде", (None, None, "200.00", None, None, None), 1),
("90.09", closing, 0),
("", (None, None, "500.00", "300.00", None, None), 0),
("Итого", (None, None, "700.00", "600.00", "400.00", "300.00"), 0),
]
def turn(debit: str | None, credit: str | None) -> tuple[str | None, ...]:
return (None, None, debit, credit, None, None)
rows_91: list[FlatRow] = [
("91.01", (None, None, None, "100.00", None, "100.00"), 0),
("Проценты к получению", turn(None, "70.00"), 1),
("Проценты по вкладам", turn(None, "70.00"), 1),
("Прочие доходы", turn(None, "30.00"), 1),
("Пени", turn(None, "20.00"), 1),
("Штрафы", turn(None, "10.00"), 1),
("91.02", (None, None, "170.00", None, "170.00", None), 0),
("91.02.1", (None, None, "170.00", None, "170.00", None), 0),
("Проценты к уплате", turn("100.00", None), 1),
("Проценты по займам", turn("60.00", None), 1),
("Проценты по кредитам", turn("40.00", None), 1),
("Прочие расходы", turn("30.00", None), 1),
("Затраты на нужды", turn("20.00", None), 1),
("Штрафы по договорам", turn("10.00", None), 1),
("Операционные расходы", turn("40.00", None), 1),
("Услуги банка", turn("40.00", None), 1),
("91.09", (None, None, None, "70.00", None, "70.00"), 0),
("", turn(None, "70.00"), 0),
("Итого", (None, None, "170.00", "170.00", "170.00", "170.00"), 0),
]
return [sheet("68", rows_68), sheet("90", rows_90), sheet("91", rows_91)]
def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree()
@ -1152,6 +1218,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
"s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx),
}
FILE_NAMES = tuple(FILES)

View File

@ -26,13 +26,13 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
# s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44
numbers = [*range(1, 37), *range(40, 47)]
numbers = [*range(1, 37), *range(40, 48)]
assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers]
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -52,6 +52,7 @@ FLAT_FORM_FILES = (
"s44_subkonto_unnamed_10.xlsx",
"s45_account_label_value_cells.xlsx",
"s46_counterparty_code_blocks.xlsx",
"s47_indent_hierarchy_code_sums.xlsx",
)
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
@ -189,6 +190,10 @@ EXPECTED: dict[str, Expect] = {
"s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None),
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
"s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None),
# #78: листы 68/90/91 по отступам: подуровень «68.01.1», безымянная строка закрытия, вложенность по суммам
"s47_indent_hierarchy_code_sums.xlsx": Expect(
"indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3
),
}

View File

@ -6,6 +6,8 @@
from decimal import Decimal
import pytest
from src.contract.common import DataType
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
from src.parser.classify import ClassifiedRow, RowGroupType
@ -27,6 +29,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
text: str = "строка",
code: str | None = "60",
indicator: bool = False,
is_period: bool = False,
) -> ClassifiedRow:
body = BodyRow(
row=number,
@ -50,6 +53,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
data_type=data_type,
extra=None,
indicator=indicator,
is_period=is_period,
)
@ -186,6 +190,176 @@ def test_expanded_parent_balance_must_match_by_columns() -> None:
assert parent_sums(rows) == [12]
def netted_child_tree(parent_opening: tuple[str, str] = ("130", "70"), parent_turnover: tuple[str, str] = ("10", "10")):
"""Развёрнутый счёт 12 над свёрнутым субсчётом 13 (нетто 60 Дт), чьи внуки 14/15 развёрнуты (90 Дт / 30 Кт):
1С схлопнула Дт/Кт субсчёта в нетто, в родителе они остались развёрнутыми (AURORA-1863)."""
opening_debit, opening_credit = parent_opening
turnover_debit, turnover_credit = parent_turnover
return [
account(12, (opening_debit, opening_credit, turnover_debit, turnover_credit, opening_debit, opening_credit)),
row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"),
row(14, ("90", None, "10", None, "90", None), level=2, parent=13, group_type="ACCOUNT"),
row(15, (None, "30", None, "10", None, "30"), level=2, parent=13, group_type="ACCOUNT"),
]
def test_expanded_parent_over_netted_child_is_compared_by_net() -> None:
"""AURORA-1863: у ребёнка сальдо свёрнуто относительно его детей (прошёл только по нетто) — развёрнутый
родитель над ним тоже сверяется по нетто."""
assert parent_sums(netted_child_tree()) == []
def test_expanded_parent_over_netted_child_with_wrong_net_is_violation() -> None:
assert parent_sums(netted_child_tree(parent_opening=("131", "70"))) == [12]
def test_expanded_parent_over_netted_child_turnovers_still_by_columns() -> None:
assert parent_sums(netted_child_tree(parent_turnover=("11", "11"))) == [12]
def test_expanded_parent_without_netted_child_stays_violation_on_rounding_gap() -> None:
"""Узость правила: расхождение 0.05 на обеих сторонах при равном нетто у развёрнутого родителя без свёрнутого
ребёнка остаётся нарушением."""
rows = [
account(12, ("9977809.90", "8829025.95", None, None, "9977809.90", "8829025.95")),
row(13, ("9977809.85", None, None, None, "9977809.85", None), level=1, parent=12, group_type="ACCOUNT"),
row(14, (None, "8829025.90", None, None, None, "8829025.90"), level=1, parent=12, group_type="ACCOUNT"),
]
assert parent_sums(rows) == [12]
def test_netted_flag_does_not_pass_through_column_matched_level() -> None:
"""Признак передаёт только строка, сама сошедшаяся с детьми по нетто (непрерывная цепочка нетто-строк): счёт 11
развёрнут, субсчёт 12 сошёлся по колонкам (над свёрнутым 13) — признака у 12 нет, 11 сверяется по колонкам."""
rows = [
row(11, ("300", "240", "10", "10", "300", "240"), group_type="ACCOUNT", text="60"),
row(12, ("130", "70", "10", "10", "130", "70"), level=1, parent=11, group_type="ACCOUNT"),
row(13, ("60", None, "10", "10", "60", None), level=2, parent=12, group_type="ACCOUNT"),
row(14, ("90", None, "10", None, "90", None), level=3, parent=13, group_type="ACCOUNT"),
row(15, (None, "30", None, "10", None, "30"), level=3, parent=13, group_type="ACCOUNT"),
row(16, ("70", "70", "0", "0", "70", "70"), level=2, parent=12, group_type="ACCOUNT"),
]
assert parent_sums(rows) == [11]
def test_netted_flag_continues_through_chain_of_net_rows() -> None:
"""Цепочка нетто-строк: 12 сошёлся с 13 по нетто, 11 над ним — тоже по нетто."""
rows = [
row(11, ("260", "200", "10", "10", "260", "200"), group_type="ACCOUNT", text="60"),
row(12, ("130", "70", "10", "10", "130", "70"), level=1, parent=11, group_type="ACCOUNT"),
row(13, ("60", None, "10", "10", "60", None), level=2, parent=12, group_type="ACCOUNT"),
row(14, ("90", None, "10", None, "90", None), level=3, parent=13, group_type="ACCOUNT"),
row(15, (None, "30", None, "10", None, "30"), level=3, parent=13, group_type="ACCOUNT"),
]
assert parent_sums(rows) == []
def test_net_child_allows_net_on_both_balance_sides() -> None:
"""R-9: ребёнок свёрнут в нетто по сальдо начала (на конец сошёлся по колонкам), а развёрнутый родитель на конец
расходится с ним симметрично: схлопнутую пару 1С не показывает ни в начале, ни в конце — нетто на обе стороны."""
rows = [
row(12, ("130", "70", "10", "10", "130", "70"), group_type="ACCOUNT"),
row(13, ("60", None, "10", "10", "60", None), level=1, parent=12, group_type="ACCOUNT"),
row(14, ("90", None, "10", None, "60", None), level=2, parent=13, group_type="ACCOUNT"),
row(15, (None, "30", None, "10", None, None), level=2, parent=13, group_type="ACCOUNT"),
]
assert parent_sums(rows) == []
def period_tree(
opening: str = "100", turnover: str = "30", closing: str = "70", *, second_is_period: bool = True
) -> list[ClassifiedRow]:
"""Контрагент 12 с помесячными строками «Период» 13 (сальдо 100 → 90, оборот Кт 10) и 14 (90 → 70, Кт 20): сальдо
родителя — начало первого периода и конец последнего, обороты — сумма (AURORA-1823)."""
return [
row(12, (opening, None, None, turnover, closing, None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(
14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=second_is_period
),
]
def test_period_children_are_chained_not_summed() -> None:
assert parent_sums(period_tree()) == []
@pytest.mark.parametrize(
"broken",
[
{"turnover": "31"}, # ошибка оборота периода
{"opening": "101"}, # сальдо начала ≠ начало первого периода
{"closing": "71"}, # сальдо конца ≠ конец последнего периода
],
)
def test_period_children_chain_violations(broken: dict[str, str]) -> None:
assert parent_sums(period_tree(**broken)) == [12]
def test_period_chain_gap_in_the_middle_is_violation() -> None:
"""Начало и конец цепочки, обороты сходятся, но сальдо конца месяца ≠ сальдо начала следующего (90 → 95)."""
rows = [
row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(14, ("95", None, None, "10", "85", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(15, ("85", None, None, "10", "70", None), level=3, parent=12, group_type="OTHER", is_period=True),
]
assert parent_sums(rows) == [12]
def test_period_chain_may_skip_months_but_must_be_continuous() -> None:
"""Пропуск месяцев без строк допустим: сравниваются соседние строки периодов."""
rows = [
row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True),
]
assert parent_sums(rows) == []
def test_period_chain_continuity_by_net_when_one_side_is_collapsed() -> None:
"""Конец месяца развёрнут (120 Дт / 20 Кт), начало следующего свёрнуто (100 Дт): нетто совпадает."""
rows = [
row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("100", None, None, "10", "120", "20"), level=3, parent=12, group_type="OTHER", is_period=True),
row(14, ("100", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True),
]
assert parent_sums(rows) == []
def test_period_balance_is_compared_by_net_for_collapsed_parent() -> None:
"""Сальдо периодов сверяется той же функцией, что и обычное: свёрнутый родитель — по нетто."""
rows = [
row(12, ("100", None, None, "30", "70", None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("120", "20", None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="OTHER", is_period=True),
]
assert parent_sums(rows) == []
def test_ordinary_children_keep_summing_balances() -> None:
"""Те же числа, но дети — не периоды: сальдо складывается (100 + 90 ≠ 100)."""
assert parent_sums(period_tree(second_is_period=False)) == [12]
def test_mixed_period_and_ordinary_children_use_plain_sums() -> None:
rows = [
row(12, ("190", None, None, "30", "160", None), level=2, parent=10, group_type="COUNTERPARTY"),
row(13, ("100", None, None, "10", "90", None), level=3, parent=12, group_type="OTHER", is_period=True),
row(14, ("90", None, None, "20", "70", None), level=3, parent=12, group_type="CONTRACT"),
]
assert parent_sums(rows) == []
def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None:
rows = [
account(12, ("16", None, "135", "112", "39", None)),

View File

@ -281,6 +281,46 @@ def test_account_subaccount_counterparty_contract(rules: Rules) -> None:
assert parsed.levels.strategy == "outline"
def test_period_group_rows_are_flagged(rules: Rules) -> None:
"""AURORA-1823: третья группировка «Период» (строки «Обороты за <месяц>») — тип OTHER, но с признаком `is_period`;
контрагент и счёт признака не несут."""
# «Период» — третья строка блока заголовка (как в выгрузке 1С): подписи «Дебет/Кредит» объединены на две строки
debit, credit = M("Дебет", 1, 2), M("Кредит", 1, 2)
sheet = sheet_from_rows(
[
["ООО Ромашка"],
[TITLE],
[],
["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, None, debit, credit, debit, credit, debit, credit],
["Период"],
r(0, "58", S100),
r(1, "58.03", S100),
r(2, "ООО Поставщик", S100, inn="7701234567"),
r(3, "Обороты за Январь 25", S100),
r(3, "Обороты за Февраль 25", S100),
r(None, "Итого", S100),
]
)
parsed = parse(sheet, rules)
assert [(row.body.text, row.group_type, row.group_name, row.is_period) for row in parsed.rows] == [
("58", "ACCOUNT", "Счет", False),
("58.03", "ACCOUNT", "Счет", False),
("ООО Поставщик", "COUNTERPARTY", "Контрагенты", False),
("Обороты за Январь 25", "OTHER", "Период", True),
("Обороты за Февраль 25", "OTHER", "Период", True),
("Итого", "TOTAL", None, False),
]
def test_contract_group_is_not_period(rules: Rules) -> None:
parsed = parse(_basic(), rules)
assert not any(row.is_period for row in parsed.rows)
def test_parents_follow_tree(rules: Rules) -> None:
parsed = parse(_basic(), rules)

View File

@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
indent: int = 0,
bold: bool = False,
sums: tuple[Decimal | None, ...] = NO_SUMS,
has_inn: bool = False,
) -> BodyRow:
match = ACCOUNT.fullmatch(text)
rank = match.group(1).count(".") + 1 if match else None
@ -54,11 +55,13 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
row=row,
text=text,
is_account=match is not None,
account_code=match.group(1) if match else None,
account_rank=rank,
bold=bold,
indent=indent,
outline=outline,
sums=sums,
has_inn=has_inn,
)
@ -498,3 +501,289 @@ def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() ->
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0]
assert set(levels.parent.values()) == {None}
# --- Отступ: подуровень счёта, закрывающая строка, вложенность по суммам (AURORA-1858) ---
def turnover(value: str) -> tuple[Decimal | None, ...]:
return money(None, None, value, None, None, None)
def test_indent_subaccount_with_code_prefix_on_same_indent_is_nested() -> None:
"""Подуровень «68.01.1» на том же отступе, что «68.01»: по коду он ребёнок, его аналитика — на уровень глубже."""
rows = numbered(
("68.01", {"indent": 0}),
("68.01.1", {"indent": 0}),
("Налог", {"indent": 1}),
("68.10", {"indent": 0}),
("Налог", {"indent": 1}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 0, 21: 1}
def test_indent_neighbour_accounts_without_code_prefix_stay_roots() -> None:
rows = numbered(
("68.01", {"indent": 0}),
("68.10", {"indent": 0}),
("68.1.1", {"indent": 0}),
("Налог", {"indent": 1}),
)
assert strategy_indent(rows) == {17: 0, 18: 0, 19: 0, 20: 1}
def test_indent_code_prefix_nesting_only_on_a_single_indent() -> None:
"""Пары «счёт — подуровень» на двух разных отступах — не признак вложенности (соседние корни намеренно)."""
rows = numbered(
("60", {"indent": 0}),
("60.01", {"indent": 0}),
("Контрагент", {"indent": 1}),
("70", {"indent": 1}),
("70.01", {"indent": 1}),
)
assert strategy_indent(rows) == {17: 0, 18: 0, 19: 1, 20: 1, 21: 1}
def test_indent_chain_of_subaccounts_on_same_indent_is_nested_step_by_step() -> None:
rows = numbered(
("68", {"indent": 0}),
("68.01", {"indent": 0}),
("68.01.1", {"indent": 0}),
("Налог", {"indent": 1}),
("69", {"indent": 0}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 0}
def test_indent_unnamed_closing_row_after_account_is_its_child() -> None:
"""Строка закрытия без текста и кода (reader не хранит пустые ячейки — отступ 0) — ребёнок предыдущего счёта."""
rows = numbered(
("90.01", {"indent": 0, "sums": turnover("10")}),
("Аренда", {"indent": 1, "sums": turnover("10")}),
("90.09", {"indent": 0, "sums": turnover("7")}),
("", {"indent": 0, "sums": turnover("7")}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1}
def test_indent_unnamed_row_without_sums_or_after_non_account_is_not_attached() -> None:
rows = numbered(
("90.01", {"indent": 0}),
("Аренда", {"indent": 1, "sums": turnover("10")}),
("", {"indent": 0, "sums": turnover("10")}),
("90.09", {"indent": 0}),
("", {"indent": 0}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 0, 21: 0}
def flat_group(*kids: str, parent: str = "100") -> list[BodyRow]:
"""Счёт с оборотом `parent` и аналитикой на одном отступе — по суммам значения `kids` (дебет оборота)."""
specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover(parent)})]
specs += [(f"Статья {n}", {"indent": 1, "sums": turnover(value)}) for n, value in enumerate(kids)]
return numbered(*specs)
def test_indent_flat_analytics_is_nested_by_sums_inside_violating_parent() -> None:
"""Сумма детей ≠ родитель, а вложенность видна только по суммам (60 = 40 + 20): строка с суммой её соседей — их
родитель."""
rows = flat_group("60", "40", "20", "40")
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 1}
def test_indent_flat_analytics_is_untouched_without_violation() -> None:
"""Родитель сходится с детьми (50 + 50 = 100): равные суммы соседей не склеиваются в родителя и ребёнка."""
rows = flat_group("50", "50")
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1}
def test_indent_nesting_by_sums_is_rolled_back_when_parent_still_violates() -> None:
"""30 + 30 + 30 ≠ 100 и вложенность по суммам (30 ⊃ 30) родителя не лечит: дерево остаётся исходным."""
rows = flat_group("30", "30", "30")
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1, 20: 1}
def test_indent_nesting_by_sums_never_nests_accounts() -> None:
rows = numbered(
("91", {"indent": 0, "sums": turnover("100")}),
("91.01", {"indent": 1, "sums": turnover("60")}),
("91.02", {"indent": 1, "sums": turnover("60")}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1}
def test_indent_second_subaccount_of_same_parent_is_nested_too() -> None:
"""«68.04.1» и «68.04.2» на одном отступе с «68.04»: оба — дети «68.04» (второй — не ребёнок первого)."""
rows = numbered(
("68.04", {"indent": 0}),
("68.04.1", {"indent": 0}),
("Налог", {"indent": 1}),
("68.04.2", {"indent": 0}),
("68.07", {"indent": 0}),
("Налог", {"indent": 1}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 1, 21: 0, 22: 1}
def test_indent_subaccount_already_under_parent_with_its_code_is_not_moved() -> None:
"""«76.09» с собственной аналитикой и «76.09.1» рядом на одном отступе — уже дети счёта «76.09»; под соседа
«76.09» их перекладывать не нужно."""
rows = numbered(
("76", {"indent": 0}),
("76.09", {"indent": 1}),
("76.09", {"indent": 2}),
("ООО А", {"indent": 3}),
("76.09.1", {"indent": 2}),
("ООО Б", {"indent": 3}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 3}
def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None:
"""Безымянная строка с суммами, не повторяющая обороты счёта, закрытием не считается."""
rows = numbered(
("26.01", {"indent": 0, "sums": money(None, None, "100", "100", None, None)}),
("Статья", {"indent": 1, "sums": turnover("100")}),
("", {"indent": 0, "sums": money(None, None, None, "100", None, None)}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}
# --- Вложенность по суммам: защиты (ревью AURORA-1858, C-1) ---
def test_indent_nesting_by_sums_is_off_when_caller_forbids_it() -> None:
"""Схема шапки называет группы аналитики после счёта: вложение изменило бы group_type/group_name строки."""
rows = flat_group("60", "40", "20", "40")
assert strategy_indent(rows, nest_by_sums=False) == {17: 0, 18: 1, 19: 1, 20: 1, 21: 1}
def test_indent_nesting_by_sums_needs_one_indent_among_children() -> None:
"""Прямые дети родителя на разных отступах (2, 1, 1): отступ их уровни различает, суммы его не переопределяют."""
rows = numbered(
("91.02", {"indent": 0, "sums": turnover("100")}),
("Статья 0", {"indent": 2, "sums": turnover("60")}),
("Статья 1", {"indent": 1, "sums": turnover("40")}),
("Статья 2", {"indent": 1, "sums": turnover("40")}),
)
assert strategy_indent(rows) == {17: 0, 18: 2, 19: 1, 20: 1}
def inn_group(*, inn_on: set[int]) -> list[BodyRow]:
"""91.02 = 100; аналитика 60, 40, 40 на одном отступе; `inn_on` — номера статей (с 0) с ИНН."""
sums = ("60", "40", "40")
specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover("100")})]
specs += [
(f"Статья {n}", {"indent": 1, "sums": turnover(value), "has_inn": n in inn_on}) for n, value in enumerate(sums)
]
return numbered(*specs)
def test_indent_nesting_by_sums_without_inn_nests_equal_neighbours() -> None:
assert strategy_indent(inn_group(inn_on=set())) == {17: 0, 18: 1, 19: 1, 20: 2}
def test_indent_nesting_by_sums_skips_row_with_inn_being_nested() -> None:
"""Вкладываемая строка с ИНН: она контрагент, а не строка под соседом."""
assert strategy_indent(inn_group(inn_on={2})) == {17: 0, 18: 1, 19: 1, 20: 1}
def test_indent_nesting_by_sums_skips_new_parent_with_inn() -> None:
"""Новый родитель с ИНН: контрагент не становится родителем строки."""
assert strategy_indent(inn_group(inn_on={1})) == {17: 0, 18: 1, 19: 1, 20: 1}
def test_resolve_levels_does_not_nest_by_sums_unless_asked() -> None:
rows = flat_group("60", "40", "20", "40")
flat = [1, 1, 1, 1]
nested = [1, 2, 2, 1]
off = resolve_levels(rows, None, never_expires())
on = resolve_levels(rows, None, never_expires(), nest_by_sums=True)
assert levels_list(rows, off.by_row)[1:] == flat
assert levels_list(rows, on.by_row)[1:] == nested
def test_indent_nesting_by_sums_accumulates_depth_for_nested_child_of_nested_parent() -> None:
"""91 = 100: X 40, Y 40, Z 60 на одном отступе (X ⊃ Y); под Y — K1 40, K2 40 глубже (K1 ⊃ K2). Поддерево Y
сдвигается вместе с Y (+1), а K2 ещё вкладывается в K1 (+1): сдвиги складываются, а не затирают друг друга."""
rows = numbered(
("91", {"indent": 0, "sums": turnover("100")}),
("X", {"indent": 1, "sums": turnover("40")}),
("Y", {"indent": 1, "sums": turnover("40")}),
("K1", {"indent": 2, "sums": turnover("40")}),
("K2", {"indent": 2, "sums": turnover("40")}),
("Z", {"indent": 1, "sums": turnover("60")}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 4, 22: 1}
def test_indent_nesting_by_sums_keeps_depth_of_children_under_a_nested_row() -> None:
"""Строка с детьми (глубже по отступу) вкладывается как поддерево: уровни детей растут на её глубину."""
rows = numbered(
("91", {"indent": 0, "sums": turnover("100")}),
("Z", {"indent": 1, "sums": turnover("60")}),
("Y", {"indent": 1, "sums": turnover("60")}),
("K1", {"indent": 2, "sums": turnover("30")}),
("K2", {"indent": 2, "sums": turnover("30")}),
("W", {"indent": 1, "sums": turnover("40")}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 3, 22: 1}
NEST_SCAN_LIMIT_PLUS = 50 # столько соседей вперёд перебирает вложенность по суммам
@pytest.mark.parametrize(("count", "nested"), [(NEST_SCAN_LIMIT_PLUS, True), (NEST_SCAN_LIMIT_PLUS + 1, False)])
def test_indent_nesting_by_sums_scan_limit_boundary(count: int, nested: bool) -> None:
"""Родитель строки, чьи суммы дают сумма `count` следующих соседей: 50 соседей находятся, 51 — нет."""
kids = [str(n) for n in range(1, count + 1)]
total = str(sum(range(1, count + 1)))
rows = flat_group(total, *kids, parent=total)
levels = strategy_indent(rows)
assert levels is not None
assert levels[FIRST + 1] == 1
assert (levels[FIRST + 2] == levels[FIRST + 1] + 1) is nested
def test_indent_account_code_without_separator_is_not_nested() -> None:
"""Второй счёт «60» после «60»: у кода нет разделителя сегментов, родителя по коду нет."""
rows = numbered(
("60", {"indent": 0}),
("Контрагент", {"indent": 1}),
("60", {"indent": 0}),
("Контрагент 2", {"indent": 1}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1}
def test_indent_closing_row_needs_nonzero_turnover() -> None:
"""Безымянная строка с нулевыми оборотами при нулевых оборотах счёта закрытием не считается."""
rows = numbered(
("90.09", {"indent": 0, "sums": money(None, None, "0", "0", None, None)}),
("Статья", {"indent": 1, "sums": turnover("5")}),
("", {"indent": 0, "sums": money(None, None, "0", "0", None, "7")}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}

View File

@ -0,0 +1,97 @@
"""Вложенность по суммам при иерархии по отступам (AURORA-1858, ревью C-1): защиты на уровне всего листа.
Правило не должно прятать реальную ошибку и менять `group_type`/`group_name` строк: при названных группах аналитики в
шапке оно отключено, с ИНН строка не вкладывается и не становится родителем.
"""
import pytest
from gridbuilder import C, M
from src.contract.result import OsvDocument
from src.parser.deadline import Deadline
from src.parser.osv import OsvParser
from src.parser.rules import Rules, load_rules
from gridbuilder import sheet_from_rows # isort: skip
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
TIMEOUT_SECONDS = 60
Row = list[object]
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def sums(value: int) -> Row:
return [None, None, value, None, value, None]
def line(indent: int, text: str, value: int, inn: str | None = None) -> Row:
return [C(text, indent=indent), inn, None, *sums(value)]
def parse(rules: Rules, head_group_rows: list[Row], body: list[Row]) -> OsvDocument:
rows: list[Row] = [
["ООО Ромашка"],
[TITLE],
[],
[
"Счет",
"ИНН",
"КПП",
M("Сальдо на начало периода", 2),
M("Обороты за период", 2),
M("Сальдо на конец периода", 2),
],
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
*head_group_rows,
*body,
]
return OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)).document
def test_reviewer_scenario_with_named_groups_and_inn_stays_needs_check(rules: Rules) -> None:
"""Счёт 60 = 100, но контрагенты 60 + 40 + 40: это ошибка файла, а не вложенность «Гамма под Бетой»."""
document = parse(
rules,
[["Договоры"]],
[
line(0, "60", 100),
line(1, "ООО Альфа", 60, "7701234567"),
line(2, "Договор 1", 60),
line(1, "ООО Бета", 40, "7707654321"),
line(1, "ООО Гамма", 40, "5001234567"),
[C("Итого"), None, None, *sums(100)],
],
)
by_text = {row.row_value: row for row in document.rows}
gamma = by_text["ООО Гамма"]
assert document.status == "needs_check"
assert gamma.group_type == "COUNTERPARTY"
assert gamma.counterparty_inn == "5001234567"
assert gamma.parent_row_number == by_text["60"].row_number
assert by_text["ООО Бета"].parent_row_number == by_text["60"].row_number
def test_named_analytic_group_in_header_disables_nesting_by_sums(rules: Rules) -> None:
"""Без ИНН и с одним отступом правило бы вложило «Б» в «В» (40 ⊃ 40): шапка называет «Контрагенты», вложение
сменило бы group_type строки, поэтому дерево остаётся по отступам и лист на проверке."""
document = parse(
rules,
[],
[
line(0, "60", 100),
line(1, "Контрагент А", 60),
line(1, "Контрагент Б", 40),
line(1, "Контрагент В", 40),
[C("Итого"), None, None, *sums(100)],
],
)
by_text = {row.row_value: row for row in document.rows}
assert document.status == "needs_check"
assert {by_text[name].group_type for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {"COUNTERPARTY"}
assert {by_text[name].level for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {1}

View File

@ -749,6 +749,26 @@ def test_counterparty_accounts_list_is_data(rules: Rules) -> None:
assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"]
@pytest.mark.parametrize(
("name", "expected"),
[
("Период", True),
("период", True),
(" Период ", True),
("Периодичность", False),
("Договоры", False),
(None, False),
],
)
def test_period_groups_are_data(rules: Rules, name: str | None, expected: bool) -> None:
"""AURORA-1823: группировка «Период» (строки «Обороты за <месяц>») — из `period_groups` словаря, не из кода."""
assert rules.groups.is_period_group(name) is expected
def test_period_groups_dictionary_key_is_loaded(rules: Rules) -> None:
assert [pattern.source for pattern in rules.groups.period_groups] == ["^период$"]
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
target = _copy_rules(tmp_path)

View File

@ -150,6 +150,26 @@ SHALL пропускаться с предупреждением файла `SHE
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
кодам счетов и жирности (`s03`)
#### Scenario: Отступ не различает подуровни счёта, строку закрытия и вложенную аналитику
- **WHEN** на листе уровни берутся по отступам (`s47`; ОСВ 1С «Субконто» без вида аналитики в шапке), но
(а) счёт `68.01.1` стоит на одном отступе с `68.01` (код продолжает код соседнего счёта того же блока);
(б) безымянная строка с суммами (пустая ячейка отступа не хранится) идёт сразу за строкой счёта и повторяет его обороты;
(в) аналитика родителя с нарушением «сумма детей = родитель» стоит на одном отступе, а вложенность видна только по
суммам (`100 = 60 + 40`)
- **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном
отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок
счёта (оборотов не нулевые); (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится
их родителем (жадно, не дальше `NEST_SCAN_LIMIT` = 50 следующих соседей), но только если после перестройки родитель
сходится, иначе дерево остаётся по отступам; родитель без нарушения не перестраивается (равные суммы соседей
`50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done`
- Вложение по суммам (в) меняет `level` и `parent_row_number` строк в `rows[]` контракта, поэтому применяется только при
одновременном выполнении трёх защит (решение владельца 28.10.2026): (1) схема шапки не называет группы аналитики
после счёта («Субконто» без вида), то есть вложение не меняет `group_type`/`group_name` строки; (2) все прямые дети
родителя стоят на одном отступе; (3) ни вкладываемая строка, ни новый родитель не несут ИНН (колонка ИНН или хвост
названия). Иначе (шапка «Счет / Контрагенты / Договоры», контрагенты с ИНН) ошибка суммы остаётся нарушением, а лист —
`needs_check`: контрагент не становится строкой под соседом. Код без разделителя сегментов («60» и второй «60»)
по (а) не вкладывается
#### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
@ -303,6 +323,25 @@ balance_identity_violations, parent_sum_violations}`. Результат про
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
или расхождение оборотов — нарушение
#### Scenario: Развёрнутый родитель над свёрнутым в нетто ребёнком
- **WHEN** сальдо родителя развёрнуто (обе стороны), а среди его прямых детей есть «нетто-ребёнок» — ребёнок, сам
сошедшийся со своими детьми по этой стороне сальдо (начало или конец) лишь по нетто, и нетто детей равно нетто
родителя; признак передаётся вверх только непрерывной цепочкой нетто-строк (строка, сошедшаяся по колонкам, его не
передаёт), отдельно для начала и конца
- **THEN** нарушения по сальдо нет (1С схлопнула Дт/Кт субсчёта в нетто, а в родителе они остались развёрнутыми,
AURORA-1863); обороты сверяются по колонкам, неверное нетто или расхождение оборотов — нарушение; развёрнутый родитель
без нетто-ребёнка по-прежнему сверяется по колонкам (расхождение даже на 0,05 при равном нетто — нарушение)
#### Scenario: Периоды под контрагентом — цепочка сальдо
- **WHEN** все дети родителя — строки группировки «Период» (`period_groups` словаря `osv_groups.yaml`; в выгрузке 1С
«Субсчета + Контрагенты» это помесячные строки «Обороты за <месяц>»; тип группы остаётся `OTHER`, у строки признак
`is_period`)
- **THEN** обороты родителя сверяются с суммой оборотов периодов, сальдо начала — с сальдо начала первого периода,
сальдо конца — с сальдо конца последнего (в порядке строк; сальдо сверяется по колонкам либо по нетто, как обычно),
а не с суммой сальдо месяцев (AURORA-1823); сальдо конца каждого периода SHALL равняться сальдо начала следующей строки
периода (по колонкам либо по нетто; месяцы без строк допустимы); расхождение в обороте, начале первого или конце
последнего периода либо разрыв цепочки — нарушение; у родителя со смешанными детьми (периоды и обычные строки) суммы считаются как прежде
#### Scenario: Счёт 91 — аналитика только с оборотами
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)