diff --git a/api/src/parser/statements/columns.py b/api/src/parser/statements/columns.py index 6d1c757..c53044e 100644 --- a/api/src/parser/statements/columns.py +++ b/api/src/parser/statements/columns.py @@ -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)] 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» — титул - return row + return _upper_header_row(sheet, rules, row, code_col, first_row) 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: """Вторая строка заголовка (продолжение подписей без кодов) и строка нумерации колонок.""" last = header_row diff --git a/api/tests/synthetic/test_statement_synthetic_files.py b/api/tests/synthetic/test_statement_synthetic_files.py index fc27a7c..dcbc76d 100644 --- a/api/tests/synthetic/test_statement_synthetic_files.py +++ b/api/tests/synthetic/test_statement_synthetic_files.py @@ -4,6 +4,7 @@ файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`). """ +from datetime import date from decimal import Decimal from pathlib import Path 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.result import StatementLine 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.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 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 lines_by_code = by_code(doc) diff --git a/api/tests/unit/parser/statements/test_columns.py b/api/tests/unit/parser/statements/test_columns.py index 35cd418..9307229 100644 --- a/api/tests/unit/parser/statements/test_columns.py +++ b/api/tests/unit/parser/statements/test_columns.py @@ -185,6 +185,28 @@ def test_layout_without_code_caption(rules: Rules) -> None: 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: sheet = sheet_from_rows( [