diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 70f0993..0618bf1 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -314,6 +314,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк row=row, text=text, is_account=account is not None, + account_code=None if account is None else account.group(1), account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1, bold=bold, indent=indent, diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 202ee87..81fe98b 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -44,7 +44,11 @@ MIN_ROWS_FOR_CASCADE = 2 TRIVIAL_STRATEGY = "trivial" # Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев DEADLINE_CHECK_EVERY = 500 +# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного +# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора +NEST_SCAN_LIMIT = 50 _ZERO = Decimal(0) +_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums` Role = Literal["counterparty", "contract"] @@ -67,6 +71,7 @@ class BodyRow: label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор role: Role | None = None + account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None` @dataclass(frozen=True) @@ -135,11 +140,218 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) - `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных отступов меньше двух. + + Три поправки к рангам отступа (AURORA-1858), когда отступа не хватает: + - подуровень счёта на том же отступе (`68.01` → `68.01.1`) — ребёнок по коду (`_nest_subaccounts`); + - безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок + (`_attach_closing_rows`); + - аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с + нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`). """ ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")]) if ranks is None: return None - return {item.row: ranks[item.indent] for item in rows} + 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) + + +def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: + """Счёт, чей непосредственный родительский код (`68.01.1` → `68.01`) есть у более раннего счёта того же уровня в + том же блоке, — его ребёнок: уровень родителя + 1, поддерево (строки глубже по исходному уровню) сдвигается вместе + с ним. Так вкладываются `68.01.1` и `68.01.2` под `68.01`, стоящие на одном отступе. + + Не трогаем счёт, который уже стоит под счётом с кодом своего родителя (блок «76.09» с собственной аналитикой и + «76.09.1» рядом на одном отступе). Применяется, только если все такие пары на одном исходном уровне (одном + отступе): иначе соседние корни с похожими кодами могут быть намеренными, и дерево остаётся по отступам. + """ + parent_of: dict[int, int] = {} # строка счёта -> строка счёта-родителя по коду + pair_levels: set[int] = set() + open_accounts: list[tuple[int, str]] = [] # (уровень, код) счетов-предков по текущим уровням + blocks: dict[int, dict[str, BodyRow]] = {} # уровень -> код -> последний счёт с этим кодом в текущем блоке + seen_codes: dict[int, set[str]] = {} # уровень -> коды всех встреченных счетов (блоки их забывают) + ambiguous = False + for item in rows: + if not item.is_account or item.account_code is None: + # строка не-счёт на уровне блока или выше разрывает его: счёт после неё — не ребёнок счёта до неё + for closed in [known for known in blocks if known >= levels[item.row]]: + del blocks[closed] + continue + level, code = levels[item.row], item.account_code + while open_accounts and open_accounts[-1][0] >= level: + 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) + 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, ()): + # родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем + # или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего + ambiguous = True + seen_codes.setdefault(level, set()).add(code) + blocks.setdefault(level, {})[code] = item + open_accounts.append((level, code)) + if not parent_of or ambiguous or len(pair_levels) > 1: + return levels + result = _shift_nested(rows, levels, parent_of) + # перекладка принимается для счёта-родителя, только если после неё сумма его детей сходится с ним: иначе (файл с + # неровными отступами, где похожий код — совпадение) этот родитель остаётся как был + kids: dict[int, tuple[Decimal, ...]] = {} + by_parent = parents(rows, result) + by_row = {item.row: item for item in rows} + for item in rows: + parent = by_parent[item.row] + if parent is not None and not item.indicator: + kids[parent] = _add(kids[parent], _vector(item)) if parent in kids else _vector(item) + broken = { + parent + for parent in set(parent_of.values()) + if parent in kids and _violates(_vector(by_row[parent]), kids[parent]) + } + if broken: + parent_of = {row: parent for row, parent in parent_of.items() if parent not in broken} + if not parent_of: + return levels + result = _shift_nested(rows, levels, parent_of) + return result + + +def _shift_nested(rows: Sequence[BodyRow], levels: dict[int, int], parent_of: dict[int, int]) -> dict[int, int]: + """Уровни, в которых счета из `parent_of` стали детьми указанных счетов (поддерево сдвинуто вместе со счётом).""" + result: dict[int, int] = {} + shifts: list[tuple[int, int]] = [] # (исходный уровень сдвинутого счёта, его сдвиг) — открытые поддеревья + for item in rows: + original = levels[item.row] + while shifts and shifts[-1][0] >= original: + shifts.pop() + level = original + (shifts[-1][1] if shifts else 0) + if item.row in parent_of: + level = result[parent_of[item.row]] + 1 + shifts.append((original, level - original)) + result[item.row] = level + return result + + +def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]: + """Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта. + + Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция. + Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами — + не закрытие, и дерево остаётся по отступам. + """ + result = dict(levels) + previous: BodyRow | None = None + for item in rows: + if ( + previous is not None + 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 _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR]) + and result[item.row] <= result[previous.row] + ): + result[item.row] = result[previous.row] + 1 + previous = item + return result + + +def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> bool: + """«Сумма детей ≠ родитель» для решения о перестройке: по паре «дебет, кредит» (начало, обороты, конец); пара, в + которой у детей пусто (аналитика без сальдо), не сверяется, а сальдо свёрнутого родителя сверяется по нетто — как + в контрольных суммах §1.4, но мягче: окончательно решают они, а не эта функция.""" + for start, is_balance in ((0, True), (2, False), (4, True)): + pair_parent, pair_children = parent[start : start + 2], children[start : start + 2] + if all(abs(value) <= SUM_TOLERANCE for value in pair_children): + continue + if _matches(pair_parent, pair_children): + continue + collapsed = any(abs(value) <= SUM_TOLERANCE for value in pair_parent) + net_equal = abs((pair_parent[0] - pair_parent[1]) - (pair_children[0] - pair_children[1])) <= SUM_TOLERANCE + if not (is_balance and collapsed and net_equal): + return True + return False + + +def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]: + """Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель + следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен + `NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех.""" + 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]): + continue + accumulated, position = zero, index + 1 + for _ in range(NEST_SCAN_LIMIT): + pacer.tick() + if position >= count: + break + accumulated = _add(accumulated, vectors[position]) + position = end[position] + if _matches(accumulated, vectors[index]): + end[index] = position + break + depths: list[int] = [] + open_ends: list[int] = [] + for index in range(count): + while open_ends and open_ends[-1] <= index: + open_ends.pop() + depths.append(len(open_ends)) + if end[index] > index + 1: + open_ends.append(end[index]) + return depths + + +def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Deadline | None) -> dict[int, int]: + """Вложенность аналитики по суммам внутри родителя с нарушением «сумма детей = родитель» (при одном отступе + уровни аналитики неразличимы, видны только суммы: 15 = 16 + 17). + + Только для родителя с нарушением, чьи дети — строки аналитики (не счета и не показатели) с суммами; перестройка + принимается, если после неё сумма прямых детей совпадает с родителем (`_violates`), иначе дерево родителя остаётся + исходным. Строки без нарушения не трогаются: равные суммы соседей (50 + 50 = 100) не делают одного ребёнком другого. + """ + pacer = _Pacer(deadline, "иерархия (indent): вложенность по суммам") + by_parent = parents(rows, levels) + kids: dict[int, list[int]] = {} + for index, item in enumerate(rows): + parent = by_parent[item.row] + if parent is not None: + kids.setdefault(parent, []).append(index) + position = {item.row: index for index, item in enumerate(rows)} + bump: dict[int, int] = {} + 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): + continue + vectors = [_vector(member) for member in members] + total = vectors[0] + for vector in vectors[1:]: + total = _add(total, vector) + parent_vector = _vector(rows[position[parent_row]]) + if not _violates(parent_vector, total): + continue + depths = _nest_roots(vectors, pacer) + 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 + for kid, depth in zip(kid_indexes, depths, strict=True): + if depth == 0: + continue + index = kid + bump[rows[index].row] = depth + index += 1 + while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]: + bump[rows[index].row] = depth + index += 1 + return {row: level + bump.get(row, 0) for row, level in levels.items()} def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7be1702..ae25f81 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -5,7 +5,8 @@ раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с -блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен +блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78); +описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]: return [sheet] +def _s47() -> list[SheetSpec]: + """Иерархия по отступам там, где отступа не хватает (AURORA-1858): три листа «Субконто» без вида аналитики в шапке. + Лист «68»: подуровень «68.01.1» на том же отступе, что «68.01». Лист «90»: безымянная строка закрытия сразу за + счётом (пустая ячейка отступа не хранит). Лист «91»: аналитика на одном отступе, а вложена по суммам + (100 = 60 + 40; 30 = 20 + 10; 40 ⊃ 40).""" + head = {1: 'ООО "Октябрьское"', 3: "Период: 9 месяцев 2024 г."} + + def sheet(account: str, rows: Sequence[FlatRow]) -> SheetSpec: + spec = _flat_sheet({**head, 2: f"Оборотно-сальдовая ведомость по счету {account}"}, 5, rows) + spec.name = f"{account} сч." + return spec + + tax = (None, None, "2333.00", "2624.00", None, "291.00") + tax_old = (None, "15687.00", "45332.00", "45332.00", None, "15687.00") + rows_68: list[FlatRow] = [ + ("68.01", tax, 0), + ("68.01.1", tax, 0), + ("Налог (взносы): начислено / уплачено", tax, 1), + ("68.10", tax_old, 0), + ("Налог (взносы): начислено / уплачено", tax_old, 1), + ("Итого", (None, "15687.00", "47665.00", "47956.00", None, "15978.00"), 0), + ] + income = (None, None, None, "300.00", None, "300.00") + expense = (None, None, "200.00", None, "200.00", None) + closing = (None, None, "500.00", "300.00", "200.00", None) + rows_90: list[FlatRow] = [ + ("90.01", income, 0), + ("90.01.1", income, 0), + ("Аренда", (None, None, None, "200.00", None, None), 1), + ("Прочие", (None, None, None, "100.00", None, None), 1), + ("90.07", expense, 0), + ("90.07.1", expense, 0), + ("Расходы по аренде", (None, None, "200.00", None, None, None), 1), + ("90.09", closing, 0), + ("", (None, None, "500.00", "300.00", None, None), 0), + ("Итого", (None, None, "700.00", "600.00", "400.00", "300.00"), 0), + ] + + def turn(debit: str | None, credit: str | None) -> tuple[str | None, ...]: + return (None, None, debit, credit, None, None) + + rows_91: list[FlatRow] = [ + ("91.01", (None, None, None, "100.00", None, "100.00"), 0), + ("Проценты к получению", turn(None, "70.00"), 1), + ("Проценты по вкладам", turn(None, "70.00"), 1), + ("Прочие доходы", turn(None, "30.00"), 1), + ("Пени", turn(None, "20.00"), 1), + ("Штрафы", turn(None, "10.00"), 1), + ("91.02", (None, None, "170.00", None, "170.00", None), 0), + ("91.02.1", (None, None, "170.00", None, "170.00", None), 0), + ("Проценты к уплате", turn("100.00", None), 1), + ("Проценты по займам", turn("60.00", None), 1), + ("Проценты по кредитам", turn("40.00", None), 1), + ("Прочие расходы", turn("30.00", None), 1), + ("Затраты на нужды", turn("20.00", None), 1), + ("Штрафы по договорам", turn("10.00", None), 1), + ("Операционные расходы", turn("40.00", None), 1), + ("Услуги банка", turn("40.00", None), 1), + ("91.09", (None, None, None, "70.00", None, "70.00"), 0), + ("", turn(None, "70.00"), 0), + ("Итого", (None, None, "170.00", "170.00", "170.00", "170.00"), 0), + ] + return [sheet("68", rows_68), sheet("90", rows_90), sheet("91", rows_91)] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -1152,6 +1218,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), + "s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx), } FILE_NAMES = tuple(FILES) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index 83a9142..684c908 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,13 +26,13 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s47_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 47)] + numbers = [*range(1, 37), *range(40, 48)] assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 7db7c77..1e21f8e 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -52,6 +52,7 @@ FLAT_FORM_FILES = ( "s44_subkonto_unnamed_10.xlsx", "s45_account_label_value_cells.xlsx", "s46_counterparty_code_blocks.xlsx", + "s47_indent_hierarchy_code_sums.xlsx", ) TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -189,6 +190,10 @@ EXPECTED: dict[str, Expect] = { "s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None), # #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ» "s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None), + # #78: листы 68/90/91 по отступам: подуровень «68.01.1», безымянная строка закрытия, вложенность по суммам + "s47_indent_hierarchy_code_sums.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3 + ), } diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 8b7db95..74244d2 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -54,6 +54,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все row=row, text=text, is_account=match is not None, + account_code=match.group(1) if match else None, account_rank=rank, bold=bold, indent=indent, @@ -498,3 +499,162 @@ def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> assert levels.strategy == "header_account" assert levels_list(rows, levels.by_row) == [0, 0] assert set(levels.parent.values()) == {None} + + +# --- Отступ: подуровень счёта, закрывающая строка, вложенность по суммам (AURORA-1858) --- + + +def turnover(value: str) -> tuple[Decimal | None, ...]: + return money(None, None, value, None, None, None) + + +def test_indent_subaccount_with_code_prefix_on_same_indent_is_nested() -> None: + """Подуровень «68.01.1» на том же отступе, что «68.01»: по коду он ребёнок, его аналитика — на уровень глубже.""" + rows = numbered( + ("68.01", {"indent": 0}), + ("68.01.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("68.10", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 0, 21: 1} + + +def test_indent_neighbour_accounts_without_code_prefix_stay_roots() -> None: + rows = numbered( + ("68.01", {"indent": 0}), + ("68.10", {"indent": 0}), + ("68.1.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 0, 19: 0, 20: 1} + + +def test_indent_code_prefix_nesting_only_on_a_single_indent() -> None: + """Пары «счёт — подуровень» на двух разных отступах — не признак вложенности (соседние корни намеренно).""" + rows = numbered( + ("60", {"indent": 0}), + ("60.01", {"indent": 0}), + ("Контрагент", {"indent": 1}), + ("70", {"indent": 1}), + ("70.01", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 0, 19: 1, 20: 1, 21: 1} + + +def test_indent_chain_of_subaccounts_on_same_indent_is_nested_step_by_step() -> None: + rows = numbered( + ("68", {"indent": 0}), + ("68.01", {"indent": 0}), + ("68.01.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("69", {"indent": 0}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 0} + + +def test_indent_unnamed_closing_row_after_account_is_its_child() -> None: + """Строка закрытия без текста и кода (reader не хранит пустые ячейки — отступ 0) — ребёнок предыдущего счёта.""" + rows = numbered( + ("90.01", {"indent": 0, "sums": turnover("10")}), + ("Аренда", {"indent": 1, "sums": turnover("10")}), + ("90.09", {"indent": 0, "sums": turnover("7")}), + ("", {"indent": 0, "sums": turnover("7")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1} + + +def test_indent_unnamed_row_without_sums_or_after_non_account_is_not_attached() -> None: + rows = numbered( + ("90.01", {"indent": 0}), + ("Аренда", {"indent": 1, "sums": turnover("10")}), + ("", {"indent": 0, "sums": turnover("10")}), + ("90.09", {"indent": 0}), + ("", {"indent": 0}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 0, 21: 0} + + +def flat_group(*kids: str, parent: str = "100") -> list[BodyRow]: + """Счёт с оборотом `parent` и аналитикой на одном отступе — по суммам значения `kids` (дебет оборота).""" + specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover(parent)})] + specs += [(f"Статья {n}", {"indent": 1, "sums": turnover(value)}) for n, value in enumerate(kids)] + return numbered(*specs) + + +def test_indent_flat_analytics_is_nested_by_sums_inside_violating_parent() -> None: + """Сумма детей ≠ родитель, а вложенность видна только по суммам (60 = 40 + 20): строка с суммой её соседей — их + родитель.""" + rows = flat_group("60", "40", "20", "40") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 1} + + +def test_indent_flat_analytics_is_untouched_without_violation() -> None: + """Родитель сходится с детьми (50 + 50 = 100): равные суммы соседей не склеиваются в родителя и ребёнка.""" + rows = flat_group("50", "50") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1} + + +def test_indent_nesting_by_sums_is_rolled_back_when_parent_still_violates() -> None: + """30 + 30 + 30 ≠ 100 и вложенность по суммам (30 ⊃ 30) родителя не лечит: дерево остаётся исходным.""" + rows = flat_group("30", "30", "30") + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1, 20: 1} + + +def test_indent_nesting_by_sums_never_nests_accounts() -> None: + rows = numbered( + ("91", {"indent": 0, "sums": turnover("100")}), + ("91.01", {"indent": 1, "sums": turnover("60")}), + ("91.02", {"indent": 1, "sums": turnover("60")}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 1} + + +def test_indent_second_subaccount_of_same_parent_is_nested_too() -> None: + """«68.04.1» и «68.04.2» на одном отступе с «68.04»: оба — дети «68.04» (второй — не ребёнок первого).""" + rows = numbered( + ("68.04", {"indent": 0}), + ("68.04.1", {"indent": 0}), + ("Налог", {"indent": 1}), + ("68.04.2", {"indent": 0}), + ("68.07", {"indent": 0}), + ("Налог", {"indent": 1}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 1, 21: 0, 22: 1} + + +def test_indent_subaccount_already_under_parent_with_its_code_is_not_moved() -> None: + """«76.09» с собственной аналитикой и «76.09.1» рядом на одном отступе — уже дети счёта «76.09»; под соседа + «76.09» их перекладывать не нужно.""" + rows = numbered( + ("76", {"indent": 0}), + ("76.09", {"indent": 1}), + ("76.09", {"indent": 2}), + ("ООО А", {"indent": 3}), + ("76.09.1", {"indent": 2}), + ("ООО Б", {"indent": 3}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 3} + + +def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None: + """Безымянная строка с суммами, не повторяющая обороты счёта, закрытием не считается.""" + rows = numbered( + ("26.01", {"indent": 0, "sums": money(None, None, "100", "100", None, None)}), + ("Статья", {"indent": 1, "sums": turnover("100")}), + ("", {"indent": 0, "sums": money(None, None, None, "100", None, None)}), + ) + + assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0} diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index b138a68..2c2360e 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -150,6 +150,18 @@ SHALL пропускаться с предупреждением файла `SHE - **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по кодам счетов и жирности (`s03`) +#### Scenario: Отступ не различает подуровни счёта, строку закрытия и вложенную аналитику +- **WHEN** на листе уровни берутся по отступам (`s47`; ОСВ 1С «Субконто» без вида аналитики в шапке), но + (а) счёт `68.01.1` стоит на одном отступе с `68.01` (код продолжает код соседнего счёта того же блока); + (б) безымянная строка с суммами (пустая ячейка отступа не хранится) идёт сразу за строкой счёта и повторяет его обороты; + (в) аналитика родителя с нарушением «сумма детей = родитель» стоит на одном отступе, а вложенность видна только по + суммам (`100 = 60 + 40`) +- **THEN** (а) `68.01.1` — ребёнок `68.01` (уровень +1, его аналитика глубже), если все такие пары листа на одном + отступе, раскладка однозначна, а сумма детей перекладываемого родителя сходится с ним; (б) строка закрытия — ребёнок + счёта; (в) внутри такого родителя строка, чьи суммы равны сумме следующих соседей, становится их родителем (жадно), но + только если после перестройки родитель сходится, иначе дерево остаётся по отступам; родитель без нарушения не + перестраивается (равные суммы соседей `50 + 50 = 100` не делают одного ребёнком другого); листы `s47` — `done` + #### Scenario: Без outline и отступов - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) - **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт