ОСВ: строки ИНН рядом с контрагентом — восемь раскладок (#35, #34) #39

Merged
aep merged 12 commits from feat/35-inn-rows-around-counterparty into main 2026-10-13 16:24:46 +03:00
4 changed files with 61 additions and 7 deletions
Showing only changes of commit 8bfaa4ec19 - Show all commits

View File

@ -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:

View File

@ -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))

View File

@ -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С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |

View File

@ -111,9 +111,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
`group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не
склеивается и без предупреждения классифицируется по схеме;
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Если у
такой строки ИНН есть дети, пара — родитель только при подсказке «под» или (без подсказки) если родитель по схеме —
COUNTERPARTY; иначе действует правило «есть дети»;
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт);
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).