diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 0618bf1..ade5b56 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -37,7 +37,7 @@ from src.parser.detect import ( table_kind_row, ) from src.parser.grid import Sheet, column_letter -from src.parser.hierarchy import BodyRow, Levels +from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels from src.parser.indicators import is_indicator_total, read_label from src.parser.numbers import column_inn, split_inn_suffix, to_money from src.parser.rules import GroupType, Rules @@ -64,7 +64,7 @@ ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:, # Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3, # AURORA-1714); без счёта шапки действует `ACCOUNT_RE` _WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) -_SEGMENT_SEPARATOR = "." +_SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None: @@ -310,6 +310,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже # любого отступа ячейки indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP + inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None return BodyRow( row=row, text=text, @@ -323,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк label_col=label_col, indicator=label.indicator, label_data_type=label.data_type, + has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None, ) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 81fe98b..54f171f 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -47,6 +47,10 @@ DEADLINE_CHECK_EVERY = 500 # Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного # родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора NEST_SCAN_LIMIT = 50 +# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка +MIN_NESTABLE_CHILDREN = 2 +# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же +ACCOUNT_SEGMENT_SEPARATOR = "." _ZERO = Decimal(0) _TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums` @@ -72,6 +76,7 @@ class BodyRow: # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор role: Role | None = None account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None` + has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики) @dataclass(frozen=True) @@ -134,7 +139,9 @@ def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None) return {item.row: item.outline - base for item in rows} -def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: +def strategy_indent( + rows: Sequence[BodyRow], deadline: Deadline | None = None, *, nest_by_sums: bool = True +) -> dict[int, int] | None: """Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа. `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа @@ -146,7 +153,10 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - - безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок (`_attach_closing_rows`); - аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с - нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`). + нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка, + ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_nest_by_sums`). Вызывающий + отключает её (`nest_by_sums=False`), когда схема шапки называет группы аналитики после счёта: вложение + изменило бы `group_type`/`group_name` строки. """ ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")]) if ranks is None: @@ -154,7 +164,7 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - levels = {item.row: ranks[item.indent] for item in rows} levels = _nest_subaccounts(rows, levels) levels = _attach_closing_rows(rows, levels) - return _nest_by_sums(rows, levels, deadline) + return _nest_by_sums(rows, levels, deadline) if nest_by_sums else levels def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: @@ -183,13 +193,14 @@ def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[i open_accounts.pop() for deeper in [known for known in blocks if known > level]: del blocks[deeper] - parent_code = code.rsplit(".", 1)[0] - candidate = blocks.get(level, {}).get(parent_code) + # у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст + parent_code = code.rpartition(ACCOUNT_SEGMENT_SEPARATOR)[0] + candidate = blocks.get(level, {}).get(parent_code) if parent_code else None under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code if candidate is not None and not under_parent: parent_of[item.row] = candidate.row pair_levels.add(level) - elif candidate is not None or parent_code in seen_codes.get(level, ()): + elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())): # родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем # или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего ambiguous = True @@ -241,8 +252,8 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic """Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта. Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция. - Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами — - не закрытие, и дерево остаётся по отступам. + Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная + строка с суммами — не закрытие, и дерево остаётся по отступам. """ result = dict(levels) previous: BodyRow | None = None @@ -252,7 +263,7 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic and previous.is_account and not item.is_account and not item.text.strip() - and any(value is not None for value in item.sums) + and any(value != _ZERO for value in _vector(item)[_TURNOVER_PAIR]) and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR]) and result[item.row] <= result[previous.row] ): @@ -278,29 +289,44 @@ def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> boo return False -def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]: - """Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель - следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен - `NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех.""" +def _greedy_ends( + vectors: Sequence[tuple[Decimal, ...]], + pacer: _Pacer, + *, + scan_limit: int | None = None, + blocked: Sequence[bool] | None = None, +) -> list[int]: + """Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает + следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм + родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца); + `blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней).""" count = len(vectors) zero = tuple(_ZERO for _ in vectors[0]) if vectors else () end = [index + 1 for index in range(count)] for index in reversed(range(count)): - if all(value == _ZERO for value in vectors[index]): + pacer.tick() + target = vectors[index] + if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]): continue - accumulated, position = zero, index + 1 - for _ in range(NEST_SCAN_LIMIT): - pacer.tick() - if position >= count: + accumulated, position, steps = zero, index + 1, 0 + while position < count and (scan_limit is None or steps < scan_limit): + if blocked is not None and blocked[position]: break + pacer.tick() accumulated = _add(accumulated, vectors[position]) position = end[position] - if _matches(accumulated, vectors[index]): + steps += 1 + if _matches(accumulated, target): end[index] = position break + return end + + +def _depths(end: Sequence[int]) -> list[int]: + """Глубина вложенности каждой строки по границам поддеревьев `end`.""" depths: list[int] = [] - open_ends: list[int] = [] - for index in range(count): + open_ends: list[int] = [] # границы поддеревьев открытых предков + for index in range(len(end)): while open_ends and open_ends[-1] <= index: open_ends.pop() depths.append(len(open_ends)) @@ -329,7 +355,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea for parent_row, kid_indexes in kids.items(): pacer.tick() members = [rows[index] for index in kid_indexes] - if len(members) < MIN_DISTINCT_LEVELS or any(m.is_account or m.indicator for m in members): + if ( + len(members) < MIN_NESTABLE_CHILDREN + or any(m.is_account or m.indicator for m in members) + or len({m.indent for m in members}) > 1 + ): continue vectors = [_vector(member) for member in members] total = vectors[0] @@ -338,7 +368,9 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea parent_vector = _vector(rows[position[parent_row]]) if not _violates(parent_vector, total): continue - depths = _nest_roots(vectors, pacer) + depths = _depths( + _greedy_ends(vectors, pacer, scan_limit=NEST_SCAN_LIMIT, blocked=[member.has_inn for member in members]) + ) top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0] if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))): continue @@ -346,10 +378,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea if depth == 0: continue index = kid - bump[rows[index].row] = depth + # сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже + bump[rows[index].row] = bump.get(rows[index].row, 0) + depth index += 1 while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]: - bump[rows[index].row] = depth + bump[rows[index].row] = bump.get(rows[index].row, 0) + depth index += 1 return {row: level + bump.get(row, 0) for row, level in levels.items()} @@ -449,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов, и такой файл завершается `TIMEOUT`, а не висит. """ - 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 - pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев") - for index in reversed(range(count)): - pacer.tick() - target = vectors[index] - if all(value == _ZERO for value in target): - continue - accumulated = zero - position = index + 1 - while position < count: - pacer.tick() - accumulated = _add(accumulated, vectors[position]) - position = end[position] - if _matches(accumulated, target): - end[index] = position - has_children = True - break - if not has_children: + end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев")) + if all(end[index] == index + 1 for index in range(len(rows))): 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 + return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)} STRATEGIES: tuple[tuple[str, Strategy], ...] = ( @@ -521,10 +527,18 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int def resolve_levels( - rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False + rows: Sequence[BodyRow], + total_row: int | None, + deadline: Deadline, + *, + header_account: bool = False, + nest_by_sums: bool = False, ) -> Levels: """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. + `nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема + шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки). + `header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account` (плоский список под счётом шапки, #59). @@ -545,7 +559,9 @@ def resolve_levels( cascade.insert(before_sums, ("header_account", strategy_header_account)) for name, strategy in cascade: deadline.check(f"иерархия ({name})") - found = strategy(body, deadline) + found = ( + strategy_indent(body, deadline, nest_by_sums=nest_by_sums) if name == "indent" else strategy(body, deadline) + ) if found is None: continue by_row = {**found, **pinned} diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index 18031a9..315af1b 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules def levels_with_indicators( - rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False + rows: Sequence[BodyRow], + total_row: int | None, + deadline: Deadline, + *, + header_account: bool = False, + nest_by_sums: bool = False, ) -> Levels: """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ. @@ -67,7 +72,11 @@ def levels_with_indicators( Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя. """ levels = resolve_levels( - [item for item in rows if not item.indicator], total_row, deadline, header_account=header_account + [item for item in rows if not item.indicator], + total_row, + deadline, + header_account=header_account, + nest_by_sums=nest_by_sums, ) by_row: dict[int, int] = {} parent: dict[int, int | None] = {} diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index d66e292..7dc57e4 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -217,7 +217,14 @@ class OsvParser: blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw) body = blocks.rows step = "иерархия строк" - levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw)) + # Вложенность по суммам меняет group_type/group_name строки: только без названных групп после счёта + levels = levels_with_indicators( + body, + total_row, + deadline, + header_account=bool(doc.account_code_raw), + nest_by_sums=all(group.name is None for group in schema.groups[1:]), + ) step = "строки ИНН" glue = glue_inn_rows( body, diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 74244d2..196f014 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все 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 @@ -60,6 +61,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все indent=indent, outline=outline, sums=sums, + has_inn=has_inn, ) @@ -658,3 +660,130 @@ def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> 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} diff --git a/api/tests/unit/parser/test_osv_indent_nesting.py b/api/tests/unit/parser/test_osv_indent_nesting.py new file mode 100644 index 0000000..2c8d6ab --- /dev/null +++ b/api/tests/unit/parser/test_osv_indent_nesting.py @@ -0,0 +1,97 @@ +"""Вложенность по суммам при иерархии по отступам (AURORA-1858, ревью C-1): защиты на уровне всего листа. + +Правило не должно прятать реальную ошибку и менять `group_type`/`group_name` строк: при названных группах аналитики в +шапке оно отключено, с ИНН строка не вкладывается и не становится родителем. +""" + +import pytest +from gridbuilder import C, M + +from src.contract.result import OsvDocument +from src.parser.deadline import Deadline +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules + +from gridbuilder import sheet_from_rows # isort: skip + +TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." +TIMEOUT_SECONDS = 60 +Row = list[object] + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def sums(value: int) -> Row: + return [None, None, value, None, value, None] + + +def line(indent: int, text: str, value: int, inn: str | None = None) -> Row: + return [C(text, indent=indent), inn, None, *sums(value)] + + +def parse(rules: Rules, head_group_rows: list[Row], body: list[Row]) -> OsvDocument: + rows: list[Row] = [ + ["ООО Ромашка"], + [TITLE], + [], + [ + "Счет", + "ИНН", + "КПП", + M("Сальдо на начало периода", 2), + M("Обороты за период", 2), + M("Сальдо на конец периода", 2), + ], + ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + *head_group_rows, + *body, + ] + return OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS)).document + + +def test_reviewer_scenario_with_named_groups_and_inn_stays_needs_check(rules: Rules) -> None: + """Счёт 60 = 100, но контрагенты 60 + 40 + 40: это ошибка файла, а не вложенность «Гамма под Бетой».""" + document = parse( + rules, + [["Договоры"]], + [ + line(0, "60", 100), + line(1, "ООО Альфа", 60, "7701234567"), + line(2, "Договор 1", 60), + line(1, "ООО Бета", 40, "7707654321"), + line(1, "ООО Гамма", 40, "5001234567"), + [C("Итого"), None, None, *sums(100)], + ], + ) + + by_text = {row.row_value: row for row in document.rows} + gamma = by_text["ООО Гамма"] + assert document.status == "needs_check" + assert gamma.group_type == "COUNTERPARTY" + assert gamma.counterparty_inn == "5001234567" + assert gamma.parent_row_number == by_text["60"].row_number + assert by_text["ООО Бета"].parent_row_number == by_text["60"].row_number + + +def test_named_analytic_group_in_header_disables_nesting_by_sums(rules: Rules) -> None: + """Без ИНН и с одним отступом правило бы вложило «Б» в «В» (40 ⊃ 40): шапка называет «Контрагенты», вложение + сменило бы group_type строки, поэтому дерево остаётся по отступам и лист на проверке.""" + document = parse( + rules, + [], + [ + line(0, "60", 100), + line(1, "Контрагент А", 60), + line(1, "Контрагент Б", 40), + line(1, "Контрагент В", 40), + [C("Итого"), None, None, *sums(100)], + ], + ) + + by_text = {row.row_value: row for row in document.rows} + assert document.status == "needs_check" + assert {by_text[name].group_type for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {"COUNTERPARTY"} + assert {by_text[name].level for name in ("Контрагент А", "Контрагент Б", "Контрагент В")} == {1}