fix(#5): ревью — листы-продолжения, подписанный столбец без чисел, адреса через перевод строки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 21:27:56 +04:00
parent 547f9efcdb
commit 06257a2522
4 changed files with 97 additions and 11 deletions

View File

@ -96,7 +96,7 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
drafts: list[ColumnDraft] = [] drafts: list[ColumnDraft] = []
warnings: list[StatementWarning] = [] warnings: list[StatementWarning] = []
columns = 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) last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules)
outside: list[int] = [] outside: list[int] = []
for col in columns: for col in columns:
numbers = has_numbers(rows, data_rows, col, code_col, source, rules) numbers = has_numbers(rows, data_rows, col, code_col, source, rules)
@ -123,8 +123,19 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
return drafts, warnings return drafts, warnings
def _last_dated_column(rows: Grid, header_rows: tuple[int, ...], columns: range, rules: Rules) -> int | None: def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы
"""Правый столбец сегмента с подписью периода (дата или относительная подпись); без шапки — `None`.""" rows: Grid,
header_rows: tuple[int, ...],
data_rows: Sequence[int],
columns: range,
code_col: int,
source: Source,
rules: Rules,
) -> int | None:
"""Правый столбец сегмента с подписью периода и числами в строках с кодами; без шапки — `None`.
Подписанный столбец без чисел (потерянная подпись объединённой ячейки) границей таблицы не служит.
"""
dated = [ dated = [
col col
for col in columns 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 (cell := _label(rows, header_rows, col)) is not None
and not (parsed := parse_period_label(cell, rules)).excluded and not (parsed := parse_period_label(cell, rules)).excluded
and parsed.kind is not PeriodKind.NONE and parsed.kind is not PeriodKind.NONE
and has_numbers(rows, data_rows, col, code_col, source, rules)
] ]
return max(dated, default=None) 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: 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 "") shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "")
message = f"числа правее таблицы формы не учитываются: {shown}" 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]: 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] = [] rest: list[StatementWarning] = []
for warning in warnings: for warning in warnings:
if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: 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: else:
rest.append(warning) rest.append(warning)
return [*rest, outside_table_warning(list(refs), rules)] if refs else rest 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: def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None:
days = [ days = [
d.date d.date

View File

@ -1,7 +1,7 @@
"""Шаг 2: сегменты сетки, повторные шапки, границы листов и сопоставление колонок с первым сегментом.""" """Шаг 2: сегменты сетки, повторные шапки, границы листов и сопоставление колонок с первым сегментом."""
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass, replace
from typing import NamedTuple from typing import NamedTuple
from arm_statements.codes import code_parts, is_code_cell, majority_section, parse_code 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, document_dates,
finalize_columns, finalize_columns,
merge_outside_table, merge_outside_table,
outside_table_warning,
segment_columns, segment_columns,
trailing_outside_refs,
) )
from arm_statements.issues import WarningCode, make_warning from arm_statements.issues import WarningCode, make_warning
from arm_statements.layout import ( from arm_statements.layout import (
@ -27,6 +29,7 @@ from arm_statements.layout import (
from arm_statements.loader import CodeSection, Rules from arm_statements.loader import CodeSection, Rules
from arm_statements.model import ( from arm_statements.model import (
FormCandidate, FormCandidate,
PeriodKind,
RowRole, RowRole,
StatementColumn, StatementColumn,
StatementHeader, StatementHeader,
@ -219,6 +222,22 @@ def _map_segment(
return layout, warning 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: def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout:
"""Раскладка кандидата: сегменты, колонки документа, роли всех строк и предупреждения шага 2.""" """Раскладка кандидата: сегменты, колонки документа, роли всех строк и предупреждения шага 2."""
segments, section = _split_and_analyse(candidate, rules) 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: if defining is not None:
dates = document_dates(defining.drafts, header) dates = document_dates(defining.drafts, header)
columns, finalize_warnings = finalize_columns(defining.drafts, dates, rules) 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( warnings.extend(
merge_outside_table( merge_outside_table(
[ [

View File

@ -77,7 +77,7 @@ def test_empty_label_right_of_dated_columns_is_outside_table(rules: Rules) -> No
columns, warnings = run(rows, OCR, rules) columns, warnings = run(rows, OCR, rules)
assert [(c.column_index, c.period_kind) for c in columns] == [(2, PeriodKind.ABSOLUTE)] 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 [(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: 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)) columns, warnings = run(rows, EXCEL, rules, data_rows=(1, 2, 3))
assert [c.column_index for c in columns] == [3, 4] 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 [(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 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) _, warnings = segment_columns(cand.rows, (0,), tuple(range(1, 13)), 2, EXCEL, rules)
assert len(warnings) == 1 assert len(warnings) == 1
assert warnings[0].message.endswith("и ещё 2") 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: 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(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4)
assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 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"]

View File

@ -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) layout = find_layout(excel([("Лист1", rows)]), parse_header(hdr(), rules), rules)
assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] 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)) 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 == ()