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,
|
||||
)
|
||||
from src.parser.grid import Sheet, column_letter
|
||||
from src.parser.hierarchy import BodyRow, Levels
|
||||
from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels
|
||||
from src.parser.indicators import is_indicator_total, read_label
|
||||
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
||||
from src.parser.rules import GroupType, Rules
|
||||
@ -64,7 +64,7 @@ ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,
|
||||
# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3,
|
||||
# AURORA-1714); без счёта шапки действует `ACCOUNT_RE`
|
||||
_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
||||
_SEGMENT_SEPARATOR = "."
|
||||
_SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR
|
||||
|
||||
|
||||
def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None:
|
||||
@ -310,6 +310,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
||||
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
|
||||
# любого отступа ячейки
|
||||
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
|
||||
inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None
|
||||
return BodyRow(
|
||||
row=row,
|
||||
text=text,
|
||||
@ -323,6 +324,7 @@ def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строк
|
||||
label_col=label_col,
|
||||
indicator=label.indicator,
|
||||
label_data_type=label.data_type,
|
||||
has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -47,6 +47,10 @@ DEADLINE_CHECK_EVERY = 500
|
||||
# Вложенность по суммам в `indent` ищет родителя среди не более чем стольких следующих соседей: аналитика одного
|
||||
# родителя вложена на единицы строк, а без предела плоский лист в тысячи строк давал бы O(n²) перебора
|
||||
NEST_SCAN_LIMIT = 50
|
||||
# Вложенность по суммам имеет смысл, только если у родителя есть кого вкладывать друг в друга: хотя бы два ребёнка
|
||||
MIN_NESTABLE_CHILDREN = 2
|
||||
# Разделитель сегментов кода счёта («68.01.1»); `classify` берёт его отсюда же
|
||||
ACCOUNT_SEGMENT_SEPARATOR = "."
|
||||
_ZERO = Decimal(0)
|
||||
_TURNOVER_PAIR = slice(2, 4) # обороты (дебет, кредит) в `BodyRow.sums`
|
||||
|
||||
@ -72,6 +76,7 @@ class BodyRow:
|
||||
# Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор
|
||||
role: Role | None = None
|
||||
account_code: str | None = None # код счёта строки, как в файле («68.01.1»); у не-счёта — `None`
|
||||
has_inn: bool = False # у строки есть ИНН: в колонке ИНН или хвостом названия (контрагент, не строка аналитики)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -134,7 +139,9 @@ def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None)
|
||||
return {item.row: item.outline - base for item in rows}
|
||||
|
||||
|
||||
def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
||||
def strategy_indent(
|
||||
rows: Sequence[BodyRow], deadline: Deadline | None = None, *, nest_by_sums: bool = True
|
||||
) -> dict[int, int] | None:
|
||||
"""Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа.
|
||||
|
||||
`indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
|
||||
@ -146,7 +153,10 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
|
||||
- безымянная строка закрытия сразу за строкой счёта (отступ пустой ячейки не хранится) — её ребёнок
|
||||
(`_attach_closing_rows`);
|
||||
- аналитика, вложенная на два уровня при одном отступе, — вложенность по суммам, но только внутри родителя с
|
||||
нарушением «сумма детей = родитель» и только если после перестройки он сходится (`_nest_by_sums`).
|
||||
нарушением «сумма детей = родитель», только если все его прямые дети на одном отступе, ни вкладываемая строка,
|
||||
ни новый родитель не несут ИНН, и только если после перестройки он сходится (`_nest_by_sums`). Вызывающий
|
||||
отключает её (`nest_by_sums=False`), когда схема шапки называет группы аналитики после счёта: вложение
|
||||
изменило бы `group_type`/`group_name` строки.
|
||||
"""
|
||||
ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
|
||||
if ranks is None:
|
||||
@ -154,7 +164,7 @@ def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -
|
||||
levels = {item.row: ranks[item.indent] for item in rows}
|
||||
levels = _nest_subaccounts(rows, levels)
|
||||
levels = _attach_closing_rows(rows, levels)
|
||||
return _nest_by_sums(rows, levels, deadline)
|
||||
return _nest_by_sums(rows, levels, deadline) if nest_by_sums else levels
|
||||
|
||||
|
||||
def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[int, int]:
|
||||
@ -183,13 +193,14 @@ def _nest_subaccounts(rows: Sequence[BodyRow], levels: dict[int, int]) -> dict[i
|
||||
open_accounts.pop()
|
||||
for deeper in [known for known in blocks if known > level]:
|
||||
del blocks[deeper]
|
||||
parent_code = code.rsplit(".", 1)[0]
|
||||
candidate = blocks.get(level, {}).get(parent_code)
|
||||
# у кода без сегментов («60» и второй «60») родителя по коду нет: `parent_code` пуст
|
||||
parent_code = code.rpartition(ACCOUNT_SEGMENT_SEPARATOR)[0]
|
||||
candidate = blocks.get(level, {}).get(parent_code) if parent_code else None
|
||||
under_parent = bool(open_accounts) and open_accounts[-1][1] == parent_code
|
||||
if candidate is not None and not under_parent:
|
||||
parent_of[item.row] = candidate.row
|
||||
pair_levels.add(level)
|
||||
elif candidate is not None or parent_code in seen_codes.get(level, ()):
|
||||
elif parent_code and (candidate is not None or parent_code in seen_codes.get(level, ())):
|
||||
# родительский код на этом уровне уже был, но счёт к нему не прикрепить (рядом уже стоит под своим родителем
|
||||
# или между ними строка другого блока): раскладка неоднозначна, по коду не перекладываем ничего
|
||||
ambiguous = True
|
||||
@ -241,8 +252,8 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic
|
||||
"""Безымянная строка с суммами (без текста и кода) сразу за строкой счёта — строка закрытия, ребёнок этого счёта.
|
||||
|
||||
Читатель не хранит пустые ячейки, поэтому её отступ — 0, как у счёта; признак — пустой текст, а не позиция.
|
||||
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта): иначе безымянная строка с суммами —
|
||||
не закрытие, и дерево остаётся по отступам.
|
||||
Строка закрытия повторяет обороты счёта (дебет и кредит равны оборотам счёта) и они не нулевые: иначе безымянная
|
||||
строка с суммами — не закрытие, и дерево остаётся по отступам.
|
||||
"""
|
||||
result = dict(levels)
|
||||
previous: BodyRow | None = None
|
||||
@ -252,7 +263,7 @@ def _attach_closing_rows(rows: Sequence[BodyRow], levels: dict[int, int]) -> dic
|
||||
and previous.is_account
|
||||
and not item.is_account
|
||||
and not item.text.strip()
|
||||
and any(value is not None for value in item.sums)
|
||||
and any(value != _ZERO for value in _vector(item)[_TURNOVER_PAIR])
|
||||
and _matches(_vector(item)[_TURNOVER_PAIR], _vector(previous)[_TURNOVER_PAIR])
|
||||
and result[item.row] <= result[previous.row]
|
||||
):
|
||||
@ -278,29 +289,44 @@ def _violates(parent: tuple[Decimal, ...], children: tuple[Decimal, ...]) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def _nest_roots(vectors: Sequence[tuple[Decimal, ...]], pacer: _Pacer) -> list[int]:
|
||||
"""Глубина вложенности каждого из подряд идущих соседей по суммам (жадно, как `strategy_sums`): сосед — родитель
|
||||
следующих соседей (с их поддеревьями), пока сумма их строк не совпадёт с его суммами. Перебор ограничен
|
||||
`NEST_SCAN_LIMIT` соседей вперёд. Все нули — глубина 0 у всех."""
|
||||
def _greedy_ends(
|
||||
vectors: Sequence[tuple[Decimal, ...]],
|
||||
pacer: _Pacer,
|
||||
*,
|
||||
scan_limit: int | None = None,
|
||||
blocked: Sequence[bool] | None = None,
|
||||
) -> list[int]:
|
||||
"""Граница поддерева каждой строки (`end[i]`, исключительно) по суммам, жадно и с конца: строка `i` перебирает
|
||||
следующие поддеревья подряд и становится родителем на первом совпадении по всем колонкам. Строка без сумм
|
||||
родителем не бывает. `scan_limit` — сколько следующих поддеревьев перебирать (`None` — до конца);
|
||||
`blocked[i]` — строка не может быть ни родителем, ни ребёнком (перебор обрывается на ней)."""
|
||||
count = len(vectors)
|
||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
||||
end = [index + 1 for index in range(count)]
|
||||
for index in reversed(range(count)):
|
||||
if all(value == _ZERO for value in vectors[index]):
|
||||
continue
|
||||
accumulated, position = zero, index + 1
|
||||
for _ in range(NEST_SCAN_LIMIT):
|
||||
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
|
||||
pacer.tick()
|
||||
accumulated = _add(accumulated, vectors[position])
|
||||
position = end[position]
|
||||
if _matches(accumulated, vectors[index]):
|
||||
steps += 1
|
||||
if _matches(accumulated, target):
|
||||
end[index] = position
|
||||
break
|
||||
return end
|
||||
|
||||
|
||||
def _depths(end: Sequence[int]) -> list[int]:
|
||||
"""Глубина вложенности каждой строки по границам поддеревьев `end`."""
|
||||
depths: list[int] = []
|
||||
open_ends: list[int] = []
|
||||
for index in range(count):
|
||||
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
||||
for index in range(len(end)):
|
||||
while open_ends and open_ends[-1] <= index:
|
||||
open_ends.pop()
|
||||
depths.append(len(open_ends))
|
||||
@ -329,7 +355,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
|
||||
for parent_row, kid_indexes in kids.items():
|
||||
pacer.tick()
|
||||
members = [rows[index] for index in kid_indexes]
|
||||
if len(members) < MIN_DISTINCT_LEVELS or any(m.is_account or m.indicator for m in members):
|
||||
if (
|
||||
len(members) < MIN_NESTABLE_CHILDREN
|
||||
or any(m.is_account or m.indicator for m in members)
|
||||
or len({m.indent for m in members}) > 1
|
||||
):
|
||||
continue
|
||||
vectors = [_vector(member) for member in members]
|
||||
total = vectors[0]
|
||||
@ -338,7 +368,9 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
|
||||
parent_vector = _vector(rows[position[parent_row]])
|
||||
if not _violates(parent_vector, total):
|
||||
continue
|
||||
depths = _nest_roots(vectors, pacer)
|
||||
depths = _depths(
|
||||
_greedy_ends(vectors, pacer, scan_limit=NEST_SCAN_LIMIT, blocked=[member.has_inn for member in members])
|
||||
)
|
||||
top = [vector for vector, depth in zip(vectors, depths, strict=True) if depth == 0]
|
||||
if not any(depths) or _violates(parent_vector, tuple(sum(column, _ZERO) for column in zip(*top, strict=True))):
|
||||
continue
|
||||
@ -346,10 +378,11 @@ def _nest_by_sums(rows: Sequence[BodyRow], levels: dict[int, int], deadline: Dea
|
||||
if depth == 0:
|
||||
continue
|
||||
index = kid
|
||||
bump[rows[index].row] = depth
|
||||
# сдвиги складываются: строка может лежать в поддереве вложенного ребёнка и сама вкладываться глубже
|
||||
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
|
||||
index += 1
|
||||
while index < len(rows) and levels[rows[index].row] > levels[rows[kid].row]:
|
||||
bump[rows[index].row] = depth
|
||||
bump[rows[index].row] = bump.get(rows[index].row, 0) + depth
|
||||
index += 1
|
||||
return {row: level + bump.get(row, 0) for row, level in levels.items()}
|
||||
|
||||
@ -449,38 +482,11 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) ->
|
||||
поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
|
||||
и такой файл завершается `TIMEOUT`, а не висит.
|
||||
"""
|
||||
count = len(rows)
|
||||
vectors = [_vector(item) for item in rows]
|
||||
zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
|
||||
end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
|
||||
has_children = False
|
||||
pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев")
|
||||
for index in reversed(range(count)):
|
||||
pacer.tick()
|
||||
target = vectors[index]
|
||||
if all(value == _ZERO for value in target):
|
||||
continue
|
||||
accumulated = zero
|
||||
position = index + 1
|
||||
while position < count:
|
||||
pacer.tick()
|
||||
accumulated = _add(accumulated, vectors[position])
|
||||
position = end[position]
|
||||
if _matches(accumulated, target):
|
||||
end[index] = position
|
||||
has_children = True
|
||||
break
|
||||
if not has_children:
|
||||
end = _greedy_ends(vectors, _Pacer(deadline, "иерархия (sums): перебор поддеревьев"))
|
||||
if all(end[index] == index + 1 for index in range(len(rows))):
|
||||
return None
|
||||
by_row: dict[int, int] = {}
|
||||
open_ends: list[int] = [] # границы поддеревьев открытых предков
|
||||
for index, item in enumerate(rows):
|
||||
while open_ends and open_ends[-1] <= index:
|
||||
open_ends.pop()
|
||||
by_row[item.row] = len(open_ends)
|
||||
if end[index] > index + 1:
|
||||
open_ends.append(end[index])
|
||||
return by_row
|
||||
return {item.row: depth for item, depth in zip(rows, _depths(end), strict=True)}
|
||||
|
||||
|
||||
STRATEGIES: tuple[tuple[str, Strategy], ...] = (
|
||||
@ -521,10 +527,18 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
|
||||
|
||||
|
||||
def resolve_levels(
|
||||
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
|
||||
rows: Sequence[BodyRow],
|
||||
total_row: int | None,
|
||||
deadline: Deadline,
|
||||
*,
|
||||
header_account: bool = False,
|
||||
nest_by_sums: bool = False,
|
||||
) -> Levels:
|
||||
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
||||
|
||||
`nest_by_sums` — разрешить стратегии `indent` вложенность аналитики по суммам (AURORA-1858): только когда схема
|
||||
шапки не называет группы аналитики после счёта (вложение не меняет `group_type`/`group_name` строки).
|
||||
|
||||
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
|
||||
(плоский список под счётом шапки, #59).
|
||||
|
||||
@ -545,7 +559,9 @@ def resolve_levels(
|
||||
cascade.insert(before_sums, ("header_account", strategy_header_account))
|
||||
for name, strategy in cascade:
|
||||
deadline.check(f"иерархия ({name})")
|
||||
found = strategy(body, deadline)
|
||||
found = (
|
||||
strategy_indent(body, deadline, nest_by_sums=nest_by_sums) if name == "indent" else strategy(body, deadline)
|
||||
)
|
||||
if found is None:
|
||||
continue
|
||||
by_row = {**found, **pinned}
|
||||
|
||||
@ -59,7 +59,12 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
|
||||
|
||||
|
||||
def levels_with_indicators(
|
||||
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
|
||||
rows: Sequence[BodyRow],
|
||||
total_row: int | None,
|
||||
deadline: Deadline,
|
||||
*,
|
||||
header_account: bool = False,
|
||||
nest_by_sums: bool = False,
|
||||
) -> Levels:
|
||||
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
|
||||
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
|
||||
@ -67,7 +72,11 @@ def levels_with_indicators(
|
||||
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
|
||||
"""
|
||||
levels = resolve_levels(
|
||||
[item for item in rows if not item.indicator], total_row, deadline, header_account=header_account
|
||||
[item for item in rows if not item.indicator],
|
||||
total_row,
|
||||
deadline,
|
||||
header_account=header_account,
|
||||
nest_by_sums=nest_by_sums,
|
||||
)
|
||||
by_row: dict[int, int] = {}
|
||||
parent: dict[int, int | None] = {}
|
||||
|
||||
@ -217,7 +217,14 @@ class OsvParser:
|
||||
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
|
||||
body = blocks.rows
|
||||
step = "иерархия строк"
|
||||
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
|
||||
# Вложенность по суммам меняет group_type/group_name строки: только без названных групп после счёта
|
||||
levels = levels_with_indicators(
|
||||
body,
|
||||
total_row,
|
||||
deadline,
|
||||
header_account=bool(doc.account_code_raw),
|
||||
nest_by_sums=all(group.name is None for group in schema.groups[1:]),
|
||||
)
|
||||
step = "строки ИНН"
|
||||
glue = glue_inn_rows(
|
||||
body,
|
||||
|
||||
@ -47,6 +47,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
||||
indent: int = 0,
|
||||
bold: bool = False,
|
||||
sums: tuple[Decimal | None, ...] = NO_SUMS,
|
||||
has_inn: bool = False,
|
||||
) -> BodyRow:
|
||||
match = ACCOUNT.fullmatch(text)
|
||||
rank = match.group(1).count(".") + 1 if match else None
|
||||
@ -60,6 +61,7 @@ def body( # noqa: PLR0913 - фабрика тестовых строк: все
|
||||
indent=indent,
|
||||
outline=outline,
|
||||
sums=sums,
|
||||
has_inn=has_inn,
|
||||
)
|
||||
|
||||
|
||||
@ -658,3 +660,130 @@ def test_indent_unnamed_row_with_other_turnover_is_not_a_closing_row() -> None:
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}
|
||||
|
||||
|
||||
# --- Вложенность по суммам: защиты (ревью AURORA-1858, C-1) ---
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_is_off_when_caller_forbids_it() -> None:
|
||||
"""Схема шапки называет группы аналитики после счёта: вложение изменило бы group_type/group_name строки."""
|
||||
rows = flat_group("60", "40", "20", "40")
|
||||
|
||||
assert strategy_indent(rows, nest_by_sums=False) == {17: 0, 18: 1, 19: 1, 20: 1, 21: 1}
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_needs_one_indent_among_children() -> None:
|
||||
"""Прямые дети родителя на разных отступах (2, 1, 1): отступ их уровни различает, суммы его не переопределяют."""
|
||||
rows = numbered(
|
||||
("91.02", {"indent": 0, "sums": turnover("100")}),
|
||||
("Статья 0", {"indent": 2, "sums": turnover("60")}),
|
||||
("Статья 1", {"indent": 1, "sums": turnover("40")}),
|
||||
("Статья 2", {"indent": 1, "sums": turnover("40")}),
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 2, 19: 1, 20: 1}
|
||||
|
||||
|
||||
def inn_group(*, inn_on: set[int]) -> list[BodyRow]:
|
||||
"""91.02 = 100; аналитика 60, 40, 40 на одном отступе; `inn_on` — номера статей (с 0) с ИНН."""
|
||||
sums = ("60", "40", "40")
|
||||
specs: list[tuple[str, dict[str, object]]] = [("91.02", {"indent": 0, "sums": turnover("100")})]
|
||||
specs += [
|
||||
(f"Статья {n}", {"indent": 1, "sums": turnover(value), "has_inn": n in inn_on}) for n, value in enumerate(sums)
|
||||
]
|
||||
return numbered(*specs)
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_without_inn_nests_equal_neighbours() -> None:
|
||||
assert strategy_indent(inn_group(inn_on=set())) == {17: 0, 18: 1, 19: 1, 20: 2}
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_skips_row_with_inn_being_nested() -> None:
|
||||
"""Вкладываемая строка с ИНН: она контрагент, а не строка под соседом."""
|
||||
assert strategy_indent(inn_group(inn_on={2})) == {17: 0, 18: 1, 19: 1, 20: 1}
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_skips_new_parent_with_inn() -> None:
|
||||
"""Новый родитель с ИНН: контрагент не становится родителем строки."""
|
||||
assert strategy_indent(inn_group(inn_on={1})) == {17: 0, 18: 1, 19: 1, 20: 1}
|
||||
|
||||
|
||||
def test_resolve_levels_does_not_nest_by_sums_unless_asked() -> None:
|
||||
rows = flat_group("60", "40", "20", "40")
|
||||
flat = [1, 1, 1, 1]
|
||||
nested = [1, 2, 2, 1]
|
||||
|
||||
off = resolve_levels(rows, None, never_expires())
|
||||
on = resolve_levels(rows, None, never_expires(), nest_by_sums=True)
|
||||
|
||||
assert levels_list(rows, off.by_row)[1:] == flat
|
||||
assert levels_list(rows, on.by_row)[1:] == nested
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_accumulates_depth_for_nested_child_of_nested_parent() -> None:
|
||||
"""91 = 100: X 40, Y 40, Z 60 на одном отступе (X ⊃ Y); под Y — K1 40, K2 40 глубже (K1 ⊃ K2). Поддерево Y
|
||||
сдвигается вместе с Y (+1), а K2 ещё вкладывается в K1 (+1): сдвиги складываются, а не затирают друг друга."""
|
||||
rows = numbered(
|
||||
("91", {"indent": 0, "sums": turnover("100")}),
|
||||
("X", {"indent": 1, "sums": turnover("40")}),
|
||||
("Y", {"indent": 1, "sums": turnover("40")}),
|
||||
("K1", {"indent": 2, "sums": turnover("40")}),
|
||||
("K2", {"indent": 2, "sums": turnover("40")}),
|
||||
("Z", {"indent": 1, "sums": turnover("60")}),
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 4, 22: 1}
|
||||
|
||||
|
||||
def test_indent_nesting_by_sums_keeps_depth_of_children_under_a_nested_row() -> None:
|
||||
"""Строка с детьми (глубже по отступу) вкладывается как поддерево: уровни детей растут на её глубину."""
|
||||
rows = numbered(
|
||||
("91", {"indent": 0, "sums": turnover("100")}),
|
||||
("Z", {"indent": 1, "sums": turnover("60")}),
|
||||
("Y", {"indent": 1, "sums": turnover("60")}),
|
||||
("K1", {"indent": 2, "sums": turnover("30")}),
|
||||
("K2", {"indent": 2, "sums": turnover("30")}),
|
||||
("W", {"indent": 1, "sums": turnover("40")}),
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 3, 21: 3, 22: 1}
|
||||
|
||||
|
||||
NEST_SCAN_LIMIT_PLUS = 50 # столько соседей вперёд перебирает вложенность по суммам
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("count", "nested"), [(NEST_SCAN_LIMIT_PLUS, True), (NEST_SCAN_LIMIT_PLUS + 1, False)])
|
||||
def test_indent_nesting_by_sums_scan_limit_boundary(count: int, nested: bool) -> None:
|
||||
"""Родитель строки, чьи суммы дают сумма `count` следующих соседей: 50 соседей находятся, 51 — нет."""
|
||||
kids = [str(n) for n in range(1, count + 1)]
|
||||
total = str(sum(range(1, count + 1)))
|
||||
rows = flat_group(total, *kids, parent=total)
|
||||
|
||||
levels = strategy_indent(rows)
|
||||
|
||||
assert levels is not None
|
||||
assert levels[FIRST + 1] == 1
|
||||
assert (levels[FIRST + 2] == levels[FIRST + 1] + 1) is nested
|
||||
|
||||
|
||||
def test_indent_account_code_without_separator_is_not_nested() -> None:
|
||||
"""Второй счёт «60» после «60»: у кода нет разделителя сегментов, родителя по коду нет."""
|
||||
rows = numbered(
|
||||
("60", {"indent": 0}),
|
||||
("Контрагент", {"indent": 1}),
|
||||
("60", {"indent": 0}),
|
||||
("Контрагент 2", {"indent": 1}),
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0, 20: 1}
|
||||
|
||||
|
||||
def test_indent_closing_row_needs_nonzero_turnover() -> None:
|
||||
"""Безымянная строка с нулевыми оборотами при нулевых оборотах счёта закрытием не считается."""
|
||||
rows = numbered(
|
||||
("90.09", {"indent": 0, "sums": money(None, None, "0", "0", None, None)}),
|
||||
("Статья", {"indent": 1, "sums": turnover("5")}),
|
||||
("", {"indent": 0, "sums": money(None, None, "0", "0", None, "7")}),
|
||||
)
|
||||
|
||||
assert strategy_indent(rows) == {17: 0, 18: 1, 19: 0}
|
||||
|
||||
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