Andrey Epifancev 43653f1b6f Заголовок ОСВ: колонка «Показатели» (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:49:28 +04:00

857 lines
35 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.

"""Детекторы по содержимому (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_indicator_column_found_by_hyphenated_title(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Счет", "Показа-\nтели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: "Дебет", 3: 10, 8: 10})],
]
)
header = find_table_header(sheet, rules)
assert [header.indicator_col] == [2]
assert [header.text_col] == [1]
assert list(header.extra_cols) == [2]
def test_no_indicator_column_when_title_absent(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
assert find_table_header(sheet, rules).indicator_col is None
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) # дедлайн в лимите не меняет результат