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