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:
parent
e01f218138
commit
ca7f64c3ef
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 выполнены, «Итого» не вложена в последний счёт
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user