diff --git a/api/src/parser/deadline.py b/api/src/parser/deadline.py new file mode 100644 index 0000000..e72ccdc --- /dev/null +++ b/api/src/parser/deadline.py @@ -0,0 +1,26 @@ +"""Кооперативный таймаут разбора (design D-8, ruling R-3). + +Разбор идёт в `asyncio.to_thread`, поток нельзя прервать снаружи, поэтому шаги парсера сами вызывают +`Deadline.check(<шаг>)` между этапами; превышение — `ParseTimeoutError` (код контракта `TIMEOUT`). +""" + +import time +from collections.abc import Callable + +from src.parser.errors import ParseTimeoutError + +__all__ = ["Deadline"] + + +class Deadline: + """Лимит времени, отсчитываемый от создания; часы подменяются в тестах.""" + + def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic) -> None: + self._seconds = seconds + self._clock = clock + self._expires_at = clock() + seconds + + def check(self, where: str) -> None: + """Бросает `ParseTimeoutError`, если лимит превышен; `where` — имя шага для сообщения.""" + if self._clock() > self._expires_at: + raise ParseTimeoutError(f"превышен лимит {self._seconds} с на шаге {where}") diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py new file mode 100644 index 0000000..34e8c7c --- /dev/null +++ b/api/src/parser/hierarchy.py @@ -0,0 +1,250 @@ +"""Иерархия строк ОСВ — каскад стратегий (spec «Иерархия строк — каскад стратегий», design D-3, §1.0 п. 5, §1.3). + +Стратегии — чистые функции над `BodyRow` (строки данных без листа): `outline` → `indent` → `account_pattern` → +`sums`. Каждая возвращает уровни `{row: level}` или `None`, если неприменима; первая, давшая непротиворечивое дерево +(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`hierarchy=<имя>` в `metrics.warnings`). + +Уровни и родители считаются по порядку строк и уровням, а не по номерам и текстам: два одноимённых контрагента под +разными субсчетами получают разных родителей (Review Focus 3). Строка «Итого» в стратегии не передаётся: она — +контрольная сумма документа, всегда уровень 0 без родителя, и не может стать ребёнком последнего счёта. +""" + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal + +from src.parser.deadline import Deadline +from src.parser.errors import InvalidFormatError + +__all__ = [ + "STRATEGIES", + "TRIVIAL_STRATEGY", + "BodyRow", + "Levels", + "Strategy", + "is_consistent", + "parents", + "resolve_levels", + "strategy_account_pattern", + "strategy_indent", + "strategy_outline", + "strategy_sums", +] + +ROOT_LEVEL = 0 +# Стратегия по оформлению (outline, отступ) применима, только если различает хотя бы два уровня +MIN_DISTINCT_LEVELS = 2 +# Допуск сравнения «сумма детей = родитель» в стратегии `sums` — тот же, что у контрольных сумм §1.4 +SUM_TOLERANCE = Decimal("0.01") +# Каскад нужен, только если строк (кроме «Итого») хотя бы две; одна строка или ни одной — тривиальное дерево +# из корней (пустая ОСВ: только «Итого», в том числе «Итого» без сумм, не распознанное как TOTAL) +MIN_ROWS_FOR_CASCADE = 2 +TRIVIAL_STRATEGY = "trivial" +_ZERO = Decimal(0) + + +@dataclass(frozen=True) +class BodyRow: + """Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`).""" + + row: int + text: str + is_account: bool + account_rank: int | None # число сегментов кода счёта: 60 → 1, 60.01 → 2, 60.01.1 → 3 + bold: bool + indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги) + outline: int + sums: tuple[Decimal | None, ...] + + +@dataclass(frozen=True) +class Levels: + """Уровни строк, их родители (`None` — корень) и имя выбравшей их стратегии.""" + + by_row: dict[int, int] + parent: dict[int, int | None] + strategy: str + + +Strategy = Callable[[Sequence[BodyRow]], dict[int, int] | None] + + +def _ranks(values: Sequence[int]) -> dict[int, int] | None: + """Значение → его ранг среди различных значений (0, 1, 2…); `None`, если различных меньше двух.""" + distinct = sorted(set(values)) + if len(distinct) < MIN_DISTINCT_LEVELS: + return None + return {value: rank for rank, value in enumerate(distinct)} + + +def strategy_outline(rows: Sequence[BodyRow]) -> dict[int, int] | None: + """Outline level Excel (группировки строк), сдвинутый так, чтобы минимальный уровень стал 0. + + Сдвиг нужен, когда вся таблица целиком вложена в группировку (уровни 1, 2, … без 0); пропуски уровней + сохраняются как в файле. `None`, если различных уровней меньше двух. + """ + outlines = [item.outline for item in rows] + if len(set(outlines)) < MIN_DISTINCT_LEVELS: + return None + base = min(outlines) + return {item.row: item.outline - base for item in rows} + + +def strategy_indent(rows: Sequence[BodyRow]) -> dict[int, int] | None: + """Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа. + + `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа + не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных + отступов меньше двух. + """ + ranks = _ranks([item.indent for item in rows]) + if ranks is None: + return None + return {item.row: ranks[item.indent] for item in rows} + + +def strategy_account_pattern(rows: Sequence[BodyRow]) -> dict[int, int] | None: + """Шаблон кода счёта и жирность. + + Строка-счёт: уровень = ранг кода (число сегментов) − минимальный ранг среди счетов листа (60 → 0, 60.01 → 1, + если 60 есть; если таблица начинается с субсчетов, они — корни). Не-счёт: уровень последнего счёта + 1. + + ASSUMPTION (правило брифа Задачи 6, §1.0 п. 5 «по жирности»): жирная не-счётная строка — заголовок группировки + (например, контрагент); жирные не-счётные строки внутри одного счёта — соседи (не глубже друг друга), а + следующие за жирной обычные строки — её дети (+1). Если жирных не-счётных строк под счётом нет, все не-счётные + строки — дети счёта одного уровня; глубже одной группировки по жирности стратегия не различает (CURRENCY/QUANTITY + и договоры под обычным контрагентом окажутся его соседями — это поймают контрольные суммы §1.4). + Не-счётные строки до первого счёта считаются корнями. `None`, если на листе нет ни одного счёта. + """ + ranks = [item.account_rank for item in rows if item.is_account and item.account_rank is not None] + if not ranks: + return None + base = min(ranks) + by_row: dict[int, int] = {} + account_level: int | None = None + group_level: int | None = None # уровень последней жирной не-счётной строки под текущим счётом + for item in rows: + if item.is_account and item.account_rank is not None: + level = item.account_rank - base + account_level, group_level = level, None + else: + child_level = ROOT_LEVEL if account_level is None else account_level + 1 + if item.bold: + level = child_level + group_level = level + elif group_level is not None: + level = group_level + 1 + else: + level = child_level + by_row[item.row] = level + return by_row + + +def _vector(item: BodyRow) -> tuple[Decimal, ...]: + return tuple(_ZERO if value is None else value for value in item.sums) + + +def _add(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> tuple[Decimal, ...]: + return tuple(a + b for a, b in zip(left, right, strict=True)) + + +def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool: + return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True)) + + +def strategy_sums(rows: Sequence[BodyRow]) -> dict[int, int] | None: + """Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами. + + Жадно и без рекурсии: поддеревья строятся с конца листа (`end[i]` — граница поддерева строки `i`), строка `i` + перебирает следующие поддеревья подряд и становится родителем на первом совпадении по всем шести колонкам + (± `SUM_TOLERANCE`; пусто = 0). Строка без сумм (все пусто/0) родителем не бывает. Соседи с равными суммами + могут быть приняты за родителя и ребёнка — это ограничение жадного правила; результат всё равно проходит + контрольные суммы §1.4. `None`, если ни одна строка не стала родителем. + """ + count = len(rows) + vectors = [_vector(item) for item in rows] + zero = tuple(_ZERO for _ in vectors[0]) if vectors else () + end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки + has_children = False + for index in reversed(range(count)): + target = vectors[index] + if all(value == _ZERO for value in target): + continue + accumulated = zero + position = index + 1 + while position < count: + accumulated = _add(accumulated, vectors[position]) + position = end[position] + if _matches(accumulated, target): + end[index] = position + has_children = True + break + if not has_children: + return None + by_row: dict[int, int] = {} + open_ends: list[int] = [] # границы поддеревьев открытых предков + for index, item in enumerate(rows): + while open_ends and open_ends[-1] <= index: + open_ends.pop() + by_row[item.row] = len(open_ends) + if end[index] > index + 1: + open_ends.append(end[index]) + return by_row + + +STRATEGIES: tuple[tuple[str, Strategy], ...] = ( + ("outline", strategy_outline), + ("indent", strategy_indent), + ("account_pattern", strategy_account_pattern), + ("sums", strategy_sums), +) + + +def is_consistent(rows: Sequence[BodyRow], levels: Mapping[int, int], total_row: int | None) -> bool: + """Уровни образуют дерево: у каждой строки есть уровень ≥ 0, первая строка — корень, у каждой строки уровня > 0 + есть предыдущая строка с меньшим уровнем, «Итого» (если есть) — уровень 0. + """ + lowest_seen: int | None = None + for item in rows: + level = levels.get(item.row) + if level is None or level < ROOT_LEVEL: + return False + if level > ROOT_LEVEL and (lowest_seen is None or lowest_seen >= level): + return False + lowest_seen = level if lowest_seen is None else min(lowest_seen, level) + return total_row is None or levels.get(total_row) == ROOT_LEVEL + + +def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int | None]: + """Родитель — ближайшая предыдущая строка с меньшим уровнем (`None` для корней); по позициям, не по текстам.""" + result: dict[int, int | None] = {} + stack: list[tuple[int, int]] = [] # (уровень, номер строки) открытых предков + for item in rows: + level = by_row[item.row] + while stack and stack[-1][0] >= level: + stack.pop() + result[item.row] = stack[-1][1] if stack else None + stack.append((level, item.row)) + return result + + +def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: + """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. + + «Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не + ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`. + """ + body = [item for item in rows if item.row != total_row] + pinned = {} if total_row is None else {total_row: ROOT_LEVEL} + if len(body) < MIN_ROWS_FOR_CASCADE: + by_row = {item.row: ROOT_LEVEL for item in rows} + return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY) + for name, strategy in STRATEGIES: + deadline.check(f"иерархия ({name})") + found = strategy(body) + if found is None: + continue + by_row = {**found, **pinned} + if is_consistent(rows, by_row, total_row): + return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=name) + raise InvalidFormatError("иерархия строк не определена") diff --git a/api/tests/unit/parser/test_deadline.py b/api/tests/unit/parser/test_deadline.py new file mode 100644 index 0000000..03638a6 --- /dev/null +++ b/api/tests/unit/parser/test_deadline.py @@ -0,0 +1,49 @@ +"""Кооперативный дедлайн разбора (design D-8, ruling R-3): проверка между шагами, часы подменяются в тестах.""" + +import pytest + +from src.parser.deadline import Deadline +from src.parser.errors import ParseTimeoutError + + +class FakeClock: + """Часы-заглушка: время двигается только вручную.""" + + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +def test_check_passes_within_limit() -> None: + clock = FakeClock() + deadline = Deadline(10, clock=clock) + + clock.now += 10 + + deadline.check("иерархия") # ровно на границе лимита — ещё не превышен + + +def test_check_raises_after_limit_with_step_name() -> None: + clock = FakeClock() + deadline = Deadline(120, clock=clock) + + clock.now += 120.5 + + with pytest.raises(ParseTimeoutError) as raised: + deadline.check("иерархия (outline)") + + assert raised.value.message == "превышен лимит 120 с на шаге иерархия (outline)" + + +def test_limit_is_counted_from_creation() -> None: + clock = FakeClock() + clock.now += 500 + deadline = Deadline(1, clock=clock) + + deadline.check("старт") + + +def test_default_clock_is_monotonic() -> None: + Deadline(60).check("сразу") diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py new file mode 100644 index 0000000..cfd454a --- /dev/null +++ b/api/tests/unit/parser/test_hierarchy.py @@ -0,0 +1,393 @@ +"""Каскад стратегий иерархии строк (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 ( + STRATEGIES, + TRIVIAL_STRATEGY, + BodyRow, + Levels, + is_consistent, + parents, + resolve_levels, + strategy_account_pattern, + 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) diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index d3ed97e..7d5410d 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -276,9 +276,9 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea def parents(rows, by_row) -> dict[int, int | None] # ближайшая предыдущая строка с меньшим уровнем ``` -- [ ] **Step 1: Тесты.** Юнит на `BodyRow`-списках: outline 0/1/2 → `outline`; все outline 0, отступы 0/2/4 → `indent`; без того и другого, коды 60/60.01/текст → `account_pattern` с уровнями 0/1/2; `sums` на трёх строках (родитель = сумма двух детей); неконсистентные уровни (ребёнок без родителя) отбрасываются, берётся следующая стратегия; «Итого» level 0 обязательно; `parents` для двух одноимённых контрагентов под разными субсчетами (Review Focus 3) различает родителей; дедлайн истёк → `ParseTimeoutError`. `test_hierarchy_real.py` (`fixtures`): все 66 — `strategy == "outline"`, `is_consistent`, у «Итого» level 0; `…66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (первое вхождение) имеет родителя-строку с текстом `66.03`. -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(parser): каскад стратегий иерархии строк (outline → indent → account_pattern → sums)`. +- [x] **Step 1: Тесты.** Юнит на `BodyRow`-списках: outline 0/1/2 → `outline`; все outline 0, отступы 0/2/4 → `indent`; без того и другого, коды 60/60.01/текст → `account_pattern` с уровнями 0/1/2; `sums` на трёх строках (родитель = сумма двух детей); неконсистентные уровни (ребёнок без родителя) отбрасываются, берётся следующая стратегия; «Итого» level 0 обязательно; `parents` для двух одноимённых контрагентов под разными субсчетами (Review Focus 3) различает родителей; дедлайн истёк → `ParseTimeoutError`. `test_hierarchy_real.py` (`fixtures`): все 66 — `strategy == "outline"`, `is_consistent`, у «Итого» level 0; `…66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (первое вхождение) имеет родителя-строку с текстом `66.03`. +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(parser): каскад стратегий иерархии строк (outline → indent → account_pattern → sums)`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 2278f03..84af963 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -33,7 +33,7 @@ период → `period_year`, `period_quarter`, `is_cumulative` по `periods.yaml` (`ASSUMPTION(OQ-02)`). Unit-тесты: обе шапки, отсутствие полей → `null` + warning, субсчёт 58.03, все строки таблицы §1.5. `fixtures`-тест: у всех 66 файлов `account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан. -- [ ] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости, +- [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости, `parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест: `hierarchy=outline` у всех 66. - [ ] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,