From 8bfaa4ec193e311e8c8e50dec112ed530b192e2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:48:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20fix-=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=201=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=98?= =?UTF-8?q?=D0=9D=D0=9D=20=E2=80=94=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7?= =?UTF-8?q?=D0=B8=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82,=20=D0=BF=D0=B0=D1=80=D0=B0=20=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B5,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B8=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/parser/inn_rows.py | 22 +++++++++-- api/tests/unit/parser/test_inn_rows.py | 37 +++++++++++++++++++ .../changes/osv-inn-rows-layouts/design.md | 3 ++ .../specs/osv-parsing/spec.md | 6 +-- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 09ea3f3..269eb0a 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -173,6 +173,16 @@ def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGro return position < len(schema) and schema[position].type == "COUNTERPARTY" +def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: + """Строка ИНН без детей под наименованием-родителем: родитель — пара, если подсказка «под», или родитель по схеме — + контрагент, или схема не позволяет сказать (design, design #15). Иначе строка — сосед наименования + контрагента под подразделением (R-2, design #15).""" + if hint is not None and hint.direction == "below": + return True + position = depth[parent] + return position >= len(schema) or schema[position].type == "COUNTERPARTY" + + def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: """С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9).""" @@ -185,7 +195,8 @@ def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) - if len(kids) != 1: continue kid = tree.by_row[kids[0]] - if tree.kind[kid.row] == "name" and kid.text and _same_sums(kid, item): + # ребёнок со своими детьми — подлинное наименование (у него своя строка ИНН), не реквизит (R-1, design #14) + if tree.kind[kid.row] == "name" and not tree.kids(kid.row) and kid.text and _same_sums(kid, item): tree.kind[kid.row] = "candidate" tree.inn[kid.row] = None pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)" @@ -195,13 +206,16 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, """Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми — над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`.""" depth = _depths(tree) - no_extra_level = _no_extra_level(depth, schema) + # подсказка в шапке — прямое свидетельство лишнего уровня: защита только без неё (R-3, design #16) + no_extra_level = hint is None and _no_extra_level(depth, schema) for row in tree.candidates(pairs): kids = tree.kids(row) parent = tree.parent[row] below = parent is not None and tree.kind[parent] == "name" - if below and kids and parent is not None: - below = _owned_by_parent(parent, depth, schema, hint) + if below and parent is not None: + below = ( + _owned_by_parent(parent, depth, schema, hint) if kids else _parent_can_own(parent, depth, schema, hint) + ) if below and parent is not None: rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"] if len(rivals) > 1: diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index b63e71f..1315571 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -265,6 +265,43 @@ def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None: assert has_warning(glue, "строка 3", "не распознан") +def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) -> None: + # R-1 T2.1: единственный ребёнок подразделения с теми же суммами — наименование, у него своя строка ИНН + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + glue = run( + rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), r(3, INN_A, A), hint=BELOW, schema=schema + ) + + assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"] + assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), True) + + +def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None: + # R-2 T2.2: родитель-подразделение — не пара; строка ИНН идёт в соседи (направление без подсказки не определено) + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + 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", "направление") + + +def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None: + # R-3 T2.3: подсказка «над» — свидетельство лишнего уровня; защита работает только без подсказки + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE, schema=SCHEMA_WITH_CONTRACTS) + + assert texts(glue) == ["60", "ООО Альфа"] + assert (glue.inns, glue.unpaired) == ({3: INN_A}, frozenset()) + + def test_column_inn_wins_over_inn_row(rules: Rules) -> None: glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A)) diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 6db60ec..bd41b86 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -138,3 +138,6 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | | 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН | | 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет | +| 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С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | 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 854ab9d..1fe5742 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 @@ -111,9 +111,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST - у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в `group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не склеивается и без предупреждения классифицируется по схеме; -- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Если у - такой строки ИНН есть дети, пара — родитель только при подсказке «под» или (без подсказки) если родитель по схеме — - COUNTERPARTY; иначе действует правило «есть дети»; +- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара — + родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки + ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт); - иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа (над → следующий, под → предыдущий).