fix(osv): вложенность по суммам только без названных групп, без ИНН и при одном отступе; сдвиги складываются (AURORA-1858, #78, ревью)
C-1: nest_by_sums включается из osv.py, если схема шапки не называет группы после счёта; строка с ИНН не вкладывается и не становится родителем; прямые дети родителя должны быть на одном отступе. M-1: сдвиги складываются. M-2: код без разделителя сегментов не вкладывается. M-3: строка закрытия — только с ненулевыми оборотами. M-4: общий жадный перебор _greedy_ends для strategy_sums и вложенности, граница NEST_SCAN_LIMIT под тестом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ca7f64c3ef
commit
e1760c08f6
@ -37,7 +37,7 @@ from src.parser.detect import (
|
|||||||
table_kind_row,
|
table_kind_row,
|
||||||
)
|
)
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.indicators import is_indicator_total, read_label
|
||||||
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
||||||
from src.parser.rules import GroupType, Rules
|
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,
|
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
|
||||||
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
||||||
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
_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:
|
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, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
||||||
# любого отступа ячейки
|
# любого отступа ячейки
|
||||||
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
|
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(
|
return BodyRow(
|
||||||
row=row,
|
row=row,
|
||||||
text=text,
|
text=text,
|
||||||
@ -323,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
|||||||
label_col=label_col,
|
label_col=label_col,
|
||||||
indicator=label.indicator,
|
indicator=label.indicator,
|
||||||
label_data_type=label.data_type,
|
label_data_type=label.data_type,
|
||||||
|
has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,10 @@ DEADLINE_CHECK_EVERY = 500
|
|||||||
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
|
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
|
||||||
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
|
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
|
||||||
NEST_SCAN_LIMIT = 50
|
NEST_SCAN_LIMIT = 50
|
||||||
|
# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка
|
||||||
|
MIN_NESTABLE_CHILDREN = 2
|
||||||
|
# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же
|
||||||
|
ACCOUNT_SEGMENT_SEPARATOR = "."
|
||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
|
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
|
||||||
|
|
||||||
@ -72,6 +76,7 @@ class BodyRow:
|
|||||||
# Роль в блоке субсчёта с кодами контрагентов (`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`
|
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
|
||||||
|
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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}
|
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), поэтому шаг отступа
|
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
|
||||||
@ -146,7 +153,10 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
|
|||||||
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
|
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
|
||||||
(`_attach_closing_rows`);
|
(`_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)")])
|
ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
|
||||||
if ranks is None:
|
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 = {item.row: ranks[item.indent] for item in rows}
|
||||||
levels = _nest_subaccounts(rows, levels)
|
levels = _nest_subaccounts(rows, levels)
|
||||||
levels = _attach_closing_rows(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]:
|
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()
|
open_accounts.pop()
|
||||||
for deeper in [known for known in blocks if known > level]:
|
for deeper in [known for known in blocks if known > level]:
|
||||||
del blocks[deeper]
|
del blocks[deeper]
|
||||||
parent_code = code.rsplit(".", 1)[0]
|
# у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст
|
||||||
candidate = blocks.get(level, {}).get(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
|
under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code
|
||||||
if candidate is not None and not under_parent:
|
if candidate is not None and not under_parent:
|
||||||
parent_of[item.row] = candidate.row
|
parent_of[item.row] = candidate.row
|
||||||
pair_levels.add(level)
|
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
|
ambiguous = True
|
||||||
@ -241,8 +252,8 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic
|
|||||||
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
|
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
|
||||||
|
|
||||||
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
|
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
|
||||||
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами —
|
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная
|
||||||
не закрытие, и дерево остаётся по отступам.
|
строка с суммами — не закрытие, и дерево остаётся по отступам.
|
||||||
"""
|
"""
|
||||||
result = dict(levels)
|
result = dict(levels)
|
||||||
previous: BodyRow | None = None
|
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 previous.is_account
|
||||||
and not item.is_account
|
and not item.is_account
|
||||||
and not item.text.strip()
|
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 _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR])
|
||||||
and result[item.row] <= result[previous.row]
|
and result[item.row] <= result[previous.row]
|
||||||
):
|
):
|
||||||
@ -278,29 +289,44 @@ def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> boo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]:
|
def _greedy_ends(
|
||||||
"""Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель
|
vectors: Sequence[tuple[Decimal, ...]],
|
||||||
следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен
|
pacer: _Pacer,
|
||||||
`NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех."""
|
*,
|
||||||
|
scan_limit: int | None = None,
|
||||||
|
blocked: Sequence[bool] | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает
|
||||||
|
следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм
|
||||||
|
родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца);
|
||||||
|
`blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней)."""
|
||||||
count = len(vectors)
|
count = len(vectors)
|
||||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
||||||
end = [index + 1 for index in range(count)]
|
end = [index + 1 for index in range(count)]
|
||||||
for index in reversed(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()
|
pacer.tick()
|
||||||
if position >= count:
|
target = vectors[index]
|
||||||
|
if all(value == _ZERO for value in target) or (blocked is not None and blocked[index]):
|
||||||
|
continue
|
||||||
|
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
|
break
|
||||||
|
pacer.tick()
|
||||||
accumulated = _add(accumulated, vectors[position])
|
accumulated = _add(accumulated, vectors[position])
|
||||||
position = end[position]
|
position = end[position]
|
||||||
if _matches(accumulated, vectors[index]):
|
steps += 1
|
||||||
|
if _matches(accumulated, target):
|
||||||
end[index] = position
|
end[index] = position
|
||||||
break
|
break
|
||||||
|
return end
|
||||||
|
|
||||||
|
|
||||||
|
def _depths(end: Sequence[int]) -> list[int]:
|
||||||
|
"""Глубина вложенности каждой строки по границам поддеревьев `end`."""
|
||||||
depths: list[int] = []
|
depths: list[int] = []
|
||||||
open_ends: list[int] = []
|
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
||||||
for index in range(count):
|
for index in range(len(end)):
|
||||||
while open_ends and open_ends[-1] <= index:
|
while open_ends and open_ends[-1] <= index:
|
||||||
open_ends.pop()
|
open_ends.pop()
|
||||||
depths.append(len(open_ends))
|
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():
|
for parent_row, kid_indexes in kids.items():
|
||||||
pacer.tick()
|
pacer.tick()
|
||||||
members = [rows[index] for index in kid_indexes]
|
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
|
continue
|
||||||
vectors = [_vector(member) for member in members]
|
vectors = [_vector(member) for member in members]
|
||||||
total = vectors[0]
|
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]])
|
parent_vector = _vector(rows[position[parent_row]])
|
||||||
if not _violates(parent_vector, total):
|
if not _violates(parent_vector, total):
|
||||||
continue
|
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]
|
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))):
|
if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))):
|
||||||
continue
|
continue
|
||||||
@ -346,10 +378,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
|
|||||||
if depth == 0:
|
if depth == 0:
|
||||||
continue
|
continue
|
||||||
index = kid
|
index = kid
|
||||||
bump[rows[index].row] = depth
|
# сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже
|
||||||
|
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
|
||||||
index += 1
|
index += 1
|
||||||
while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]:
|
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
|
index += 1
|
||||||
return {row: level + bump.get(row, 0) for row, level in levels.items()}
|
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` шагов,
|
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
|
||||||
и такой файл завершается `TIMEOUT`, а не висит.
|
и такой файл завершается `TIMEOUT`, а не висит.
|
||||||
"""
|
"""
|
||||||
count = len(rows)
|
|
||||||
vectors = [_vector(item) for item in rows]
|
vectors = [_vector(item) for item in rows]
|
||||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев"))
|
||||||
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
|
if all(end[index] == index + 1 for index in range(len(rows))):
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
by_row: dict[int, int] = {}
|
return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)}
|
||||||
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
|
||||||
for index, item in enumerate(rows):
|
|
||||||
while open_ends and open_ends[-1] <= index:
|
|
||||||
open_ends.pop()
|
|
||||||
by_row[item.row] = len(open_ends)
|
|
||||||
if end[index] > index + 1:
|
|
||||||
open_ends.append(end[index])
|
|
||||||
return by_row
|
|
||||||
|
|
||||||
|
|
||||||
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
|
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(
|
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:
|
) -> Levels:
|
||||||
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
||||||
|
|
||||||
|
`nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема
|
||||||
|
шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки).
|
||||||
|
|
||||||
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
||||||
(плоский список под счётом шапки, #59).
|
(плоский список под счётом шапки, #59).
|
||||||
|
|
||||||
@ -545,7 +559,9 @@ def resolve_levels(
|
|||||||
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
||||||
for name, strategy in cascade:
|
for name, strategy in cascade:
|
||||||
deadline.check(f"иерархия ({name})")
|
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:
|
if found is None:
|
||||||
continue
|
continue
|
||||||
by_row = {**found, **pinned}
|
by_row = {**found, **pinned}
|
||||||
|
|||||||
@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
|
|||||||
|
|
||||||
|
|
||||||
def levels_with_indicators(
|
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:
|
) -> Levels:
|
||||||
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
||||||
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
||||||
@ -67,7 +72,11 @@ def levels_with_indicators(
|
|||||||
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
||||||
"""
|
"""
|
||||||
levels = resolve_levels(
|
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] = {}
|
by_row: dict[int, int] = {}
|
||||||
parent: dict[int, int | None] = {}
|
parent: dict[int, int | None] = {}
|
||||||
|
|||||||
@ -217,7 +217,14 @@ class OsvParser:
|
|||||||
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
||||||
body = blocks.rows
|
body = blocks.rows
|
||||||
step = "иерархия строк"
|
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 = "строки ИНН"
|
step = "строки ИНН"
|
||||||
glue = glue_inn_rows(
|
glue = glue_inn_rows(
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
|||||||
indent: int = 0,
|
indent: int = 0,
|
||||||
bold: bool = False,
|
bold: bool = False,
|
||||||
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
||||||
|
has_inn: bool = False,
|
||||||
) -> BodyRow:
|
) -> BodyRow:
|
||||||
match = ACCOUNT.fullmatch(text)
|
match = ACCOUNT.fullmatch(text)
|
||||||
rank = match.group(1).count(".") + 1 if match else None
|
rank = match.group(1).count(".") + 1 if match else None
|
||||||
@ -60,6 +61,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
|||||||
indent=indent,
|
indent=indent,
|
||||||
outline=outline,
|
outline=outline,
|
||||||
sums=sums,
|
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}
|
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}
|
||||||
|
|||||||
97
api/tests/unit/parser/test_osv_indent_nesting.py
Normal file
97
api/tests/unit/parser/test_osv_indent_nesting.py
Normal file
@ -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}
|
||||||
Loading…
x
Reference in New Issue
Block a user