From 71d56bd5ed715cd89412bf4c68234bf12bf2f190 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:53:58 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=E2=80=94=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D0=B5=D0=B4=D0=B8=20=D0=BF=D0=BE=20=D1=81=D1=83=D0=BC?= =?UTF-8?q?=D0=BC=D0=B0=D0=BC=20=D0=B8=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строки ИНН не в дереве: с суммами — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и однозначных блоков детей. Противоречие или отсутствие сведений — строка без пары. Хвосты ревью Task 2: тест подразделения переписан на итоговый результат (T2.9); в спеке уточнены реквизит «нерез.» (только лист, design #14) и защита «над, в дереве» с подсказкой шапки (design #16) (T2.10); опечатка в докстринге _parent_can_own (T2.11). Co-Authored-By: Claude Opus 5.5 --- api/src/parser/inn_rows.py | 91 +++++++++++- api/tests/unit/parser/test_inn_rows.py | 133 +++++++++++++++++- .../changes/osv-inn-rows-layouts/design.md | 1 + .../specs/osv-parsing/spec.md | 7 +- 4 files changed, 221 insertions(+), 11 deletions(-) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 269eb0a..6bc27ba 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -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: - pairs.reason[row] = _UNKNOWN_DIRECTION + 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: diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index 1315571..1a03d15 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -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", "нет наименования") diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index bd41b86..5026556 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -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` | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 1fe5742..c7adcae 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -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 (или позиция вне схемы); иначе у строки ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт);