fix(parser): детектор заголовка — обязательны обороты и конец, ИНН без ведущего нуля (T4.1–T4.4, T4.6)

- T4.1 (R-12): строка групп обязана содержать «обороты» и «сальдо на конец»; строка
  только с «началом» и «концом» (преамбула) пропускается, нет подходящей — InvalidFormatError
  с недостающими группами лучшего кандидата.
- T4.2: сторона в заголовке в одну строку — после пунктуации («Сальдо на начало, Дт», «(Кт)»).
- T4.3: inn_from_cell — общий хелпер ИНН (10/12 цифр); число из 9/11 цифр дополняется
  ведущим нулём; переиспользуется классификатором (Task 8).
- T4.4: колонка ИНН по значениям — не меньше INN_MIN_VALUES = 3 значений-ИНН.
- T4.6: у слитого (в том числе только по вертикали) названия группы поиск подзаголовков
  ограничен его диапазоном.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:03:15 +04:00
parent a833945669
commit 0015532019
2 changed files with 186 additions and 18 deletions

View File

@ -25,6 +25,7 @@ __all__ = [
"classify_sheet",
"find_table_header",
"first_data_row",
"inn_from_cell",
"is_header_repeat",
]
@ -38,12 +39,21 @@ HEADER_MAX_ROWS = 4
# Сколько строк данных смотреть, чтобы найти колонку текста строки и колонку ИНН по значениям
TEXT_COL_SAMPLE_ROWS = 20
INN_SAMPLE_ROWS = 200
# Колонка без заголовка «ИНН» считается колонкой ИНН, если не меньше 80 % её непустых значений — 10 или 12 цифр
# Колонка без заголовка «ИНН» считается колонкой ИНН, если в ней не меньше 3 непустых значений и не меньше 80 % из
# них — ИНН (10 или 12 цифр): одно случайное десятизначное число на всю колонку — не признак
INN_SHARE_PERCENT = 80
PERCENT = 100
INN_MIN_VALUES = 3
INN_LENGTHS = frozenset({10, 12})
# Нужно не меньше двух групп сумм в одной строке, чтобы считать её заголовком таблицы (spec: «меньше двух» — ошибка)
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
# Строка групп сумм обязана содержать «обороты» и «сальдо на конец»; «сальдо на начало» может отсутствовать
# (spec «Допустимы: отсутствие группы «начало»», ruling R-12)
REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
# Повтор заголовка внутри данных: строка, где встретились хотя бы две группы сумм
MIN_GROUPS = 2
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
_NORMALIZE_CACHE_SIZE = 16_384
@ -179,7 +189,8 @@ def _group_cells(sheet: Sheet, row: int, rules: Rules) -> list[_GroupCell]:
for group in GROUP_ORDER:
match = _search(rules.headers.groups[group], text)
if match is not None:
found.append(_GroupCell(group, col, _side(text[match.end() :].strip(), rules)))
remainder = _LEADING_PUNCTUATION.sub("", text[match.end() :])
found.append(_GroupCell(group, col, _side(remainder, rules)))
break
return found
@ -197,12 +208,16 @@ def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
def _find_group_row(sheet: Sheet, rules: Rules) -> tuple[int, list[_GroupCell]]:
"""Первая сверху строка, где встретились хотя бы две группы сумм; иначе `InvalidFormatError` с недостающими."""
"""Первая сверху строка с «оборотами» и «сальдо на конец» («начало» — по возможности).
Строка, где нет одной из обязательных групп (например, преамбула «Сальдо на начало: …» | «Сальдо на конец: …»),
пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
"""
best: set[SumGroup] = set()
for row in range(1, sheet.n_rows + 1):
cells = _group_cells(sheet, row, rules)
groups = {cell.group for cell in cells}
if len(groups) >= MIN_GROUPS:
if REQUIRED_GROUPS <= groups:
return row, cells
if len(groups) > len(best):
best = groups
@ -228,7 +243,8 @@ def _columns_of_group(
return _check_sides(sheet, group, debit, credit)
title = own[0]
title_span = sheet.merged_span(group_row, title.col)
if title_span is not None and title_span.right > title.col:
if title_span is not None:
# Слитое название (в том числе только по вертикали): подзаголовки — строго под его диапазоном
right = title_span.right
else:
following = [cell.col for cell in group_cells if cell.col > title.col]
@ -315,17 +331,23 @@ def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> in
return min(candidates)
def _inn_digits(cell: Cell) -> str:
"""Текст значения для проверки «10/12 цифр»: целое число из числовой ячейки — без дробной части и экспоненты."""
def inn_from_cell(cell: Cell) -> str | None:
"""ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None` (единое правило для детектора и классификатора).
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом
потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется:
в тексте ноль не теряется.
"""
value = cell.value
if isinstance(value, Decimal) and value.is_finite() and value == value.to_integral_value():
return str(int(value))
return cell.text
def _is_inn(cell: Cell) -> bool:
digits = _inn_digits(cell)
return digits.isdigit() and len(digits) in INN_LENGTHS
if isinstance(value, Decimal):
if not value.is_finite() or value != value.to_integral_value() or value < 0:
return None
digits = str(int(value))
if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO:
digits = digits.zfill(len(digits) + 1)
else:
digits = cell.text
return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None:
@ -342,8 +364,8 @@ def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: R
values.setdefault(col, []).append(cell)
for col in sorted(values):
cells = values[col]
inn_count = sum(1 for cell in cells if _is_inn(cell))
if inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
inn_count = sum(1 for cell in cells if inn_from_cell(cell) is not None)
if inn_count >= INN_MIN_VALUES and inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
return col
return None

View File

@ -15,6 +15,7 @@ from src.parser.detect import (
classify_sheet,
find_table_header,
first_data_row,
inn_from_cell,
is_header_repeat,
)
from src.parser.errors import InvalidFormatError
@ -591,3 +592,148 @@ def test_row_with_only_cells_right_of_table_is_not_a_header_repeat(rules: Rules)
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"),
[
(Decimal("123456789"), "0123456789"), # ИНН региона 01–09 числом: ведущий ноль потерян
(Decimal("12345678901"), "012345678901"),
(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_pads_numeric_inn_that_lost_leading_zero(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 test_inn_column_of_numbers_with_lost_leading_zeros(rules: Rules) -> None:
rows: list[list[object]] = [
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
header = find_table_header(sheet_from_rows(rows), rules)
assert [header.inn_col] == [2]
assert header.extra_cols == {}
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]