fix(osv): вложенность по суммам только без названных групп, без ИНН и при одном отступе; сдвиги складываются (AURORA-1858, #78, ревью)

C-1: nest_by_sums включается из osv.py, если схема шапки не называет группы после счёта; строка с ИНН не вкладывается
и не становится родителем; прямые дети родителя должны быть на одном отступе. M-1: сдвиги складываются.
M-2: код без разделителя сегментов не вкладывается. M-3: строка закрытия — только с ненулевыми оборотами.
M-4: общий жадный перебор _greedy_ends для strategy_sums и вложенности, граница NEST_SCAN_LIMIT под тестом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 10:43:39 +04:00
parent ca7f64c3ef
commit e1760c08f6
6 changed files with 322 additions and 62 deletions

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:
@ -310,6 +310,7 @@ 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,
@ -323,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,
)

View File

@ -47,6 +47,10 @@ 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`
@ -72,6 +76,7 @@ class BodyRow:
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
role: Role | None = None
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
@dataclass(frozen=True)
@ -134,7 +139,9 @@ 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), поэтому шаг отступа
@ -146,7 +153,10 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
(`_attach_closing_rows`);
- аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с
нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`).
нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка,
ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_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:
@ -154,7 +164,7 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
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)
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]:
@ -183,13 +193,14 @@ def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[i
open_accounts.pop()
for deeper in [known for known in blocks if known > level]:
del blocks[deeper]
parent_code = code.rsplit(".", 1)[0]
candidate = blocks.get(level, {}).get(parent_code)
# у кода без сегментов («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 candidate is not None or parent_code in seen_codes.get(level, ()):
elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())):
# родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем
# или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего
ambiguous = True
@ -241,8 +252,8 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами —
не закрытие, и дерево остаётся по отступам.
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная
строка с суммами — не закрытие, и дерево остаётся по отступам.
"""
result = dict(levels)
previous: BodyRow | None = None
@ -252,7 +263,7 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic
and previous.is_account
and not item.is_account
and not item.text.strip()
and any(value is not None for value in item.sums)
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]
):
@ -278,29 +289,44 @@ def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> boo
return False
def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]:
"""Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель
следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен
`NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех."""
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)):
if all(value == _ZERO for value in vectors[index]):
pacer.tick()
target = vectors[index]
if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]):
continue
accumulated, position = zero, index + 1
for _ in range(NEST_SCAN_LIMIT):
pacer.tick()
if position >= count:
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]
if _matches(accumulated, vectors[index]):
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(count):
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))
@ -329,7 +355,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
for parent_row, kid_indexes in kids.items():
pacer.tick()
members = [rows[index] for index in kid_indexes]
if len(members) < MIN_DISTINCT_LEVELS or any(m.is_account or m.indicator for m in members):
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]
@ -338,7 +368,9 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
parent_vector = _vector(rows[position[parent_row]])
if not _violates(parent_vector, total):
continue
depths = _nest_roots(vectors, pacer)
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
@ -346,10 +378,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
if depth == 0:
continue
index = kid
bump[rows[index].row] = depth
# сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже
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] = depth
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()}
@ -449,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], ...] = (
@ -521,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).
@ -545,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

@ -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
@ -60,6 +61,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
indent=indent,
outline=outline,
sums=sums,
has_inn=has_inn,
)
@ -658,3 +660,130 @@ def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> 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}