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

Модуль inn_rows: строка с одним ИНН сопоставляется с наименованием в дереве (родитель или ребёнок), без пары
остаётся с причиной; подсказка шапки InnHint отменяет контрольную сумму. row_data_type стал публичным.
ASCII-проверка ИНН (ревью T1.2), комментарий про zip(strict=False) (T1.3), тест «770123456²» (T1.1).
Решения 12–13 в design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 14:41:14 +04:00
parent 1e5f28bd50
commit 816719ca92
7 changed files with 647 additions and 6 deletions

View File

@ -42,6 +42,7 @@ __all__ = [
"classify_rows",
"collect_body_rows",
"glue_inn_attributes",
"row_data_type",
]
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
@ -339,7 +340,7 @@ def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None
return values if any(value is not None for value in values.values()) else None
def _row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
def row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
found = first_match(rules.data_types.row, text)
if found is not None:
@ -401,9 +402,9 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
code = inherited.account_code_raw
row_data_type = _row_data_type(item.text, context.rules, context.currency_codes)
if row_data_type is not None:
return _Kind("OTHER", None, code, row_data_type), inherited
data_type = row_data_type(item.text, context.rules, context.currency_codes)
if data_type is not None:
return _Kind("OTHER", None, code, data_type), inherited
position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position)

View File

