diff --git a/arm_statements/columns.py b/arm_statements/columns.py index e3ab9d6..451f196 100644 --- a/arm_statements/columns.py +++ b/arm_statements/columns.py @@ -15,6 +15,10 @@ from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, Stat _FULL_CONFIDENCE = 100.0 _MAX_REFS = 10 +_SPARSE_MAX_NUMBERS = 1 # редкий столбец: число не более чем в одной строке с кодом (AURORA-1735) +_PERIOD_MIN_NUMBERS = 3 # … при числах не менее чем в трёх строках у каждой колонки периода +_RIGHT_TEXT = "числа правее таблицы формы не учитываются" +_BESIDE_TEXT = "числа вне колонок формы не учитываются" @dataclass(frozen=True, slots=True) @@ -59,6 +63,15 @@ def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана п return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules)) +def _number_count( # noqa: PLR0913, PLR0917 - как has_numbers + rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules +) -> int: + """Сколько строк с кодом содержат в столбце число.""" + return sum( + 1 for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules) + ) + + def _width(rows: Grid, row_indexes: Sequence[int]) -> int: return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0) @@ -97,7 +110,9 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана warnings: list[StatementWarning] = [] columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) + dated = _dated_counts(rows, header_rows, data_rows, columns, code_col, source, rules) outside: list[int] = [] + beside: list[int] = [] for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) if not header_rows: @@ -113,16 +128,63 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана 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 and cell is not None and _is_sparse(col, dated, rows, data_rows, code_col, source, rules): + beside.append(col) # обрывок подписи периода с посторонним числом — не колонка сумм (AURORA-1735) 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)) + if outside or beside: + warnings.append( + _outside_table_warning(rows, data_rows, [*beside, *outside], code_col, source, rules, beside=bool(beside)) + ) return drafts, warnings +def _dated_counts( # noqa: PLR0913, PLR0917 - внутренняя сборка + rows: Grid, + header_rows: tuple[int, ...], + data_rows: Sequence[int], + columns: range, + code_col: int, + source: Source, + rules: Rules, +) -> dict[int, int]: + """Столбцы сегмента с подписью периода и числами → число строк с кодом, где есть число.""" + counts: dict[int, int] = {} + if not header_rows: + return counts + for col in columns: + cell = _label(rows, header_rows, col) + if cell is None: + continue + parsed = parse_period_label(cell, rules) + if parsed.excluded or parsed.kind is PeriodKind.NONE: + continue + count = _number_count(rows, data_rows, col, code_col, source, rules) + if count: + counts[col] = count + return counts + + +def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверка + col: int, + dated: dict[int, int], + rows: Grid, + data_rows: Sequence[int], + code_col: int, + source: Source, + rules: Rules, +) -> bool: + """Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3.""" + if not any(c < col for c in dated) or not any(c > col for c in dated): + return False + if any(count < _PERIOD_MIN_NUMBERS for count in dated.values()): + return False + return _number_count(rows, data_rows, col, code_col, source, rules) <= _SPARSE_MAX_NUMBERS + + def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы rows: Grid, header_rows: tuple[int, ...], @@ -149,7 +211,14 @@ def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сбо def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения - rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules + rows: Grid, + data_rows: Sequence[int], + outside: Sequence[int], + code_col: int, + source: Source, + rules: Rules, + *, + beside: bool = False, ) -> StatementWarning: refs = [ cell.ref @@ -157,13 +226,15 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с 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) + return outside_table_warning(refs, rules, beside=beside) -def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: - """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" +def outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning: + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек вне колонок формы (`locator` — все, через перевод строки). + + `beside` — среди адресов есть редкий столбец между колонками периодов (AURORA-1735): текст «вне колонок».""" shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") - message = f"числа правее таблицы формы не учитываются: {shown}" + message = f"{_BESIDE_TEXT if beside else _RIGHT_TEXT}: {shown}" return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) @@ -171,12 +242,14 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" refs: dict[str, None] = {} rest: list[StatementWarning] = [] + beside = False for warning in warnings: if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: refs.update(dict.fromkeys((warning.locator or "").split("\n"))) + beside = beside or warning.message.startswith(_BESIDE_TEXT) else: rest.append(warning) - return [*rest, outside_table_warning(list(refs), rules)] if refs else rest + return [*rest, outside_table_warning(list(refs), rules, beside=beside)] if refs else rest def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b7df080..940e74b 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -210,3 +210,50 @@ def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: 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) + + +# --- редкий столбец с неразобранной подписью между периодами (AURORA-1735, решение владельца 06.11.2026) --- + +SPARSE_COL = 3 # столбец «31» раскладки Калтана +KALTAN_HEAD = ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"] + + +def kaltan_rows(caption: str = "31", second: str | None = None) -> list[list[str | None]]: + head = [*KALTAN_HEAD] + head[3] = caption + return [ + head, + ["Выручка", "2110", "9671", None, "15405"], + ["Себестоимость продаж", "2120", "-7084", second, "-10446"], + ["Валовая прибыль (убыток)", "2100", "2587", None, "4959"], + ["Прочие расходы", "2350", "-486", "——-", "-348"], + ["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"], + ] + + +def test_sparse_column_between_periods_is_outside(rules: Rules) -> None: + doc = normalize_candidate(ocr(kaltan_rows()), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 4] + assert all(v.column_index != SPARSE_COL for line in doc.lines for v in line.values) + (warning,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"] + assert warning.severity is Severity.INFO + assert warning.message == "числа вне колонок формы не учитываются: row:5" + assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings] + + +def test_unparsed_caption_with_two_numbers_stays_column(rules: Rules) -> None: + doc = normalize_candidate(ocr(kaltan_rows("Прим.", second="12")), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 3, 4] + assert [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] == [3] + assert "NUMBERS_OUTSIDE_TABLE" not in [w.code for w in doc.warnings] + + +def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None: + rows = kaltan_rows("За I п\n20:") + rows[0][4] = "юлугодие\n25 года" + doc = normalize_candidate(ocr(rows), rules).document + assert doc is not None + assert [c.column_index for c in doc.columns] == [2, 3, 4] + assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"]