Andrey Epifancev 816719ca92 ОСВ: модуль строк ИНН — склейка в дереве и контрольная сумма (#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>
2026-10-13 16:27:02 +04:00

281 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Строки ИНН рядом с контрагентом (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)