From 547f9efcdb6b2835d7dd8eef7aa4916fba144a54 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:18:07 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(#5):=20=D1=88=D0=B0=D0=B3=202=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=D1=83=D0=BC=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Столбец без подписи правее последней колонки с подписью периода колонкой сумм не считается; вместо COLUMN_WITHOUT_DATE — info NUMBERS_OUTSIDE_TABLE с адресами ячеек. Решение владельца 18.10.2026 (arm-ka-docs#116, Р-31). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- arm_statements/columns.py | 54 +++++++++++++++++++- arm_statements/issues.py | 1 + arm_statements/rules/statement_warnings.yaml | 1 + arm_statements/segments.py | 22 +++++++- arm_statements/tests/test_columns.py | 53 +++++++++++++++++-- arm_statements/tests/test_loader.py | 1 + arm_statements/tests/test_pipeline.py | 16 ++++++ arm_statements/tests/test_segments.py | 14 +++++ 9 files changed, 155 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 25a5692..7983673 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ make venv && make check | `DocStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"` | | `EnvelopeStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"`, `ERROR = "error"` | | `SignForm` | `PARENTHESES = "parentheses"`, `MINUS = "minus"`, `UNSIGNED = "unsigned"` | -| `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | +| `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `NUMBERS_OUTSIDE_TABLE`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | ### Вход шага 1 (строит адаптер хоста) diff --git a/arm_statements/columns.py b/arm_statements/columns.py index 75ae3ff..bbc3f8e 100644 --- a/arm_statements/columns.py +++ b/arm_statements/columns.py @@ -14,6 +14,7 @@ from arm_statements.loader import Rules from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, StatementHeader, StatementWarning _FULL_CONFIDENCE = 100.0 +_MAX_REFS = 10 @dataclass(frozen=True, slots=True) @@ -94,7 +95,10 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана """Колонки значений сегмента и предупреждения о пропущенных/недатированных столбцах.""" drafts: list[ColumnDraft] = [] warnings: list[StatementWarning] = [] - for col in range(code_col + 1, _width(rows, [*header_rows, *data_rows])): + columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) + last_dated = _last_dated_column(rows, header_rows, columns, rules) + outside: list[int] = [] + for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) if not header_rows: if numbers: @@ -107,14 +111,62 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана warnings.append(make_warning(WarningCode.COLUMN_SKIPPED, message, rules, column_index=col)) elif cell is not None and parsed.kind is not PeriodKind.NONE: drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date)) + elif numbers and cell is None and last_dated is not None and col > last_dated: + outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм elif numbers: label, conf = (cell.text, cell.confidence) if cell is not None else ("", None) message = f"у столбца {col} с числами нет разобранной подписи периода" warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col)) drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None)) + if outside: + warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules)) return drafts, warnings +def _last_dated_column(rows: Grid, header_rows: tuple[int, ...], columns: range, rules: Rules) -> int | None: + """Правый столбец сегмента с подписью периода (дата или относительная подпись); без шапки — `None`.""" + dated = [ + col + for col in columns + if header_rows + and (cell := _label(rows, header_rows, col)) is not None + and not (parsed := parse_period_label(cell, rules)).excluded + and parsed.kind is not PeriodKind.NONE + ] + return max(dated, default=None) + + +def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения + rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules +) -> StatementWarning: + refs = [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in outside + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + return outside_table_warning(refs, rules) + + +def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы формы (в `locator` все, в тексте первые).""" + shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") + message = f"числа правее таблицы формы не учитываются: {shown}" + return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator=", ".join(refs)) + + +def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]: + """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" + refs: dict[str, None] = {} + rest: list[StatementWarning] = [] + for warning in warnings: + if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: + refs.update(dict.fromkeys((warning.locator or "").split(", "))) + else: + rest.append(warning) + return [*rest, outside_table_warning(list(refs), rules)] if refs else rest + + def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None: days = [ d.date diff --git a/arm_statements/issues.py b/arm_statements/issues.py index 4f54512..0efc9c4 100644 --- a/arm_statements/issues.py +++ b/arm_statements/issues.py @@ -45,6 +45,7 @@ class WarningCode(StrEnum): VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" SHEET_OTHER = "SHEET_OTHER" ROW_SPLIT = "ROW_SPLIT" + NUMBERS_OUTSIDE_TABLE = "NUMBERS_OUTSIDE_TABLE" def make_warning( # noqa: PLR0913 -- сигнатура задана публичным API пакета diff --git a/arm_statements/rules/statement_warnings.yaml b/arm_statements/rules/statement_warnings.yaml index d81a271..03699c9 100644 --- a/arm_statements/rules/statement_warnings.yaml +++ b/arm_statements/rules/statement_warnings.yaml @@ -34,3 +34,4 @@ warnings: VALUE_OUT_OF_RANGE: {severity: info, scope: document} SHEET_OTHER: {severity: info, scope: file} ROW_SPLIT: {severity: info, scope: document} + NUMBERS_OUTSIDE_TABLE: {severity: info, scope: document} diff --git a/arm_statements/segments.py b/arm_statements/segments.py index 9cbedec..1149ed3 100644 --- a/arm_statements/segments.py +++ b/arm_statements/segments.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from typing import NamedTuple from arm_statements.codes import code_parts, is_code_cell, majority_section, parse_code -from arm_statements.columns import ColumnDraft, DocumentDates, document_dates, finalize_columns, segment_columns +from arm_statements.columns import ( + ColumnDraft, + DocumentDates, + document_dates, + finalize_columns, + merge_outside_table, + segment_columns, +) from arm_statements.issues import WarningCode, make_warning from arm_statements.layout import ( Grid, @@ -223,7 +230,18 @@ def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) if defining is not None: dates = document_dates(defining.drafts, header) columns, finalize_warnings = finalize_columns(defining.drafts, dates, rules) - warnings.extend(defining.draft_warnings) + # числа правее таблицы — по всем сегментам (формула аналитика может стоять на любой странице формы) + warnings.extend( + merge_outside_table( + [ + w + for seg in segments + for w in seg.draft_warnings + if seg is defining or w.code == WarningCode.NUMBERS_OUTSIDE_TABLE + ], + rules, + ) + ) warnings.extend(finalize_warnings) if not any(s.header_rows for s in coded): warnings.append( diff --git a/arm_statements/tests/test_columns.py b/arm_statements/tests/test_columns.py index 2a12edc..bc29f79 100644 --- a/arm_statements/tests/test_columns.py +++ b/arm_statements/tests/test_columns.py @@ -68,18 +68,61 @@ def test_junk_label_over_numbers_is_kept(rules: Rules) -> None: assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)] -def test_empty_label_over_numbers_is_kept(rules: Rules) -> None: +def test_empty_label_right_of_dated_columns_is_outside_table(rules: Rules) -> None: rows: list[list[Any]] = [ ["Наименование", "Код", "На 30.09.2025", ""], ["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"], ] columns, warnings = run(rows, OCR, rules) - assert [(c.column_index, c.label, c.label_confidence, c.period_kind) for c in columns] == [ - (2, "На 30.09.2025", 100.0, PeriodKind.ABSOLUTE), - (3, "", None, PeriodKind.NONE), + assert [(c.column_index, c.period_kind) for c in columns] == [(2, PeriodKind.ABSOLUTE)] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO, None)] + assert warnings[0].locator == "row:1, row:2" + + +def test_numbers_right_of_last_dated_column_are_not_amounts(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None, None], + ["Запасы", "", "1210", D("5"), D("4"), None, D("9")], + ["Баланс", "", "1600", D("5"), D("4"), D("7"), None], + ["Итого", "", "1700", D("5"), D("4"), None, None], ] - assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3)] + columns, warnings = run(rows, EXCEL, rules, data_rows=(1, 2, 3)) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.severity) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert warnings[0].locator == "Лист1!G2, Лист1!F3" + assert "G2" in warnings[0].message and "F3" in warnings[0].message + + +def test_numbers_outside_table_do_not_depend_on_cell_count(rules: Rules) -> None: + rows: list[list[Any]] = [["Наименование", "", "Код", "На 30.09.2025", None]] + rows += [["Строка", "", str(1200 + i), D("1"), D("2")] for i in range(12)] + cand = excel([("Лист1", rows)]) + _, warnings = segment_columns(cand.rows, (0,), tuple(range(1, 13)), 2, EXCEL, rules) + assert len(warnings) == 1 + assert warnings[0].message.endswith("и ещё 2") + + +def test_no_dated_columns_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "", ""], + ["Запасы", "", "1210", D("5"), D("4")], + ["Итого", "", "1200", D("5"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3), ("COLUMN_WITHOUT_DATE", 4)] + + +def test_undated_column_between_dated_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None, "На 31.12.2024"], + ["Запасы", "", "1210", D("5"), D("9"), D("4")], + ["Итого", "", "1200", D("5"), D("9"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 4)] def test_two_row_header_is_joined_with_min_confidence(rules: Rules) -> None: diff --git a/arm_statements/tests/test_loader.py b/arm_statements/tests/test_loader.py index f5df35c..fe1e0f9 100644 --- a/arm_statements/tests/test_loader.py +++ b/arm_statements/tests/test_loader.py @@ -43,6 +43,7 @@ SPEC_WARNINGS = { "VALUE_OUT_OF_RANGE": ("info", "document"), "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), + "NUMBERS_OUTSIDE_TABLE": ("info", "document"), "XLS_NO_CODEPAGE": ("info", "file"), } diff --git a/arm_statements/tests/test_pipeline.py b/arm_statements/tests/test_pipeline.py index 45af8d9..4c5ad8a 100644 --- a/arm_statements/tests/test_pipeline.py +++ b/arm_statements/tests/test_pipeline.py @@ -194,3 +194,19 @@ def test_document_warnings_order(rules: Rules) -> None: ] assert [c for c in codes if c in expected] == [c for c in expected if c in codes] assert codes[0] == "NUMBER_FORMAT_UNPARSED" and codes.index("TYPE_CONFLICT") < codes.index("NO_ORGANIZATION_NO_INN") + + +# --- числа правее таблицы формы (решение владельца 18.10.2026): не колонки сумм, статус не портят --- + + +def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: Rules) -> None: + rows: list[list[str | None]] = [[*r, None] for r in bal_rows()] + rows[6][3] = "7" # формула аналитика правее таблицы, в строке БАЛАНС (1600) + rows[9][3] = "8" + rows.insert(7, [*HEAD, None]) # повторная шапка второго сегмента (страница 2 формы) + cand = ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE) + doc = normalize_candidate(cand, rules).document + assert doc is not None and doc.status is DocStatus.DONE + assert [c.column_index for c in doc.columns] == [2] + assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert all(len(line.values) == 1 for line in doc.lines) diff --git a/arm_statements/tests/test_segments.py b/arm_statements/tests/test_segments.py index d636409..7be7dda 100644 --- a/arm_statements/tests/test_segments.py +++ b/arm_statements/tests/test_segments.py @@ -187,3 +187,17 @@ def test_continuation_sheet_with_duplicate_date_maps_by_order(rules: Rules) -> N cand = excel(_npz_sheets(duplicate)) layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules) assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24)) + + +def test_numbers_right_of_table_in_all_segments_give_one_warning(rules: Rules) -> None: + head: list[str | D | None] = ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", None] + rows: list[list[str | D | None]] = [ + head, + ["Запасы", "1210", D("5"), D("4"), D("9")], + head, + ["Итого", "1200", D("5"), D("4"), D("8")], + ] + layout = find_layout(excel([("Лист1", rows)]), parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!E2, Лист1!E4" + assert layout.segments[1].value_cols == ((2, 2), (3, 3)) From 06257a2522f1d1fe2bf6c8fcb6933675a32ce5b4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:27:56 +0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(#5):=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20=E2=80=94=20=D0=BB=D0=B8=D1=81=D1=82=D1=8B-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D0=B5=D1=86=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB,=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- arm_statements/columns.py | 36 ++++++++++++++++++++++----- arm_statements/segments.py | 28 +++++++++++++++++++-- arm_statements/tests/test_columns.py | 28 +++++++++++++++++++-- arm_statements/tests/test_segments.py | 16 +++++++++++- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/arm_statements/columns.py b/arm_statements/columns.py index bbc3f8e..e3ab9d6 100644 --- a/arm_statements/columns.py +++ b/arm_statements/columns.py @@ -96,7 +96,7 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана drafts: list[ColumnDraft] = [] warnings: list[StatementWarning] = [] columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) - last_dated = _last_dated_column(rows, header_rows, columns, rules) + last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) outside: list[int] = [] for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) @@ -123,8 +123,19 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана return drafts, warnings -def _last_dated_column(rows: Grid, header_rows: tuple[int, ...], columns: range, rules: Rules) -> int | None: - """Правый столбец сегмента с подписью периода (дата или относительная подпись); без шапки — `None`.""" +def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы + rows: Grid, + header_rows: tuple[int, ...], + data_rows: Sequence[int], + columns: range, + code_col: int, + source: Source, + rules: Rules, +) -> int | None: + """Правый столбец сегмента с подписью периода и числами в строках с кодами; без шапки — `None`. + + Подписанный столбец без чисел (потерянная подпись объединённой ячейки) границей таблицы не служит. + """ dated = [ col for col in columns @@ -132,6 +143,7 @@ def _last_dated_column(rows: Grid, header_rows: tuple[int, ...], columns: range, and (cell := _label(rows, header_rows, col)) is not None and not (parsed := parse_period_label(cell, rules)).excluded and parsed.kind is not PeriodKind.NONE + and has_numbers(rows, data_rows, col, code_col, source, rules) ] return max(dated, default=None) @@ -149,10 +161,10 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: - """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы формы (в `locator` все, в тексте первые).""" + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") message = f"числа правее таблицы формы не учитываются: {shown}" - return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator=", ".join(refs)) + return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]: @@ -161,12 +173,24 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l rest: list[StatementWarning] = [] for warning in warnings: if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: - refs.update(dict.fromkeys((warning.locator or "").split(", "))) + refs.update(dict.fromkeys((warning.locator or "").split("\n"))) else: rest.append(warning) return [*rest, outside_table_warning(list(refs), rules)] if refs else rest +def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка + rows: Grid, data_rows: Sequence[int], cols: Sequence[int], code_col: int, source: Source, rules: Rules +) -> list[str]: + """Адреса чисел в строках с кодами по столбцам `cols` (хвост сегмента правее колонок документа).""" + return [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in cols + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + + def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None: days = [ d.date diff --git a/arm_statements/segments.py b/arm_statements/segments.py index 1149ed3..9bdce13 100644 --- a/arm_statements/segments.py +++ b/arm_statements/segments.py @@ -1,7 +1,7 @@ """Шаг 2: сегменты сетки, повторные шапки, границы листов и сопоставление колонок с первым сегментом.""" from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import NamedTuple from arm_statements.codes import code_parts, is_code_cell, majority_section, parse_code @@ -11,7 +11,9 @@ from arm_statements.columns import ( document_dates, finalize_columns, merge_outside_table, + outside_table_warning, segment_columns, + trailing_outside_refs, ) from arm_statements.issues import WarningCode, make_warning from arm_statements.layout import ( @@ -27,6 +29,7 @@ from arm_statements.layout import ( from arm_statements.loader import CodeSection, Rules from arm_statements.model import ( FormCandidate, + PeriodKind, RowRole, StatementColumn, StatementHeader, @@ -219,6 +222,22 @@ def _map_segment( return layout, warning +def _trim_outside(seg: _Analysed, width: int, candidate: FormCandidate, rules: Rules) -> _Analysed: + """Сегмент без подписанных колонок: колонки сверх `width` документа — числа правее таблицы, не колонки.""" + if seg.code_col is None or len(seg.drafts) <= width or any(d.kind is not PeriodKind.NONE for d in seg.drafts): + return seg + tail = seg.drafts[width:] + if any(d.label for d in tail): # столбец с текстом подписи — не формула аналитика, остаётся расхождением + return seg + data = [r for r, role in zip(range(seg.first, seg.last + 1), seg.roles, strict=True) if role is RowRole.DATA] + refs = trailing_outside_refs( + candidate.rows, data, [d.grid_col for d in tail], seg.code_col, candidate.source, rules + ) + return replace( + seg, drafts=seg.drafts[:width], draft_warnings=(*seg.draft_warnings, outside_table_warning(refs, rules)) + ) + + def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout: """Раскладка кандидата: сегменты, колонки документа, роли всех строк и предупреждения шага 2.""" segments, section = _split_and_analyse(candidate, rules) @@ -230,7 +249,12 @@ def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) if defining is not None: dates = document_dates(defining.drafts, header) columns, finalize_warnings = finalize_columns(defining.drafts, dates, rules) - # числа правее таблицы — по всем сегментам (формула аналитика может стоять на любой странице формы) + # числа правее таблицы — по всем сегментам (формула аналитика может стоять на любой странице формы); + # сегмент без подписанных колонок (лист-продолжение) сверх колонок документа тоже «правее таблицы» + if any(d.kind is not PeriodKind.NONE for d in defining.drafts): + segments = [ + seg if seg is defining else _trim_outside(seg, len(columns), candidate, rules) for seg in segments + ] warnings.extend( merge_outside_table( [ diff --git a/arm_statements/tests/test_columns.py b/arm_statements/tests/test_columns.py index bc29f79..c516d4d 100644 --- a/arm_statements/tests/test_columns.py +++ b/arm_statements/tests/test_columns.py @@ -77,7 +77,7 @@ def test_empty_label_right_of_dated_columns_is_outside_table(rules: Rules) -> No columns, warnings = run(rows, OCR, rules) assert [(c.column_index, c.period_kind) for c in columns] == [(2, PeriodKind.ABSOLUTE)] assert [(w.code, w.severity, w.column_index) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO, None)] - assert warnings[0].locator == "row:1, row:2" + assert warnings[0].locator == "row:1\nrow:2" def test_numbers_right_of_last_dated_column_are_not_amounts(rules: Rules) -> None: @@ -90,7 +90,7 @@ def test_numbers_right_of_last_dated_column_are_not_amounts(rules: Rules) -> Non columns, warnings = run(rows, EXCEL, rules, data_rows=(1, 2, 3)) assert [c.column_index for c in columns] == [3, 4] assert [(w.code, w.severity) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] - assert warnings[0].locator == "Лист1!G2, Лист1!F3" + assert warnings[0].locator == "Лист1!G2\nЛист1!F3" assert "G2" in warnings[0].message and "F3" in warnings[0].message @@ -101,6 +101,8 @@ def test_numbers_outside_table_do_not_depend_on_cell_count(rules: Rules) -> None _, warnings = segment_columns(cand.rows, (0,), tuple(range(1, 13)), 2, EXCEL, rules) assert len(warnings) == 1 assert warnings[0].message.endswith("и ещё 2") + assert warnings[0].message.count("Лист1!E") == 10 # noqa: PLR2004 + assert len((warnings[0].locator or "").split("\n")) == 12 # noqa: PLR2004 def test_no_dated_columns_keeps_old_behaviour(rules: Rules) -> None: @@ -187,3 +189,25 @@ def test_public_amount_columns(rules: Rules) -> None: ] assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) + + +def test_dated_column_without_numbers_is_not_the_table_edge(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "", "На 31.12.2024", ""], + ["Запасы", "", "1210", None, D("5"), None, D("4")], + ["Итого", "", "1200", None, D("5"), None, D("4")], + ] # подпись объединённой ячейки стоит левее значений: границы таблицы нет, колонки остаются + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5, 6][:0] or [w.code for w in warnings].count( + "NUMBERS_OUTSIDE_TABLE" + ) == 0 + + +def test_sheet_name_with_comma_keeps_addresses(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None], + ["Запасы", "", "1210", D("5"), D("9")], + ] + cand = excel([("Баланс, 2025", rows)]) + _, warnings = segment_columns(cand.rows, (0,), (1,), 2, EXCEL, rules) + assert (warnings[0].locator or "").split("\n") == ["'Баланс, 2025'!E2"] diff --git a/arm_statements/tests/test_segments.py b/arm_statements/tests/test_segments.py index 7be7dda..3f18448 100644 --- a/arm_statements/tests/test_segments.py +++ b/arm_statements/tests/test_segments.py @@ -199,5 +199,19 @@ def test_numbers_right_of_table_in_all_segments_give_one_warning(rules: Rules) - ] layout = find_layout(excel([("Лист1", rows)]), parse_header(hdr(), rules), rules) assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] - assert layout.warnings[0].locator == "Лист1!E2, Лист1!E4" + assert layout.warnings[0].locator == "Лист1!E2\nЛист1!E4" assert layout.segments[1].value_cols == ((2, 2), (3, 3)) + + +def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> None: + first: list[list[str | D | None]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None], + ["Запасы", "", "1210", D("5"), D("4"), D("9")], + ] + second: list[list[str | D | None]] = [["Итого", "", "1200", D("5"), D("4"), D("8")]] + cand = excel([("Лист1", first), ("Лист2", second)]) + layout = find_layout(cand, parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1" + assert layout.segments[1].value_cols == ((3, 3), (4, 4)) + assert layout.segments[1].extra_cols == ()