I-1 (R-17): синоним COUNTERPARTY только «Организации» (^организации\b). I-2 (R-18): правило расчётных счетов не применяется к строкам, известным словарю как не-контрагенты (валюта, «В валюте»). I-4 (R-19): тождество у второй итоговой строки пропускается только без оборотов и при наличии другой «Итого». M-1, M-5, M-6, M-7 (вымышленное ФИО ИП в синтетике), M-9. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
501 lines
19 KiB
Python
501 lines
19 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,
|
||
) -> 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_rank=rank,
|
||
bold=bold,
|
||
indent=indent,
|
||
outline=outline,
|
||
sums=sums,
|
||
)
|
||
|
||
|
||
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_account_pattern_sums() -> None:
|
||
assert [name for name, _ in STRATEGIES] == ["outline", "indent", "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}
|