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",
|
||||
"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
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user