ОСВ: модуль строк ИНН — склейка в дереве и контрольная сумма (#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:
parent
1e5f28bd50
commit
816719ca92
@ -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)
|
||||
|
||||
@ -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
344
api/src/parser/inn_rows.py
Normal 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)
|
||||
@ -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])
|
||||
|
||||
280
api/tests/unit/parser/test_inn_rows.py
Normal file
280
api/tests/unit/parser/test_inn_rows.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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`) | Переименование локальной переменной, поведение не меняется | Нет |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user