arm-tabular-parser/api/tests/unit/parser/test_hierarchy.py
Andrey Epifancev e1760c08f6 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>
2026-10-28 10:43:39 +04:00

790 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Каскад стратегий иерархии строк (spec «Иерархия строк — каскад стратегий», design D-3, §1.0 п. 5, §1.3).
Строки данных — `BodyRow` (их строит классификатор Задачи 8); номера строк в тестах произвольные: стратегии и
`parents` работают по порядку и уровням строк, а не по номерам или текстам.
"""
import re
from collections.abc import Sequence
from decimal import Decimal
import pytest
from src.parser.deadline import Deadline
from src.parser.errors import InvalidFormatError, ParseTimeoutError
from src.parser.hierarchy import (
DEADLINE_CHECK_EVERY,
STRATEGIES,
TRIVIAL_STRATEGY,
BodyRow,
Levels,
Strategy,
is_consistent,
parents,
resolve_levels,
strategy_account_pattern,
strategy_header_account,
strategy_indent,
strategy_outline,
strategy_sums,
)
ACCOUNT = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*.*)?$")
NO_SUMS: tuple[Decimal | None, ...] = (None,) * 6
FIRST = 17 # произвольное начало данных: стратегии не знают номеров строк
def money(*values: str | None) -> tuple[Decimal | None, ...]:
assert len(values) == len(NO_SUMS)
return tuple(None if value is None else Decimal(value) for value in values)
def body( # noqa: PLR0913 - фабрика тестовых строк: все признаки оформления именованные
row: int,
text: str,
*,
outline: int = 0,
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
return BodyRow(
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,
)
def numbered(*specs: tuple[str, dict[str, object]]) -> list[BodyRow]:
"""Строки подряд с произвольного номера `FIRST`."""
return [body(FIRST + offset, text, **options) for offset, (text, options) in enumerate(specs)] # type: ignore[arg-type]
def levels_list(rows: Sequence[BodyRow], by_row: dict[int, int]) -> list[int]:
return [by_row[item.row] for item in rows]
def never_expires() -> Deadline:
return Deadline(3600)
# --- Каскад: какая стратегия выбирается ---
def test_strategies_are_ordered_outline_indent_code_blocks_account_pattern_sums() -> None:
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "code_blocks", "account_pattern", "sums"]
def test_outline_levels_win_first() -> None:
rows = numbered(
("66", {"outline": 0, "indent": 3}),
("66.01", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
("66.03", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "outline"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
assert levels.parent == {17: None, 18: 17, 19: 18, 20: 17, 21: 20}
def test_outline_is_shifted_to_zero_when_table_is_grouped_entirely() -> None:
rows = numbered(("60", {"outline": 1}), ("60.01", {"outline": 2}), ("60.02", {"outline": 2}))
assert strategy_outline(rows) == {17: 0, 18: 1, 19: 1}
def test_outline_with_single_level_is_not_applicable() -> None:
rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 0}))
assert strategy_outline(rows) is None
def test_indent_is_used_when_outline_is_flat() -> None:
rows = numbered(
("Расчеты", {"indent": 0}),
("Поставщики", {"indent": 2}),
("ООО Ромашка", {"indent": 4}),
("Покупатели", {"indent": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "indent"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1]
@pytest.mark.parametrize(
"indents",
[(0, 2, 4), (0, 1, 2), (0, 3, 7)],
ids=["leading-spaces", "alignment-indent", "uneven-steps"],
)
def test_indent_levels_are_ranks_of_distinct_values_in_any_units(indents: tuple[int, int, int]) -> None:
top, middle, deep = indents
rows = numbered(
("Группа", {"indent": top}),
("Подгруппа", {"indent": middle}),
("Строка", {"indent": deep}),
("Строка 2", {"indent": deep}),
("Группа 2", {"indent": top}),
)
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 0}
def test_indent_with_single_value_is_not_applicable() -> None:
rows = numbered(("Группа", {"indent": 2}), ("Строка", {"indent": 2}))
assert strategy_indent(rows) is None
def test_account_pattern_when_no_outline_and_no_indent() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("60.02", {}), ("ООО Лютик", {}))
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "account_pattern"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
def test_account_pattern_levels_are_relative_to_minimal_rank() -> None:
rows = numbered(("60.01", {}), ("60.01.1", {}), ("Контрагент", {}), ("60.02", {}))
assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2, 20: 0}
def test_account_pattern_account_with_name_after_comma_is_an_account() -> None:
rows = numbered(("60", {}), ("60.01, Расчеты с поставщиками", {}), ("ООО Ромашка", {}))
assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2}
def test_account_pattern_bold_groups_and_their_plain_details() -> None:
rows = numbered(
("60", {"bold": True}),
("60.01", {"bold": True}),
("ООО Ромашка", {"bold": True}),
("Договор 1", {}),
("Договор 2", {}),
("ООО Лютик", {"bold": True}),
("Договор 3", {}),
("60.02", {"bold": True}),
("ИП Иванов", {}),
)
assert levels_list(rows, strategy_account_pattern(rows) or {}) == [0, 1, 2, 3, 3, 2, 3, 1, 2]
def test_account_pattern_without_accounts_is_not_applicable() -> None:
rows = numbered(("ООО Ромашка", {}), ("Договор", {}))
assert strategy_account_pattern(rows) is None
def test_sums_parent_equals_sum_of_two_children() -> None:
rows = numbered(
("Расчеты", {"sums": money("100.00", None, "50.00", "30.00", "120.00", None)}),
("ООО Ромашка", {"sums": money("60.00", None, "50.00", None, "110.00", None)}),
("ООО Лютик", {"sums": money("40.00", None, None, "30.00", "10.00", None)}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "sums"
assert levels_list(rows, levels.by_row) == [0, 1, 1]
assert levels.parent == {17: None, 18: 17, 19: 17}
def test_sums_builds_nested_tree_and_several_roots() -> None:
rows = numbered(
("Счёт", {"sums": money("100.00", None, None, None, "100.00", None)}),
("Субсчёт А", {"sums": money("70.00", None, None, None, "70.00", None)}),
("Контрагент 1", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Контрагент 2", {"sums": money("40.00", None, None, None, "40.00", None)}),
("Субсчёт Б", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Контрагент 3", {"sums": money("30.00", None, None, None, "30.00", None)}),
("Другой счёт", {"sums": money(None, "5.00", None, None, None, "5.00")}),
("Контрагент 4", {"sums": money(None, "5.00", None, None, None, "5.00")}),
)
assert levels_list(rows, strategy_sums(rows) or {}) == [0, 1, 2, 2, 1, 2, 0, 1]
def test_sums_without_any_matching_parent_is_not_applicable() -> None:
rows = numbered(
("А", {"sums": money("1.00", None, None, None, "1.00", None)}),
("Б", {"sums": money("2.00", None, None, None, "2.00", None)}),
)
assert strategy_sums(rows) is None
def test_inconsistent_outline_is_rejected_and_next_strategy_is_used() -> None:
# outline даёт первой строке уровень 1 — ребёнок без родителя; отступы дают корректное дерево
rows = numbered(
("Группа", {"outline": 1, "indent": 0}),
("Строка", {"outline": 0, "indent": 2}),
("Строка 2", {"outline": 1, "indent": 2}),
)
levels = resolve_levels(rows, None, never_expires())
assert levels.strategy == "indent"
assert levels_list(rows, levels.by_row) == [0, 1, 1]
def test_no_strategy_fits_raises_invalid_format() -> None:
rows = numbered(("Строка", {}), ("Строка 2", {}))
with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
resolve_levels(rows, None, never_expires())
# --- «Итого» ---
def test_total_is_level_zero_without_parent_even_if_formatted_deeper() -> None:
rows = numbered(
("62", {"outline": 0}),
("62.01", {"outline": 1}),
("Покупатель", {"outline": 2}),
("Итого", {"outline": 2, "indent": 4}),
)
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "outline"
assert levels.by_row[total] == 0
assert levels.parent[total] is None
def test_total_is_not_a_child_of_last_account_in_account_pattern() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("Итого", {}))
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "account_pattern"
assert levels_list(rows, levels.by_row) == [0, 1, 2, 0]
assert levels.parent[total] is None
def test_total_does_not_break_sums_strategy() -> None:
rows = numbered(
("Расчеты", {"sums": money("10.00", None, None, None, "10.00", None)}),
("А", {"sums": money("4.00", None, None, None, "4.00", None)}),
("Б", {"sums": money("6.00", None, None, None, "6.00", None)}),
("Итого", {"sums": money("10.00", None, None, None, "10.00", None)}),
)
total = rows[-1].row
levels = resolve_levels(rows, total, never_expires())
assert levels.strategy == "sums"
assert levels_list(rows, levels.by_row) == [0, 1, 1, 0]
def test_table_with_only_total_has_trivial_hierarchy() -> None:
rows = numbered(("Итого", {"sums": money("0.00", None, None, None, "0.00", None)}))
levels = resolve_levels(rows, rows[0].row, never_expires())
assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
def test_single_row_without_total_has_trivial_hierarchy() -> None:
# пустая ОСВ, где «Итого» без сумм не распознано как TOTAL: одна строка — дерево из одного корня
rows = numbered(("Итого", {}))
levels = resolve_levels(rows, None, never_expires())
assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
def test_single_body_row_with_total_has_trivial_hierarchy() -> None:
rows = numbered(("58", {"outline": 1}), ("Итого", {}))
levels = resolve_levels(rows, rows[-1].row, never_expires())
assert levels == Levels(by_row={17: 0, 18: 0}, parent={17: None, 18: None}, strategy=TRIVIAL_STRATEGY)
def test_empty_table_has_trivial_hierarchy() -> None:
assert resolve_levels([], None, never_expires()) == Levels(by_row={}, parent={}, strategy=TRIVIAL_STRATEGY)
# --- is_consistent ---
def test_is_consistent_accepts_tree_with_root_first_and_total_zero() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
assert is_consistent(rows, {17: 0, 18: 1, 19: 2, 20: 0}, 20)
@pytest.mark.parametrize(
("by_row", "total"),
[
({17: 1, 18: 1, 19: 2, 20: 0}, 20), # первая строка не корень
({17: 0, 18: 1, 19: 2, 20: 1}, 20), # «Итого» не на уровне 0
({17: 0, 18: 1, 19: 2}, 20), # у «Итого» нет уровня
({17: 0, 18: 1, 20: 0}, 20), # у строки нет уровня
({17: 0, 18: -1, 19: 0, 20: 0}, 20), # отрицательный уровень
],
ids=["first-not-root", "total-nested", "total-missing", "row-missing", "negative"],
)
def test_is_consistent_rejects_broken_trees(by_row: dict[int, int], total: int) -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
assert not is_consistent(rows, by_row, total)
def test_is_consistent_without_total() -> None:
rows = numbered(("60", {}), ("А", {}))
assert is_consistent(rows, {17: 0, 18: 1}, None)
# --- parents: по структуре, не по имени (Review Focus 3) ---
def test_parents_are_nearest_previous_rows_with_smaller_level() -> None:
rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Договор", {}), ("Б", {}), ("62", {}), ("В", {}))
by_row = {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 0, 23: 1}
assert parents(rows, by_row) == {17: None, 18: 17, 19: 18, 20: 19, 21: 18, 22: None, 23: 22}
def test_same_named_counterparties_under_different_subaccounts_have_different_parents() -> None:
rows = numbered(
("66", {"outline": 0}),
("66.01", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
("66.03", {"outline": 1}),
("Юг-Финанс", {"outline": 2}),
)
first, second = (item.row for item in rows if item.text == "Юг-Финанс")
for by_row in (strategy_outline(rows), strategy_account_pattern(rows)):
assert by_row is not None
tree = parents(rows, by_row)
assert tree[first] == rows[1].row # 66.01
assert tree[second] == rows[3].row # 66.03
# --- Дедлайн ---
def test_expired_deadline_raises_timeout() -> None:
now = [0.0]
deadline = Deadline(5, clock=lambda: now[0])
now[0] = 6.0
rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 1}))
with pytest.raises(ParseTimeoutError, match="иерархия"):
resolve_levels(rows, None, deadline)
CLOCK_READS_CREATE_AND_ONE_CHECK = 2 # чтение часов при создании Deadline + одна проверка на DEADLINE_CHECK_EVERY строк
class TickingClock:
"""Часы, которые сдвигаются на 1 с при каждом обращении: дедлайн истекает на заданной по счёту проверке."""
def __init__(self) -> None:
self.calls = 0
def __call__(self) -> float:
value = float(self.calls)
self.calls += 1
return value
@pytest.mark.parametrize("expired_at", range(len(STRATEGIES)), ids=[name for name, _ in STRATEGIES])
def test_deadline_is_checked_before_each_strategy(expired_at: int) -> None:
# ни одна стратегия не подходит, каскад проходит все четыре; строк меньше DEADLINE_CHECK_EVERY — проверки
# только между стратегиями: создание — t=0, перед k-й стратегией — t=k
rows = numbered(("Строка", {}), ("Строка 2", {}))
name = STRATEGIES[expired_at][0]
with pytest.raises(ParseTimeoutError) as raised:
resolve_levels(rows, None, Deadline(expired_at, clock=TickingClock()))
assert raised.value.message == f"превышен лимит {expired_at} с на шаге иерархия ({name})"
def _never_matching_sums(count: int) -> list[BodyRow]:
"""Строки без outline/отступов/счетов с растущими суммами: ни одна не равна сумме следующих — худший случай sums."""
return [
body(FIRST + offset, f"Строка {offset}", sums=money(str(offset + 1), None, None, None, None, None))
for offset in range(count)
]
def test_deadline_expires_inside_sums_scan() -> None:
rows = _never_matching_sums(60) # ≈ 1800 шагов перебора > DEADLINE_CHECK_EVERY, строк < DEADLINE_CHECK_EVERY
# четыре проверки между стратегиями (t=1…4) проходят; следующая — уже внутри перебора sums
deadline = Deadline(len(STRATEGIES), clock=TickingClock())
with pytest.raises(ParseTimeoutError, match=r"иерархия \(sums\): перебор поддеревьев"):
resolve_levels(rows, None, deadline)
def test_sums_without_deadline_finishes_worst_case() -> None:
assert strategy_sums(_never_matching_sums(60)) is None
@pytest.mark.parametrize(
("strategy", "name"),
[(strategy_outline, "outline"), (strategy_indent, "indent"), (strategy_account_pattern, "account_pattern")],
)
def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name: str) -> None:
rows = [body(FIRST + offset, "60") for offset in range(DEADLINE_CHECK_EVERY)]
clock = TickingClock()
deadline = Deadline(0, clock=clock) # истекает на первой же проверке
with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"):
strategy(rows, deadline)
assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK
# --- Плоский список под счётом шапки (#59, стратегия header_account) ---
def test_header_account_strategy_flat_rows() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0, 0]
assert set(levels.parent.values()) == {None}
def test_header_account_not_used_without_header_code() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
resolve_levels(rows, None, never_expires(), header_account=False)
def test_header_account_not_used_with_account_rows() -> None:
rows = numbered(("60.01", {}), ("ООО А", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "account_pattern"
assert strategy_header_account(rows) is None
def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> None:
"""Две соседние строки с равными суммами при счёте шапки — плоский список, а не вложенность по суммам."""
same = money("100.00", None, "50.00", None, "150.00", None)
rows = numbered(("ООО Ромашка", {"sums": same}), ("ООО Лютик", {"sums": same}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
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}