Andrey Epifancev ad1a6ec10e ОСВ: правки финального ревью строк ИНН — реквизит по схеме, подсказка «над», страховка владельца, родитель после склейки (#35)
R-6 (design #22), R-7 (#23), R-8 (#24), R-9 (#25), R-10 (#26), H.1/H.2 (#27); словарь позиций соседей (T2.6/T3.3);
spec/design/README: текст предупреждения, цена ошибки design #1, ссылки на решение владельца 10 (M-3, M-4, M-5, M-8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:27:02 +04:00

625 lines
26 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_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) -> None:
# R-1 T2.1: единственный ребёнок подразделения с теми же суммами — наименование, у него своя строка ИНН
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, "ООО Альфа", A), r(3, INN_A, A), hint=BELOW, schema=schema
)
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), True)
def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None:
# R-2 T2.2 / T2.9: родитель-подразделение — не пара; ИНН без детей уходит соседу-контрагенту (блок «над»)
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), r(2, "ООО Альфа", A), schema=schema)
assert glue.inns == {4: INN_A}
assert glue.unpaired == frozenset()
assert glue.ok
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None:
# R-3 T2.3: подсказка «над» — свидетельство лишнего уровня; защита работает только без подсказки
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE, schema=SCHEMA_WITH_CONTRACTS)
assert texts(glue) == ["60", "ООО Альфа"]
assert (glue.inns, glue.unpaired) == ({3: INN_A}, frozenset())
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)
# --- Не в дереве: соседи того же родителя ---
def test_inn_above_sibling_without_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B))
assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"]
assert glue.inns == {3: INN_A, 5: INN_B}
assert glue.ok
def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None:
# Дефект main: ИНН первого контрагента доставался второму
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B))
assert glue.inns == {2: INN_A, 4: INN_B}
assert glue.ok
def test_inn_above_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B))
assert glue.inns == {3: INN_A, 5: INN_B}
def test_inn_below_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B))
assert glue.inns == {2: INN_A, 4: INN_B}
def test_sums_match_no_neighbour(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A))
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "суммы не совпали")
def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None:
rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A))
assert run(rules, *rows).unpaired == frozenset({3})
assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A}
def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B))
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "направление")
def test_direction_from_other_block(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A),
r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт
r(1, "60.02", B),
r(2, "ООО Гамма", B),
r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием
)
assert glue.inns == {3: INN_A, 7: INN_B}
def test_direction_from_sum_pair(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(1, INN_A, A),
r(1, "ООО Бета", B),
r(1, INN_B),
r(1, "Иванов Иван", B),
)
assert glue.inns == {2: INN_A, 4: INN_B}
def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, INN_A),
r(2, "ООО Альфа", A), # начинается строкой ИНН — над
r(1, "60.02", B),
r(2, "ООО Бета", B),
r(2, INN_B), # кончается строкой ИНН — под
)
assert glue.unpaired == frozenset({3, 7})
def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "строка 2", "начинается и заканчивается")
def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE)
assert glue.inns == {4: BAD}
def test_sum_pair_below_and_block_above_contradict_each_other(rules: Rules) -> None:
# T3.2: пара по суммам говорит «под», однозначный блок (начинается строкой ИНН) — «над»: направление неизвестно
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A, A), # суммы совпали только с предыдущим — «под»
r(2, "ООО Бета", B),
r(1, "60.02", B),
r(2, INN_B), # блок 60.02 начинается строкой ИНН — «над»
r(2, "ООО Гамма", B),
)
assert glue.unpaired == frozenset({7})
assert not glue.ok
assert has_warning(glue, "строка 7", "направление")
def test_hint_overrides_opposite_sum_pair_direction(rules: Rules) -> None:
# T3.2: пара по суммам даёт «под»; подсказка шапки «над» перекрывает это сведение для строки ИНН без сумм
rows = (
r(0, "60", A),
r(1, "ООО Альфа", A),
r(1, INN_A, A), # пара по суммам с предыдущим — «под»
r(1, "ООО Бета", B),
r(1, INN_B),
r(1, "ООО Гамма", B),
)
assert run(rules, *rows).inns == {2: INN_A, 4: INN_B}
assert run(rules, *rows, hint=ABOVE).inns == {2: INN_A, 6: INN_B}
def test_two_inn_rows_claim_one_name(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "претендуют")
def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE)
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "нет наименования")
# --- Раскладка противоречит подсказке шапки: склеить, но подсветить (H-2, design #10) ---
def conflict_warning(glue: InnGlue, row: int, partner: int, where: str, expected: str) -> bool:
return has_warning(
glue,
f"строка {row}: ИНН",
f"стоит {where} наименованием в строке {partner}",
f"указывает {expected} — проверьте",
)
def test_hint_above_inn_child_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
# T2.12: подсказка «над», строка ИНН без детей под родителем-контрагентом — склеена с родителем, но подсвечена
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=ABOVE)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
assert has_warning(glue, f"ИНН {INN_A} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
assert conflict_warning(glue, 3, 2, "под", "над")
assert has_warning(glue, "шапка («Контрагенты.ИНН»)")
def test_hint_below_inn_parent_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=BELOW)
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), False)
assert conflict_warning(glue, 2, 3, "над", "под")
def test_hint_above_sibling_below_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), hint=ABOVE)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
assert conflict_warning(glue, 3, 2, "под", "над")
def test_hint_below_sibling_above_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Бета", B), r(1, INN_A, A), r(1, "ООО Альфа", A), hint=BELOW)
assert (glue.inns, glue.unpaired, glue.ok) == ({4: INN_A}, frozenset(), False)
assert conflict_warning(glue, 3, 4, "над", "под")
def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None:
below = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=BELOW)
above = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE)
blank = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=BELOW)
for glue in (below, above, blank):
assert glue.ok
assert not has_warning(glue, "проверьте")
# --- Финальное ревью: R-6 … R-10, T2.6 ---
SCHEMA_DEPARTMENT = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
]
def test_hint_below_unrecognised_attribute_with_contracts_under_it_is_other(rules: Rules) -> None:
# R-6 (design #22): родитель — контрагент: «нерез.» с договором под ней — реквизит (OTHER), договор — договор
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Нерезидент", A),
r(2, "нерез.", A),
r(3, "Договор 1", A),
hint=BELOW,
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.unpaired == frozenset({3})
assert glue.inns == {}
assert not glue.ok
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (3, 3) # дерево не тронуто: классификатор пропустит OTHER
def test_hint_below_single_child_of_department_is_not_an_attribute(rules: Rules) -> None:
# R-6 (design #22): родитель по схеме — подразделение, его единственный ребёнок-лист — контрагент, не реквизит
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), hint=BELOW, schema=SCHEMA_DEPARTMENT)
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), True)
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
def test_hint_below_inn_with_single_contract_of_same_sums_is_glued_without_check(rules: Rules) -> None:
# R-6 (design #22): контрагент -> ИНН -> один договор с теми же суммами: ИНН у контрагента, договор остаётся
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор 1", A),
hint=BELOW,
schema=SCHEMA_WITH_CONTRACTS,
)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), True)
assert texts(glue) == ["60", "ООО Альфа", "Договор 1"]
def test_hint_above_inn_with_contract_under_counterparty_stays_with_parent_and_is_flagged(rules: Rules) -> None:
# R-7 (design #23): подсказка «над», контрагент -> ИНН -> договор: пара — родитель по схеме, H-2 подсвечивает
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор 1", A),
hint=ABOVE,
schema=SCHEMA_WITH_CONTRACTS,
)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
assert texts(glue) == ["60", "ООО Альфа", "Договор 1"]
assert conflict_warning(glue, 3, 2, "под", "над")
def test_levels_skipping_keep_nearest_surviving_ancestor(rules: Rules) -> None:
# R-9 (design #25): outline с пропуском уровней — новый родитель ближайший неудалённый предок (строка ИНН убрана)
glue = run(
rules,
r(0, "60", A),
r(1, "Физлицо", A),
r(3, "Договор", A),
r(1, INN_A, A),
r(3, "ООО Альфа", A),
)
assert glue.inns == {5: INN_A}
kept = {item.text: glue.levels.parent[item.row] for item in glue.rows}
assert kept == {"60": None, "Физлицо": 1, "Договор": 2, "ООО Альфа": 1}
def test_column_mismatch_plus_contradiction_gives_one_warning(rules: Rules) -> None:
# H.2: расхождение с колонкой ИНН и противоречие подсказке — предупреждение только о расхождении
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, INN_B), r(2, INN_A, A), hint=ABOVE)
assert glue.inns == {}
assert not glue.ok
assert has_warning(glue, "расходится с ИНН", INN_B)
assert not has_warning(glue, "шапка («")