Склейка двухстрочной шапки, когда «Код» в верхней строке (I-1)

После выбора строки по подписи кода проверяется и нижняя строка (годы):
если подпись шапки отдельно не период, а вместе с нижней — период,
строки склеиваются. Тесты: Excel, OCR, повторная шапка в два ряда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 14:27:42 +04:00
parent aa46e7ad4a
commit be3b9c8905
3 changed files with 90 additions and 1 deletions

View File

@ -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,)

View File

@ -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,)

View File

@ -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)