"""Детекторы по содержимому (design D-2, spec «Листы ОСВ находятся по содержимому», «Заголовок таблицы и колонки сумм определяются по тексту»): вид листа, блок заголовка, шесть колонок сумм, колонки текста и ИНН, extra, повтор заголовка в данных. Сетки собираются `gridbuilder`; позиции в них произвольные — детектор их не знает заранее. """ from decimal import Decimal import pytest from gridbuilder import L, M, at, sheet_from_rows from src.parser.deadline import Deadline from src.parser.detect import ( DEADLINE_CHECK_EVERY_ROWS, ColumnMap, SheetKind, TableHeader, classify_sheet, find_table_header, first_data_row, inn_from_cell, is_header_repeat, ) from src.parser.errors import InvalidFormatError, ParseTimeoutError from src.parser.grid import Cell, Sheet from src.parser.numbers import inn_digits from src.parser.rules import Rules, load_rules TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." OPENING = "Сальдо на начало периода" TURNOVER = "Обороты за период" CLOSING = "Сальдо на конец периода" @pytest.fixture(scope="module") def rules() -> Rules: return load_rules() def _doc_head() -> list[list[object]]: """Шапка варианта 1 (строки 1–5); заголовок таблицы — с строки 6.""" return [["АО «Ромашка»"], [TITLE], [], ["Выводимые данные: БУ (данные бухгалтерского учета)"], []] def _rb_like() -> Sheet: """Вариант 1, заголовок в две строки (как АО РБ): суммы E/H, J/L, O/Q, подзаголовки merged на две строки.""" return sheet_from_rows( [ *_doc_head(), [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)], # 6 [M("Контрагенты", 4, 2), M("Дебет", 3, 2), M("Кредит", 2, 2), M("Дебет", 2, 2)] + [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7 [], # 8 — под merged-подзаголовками [L(0), *at({1: "60", 8: 300, 10: 1000, 12: 900, 17: 200})], # 9 [L(1), *at({1: "60.01", 8: 300, 10: 1000, 12: 900, 17: 200})], [L(2), *at({1: "ООО Поставщик", 8: 300, 10: 1000, 12: 900, 17: 200})], at({1: "Итого", 8: 300, 10: 1000, 12: 900, 17: 200}), ] ) def _schastl_like() -> Sheet: """Вариант 1, три строки (как Счастливочная): Счет / Подразделение / Контрагенты + ИНН в E, суммы G/J, K/N, Q/S.""" return sheet_from_rows( [ *_doc_head(), [M("Счет", 6), M(OPENING, 4), M(TURNOVER, 6), M(CLOSING, 3)], # 6 [M("Подразделение", 6), M("Дебет", 3, 2), M("Кредит", 1, 2), M("Дебет", 3, 2)] + [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7 [M("Контрагенты", 4), M("ИНН", 2)], # 8 [L(0), *at({1: "62", 7: 500, 11: 100, 14: 50, 17: 550})], # 9 [L(1), *at({1: "62.01", 7: 500, 11: 100, 14: 50, 17: 550})], [L(2), *at({1: "БП Аренда", 7: 500, 11: 100, 14: 50, 17: 550})], [L(3), *at({1: "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО", 5: "3523023093", 7: 500, 11: 100, 14: 50, 17: 550})], at({1: "Итого", 7: 500, 11: 100, 14: 50, 17: 550, 20: 777, 21: Decimal("0.5")}), # мусор правее S ] ) # --- classify_sheet --- def test_classify_osv_sheet_by_title_anywhere_in_first_rows(rules: Rules) -> None: sheet = sheet_from_rows([[], [None, None, "ОБОРОТНО-САЛЬДОВАЯ\nведомость по счёту 60"]]) assert classify_sheet(sheet, rules) is SheetKind.OSV @pytest.mark.parametrize("marker", ["Бухгалтерский баланс", "Отчет о финансовых результатах", "Форма по ОКУД 0710001"]) def test_classify_statement_sheet(rules: Rules, marker: str) -> None: sheet = sheet_from_rows([["ООО Ромашка"], [marker]]) assert classify_sheet(sheet, rules) is SheetKind.STATEMENT def test_classify_other_sheet(rules: Rules) -> None: sheet = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]]) assert classify_sheet(sheet, rules) is SheetKind.OTHER def test_osv_marker_wins_over_statement_marker(rules: Rules) -> None: sheet = sheet_from_rows([["Бухгалтерский баланс"], [TITLE]]) assert classify_sheet(sheet, rules) is SheetKind.OSV def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None: sheet = sheet_from_rows([*([["строка"]] * 40), [TITLE]]) assert classify_sheet(sheet, rules) is SheetKind.OTHER # --- find_table_header: варианты заголовка --- def test_two_row_header_rb_like(rules: Rules) -> None: sheet = _rb_like() header = find_table_header(sheet, rules) assert header.columns == ColumnMap( opening_debit=5, opening_credit=8, turnover_debit=10, turnover_credit=12, closing_debit=15, closing_credit=17, collapsed=frozenset(), ) assert header.columns.as_diagnostics() == { "opening_debit": "E", "opening_credit": "H", "turnover_debit": "J", "turnover_credit": "L", "closing_debit": "O", "closing_credit": "Q", } assert (header.first_row, header.last_row) == (6, 8) assert header.text_col == 1 assert header.inn_col is None assert [header.right_edge] == [17] assert header.extra_cols == {} assert header.group_label_rows == [6, 7] assert [first_data_row(sheet, header)] == [9] def test_three_row_header_with_inn_schastl_like(rules: Rules) -> None: sheet = _schastl_like() header = find_table_header(sheet, rules) assert header.columns.as_diagnostics() == { "opening_debit": "G", "opening_credit": "J", "turnover_debit": "K", "turnover_credit": "N", "closing_debit": "Q", "closing_credit": "S", } assert [header.inn_col] == [5] assert (header.first_row, header.last_row) == (6, 8) assert header.group_label_rows == [6, 7, 8] assert [first_data_row(sheet, header)] == [9] def test_garbage_right_of_table_is_neither_sum_nor_extra(rules: Rules) -> None: header = find_table_header(_schastl_like(), rules) assert [header.right_edge] == [19] assert header.extra_cols == {} sums = { header.columns.opening_debit, header.columns.opening_credit, header.columns.turnover_debit, header.columns.turnover_credit, header.columns.closing_debit, header.columns.closing_credit, } assert not sums & {20, 21} def test_four_row_header(rules: Rules) -> None: sheet = sheet_from_rows( [ *_doc_head(), ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 6 ["Подразделение", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ["Контрагенты", "ИНН"], ["Договоры"], # 9 [L(0), *at({1: "62", 3: 10, 8: 10})], [L(3), *at({1: "Договор 1", 3: 10, 8: 10})], ] ) header = find_table_header(sheet, rules) assert (header.first_row, header.last_row) == (6, 9) assert header.group_label_rows == [6, 7, 8, 9] assert [header.inn_col] == [2] assert [header.columns.opening_debit] == [3] assert [header.columns.closing_credit] == [8] assert [first_data_row(sheet, header)] == [10] def test_label_row_of_other_grouping_belongs_to_the_block(rules: Rules) -> None: # «Основные средства» — группировка OTHER из словаря osv_groups: строка блока, а не первая строка данных sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Подразделение", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ["Основные средства"], [L(0), *at({1: "01", 2: 10, 6: 10})], ] ) header = find_table_header(sheet, rules) assert header.group_label_rows == [2, 3, 4] assert [first_data_row(sheet, header)] == [5] def test_counterparty_named_like_a_grouping_right_after_header_is_data(rules: Rules) -> None: # «Контрагенты-Сервис ООО» матчит метку «Контрагент…», но у неё суммы — это строка данных, а не часть блока sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(1), *at({1: "Контрагенты-Сервис ООО", 2: 10, 6: 10})], ] ) header = find_table_header(sheet, rules) assert [header.last_row] == [3] assert [first_data_row(sheet, header)] == [4] def test_variant_2_subkonto_header(rules: Rules) -> None: sheet = sheet_from_rows( [ ["Оборотно-сальдовая ведомость по счету 60"], ["Период: 1 квартал 2026 г."], ["Детализация по субсчетам, субконто: Контрагенты, Договоры"], ["Выводимые данные: сумма"], ["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M(CLOSING, 2)], # 5 [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert (header.first_row, header.last_row) == (5, 6) assert header.group_label_rows == [5] assert [header.columns.turnover_debit] == [4] assert header.text_col == 1 def test_one_row_header_with_sides_in_group_cells(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], [ "Счет", "Нач. сальдо Дт", "Нач. сальдо Кт", "Оборот Дт", "Оборот Кт", "Кон. сальдо Д-т", "Кон. сальдо К-т", ], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset()) assert (header.first_row, header.last_row) == (2, 2) assert [first_data_row(sheet, header)] == [3] def test_unmerged_group_titles_take_subheaders_up_to_the_next_group(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", OPENING, None, TURNOVER, None, CLOSING], [None, "Дт", "Кт", "Дт", "Кт", "Дт", "Кт"], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset()) assert [header.right_edge] == [7] def test_table_text_column_is_not_necessarily_the_first(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], [None, "Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], [None, "Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({2: "60", 3: 10, 8: 10})], [L(1), *at({2: "ООО Поставщик", 3: 10, 8: 10})], ] ) header = find_table_header(sheet, rules) assert [header.text_col] == [2] assert [header.columns.opening_debit] == [3] assert header.group_label_rows == [2, 3] # --- Без «начала», свёрнутое сальдо --- def test_header_without_opening_group(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 5: 10})], ] ) header = find_table_header(sheet, rules) assert header.columns == ColumnMap(None, None, 2, 3, 4, 5, frozenset()) assert header.columns.as_diagnostics() == { "turnover_debit": "B", "turnover_credit": "C", "closing_debit": "D", "closing_credit": "E", } def test_collapsed_closing_balance_is_one_column_in_debit_slot(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 1, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", None], [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})], [L(1), *at({1: "ООО Должник", 2: 10, 4: 5, 6: -15})], ] ) header = find_table_header(sheet, rules) assert [header.columns.closing_debit] == [6] assert header.columns.closing_credit is None assert header.columns.collapsed == frozenset({"closing"}) assert [header.right_edge] == [6] assert header.columns.as_diagnostics()["closing_debit"] == "F" def test_collapsed_group_with_empty_subheaders_under_wide_title(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})], ] ) header = find_table_header(sheet, rules) assert header.columns.collapsed == frozenset({"opening"}) assert (header.columns.opening_debit, header.columns.opening_credit) == (2, None) def test_collapsed_turnover_is_invalid_format(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 1, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", None, "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 6: 10})], ] ) with pytest.raises(InvalidFormatError, match="обороты"): find_table_header(sheet, rules) def test_group_with_debit_but_no_credit_is_invalid_format(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Итог"], [L(0), *at({1: "60", 2: 10, 6: 10})], ] ) with pytest.raises(InvalidFormatError, match=r"сальдо на конец.*Кредит"): find_table_header(sheet, rules) # --- Ошибки: нет заголовка --- def test_no_table_header_is_invalid_format_naming_the_groups(rules: Rules) -> None: sheet = sheet_from_rows([[TITLE], ["Наименование", "Сумма"], ["Аренда", 100]]) with pytest.raises(InvalidFormatError, match="сальдо на начало") as caught: find_table_header(sheet, rules) assert "обороты" in caught.value.message assert "сальдо на конец" in caught.value.message def test_single_group_is_not_a_header_and_missing_groups_are_named(rules: Rules) -> None: sheet = sheet_from_rows([[TITLE], ["Счет", M(OPENING, 2)], [None, "Дебет", "Кредит"], ["60", 10]]) with pytest.raises(InvalidFormatError) as caught: find_table_header(sheet, rules) assert "обороты" in caught.value.message assert "сальдо на конец" in caught.value.message assert "сальдо на начало" not in caught.value.message def test_header_without_text_column_left_of_sums_is_invalid_format(rules: Rules) -> None: sheet = sheet_from_rows([[TITLE], [M(TURNOVER, 2), M(CLOSING, 2)], ["Дебет", "Кредит", "Дебет", "Кредит"], [1, 2]]) with pytest.raises(InvalidFormatError, match="текст"): find_table_header(sheet, rules) # --- ИНН и extra --- def test_kpp_between_inn_and_sums_goes_to_extra(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 2 ["Контрагенты", "ИНН", "КПП", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 4: 10, 9: 10})], [L(1), *at({1: "ООО Поставщик", 2: "7701234567", 3: "770101001", 4: 10, 9: 10, 11: 99})], ] ) header = find_table_header(sheet, rules) assert [header.inn_col] == [2] assert header.extra_cols == {3: "КПП"} assert [header.columns.opening_debit] == [4] assert [header.right_edge] == [9] def test_unnamed_column_with_data_is_extra_under_its_letter(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: "прим.", 3: 10, 8: 10})], ] ) header = find_table_header(sheet, rules) assert header.extra_cols == {2: "B"} def test_inn_column_found_by_digits_without_header(rules: Rules) -> None: rows: list[list[object]] = [ [TITLE], ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 4: 10, 9: 10})], ] inns: list[object] = ["7701234567", Decimal("3523023093"), "770123456789", "7701234567", "не указан"] for index, inn in enumerate(inns): rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: f"код {index}", 4: 1, 9: 1})]) header = find_table_header(sheet_from_rows(rows), rules) assert [header.inn_col] == [2] # 4 из 5 непустых — 10/12 цифр (80 %) assert header.extra_cols == {3: "C"} def test_mostly_non_inn_digits_are_not_an_inn_column(rules: Rules) -> None: rows: list[list[object]] = [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Код", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] for index, code in enumerate(["7701234567", "12", "345", "7701234567", "000123"]): rows.append([L(1), *at({1: f"Контрагент {index}", 2: code, 3: 1, 8: 1})]) header = find_table_header(sheet_from_rows(rows), rules) assert header.inn_col is None assert header.extra_cols == {2: "Код"} # --- Пустые строки и повтор заголовка (Review Focus 4, 5) --- def test_whitespace_rows_after_header_are_empty(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [" ", "\u00a0", None, " "], [], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert [header.last_row] == [3] assert [first_data_row(sheet, header)] == [6] def test_first_data_row_of_table_without_data_is_past_the_sheet(rules: Rules) -> None: sheet = sheet_from_rows( [[TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", *(["Дебет", "Кредит"] * 3)]] ) header = find_table_header(sheet, rules) assert first_data_row(sheet, header) == sheet.n_rows + 1 def _with_repeated_header() -> Sheet: group_row = ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)] side_row = ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"] return sheet_from_rows( [ [TITLE], group_row, # 2 side_row, # 3 [L(0), *at({1: "60", 2: 30, 7: 30})], # 4 [L(1), *at({1: "ООО Первый", 2: 10, 7: 10})], # 5 [], # 6 group_row, # 7 — повтор на «странице» печати side_row, # 8 [L(1), *at({1: "ООО Второй", 2: 20, 7: 20})], # 9 ["Сальдо на начало периода", "Обороты за период", None, None, 5], # 10 — повтор без merged [L(1), *at({1: "Контрагенты", 2: 1, 7: 1})], # 11 — контрагент с таким именем, но с суммами ] ) def test_header_repeat_inside_data_is_recognised(rules: Rules) -> None: sheet = _with_repeated_header() header = find_table_header(sheet, rules) assert is_header_repeat(sheet, 7, header, rules) assert is_header_repeat(sheet, 8, header, rules) assert is_header_repeat(sheet, 10, header, rules) @pytest.mark.parametrize("row", [4, 5, 6, 9, 11]) def test_data_and_empty_rows_are_not_header_repeats(rules: Rules, row: int) -> None: sheet = _with_repeated_header() header = find_table_header(sheet, rules) assert not is_header_repeat(sheet, row, header, rules) def test_header_is_found_from_the_top(rules: Rules) -> None: sheet = _with_repeated_header() header: TableHeader = find_table_header(sheet, rules) assert [header.first_row] == [2] def test_row_with_only_cells_right_of_table_is_not_a_header_repeat(rules: Rules) -> None: # В блоке rb-подобного заголовка есть пустая строка (под merged-подзаголовками); строка, где есть только «хвост» # правее таблицы, после отсечения по right_edge тоже пуста — но это не повтор заголовка base = _rb_like() sheet = Sheet( name=base.name, cells={**base.cells, (base.n_rows + 1, 19): Cell(Decimal("375490642.46"), "number")}, merged=base.merged, row_outline=base.row_outline, n_rows=base.n_rows + 1, n_cols=19, ) header = find_table_header(sheet, rules) assert not is_header_repeat(sheet, sheet.n_rows, header, rules) # --- Fix round 1 (T4.1–T4.4, T4.6) --- def test_header_without_turnover_is_invalid_format(rules: Rules) -> None: # Допустимо отсутствие только «начала» (spec; ruling R-12): «начало» + «конец» без оборотов — не заголовок sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(CLOSING, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 5: 10})], ] ) with pytest.raises(InvalidFormatError) as caught: find_table_header(sheet, rules) assert "«обороты»" in caught.value.message assert "сальдо на начало" not in caught.value.message assert "сальдо на конец" not in caught.value.message def test_header_without_closing_is_invalid_format(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 5: 10})], ] ) with pytest.raises(InvalidFormatError) as caught: find_table_header(sheet, rules) assert "«сальдо на конец»" in caught.value.message assert "обороты" not in caught.value.message def test_preamble_row_with_opening_and_closing_is_skipped(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Сальдо на начало: 01.01.2025", None, "Сальдо на конец: 31.03.2025"], # 2 — не заголовок таблицы [], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4 ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert [header.first_row] == [4] assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset()) def test_one_row_header_side_after_punctuation(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", "Сальдо на начало, Дт", "Сальдо на начало, Кт", "Обороты: Дт", "Обороты: Кт"] + ["Сальдо на конец (Дт)", "Сальдо на конец (Кт)"], [L(0), *at({1: "60", 2: 10, 7: 10})], ] ) header = find_table_header(sheet, rules) assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset()) @pytest.mark.parametrize( ("value", "expected"), [ # R-24: правило детектора не дополняет 9/11 цифр нулём — иначе числовая колонка КПП (9 цифр) сошла бы за # колонку ИНН; ведущий ноль восстанавливает только извлечение `numbers.inn_digits` из выбранной колонки (Decimal("123456789"), None), (Decimal("12345678901"), None), (Decimal("3523023093"), "3523023093"), (Decimal("770123456789"), "770123456789"), (Decimal("3523023093.0"), "3523023093"), (Decimal("12345"), None), (Decimal("3523023093.5"), None), ("3523023093", "3523023093"), ("0123456789", "0123456789"), ("123456789", None), # в тексте ведущий ноль не теряется: 9 цифр — не ИНН ("не указан", None), ], ) def test_inn_from_cell_is_strict_and_does_not_pad(value: object, expected: str | None) -> None: cell = Cell(value, "number") if isinstance(value, Decimal) else Cell(value, "text") # type: ignore[arg-type] assert inn_from_cell(cell) == expected def _numeric_inn_rows(inn_title: str | None) -> list[list[object]]: """ИНН записаны числами, у трёх из четырёх потерян ведущий ноль (9 цифр); `inn_title` — заголовок колонки.""" rows: list[list[object]] = [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", inn_title, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]): rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})]) return rows def test_inn_column_of_numbers_with_lost_leading_zeros_found_by_header(rules: Rules) -> None: """T4.3 + R-24: колонка с заголовком «ИНН» выбирается по заголовку; значения дополняются нулём при извлечении.""" sheet = sheet_from_rows(_numeric_inn_rows("ИНН")) header = find_table_header(sheet, rules) assert [header.inn_col] == [2] assert header.extra_cols == {} data = range(header.last_row + 1, sheet.n_rows + 1) assert [inn_digits(sheet.cell(row, 2)) for row in data] == ["0123456789", "0234567890", "0345678901", "7701234567"] def test_numeric_column_with_lost_leading_zeros_without_header_is_not_inn(rules: Rules) -> None: """R-24: без заголовка «ИНН» колонка из 9/10-значных чисел — не колонка ИНН (по значениям нужно ≥ 80 % ровно 10/12 цифр), она уходит в extra.""" header = find_table_header(sheet_from_rows(_numeric_inn_rows(None)), rules) assert header.inn_col is None assert header.extra_cols == {2: "B"} def test_numeric_kpp_column_is_not_taken_for_inn_column(rules: Rules) -> None: """M-3: числовой КПП (9 цифр) левее текстовой колонки ИНН, обе без заголовка: ИНН — колонка с 10/12 цифрами, КПП не выбирается (дополнение нулём превратило бы КПП в «0» + КПП).""" rows: list[list[object]] = [ [TITLE], ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] pairs = [(770101001, "7701234567"), (352801001, "3528052718"), (500101001, "5001012345"), (771401001, "7714012345")] for index, (kpp, inn) in enumerate(pairs): rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: inn, 4: 1, 9: 1})]) header = find_table_header(sheet_from_rows(rows), rules) assert [header.inn_col] == [3] assert header.extra_cols == {2: "B"} def test_numeric_kpp_column_alone_is_not_an_inn_column(rules: Rules) -> None: rows: list[list[object]] = [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] for index, kpp in enumerate([770101001, 352801001, 500101001, 771401001]): rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: 1, 8: 1})]) header = find_table_header(sheet_from_rows(rows), rules) assert header.inn_col is None assert header.extra_cols == {2: "B"} def test_too_few_inn_like_values_do_not_make_an_inn_column(rules: Rules) -> None: rows: list[list[object]] = [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], ] for index in range(29): values: dict[int, object] = {1: f"Контрагент {index}", 3: 1, 8: 1} if index == 0: values[2] = "7701234567" rows.append([L(1), *at(values)]) header = find_table_header(sheet_from_rows(rows), rules) assert header.inn_col is None assert header.extra_cols == {2: "B"} def test_vertically_merged_collapsed_title_does_not_reach_junk_to_the_right(rules: Rules) -> None: sheet = sheet_from_rows( [ [TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 1, 2)], ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", None], at({7: "Дт", 8: "Кт"}), # 4 — мусор правее таблицы, ниже merged-названия «конца» [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})], ] ) header = find_table_header(sheet, rules) assert header.columns.collapsed == frozenset({"closing"}) assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None) assert [header.right_edge] == [6] # --- Кооперативный дедлайн в долгих циклах (R-23, I-1) --- class _ClockExpiringAfter: """Часы-заглушка: первые `calls` обращений — время 0, дальше — далеко за лимитом.""" def __init__(self, calls: int) -> None: self._left = calls def __call__(self) -> float: self._left -= 1 return 0.0 if self._left >= 0 else 1e9 def test_group_row_search_checks_deadline(rules: Rules) -> None: sheet = sheet_from_rows([[f"строка {index}"] for index in range(2 * DEADLINE_CHECK_EVERY_ROWS)]) with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"): find_table_header(sheet, rules, Deadline(1, clock=_ClockExpiringAfter(1))) with pytest.raises(InvalidFormatError): # без дедлайна — прежнее поведение find_table_header(sheet, rules) def test_extra_columns_scan_checks_deadline(rules: Rules) -> None: # Создание дедлайна и одна проверка при поиске строки групп — в лимите; сбор extra — уже нет with pytest.raises(ParseTimeoutError, match="дополнительные колонки"): find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(2))) header = find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(10**6))) assert header == find_table_header(_rb_like(), rules) # дедлайн в лимите не меняет результат