From 43653f1b6fa86ab36a0acbd1d2fcac9da4fa3f13 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 11:49:28 +0400 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=9E=D0=A1=D0=92:=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=C2=AB=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8=C2=BB=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/detect.py | 19 ++++++++++++++---- api/tests/unit/parser/test_detect.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 3ff809b..5e1359b 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -122,6 +122,7 @@ class TableHeader: right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет) group_label_rows: list[int] # строки блока, где в text_col начинается текст группировки («Счет», «Контрагенты») + indicator_col: int | None = None # колонка «Показатели» — метка вида суммы строки, §1.3 # --- Текст ячеек --- @@ -381,13 +382,20 @@ def inn_from_cell(cell: Cell) -> str | None: 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: - """Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр.""" - inn_patterns = rules.headers.service_columns["inn"] +def _service_col(sheet: Sheet, block: range, text_col: int, first_sum: int, patterns: Iterable[Pattern]) -> int | None: + """Первая колонка между текстом и суммами, чей нормализованный заголовок в блоке матчит `patterns`.""" for row in block: for col, text in _texts(sheet, row): - if text_col < col < first_sum and _search(inn_patterns, text): + if text_col < col < first_sum and _search(patterns, text): return col + return None + + +def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None: + """Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр.""" + by_title = _service_col(sheet, block, text_col, first_sum, rules.headers.service_columns["inn"]) + if by_title is not None: + return by_title values: dict[int, list[Cell]] = {} for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS): for col, cell in sheet.row_cells(row): @@ -465,6 +473,8 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No last_row = _block_last_row(sheet, group_row, sum_cells, rules) text_col = _text_col(sheet, group_row, last_row, first_sum) inn_col = _inn_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules) + indicator_patterns = rules.headers.service_columns.get("indicator", []) + indicator_col = _service_col(sheet, range(group_row, last_row + 1), text_col, first_sum, indicator_patterns) partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, []) occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set()) @@ -485,6 +495,7 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No right_edge=right_edge, extra_cols=dict(sorted(extra_cols.items())), group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules), + indicator_col=indicator_col, ) diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py index 9cfe023..87ec004 100644 --- a/api/tests/unit/parser/test_detect.py +++ b/api/tests/unit/parser/test_detect.py @@ -438,6 +438,36 @@ def test_header_without_text_column_left_of_sums_is_invalid_format(rules: 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( [