From 001553201996f7df57889c783a229f92f49b2db9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:03:15 +0400 Subject: [PATCH] =?UTF-8?q?fix(parser):=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=20=D0=BE=D0=B1=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D0=B5=D1=86,=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BD=D1=83=D0=BB=D1=8F=20(T4?= =?UTF-8?q?.1=E2=80=93T4.4,=20T4.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/parser/detect.py | 58 +++++++---- api/tests/unit/parser/test_detect.py | 146 +++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 18 deletions(-) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 38d738f..e5c9e6c 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -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 diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 802180e..6318a82 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -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]