fix(statements): верхняя строка двухстрочного заголовка без подписи «Код» не теряется (T13.I2)

Без подписи «Код» строкой заголовка выбиралась нижняя строка с датами; верхняя («На 31 декабря», «Изменение, %»)
оставалась над заголовком: колонка без периода не попадала в skipped_labels, метки периодов не склеивались.
Тест синтетики st02 проверяет точное предупреждение, две колонки и отсутствие значений «Изменение, %».

Refs #14

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 19:09:35 +04:00
parent fad0c10412
commit e7552dcdb0
3 changed files with 43 additions and 1 deletions

View File

@ -134,10 +134,22 @@ def _find_header_row(sheet: Sheet, rules: Rules, code_col: int, first_row: int,
strong = [label for label in labels if _is_period_label(label, rules, allow_bare_year=False)] strong = [label for label in labels if _is_period_label(label, rules, allow_bare_year=False)]
weak = [label for label in labels if _is_period_label(label, rules, allow_bare_year=True)] weak = [label for label in labels if _is_period_label(label, rules, allow_bare_year=True)]
if strong or len(weak) >= 2: # noqa: PLR2004 - две даты подряд — заголовок, одна «2025» — титул if strong or len(weak) >= 2: # noqa: PLR2004 - две даты подряд — заголовок, одна «2025» — титул
return row return _upper_header_row(sheet, rules, row, code_col, first_row)
return None return None
def _upper_header_row(sheet: Sheet, rules: Rules, row: int, code_col: int, first_row: int) -> int:
"""Без подписи «Код» найденная строка — нижняя в двухстрочном заголовке: строка выше с подписями только правее кодов
и без чисел («На 31 декабря» / «Изменение, %») — его первая строка."""
above = row - 1
if above < first_row or is_numbering_row(sheet, above, rules) or sheet.text_at(above, code_col) != "":
return row
cells = sheet.row_cells(above)
if any(cell.kind == "number" for _, cell in cells) or not any(col > code_col for col, _ in cells):
return row
return above
def _header_last_row(sheet: Sheet, rules: Rules, header_row: int, code_col: int, first_code_row: int) -> int: def _header_last_row(sheet: Sheet, rules: Rules, header_row: int, code_col: int, first_code_row: int) -> int:
"""Вторая строка заголовка (продолжение подписей без кодов) и строка нумерации колонок.""" """Вторая строка заголовка (продолжение подписей без кодов) и строка нумерации колонок."""
last = header_row last = header_row

View File

@ -4,6 +4,7 @@
файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`). файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`).
""" """
from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@ -14,6 +15,7 @@ from result_contract import assert_valid_result
from src.contract import FileType, OsvDocument, ResultStatus, StatementDocument from src.contract import FileType, OsvDocument, ResultStatus, StatementDocument
from src.contract.result import StatementLine from src.contract.result import StatementLine
from src.parser import ParseOutcome, get_parser from src.parser import ParseOutcome, get_parser
from src.parser.statements.statement import SKIPPED_COLUMN_WARNING
from tests.fixtures_helpers import statement_synthetic_skip_reason from tests.fixtures_helpers import statement_synthetic_skip_reason
from tests.synthetic.generate_statements import FILE_NAMES from tests.synthetic.generate_statements import FILE_NAMES
@ -145,6 +147,12 @@ def test_st02_csv_like_format(fixtures_dir: Path) -> None:
# Проверяем колонки: две даты # Проверяем колонки: две даты
assert len(doc.columns) == EXPECTED_COLUMN_COUNT assert len(doc.columns) == EXPECTED_COLUMN_COUNT
assert all(col.period_date is not None for col in doc.columns) assert all(col.period_date is not None for col in doc.columns)
assert [col.period_date for col in doc.columns] == [date(2024, 12, 31), date(2023, 12, 31)]
# T13.I2: «Изменение, %» не колонка, её значения (5) не читаются, предупреждение — ровно одно
assert all("Изменение" not in col.label for col in doc.columns)
assert all(len(line.values) == EXPECTED_COLUMN_COUNT for line in doc.lines)
skipped = [w for w in warnings_of(outcome) if SKIPPED_COLUMN_WARNING in w]
assert skipped == [f"sheet=Лист1 {SKIPPED_COLUMN_WARNING}: Изменение, %"]
# Проверяем код 1210 = -50 # Проверяем код 1210 = -50
lines_by_code = by_code(doc) lines_by_code = by_code(doc)

View File

@ -185,6 +185,28 @@ def test_layout_without_code_caption(rules: Rules) -> None:
assert [column.period_date for column in layout.periods] == expected_dates assert [column.period_date for column in layout.periods] == expected_dates
def test_layout_two_row_header_without_code_caption_keeps_upper_row(rules: Rules) -> None:
"""T13.I2: без подписи «Код» верхняя строка двухстрочного заголовка не теряется (st02): «Изменение, %» пропущена."""
sheet = sheet_from_rows(
[
["Показатель", None, "На 31 декабря", "На 31 декабря", "Изменение, %"],
[None, None, "2024 г.", "2023 г."],
["Основные средства", "1150", 100, 90, 5],
["Итого по разделу I", "1100", 100, 90, 5],
["Запасы", "1210", 50, 40, 5],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
assert layout.header_row == 1
assert layout.first_data_row == 3 # noqa: PLR2004
expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)]
assert [column.period_date for column in layout.periods] == expected_dates
assert [column.label for column in layout.periods] == ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."]
assert layout.skipped_labels == ("Изменение, %",)
def test_layout_continuation_without_header(rules: Rules) -> None: def test_layout_continuation_without_header(rules: Rules) -> None:
sheet = sheet_from_rows( sheet = sheet_from_rows(
[ [