From e7552dcdb089ba413d5acae939e36cded4759e3f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 19:09:35 +0400 Subject: [PATCH] =?UTF-8?q?fix(statements):=20=D0=B2=D0=B5=D1=80=D1=85?= =?UTF-8?q?=D0=BD=D1=8F=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=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=B3=D0=BE=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=C2=AB=D0=9A=D0=BE=D0=B4=C2=BB=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20(T1?= =?UTF-8?q?3.I2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без подписи «Код» строкой заголовка выбиралась нижняя строка с датами; верхняя («На 31 декабря», «Изменение, %») оставалась над заголовком: колонка без периода не попадала в skipped_labels, метки периодов не склеивались. Тест синтетики st02 проверяет точное предупреждение, две колонки и отсутствие значений «Изменение, %». Refs #14 Co-Authored-By: Claude Sonnet Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/src/parser/statements/columns.py | 14 +++++++++++- .../test_statement_synthetic_files.py | 8 +++++++ .../unit/parser/statements/test_columns.py | 22 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) 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( [