@ -24,6 +24,7 @@ __all__ = [
"DocHeader",
"GroupSchema",
"InnAttribute",
"InnHint",
"build_group_levels",
"build_group_schema",
"parse_doc_header",
@ -489,6 +490,17 @@ class InnAttribute:
name: str
@dataclass(frozen=True)
class InnHint:
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`).
С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`)."""
name: str
direction: Literal["above", "below"]
@dataclass(frozen=True)
class GroupSchema:
"""Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`).

344
api/src/parser/inn_rows.py Normal file
View File

@ -0,0 +1,344 @@
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`;
`05-domain-rules.md` §1.3; OQ-130, OQ-162, #35).
Один проход между каскадом иерархии и классификацией. Строка, где написан только ИНН, сопоставляется с наименованием
в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве
(сосед того же родителя) × с суммами / без сумм. Связь в дереве важнее соседства. Склеенная строка уходит из строк и
дерева (её дети — к наименованию), ИНН — наименованию; строка без пары остаётся (классификатор ставит ей OTHER), а
документ уходит на проверку. Классификатор получает чистое дерево.
Без подсказки шапки (`InnHint`) строка — строка ИНН только при верной контрольной сумме; строка из 10/12 цифр с
неверной суммой рядом с наименованием — «похожа на ИНН»: остаётся как есть, предупреждение, `needs_check`.
"""
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal
from src.contract.result import OsvGroup
from src.parser.classify import row_data_type
from src.parser.grid import Cell, Sheet
from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels, parents
from src.parser.numbers import column_inn, inn_checksum_ok, row_inn
from src.parser.rules import Rules
__all__ = ["InnGlue", "glue_inn_rows"]
Kind = Literal["total", "account", "indicator", "candidate", "name"]
_ZERO = Decimal(0)
_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено"
@dataclass(frozen=True)
class InnGlue:
"""Результат склейки: строки и дерево без склеенных строк ИНН, ИНН по строкам наименований, строки ИНН без пары
(классификатор ставит им OTHER), предупреждения и признак «всё склеено, похожих на ИНН строк и расхождений нет»."""
rows: list[BodyRow]
levels: Levels
inns: dict[int, str]
unpaired: frozenset[int]
warnings: list[str]
ok: bool
@dataclass
class _Tree:
"""Строки листа, их дерево и вид: счёт, «Итого», Вал./Кол., строка ИНН или наименование."""
rows: Sequence[BodyRow]
by_row: dict[int, BodyRow]
parent: dict[int, int | None]
children: dict[int | None, list[int]]
kind: dict[int, Kind]
inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан
lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой
def kids(self, row: int) -> list[int]:
"""Дети строки, кроме Вал./Кол. (они не уровень аналитики)."""
return [kid for kid in self.children.get(row, []) if self.kind[kid] != "indicator"]
def candidates(self, done: "_Pairs") -> list[int]:
return [item.row for item in self.rows if self.kind[item.row] == "candidate" and not done.decided(item.row)]
@dataclass
class _Pairs:
partner: dict[int, int] = field(default_factory=dict) # строка ИНН -> строка наименования
reason: dict[int, str] = field(default_factory=dict) # строка ИНН без пары -> причина
flat: list[int] = field(default_factory=list) # строки ИНН не в дереве (соседи), по порядку строк
def decided(self, row: int) -> bool:
return row in self.partner or row in self.reason
def _label_cell(sheet: Sheet, item: BodyRow) -> Cell:
if item.label_col is None:
return Cell(item.text or None, "text" if item.text else "empty")
return sheet.cell(*sheet.merged_origin(item.row, item.label_col))
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _same_sums(left: BodyRow, right: BodyRow) -> bool:
"""Шесть сумм равны; пустая ячейка = 0 (в ОСВ 1С это одно и то же)."""
return [value or _ZERO for value in left.sums] == [value or _ZERO for value in right.sums]
def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида строки приходит явно
rows: Sequence[BodyRow],
levels: Levels,
total_row: int | None,
sheet: Sheet,
hint: InnHint | None,
rules: Rules,
) -> _Tree:
currency = frozenset(rules.data_types.currency_codes)
children: dict[int | None, list[int]] = {}
for item in rows:
children.setdefault(levels.parent[item.row], []).append(item.row)
tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {})
for item in rows:
tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree)
if hint is not None and hint.direction == "below":
# «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения
for item in rows:
parent = tree.parent[item.row]
if tree.kind[item.row] == "name" and not item.text and parent is not None and tree.kind[parent] == "name":
tree.kind[item.row] = "candidate"
tree.inn[item.row] = None
return tree
def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от всех этих данных
item: BodyRow,
total_row: int | None,
sheet: Sheet,
hint: InnHint | None,
rules: Rules,
currency: frozenset[str],
tree: _Tree,
) -> Kind:
if total_row is not None and item.row >= total_row:
return "total"
if item.is_account:
return "account"
if row_data_type(item.text, rules, currency) is not None:
return "indicator"
found = row_inn(_label_cell(sheet, item), rules)
if found is None:
return "name"
if hint is None and not inn_checksum_ok(found):
tree.lookalike[item.row] = found
return "name"
tree.inn[item.row] = found
return "candidate"
def _depths(tree: _Tree) -> dict[int, int]:
"""Позиция строки в схеме группировок, как у классификатора: счёт и «Итого» — 0, Вал./Кол. — позиция родителя,
прочие — позиция родителя + 1 (строки ИНН считаются уровнем: склейки ещё не было)."""
depth: dict[int, int] = {}
for item in tree.rows:
parent = tree.parent[item.row]
base = 0 if parent is None else depth.get(parent, 0)
kind = tree.kind[item.row]
if kind in {"account", "total"}:
depth[item.row] = 0
elif kind == "indicator":
depth[item.row] = base
else:
depth[item.row] = base + 1
return depth
def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool:
"""Защита «над, в дереве»: схема задаёт группировки и ни одна строка листа не стоит глубже схемы — лишнего уровня
нет, строка ИНН с детьми — наименование по схеме (контрагент, названный ИНН, с одним договором)."""
deepest = len(schema) - 1
return deepest >= 1 and all(value <= deepest for value in depth.values())
def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
"""Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка
шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10)."""
if hint is not None:
return hint.direction == "below"
position = depth[parent]
return position < len(schema) and schema[position].type == "COUNTERPARTY"
def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
"""С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка
реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9)."""
if hint is None or hint.direction != "below":
return
for item in tree.rows:
if tree.kind[item.row] != "name":
continue
kids = tree.kids(item.row)
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):
tree.kind[kid.row] = "candidate"
tree.inn[kid.row] = None
pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)"
def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None:
"""Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми —
над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`."""
depth = _depths(tree)
no_extra_level = _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:
rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"]
if len(rivals) > 1:
pairs.reason[row] = f"у контрагента в строке {parent} несколько строк ИНН"
else:
pairs.partner[row] = parent
elif kids:
if no_extra_level:
tree.kind[row] = "name"
elif len(kids) > 1:
pairs.reason[row] = f"под ней несколько строк ({len(kids)}), а не одно наименование"
elif tree.kind[kids[0]] != "name":
pairs.reason[row] = "под ней не наименование"
else:
pairs.partner[row] = kids[0]
else:
pairs.flat.append(row)
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3
"""Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары."""
for row in pairs.flat:
pairs.reason[row] = _UNKNOWN_DIRECTION
def _check_sums(tree: _Tree, pairs: _Pairs) -> None:
for row, partner in list(pairs.partner.items()):
if _has_sums(tree.by_row[row]) and not _same_sums(tree.by_row[row], tree.by_row[partner]):
del pairs.partner[row]
pairs.reason[row] = f"суммы не равны суммам строки {partner}"
def _drop_conflicts(pairs: _Pairs) -> None:
claimed = Counter(pairs.partner.values())
for row, partner in list(pairs.partner.items()):
if claimed[partner] > 1:
del pairs.partner[row]
pairs.reason[row] = f"на строку {partner} претендуют несколько строк ИНН"
def _near_name(tree: _Tree, row: int) -> bool:
"""Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование."""
parent = tree.parent[row]
siblings = tree.children.get(parent, [])
index = siblings.index(row)
near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])]
if parent is not None:
near.append(parent)
return any(tree.kind[other] == "name" for other in near)
def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]:
"""Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются."""
if not removed:
return list(rows), levels
lifted: dict[int, int] = {}
by_row: dict[int, int] = {}
kept: list[BodyRow] = []
for item in rows:
parent = levels.parent[item.row]
lift = 0 if parent is None else lifted[parent] + (1 if parent in removed else 0)
lifted[item.row] = lift
if item.row in removed:
continue
by_row[item.row] = levels.by_row[item.row] - lift
kept.append(item)
return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy)
def _result( # noqa: PLR0913, PLR0917 - сборка результата из всех частей прохода
rows: Sequence[BodyRow],
levels: Levels,
tree: _Tree,
pairs: _Pairs,
sheet: Sheet,
inn_col: int | None,
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
label = "" if hint is None else f" («{hint.name}»)"
warnings: list[str] = []
inns: dict[int, str] = {}
ok = not pairs.reason
for item in rows:
row = item.row
inn = tree.inn.get(row)
if row in pairs.partner:
partner = pairs.partner[row]
if inn is None:
warnings.append(
f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН"
)
continue
column = None if inn_col is None else column_inn(sheet.cell(partner, inn_col), rules)
if column is not None and column != inn:
ok = False
warnings.append(
f"строка {row}: ИНН {inn}{label} расходится с ИНН {column} колонки ИНН контрагента в строке "
f"{partner} — оставлен ИНН колонки"
)
continue
inns[partner] = inn
warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}")
elif row in pairs.reason:
shown = "" if inn is None else f" {inn}"
warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}")
elif row in tree.lookalike and tree.kind[row] == "name" and _near_name(tree, row):
ok = False
warnings.append(
f"строка {row}: похожа на ИНН {tree.lookalike[row]}, но контрольная сумма не сходится — строка "
"оставлена как есть"
)
kept, kept_levels = _without(rows, levels, set(pairs.partner))
return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok)
def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно
rows: Sequence[BodyRow],
levels: Levels,
total_row: int | None,
sheet: Sheet,
*,
inn_col: int | None,
schema: Sequence[OsvGroup],
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
"""Строки ИНН рядом с наименованием → ИНН наименованию (spec «Строки ИНН рядом с контрагентом»).
`levels` — дерево каскада иерархии со строками ИНН; `schema` — группировки шапки (`group_schema`) для защиты «над,
в дереве»; `hint` — подсказка `<группировка>.ИНН` (без неё ИНН строки должен пройти контрольную сумму).
"""
tree = _build(rows, levels, total_row, sheet, hint, rules)
pairs = _Pairs()
_unrecognised_attributes(tree, hint, pairs)
_pair_in_tree(tree, schema, hint, pairs)
_pair_flat(tree, hint, pairs)
_check_sums(tree, pairs)
_drop_conflicts(pairs)
return _result(rows, levels, tree, pairs, sheet, inn_col, hint, rules)

View File

@ -199,6 +199,8 @@ def inn_only(text: str, rules: Rules) -> str | None:
def _check_digit(digits: str, weights: tuple[int, ...]) -> int:
# strict=False намеренно: zip обрезает цифры по длине весов (ИНН длиннее весов на 1–2 символа);
# последние разряды — контрольные, в сумму не входят
total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False))
return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS
@ -208,7 +210,7 @@ def inn_checksum_ok(inn: str) -> bool:
Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки.
"""
if not inn.isdecimal(): # isdecimal: int() по каждому символу гарантированно сработает
if not (inn.isascii() and inn.isdigit()): # ASCII-цифры: int() по каждому символу гарантированно сработает
return False
if len(inn) == _INN_LEGAL_LENGTH:
return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1])

View File

@ -0,0 +1,280 @@
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35).
Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки).
Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`.
"""
from decimal import Decimal
import pytest
from gridbuilder import sheet_from_rows
from src.contract.result import OsvGroup
from src.parser.classify import ACCOUNT_RE
from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels, parents
from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.rules import Rules, load_rules
Sums = tuple[object, object, object, object, object, object]
NO: Sums = (None, None, None, None, None, None)
A: Sums = (None, None, 100, 40, 60, None)
B: Sums = (10, None, None, 30, None, 20)
INN_A = "7701234560"
INN_B = "7707654321"
INN_12 = "500100732259"
BAD = "7701234567" # контрольная сумма не сходится
LABEL_COL = 1
INN_COL = 2
SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")]
SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")]
BELOW = InnHint("Контрагенты.ИНН", "below")
ABOVE = InnHint("Контрагенты.ИНН", "above")
Row = tuple[int, object, Sums, object]
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row:
return level, label, sums, inn
def run(rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None) -> InnGlue:
sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows])
body: list[BodyRow] = []
for number, (level, _, sums, _) in enumerate(rows, start=1):
text = sheet.text_at(number, LABEL_COL)
account = ACCOUNT_RE.match(text)
body.append(
BodyRow(
row=number,
text=text,
is_account=account is not None,
account_rank=None if account is None else account.group(1).count(".") + 1,
bold=False,
indent=0,
outline=level,
sums=tuple(None if value is None else Decimal(str(value)) for value in sums),
label_col=LABEL_COL,
)
)
by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)}
levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline")
return glue_inn_rows(body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules)
def texts(glue: InnGlue) -> list[str]:
return [item.text for item in glue.rows]
def has_warning(glue: InnGlue, *parts: str) -> bool:
return any(all(part in warning for part in parts) for warning in glue.warnings)
# --- ИНН под наименованием, в дереве ---
def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {2: INN_A}
assert (glue.unpaired, glue.ok) == (frozenset(), True)
assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2")
def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12))
assert glue.inns == {2: INN_12}
assert glue.ok
def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None:
# Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {2: INN_A}
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
def test_inn_between_department_and_name_is_above(rules: Rules) -> None:
# Родитель по схеме — подразделение: строка ИНН стоит над наименованием
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, A), r(3, "ООО Альфа", A), schema=schema)
assert glue.inns == {4: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B))
assert glue.unpaired == frozenset({3, 4})
assert not glue.ok
assert has_warning(glue, "строка 3", "несколько строк ИНН")
def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B))
assert glue.unpaired == frozenset({3})
assert glue.inns == {}
assert has_warning(glue, "строка 3", "суммы не равны")
# --- ИНН над наименованием, в дереве ---
def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1)
def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A))
assert glue.inns == {3: INN_A}
assert glue.ok
def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B))
assert glue.unpaired == frozenset({2})
assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"]
assert has_warning(glue, "строка 2", "несколько")
def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None:
# Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS)
assert texts(glue) == ["60", INN_A, "Договор № 1"]
assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True)
def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, INN_A, A),
r(2, "ООО Альфа", A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3)
# --- Распознавание и контрольная сумма ---
@pytest.mark.parametrize(
("label", "inn"),
[
(Decimal(7701234560), INN_A),
(123456788, "0123456788"), # 9 цифр числом (#34)
("ИНН 7701234560", INN_A),
("ИНН: 7701234560 / 770101001", INN_A),
(INN_12, INN_12),
],
)
def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A))
assert glue.inns == {2: inn}
def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW)
assert glue.inns == {2: "0105012345"}
def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A))
assert texts(glue) == ["60", "ООО Альфа", BAD]
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False)
assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится")
def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, BAD, A))
assert (glue.warnings, glue.ok) == ([], True)
def test_hint_skips_checksum(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW)
assert glue.inns == {2: BAD}
assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None:
# R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой
glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW)
assert texts(glue) == ["60", "Иванов Иван"]
assert (glue.inns, glue.ok) == ({}, True)
assert has_warning(
glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН"
)
def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW)
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "не распознан")
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))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {}
assert not glue.ok
assert has_warning(glue, "строка 3", "расходится", INN_B)
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)

View File

@ -257,6 +257,7 @@ def test_detector_rule_stays_strict_for_prefixed_text() -> None:
("770123456", False),
("77012345601", False),
("77012345a0", False),
("770123456²", False),
],
)
def test_inn_checksum_ok(inn: str, ok: bool) -> None:

View File

@ -136,4 +136,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
| 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` |
| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится |
| 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`: проверка «только цифры» через `isdecimal()`, не `isdigit()` | `isdigit()` пропускает «²» и подобные, на которых `int()` падает с ValueError | Нет: вход — строки ИНН из `[0-9]`-выражений, на практике не встречается |
| 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`) | Переименование локальной переменной, поведение не меняется | Нет |