fix(osv): иерархия по отступам — подуровень счёта, строка закрытия, вложенность по суммам (AURORA-1858, #78)

В strategy_indent: счёт, чей код продолжает код соседа того же отступа (68.01 → 68.01.1), — ребёнок;
безымянная строка с оборотами счёта сразу за ним — его строка закрытия; аналитика на одном отступе
вкладывается по суммам только внутри родителя с нарушением и только если он после этого сходится.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 01:03:02 +04:00
parent e01f218138
commit ca7f64c3ef
7 changed files with 461 additions and 4 deletions

View File

@ -314,6 +314,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
row=row, row=row,
text=text, text=text,
is_account=account is not None, 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, account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
bold=bold, bold=bold,
indent=indent, indent=indent,

View File

@ -44,7 +44,11 @@ MIN_ROWS_FOR_CASCADE = 2
TRIVIAL_STRATEGY = "trivial" TRIVIAL_STRATEGY = "trivial"
# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев # Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
DEADLINE_CHECK_EVERY = 500 DEADLINE_CHECK_EVERY = 500
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
NEST_SCAN_LIMIT = 50
_ZERO = Decimal(0) _ZERO = Decimal(0)
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
Role = Literal["counterparty", "contract"] Role = Literal["counterparty", "contract"]
@ -67,6 +71,7 @@ class BodyRow:
label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.»)
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
role: Role | None = None role: Role | None = None
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
@dataclass(frozen=True) @dataclass(frozen=True)
@ -135,11 +140,218 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных не предполагается: (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)")]) ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
if ranks is None: if ranks is None:
return 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: def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:

View File

@ -5,7 +5,8 @@
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты
другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с
блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78);
описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги
и zip постоянны, порядок записи фиксирован. и zip постоянны, порядок записи фиксирован.
@ -1037,6 +1038,71 @@ def _s46() -> list[SheetSpec]:
return [sheet] 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]: def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree() 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), "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx),
"s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s45_account_label_value_cells.xlsx": (_s45, to_xlsx),
"s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx),
"s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx),
} }
FILE_NAMES = tuple(FILES) FILE_NAMES = tuple(FILES)

View File

@ -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 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)] names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES) assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(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 # 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[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 [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) assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)

View File

@ -52,6 +52,7 @@ FLAT_FORM_FILES = (
"s44_subkonto_unnamed_10.xlsx", "s44_subkonto_unnamed_10.xlsx",
"s45_account_label_value_cells.xlsx", "s45_account_label_value_cells.xlsx",
"s46_counterparty_code_blocks.xlsx", "s46_counterparty_code_blocks.xlsx",
"s47_indent_hierarchy_code_sums.xlsx",
) )
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [ 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), "s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None),
# #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ» # #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ»
"s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None), "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
),
} }

View File

@ -54,6 +54,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
row=row, row=row,
text=text, text=text,
is_account=match is not None, is_account=match is not None,
account_code=match.group(1) if match else None,
account_rank=rank, account_rank=rank,
bold=bold, bold=bold,
indent=indent, 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.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0] assert levels_list(rows, levels.by_row) == [0, 0]
assert set(levels.parent.values()) == {None} 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}

View File

@ -150,6 +150,18 @@ SHALL пропускаться с предупреждением файла `SHE
- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по - **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
кодам счетов и жирности (`s03`) кодам счетов и жирности (`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 и отступов #### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт - **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт