"""Строки ИНН рядом с контрагентом (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: родитель-подразделение — не пара; строка ИНН идёт в соседи (направление без подсказки не определено) 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 == {} assert glue.unpaired == frozenset({3}) assert has_warning(glue, "строка 3", "направление") 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)