feat(parser): каскад стратегий иерархии строк (outline → indent → account_pattern → sums)

hierarchy.py: BodyRow, Levels, стратегии outline (сдвиг к 0), indent (ранги
различных отступов — единицы alignment.indent и пробелов не смешиваются),
account_pattern (ранг кода + жирность, ASSUMPTION в docstring), sums
(жадно, без рекурсии); is_consistent, parents (по позициям и уровням,
Review Focus 3), resolve_levels: «Итого» в стратегии не передаётся и
получает уровень 0; меньше двух строк кроме «Итого» — дерево «trivial».
deadline.py: кооперативный Deadline (R-3). Unit-тесты; fixtures-тест — в
Задаче 8 (R-2). Отметки плана и tasks.md 2.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:31:59 +04:00
parent 8e627e80c5
commit 1eef422b6e
6 changed files with 722 additions and 4 deletions

View File

@ -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}")

250
api/src/parser/hierarchy.py Normal file
View File

@ -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("иерархия строк не определена")

View File

@ -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("сразу")

View File

@ -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)

View File

@ -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)`.
---

View File

@ -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 при выходе,