Модуль 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>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""Строки ИНН рядом с контрагентом (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)
|