ОСВ: строки ИНН — соседи по суммам и направление листа (#35)

Строки ИНН не в дереве: с суммами — сосед с теми же суммами (совпали оба — по направлению);
без сумм — сосед по направлению листа: подсказка шапки, иначе единогласные сведения пар по
суммам и однозначных блоков детей. Противоречие или отсутствие сведений — строка без пары.

Хвосты ревью Task 2: тест подразделения переписан на итоговый результат (T2.9); в спеке
уточнены реквизит «нерез.» (только лист, design #14) и защита «над, в дереве» с подсказкой
шапки (design #16) (T2.10); опечатка в докстринге _parent_can_own (T2.11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 14:53:58 +04:00
parent 8bfaa4ec19
commit 71d56bd5ed
4 changed files with 221 additions and 11 deletions

View File

@ -28,6 +28,7 @@ from src.parser.rules import Rules
__all__ = ["InnGlue", "glue_inn_rows"]
Kind = Literal["total", "account", "indicator", "candidate", "name"]
Direction = Literal["above", "below"] # направление листа: строка ИНН над наименованием или под ним
_ZERO = Decimal(0)
_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено"
@ -175,7 +176,7 @@ def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGro
def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
"""Строка ИНН без детей под наименованием-родителем: родитель — пара, если подсказка «под», или родитель по схеме —
контрагент, или схема не позволяет сказать (design, design #15). Иначе строка — сосед наименования
контрагент, или схема не позволяет сказать (design #15). Иначе строка — сосед наименования
контрагента под подразделением (R-2, design #15)."""
if hint is not None and hint.direction == "below":
return True
@ -235,10 +236,92 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None,
pairs.flat.append(row)
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3
"""Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары."""
def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]:
"""Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование)."""
siblings = tree.children.get(tree.parent[row], [])
index = siblings.index(row)
before = siblings[index - 1] if index > 0 else None
after = siblings[index + 1] if index + 1 < len(siblings) else None
return (
before if before is not None and tree.kind[before] == "name" else None,
after if after is not None and tree.kind[after] == "name" else None,
)
def _block_direction(tree: _Tree, pending: list[int], pairs: _Pairs) -> set[Direction]:
"""Сведения о направлении из блоков: дети одного родителя — несопоставленные строки ИНН и наименования.
Начинается строкой ИНН — над (иначе первой строке ИНН не досталось бы владельца), кончается — под; начинается и
кончается — блок сломан, его строки ИНН без пары; ни то ни другое — сведений нет.
"""
waiting = set(pending)
claimed = set(pairs.partner.values())
found: set[Direction] = set()
for parent in dict.fromkeys(tree.parent[row] for row in pending):
block = [
row
for row in tree.children.get(parent, [])
if row in waiting or (tree.kind[row] == "name" and row not in claimed)
]
starts, ends = block[0] in waiting, block[-1] in waiting
if starts and ends:
for row in block:
if row in waiting:
pairs.reason[row] = "блок строк начинается и заканчивается строкой ИНН — направление не определить"
elif starts:
found.add("above")
elif ends:
found.add("below")
return found
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
"""Строки ИНН не в дереве: с суммами — сосед с теми же суммами; без сумм (или совпали оба соседа) — сосед по
направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и блоков."""
pending: list[int] = []
evidence: set[Direction] = set()
for row in pairs.flat:
item = tree.by_row[row]
if not _has_sums(item):
pending.append(row)
continue
before, after = _neighbours(tree, row)
sides: list[tuple[int | None, Direction]] = [(before, "below"), (after, "above")]
matches = [(other, side) for other, side in sides if other is not None and _same_sums(tree.by_row[other], item)]
if len(matches) == 1:
other, side = matches[0]
pairs.partner[row] = other
evidence.add(side)
elif matches:
pending.append(row)
else:
pairs.reason[row] = "суммы не совпали ни с одним соседним наименованием"
if pending:
_pair_by_direction(tree, pending, hint, evidence, pairs)
def _pair_by_direction(
tree: _Tree, pending: list[int], hint: InnHint | None, evidence: set[Direction], pairs: _Pairs
) -> None:
"""Строки ИНН без сумм (и с неоднозначными соседями по суммам) — сосед по направлению листа."""
if hint is not None:
direction: Direction | None = hint.direction
else:
evidence |= _block_direction(tree, pending, pairs)
direction = next(iter(evidence)) if len(evidence) == 1 else None
for row in pending:
if row in pairs.reason:
continue
if direction is None:
pairs.reason[row] = _UNKNOWN_DIRECTION
continue
before, after = _neighbours(tree, row)
other = after if direction == "above" else before
if other is None:
where = "под" if direction == "above" else "над"
pairs.reason[row] = f"{where} строкой ИНН нет наименования"
else:
pairs.partner[row] = other
def _check_sums(tree: _Tree, pairs: _Pairs) -> None:

View File

@ -281,7 +281,7 @@ def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules)
def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None:
# R-2 T2.2: родитель-подразделение — не пара; строка ИНН идёт в соседи (направление без подсказки не определено)
# R-2 T2.2 / T2.9: родитель-подразделение — не пара; ИНН без детей уходит соседу-контрагенту (блок «над»)
schema = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
@ -289,9 +289,9 @@ def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_
]
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A), r(2, "ООО Альфа", A), schema=schema)
assert glue.inns == {}
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "направление")
assert glue.inns == {4: INN_A}
assert glue.unpaired == frozenset()
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None:
@ -315,3 +315,128 @@ def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A))
assert (glue.inns, glue.ok) == ({2: INN_A}, True)
# --- Не в дереве: соседи того же родителя ---
def test_inn_above_sibling_without_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B))
assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"]
assert glue.inns == {3: INN_A, 5: INN_B}
assert glue.ok
def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None:
# Дефект main: ИНН первого контрагента доставался второму
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B))
assert glue.inns == {2: INN_A, 4: INN_B}
assert glue.ok
def test_inn_above_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B))
assert glue.inns == {3: INN_A, 5: INN_B}
def test_inn_below_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B))
assert glue.inns == {2: INN_A, 4: INN_B}
def test_sums_match_no_neighbour(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A))
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "суммы не совпали")
def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None:
rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A))
assert run(rules, *rows).unpaired == frozenset({3})
assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A}
def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B))
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "направление")
def test_direction_from_other_block(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A),
r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт
r(1, "60.02", B),
r(2, "ООО Гамма", B),
r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием
)
assert glue.inns == {3: INN_A, 7: INN_B}
def test_direction_from_sum_pair(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(1, INN_A, A),
r(1, "ООО Бета", B),
r(1, INN_B),
r(1, "Иванов Иван", B),
)
assert glue.inns == {2: INN_A, 4: INN_B}
def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, INN_A),
r(2, "ООО Альфа", A), # начинается строкой ИНН — над
r(1, "60.02", B),
r(2, "ООО Бета", B),
r(2, INN_B), # кончается строкой ИНН — под
)
assert glue.unpaired == frozenset({3, 7})
def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "строка 2", "начинается и заканчивается")
def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE)
assert glue.inns == {4: BAD}
def test_two_inn_rows_claim_one_name(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "претендуют")
def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE)
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "нет наименования")

View File

@ -141,3 +141,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` |
| 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` |
| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |
| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` |

View File

@ -105,12 +105,13 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
`needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»).
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); таким ребёнком MUST быть только строка без детей
(лист), у строки со своими детьми — подлинное наименование (design #14).
**Пара строки ИНН** (связь в дереве важнее соседства):
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
`group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не
склеивается и без предупреждения классифицируется по схеме;
`group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет
подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16);
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт);