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:
parent
a833945669
commit
0015532019
@ -25,6 +25,7 @@ __all__ = [
|
|||||||
"classify_sheet",
|
"classify_sheet",
|
||||||
"find_table_header",
|
"find_table_header",
|
||||||
"first_data_row",
|
"first_data_row",
|
||||||
|
"inn_from_cell",
|
||||||
"is_header_repeat",
|
"is_header_repeat",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -38,12 +39,21 @@ HEADER_MAX_ROWS = 4
|
|||||||
# Сколько строк данных смотреть, чтобы найти колонку текста строки и колонку ИНН по значениям
|
# Сколько строк данных смотреть, чтобы найти колонку текста строки и колонку ИНН по значениям
|
||||||
TEXT_COL_SAMPLE_ROWS = 20
|
TEXT_COL_SAMPLE_ROWS = 20
|
||||||
INN_SAMPLE_ROWS = 200
|
INN_SAMPLE_ROWS = 200
|
||||||
# Колонка без заголовка «ИНН» считается колонкой ИНН, если не меньше 80 % её непустых значений — 10 или 12 цифр
|
# Колонка без заголовка «ИНН» считается колонкой ИНН, если в ней не меньше 3 непустых значений и не меньше 80 % из
|
||||||
|
# них — ИНН (10 или 12 цифр): одно случайное десятизначное число на всю колонку — не признак
|
||||||
INN_SHARE_PERCENT = 80
|
INN_SHARE_PERCENT = 80
|
||||||
PERCENT = 100
|
PERCENT = 100
|
||||||
|
INN_MIN_VALUES = 3
|
||||||
INN_LENGTHS = frozenset({10, 12})
|
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
|
MIN_GROUPS = 2
|
||||||
|
# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
|
||||||
|
_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
|
||||||
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
|
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
|
||||||
_NORMALIZE_CACHE_SIZE = 16_384
|
_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:
|
for group in GROUP_ORDER:
|
||||||
match = _search(rules.headers.groups[group], text)
|
match = _search(rules.headers.groups[group], text)
|
||||||
if match is not None:
|
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
|
break
|
||||||
return found
|
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]]:
|
def _find_group_row(sheet: Sheet, rules: Rules) -> tuple[int, list[_GroupCell]]:
|
||||||
"""Первая сверху строка, где встретились хотя бы две группы сумм; иначе `InvalidFormatError` с недостающими."""
|
"""Первая сверху строка с «оборотами» и «сальдо на конец» («начало» — по возможности).
|
||||||
|
|
||||||
|
Строка, где нет одной из обязательных групп (например, преамбула «Сальдо на начало: …» | «Сальдо на конец: …»),
|
||||||
|
пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
|
||||||
|
"""
|
||||||
best: set[SumGroup] = set()
|
best: set[SumGroup] = set()
|
||||||
for row in range(1, sheet.n_rows + 1):
|
for row in range(1, sheet.n_rows + 1):
|
||||||
cells = _group_cells(sheet, row, rules)
|
cells = _group_cells(sheet, row, rules)
|
||||||
groups = {cell.group for cell in cells}
|
groups = {cell.group for cell in cells}
|
||||||
if len(groups) >= MIN_GROUPS:
|
if REQUIRED_GROUPS <= groups:
|
||||||
return row, cells
|
return row, cells
|
||||||
if len(groups) > len(best):
|
if len(groups) > len(best):
|
||||||
best = groups
|
best = groups
|
||||||
@ -228,7 +243,8 @@ def _columns_of_group(
|
|||||||
return _check_sides(sheet, group, debit, credit)
|
return _check_sides(sheet, group, debit, credit)
|
||||||
title = own[0]
|
title = own[0]
|
||||||
title_span = sheet.merged_span(group_row, title.col)
|
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
|
right = title_span.right
|
||||||
else:
|
else:
|
||||||
following = [cell.col for cell in group_cells if cell.col > title.col]
|
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)
|
return min(candidates)
|
||||||
|
|
||||||
|
|
||||||
def _inn_digits(cell: Cell) -> str:
|
def inn_from_cell(cell: Cell) -> str | None:
|
||||||
"""Текст значения для проверки «10/12 цифр»: целое число из числовой ячейки — без дробной части и экспоненты."""
|
"""ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None` (единое правило для детектора и классификатора).
|
||||||
|
|
||||||
|
Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом
|
||||||
|
потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется:
|
||||||
|
в тексте ноль не теряется.
|
||||||
|
"""
|
||||||
value = cell.value
|
value = cell.value
|
||||||
if isinstance(value, Decimal) and value.is_finite() and value == value.to_integral_value():
|
if isinstance(value, Decimal):
|
||||||
return str(int(value))
|
if not value.is_finite() or value != value.to_integral_value() or value < 0:
|
||||||
return cell.text
|
return None
|
||||||
|
digits = str(int(value))
|
||||||
|
if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO:
|
||||||
def _is_inn(cell: Cell) -> bool:
|
digits = digits.zfill(len(digits) + 1)
|
||||||
digits = _inn_digits(cell)
|
else:
|
||||||
return digits.isdigit() and len(digits) in INN_LENGTHS
|
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:
|
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)
|
values.setdefault(col, []).append(cell)
|
||||||
for col in sorted(values):
|
for col in sorted(values):
|
||||||
cells = values[col]
|
cells = values[col]
|
||||||
inn_count = sum(1 for cell in cells if _is_inn(cell))
|
inn_count = sum(1 for cell in cells if inn_from_cell(cell) is not None)
|
||||||
if inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
|
if inn_count >= INN_MIN_VALUES and inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
|
||||||
return col
|
return col
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from src.parser.detect import (
|
|||||||
classify_sheet,
|
classify_sheet,
|
||||||
find_table_header,
|
find_table_header,
|
||||||
first_data_row,
|
first_data_row,
|
||||||
|
inn_from_cell,
|
||||||
is_header_repeat,
|
is_header_repeat,
|
||||||
)
|
)
|
||||||
from src.parser.errors import InvalidFormatError
|
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)
|
header = find_table_header(sheet, rules)
|
||||||
|
|
||||||
assert not is_header_repeat(sheet, sheet.n_rows, header, 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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user