"""Каскад стратегий иерархии строк (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}