diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 4ce1629..048b7c0 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index c662c16..52ca6c2 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -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`). diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py new file mode 100644 index 0000000..09ea3f3 --- /dev/null +++ b/api/src/parser/inn_rows.py @@ -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) diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 22f6c7e..1162b06 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -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]) diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py new file mode 100644 index 0000000..b63e71f --- /dev/null +++ b/api/tests/unit/parser/test_inn_rows.py @@ -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) diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 43179db..13ac112 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -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: diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index f7af876..6db60ec 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -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`) | Переименование локальной переменной, поведение не меняется | Нет |