From be3b9c89052b78d00cefe1d7e620052d907c2322 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:27:42 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B2=D1=83=D1=85=D1=81=D1=82=D1=80=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B3=D0=B4=D0=B0=20=C2=AB=D0=9A=D0=BE=D0=B4=C2=BB=20=D0=B2=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=B5=D0=B9=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=20(I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После выбора строки по подписи кода проверяется и нижняя строка (годы): если подпись шапки отдельно не период, а вместе с нижней — период, строки склеиваются. Тесты: Excel, OCR, повторная шапка в два ряда. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- arm_statements/layout.py | 10 ++++++ arm_statements/tests/test_layout.py | 34 +++++++++++++++++++ arm_statements/tests/test_segments.py | 47 ++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/arm_statements/layout.py b/arm_statements/layout.py index f79798f..3f86a5b 100644 --- a/arm_statements/layout.py +++ b/arm_statements/layout.py @@ -201,6 +201,13 @@ def _upper_candidate(rows: Grid, row: int, first: int, rules: Rules) -> int | No return above if above >= first else None +def _lower_candidate(rows: Grid, row: int, first_code: int, rules: Rules) -> int | None: + below = row + 1 + while below < first_code and is_numbering_row(rows[below], rules): + below += 1 + return below if below < first_code else None + + def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rules: Rules) -> tuple[int, ...]: """Строка заголовка (одна или две, склеенные); не найдена — `()`.""" candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)] @@ -215,6 +222,9 @@ def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rul upper = _upper_candidate(rows, header, first, rules) if upper is not None and _glued_with_upper(rows, upper, header, code_col, rules): return (upper, header) + lower = _lower_candidate(rows, header, first_code, rules) + if lower is not None and _glued_with_upper(rows, header, lower, code_col, rules): + return (header, lower) return (header,) diff --git a/arm_statements/tests/test_layout.py b/arm_statements/tests/test_layout.py index 74c15f3..1b029e3 100644 --- a/arm_statements/tests/test_layout.py +++ b/arm_statements/tests/test_layout.py @@ -137,3 +137,37 @@ def test_name_cell_falls_back_to_left_text(rules: Rules) -> None: cell = name_cell(row, 2, 3, 0) assert cell is not None and cell.text == "Запасы" assert name_cell(ocr([["5.1", "", "", "1210"]]).rows[0], 2, 3, 0) is None + + +def test_two_row_header_with_caption_in_upper_row_is_glued_with_lower(rules: Rules) -> None: + """I-1: «Код» в верхней строке, годы — в нижней (шаблон банка).""" + cand = ocr( + [ + ["Наименование", "Код", "На 30 сентября", "На 31 декабря", "На 31 декабря"], + ["", "", "2025 г.", "2024 г.", "2023 г."], + ["Запасы", "1210", "5", "4", "3"], + ] + ) + header = find_header_rows(cand.rows, 0, 2, 1, rules) + assert header == (0, 1) + assert segment_roles(cand.rows, 0, 2, header, rules) == [RowRole.HEADER, RowRole.HEADER, RowRole.DATA] + + +def test_two_row_header_lower_skips_numbering_and_ignores_unrelated_row(rules: Rules) -> None: + glued = ocr( + [ + ["Наименование", "Код", "На 30 сентября", "На 31 декабря"], + ["", "", "2025 г.", "2024 г."], + ["1", "2", "3", "4"], + ["Запасы", "1210", "5", "4"], + ] + ) + assert find_header_rows(glued.rows, 0, 3, 1, rules) == (0, 1) + plain = ocr( + [ + ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"], + ["Справочно", "", "", ""], + ["Запасы", "1210", "5", "4"], + ] + ) + assert find_header_rows(plain.rows, 0, 2, 1, rules) == (0,) diff --git a/arm_statements/tests/test_segments.py b/arm_statements/tests/test_segments.py index fe86101..83c536d 100644 --- a/arm_statements/tests/test_segments.py +++ b/arm_statements/tests/test_segments.py @@ -3,7 +3,7 @@ from decimal import Decimal as D from arm_statements.header import parse_header from arm_statements.loader import Rules from arm_statements.model import RowRole -from arm_statements.segments import find_layout +from arm_statements.segments import Layout, find_layout from arm_statements.tests.builders import excel, hdr, ocr, place DATES3 = ["На 30 сентября 2025 г.", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."] @@ -113,3 +113,48 @@ def test_no_codes_anywhere_has_no_segment_warnings(rules: Rules) -> None: layout = find_layout(ocr([["Просто", "текст"], ["ещё", "строка"]]), parse_header(hdr(), rules), rules) assert not layout.code_found and layout.columns == () and layout.warnings == () assert layout.roles == (RowRole.DATA, RowRole.DATA) + + +def _no_date_noise(layout: Layout) -> None: + assert not [w for w in layout.warnings if w.code == "COLUMN_WITHOUT_DATE"] + + +def test_two_row_header_with_caption_on_top_ocr(rules: Rules) -> None: + """I-1: годы во второй строке шапки — не строка данных, колонки с абсолютными датами.""" + rows = [ + ["Наименование", "Код", "На 30 сентября", "На 31 декабря", "На 31 декабря"], + ["", "", "2025 г.", "2024 г.", "2023 г."], + ["Запасы", "1210", "5", "4", "3"], + ] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + assert layout.roles[:2] == (RowRole.HEADER, RowRole.HEADER) and layout.roles[2] is RowRole.DATA + assert [c.period_kind.value != "none" for c in layout.columns] == [True, True, True] + _no_date_noise(layout) + + +def test_two_row_header_with_caption_on_top_excel(rules: Rules) -> None: + sheet = [ + place({0: "Наименование", 1: "Код", 2: "На 30 сентября", 3: "На 31 декабря", 4: "На 31 декабря"}), + place({2: "2025 г.", 3: "2024 г.", 4: "2023 г."}), + place({0: "Запасы", 1: "1210", 2: D("5"), 3: D("4"), 4: D("3")}), + ] + layout = find_layout(excel([("Баланс", sheet)]), parse_header(hdr(), rules), rules) + assert layout.roles[1] is RowRole.HEADER and layout.roles[2] is RowRole.DATA + assert [c.period_kind.value != "none" for c in layout.columns] == [True, True, True] + _no_date_noise(layout) + + +def test_repeat_header_in_two_rows(rules: Rules) -> None: + """T10.5: повторная шапка в два ряда использует ту же склейку.""" + rows = [ + ["Наименование", "Код", "На 30 сентября", "На 31 декабря"], + ["", "", "2025 г.", "2024 г."], + ["Запасы", "1210", "5", "4"], + ["Наименование", "Код", "На 30 сентября", "На 31 декабря"], + ["", "", "2025 г.", "2024 г."], + ["Итого", "1200", "5", "4"], + ] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + assert [s.first_row for s in layout.segments] == [0, 3] + assert layout.roles[4] is RowRole.HEADER and layout.roles[5] is RowRole.DATA + _no_date_noise(layout)