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