From 01968eb121a67e1fb7719469c56e0c7abf4f3d0e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 09:10:38 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1734/1735:=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=B4=D0=B2=D0=B8=D0=BD=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=82=D1=81=D0=B5=D0=B8=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F,=20XLSB=5FNO=5FFORMATS=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B6=D0=B4=D0=BE=D0=B9=20=D1=87=D0=B0=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- arm_statements/columns.py | 8 ++++++-- arm_statements/split.py | 9 +++++++-- tests/test_pipeline.py | 12 ++++++++++++ tests/test_split.py | 11 ++++++++++- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ebf23..18640d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ `min_codes` кодов которого — другой формы, начинает новую часть со своими колонками и периодами (`locator` с хвостом `;form:`, `type_hint = None`). Хост вызывает разрез перед `normalize_candidate`; без вызова поведение прежнее. - Редкий столбец с неразобранной подписью между колонками периодов (число не более чем в одной строке с кодом при трёх и - более у каждой колонки периода) — не колонка сумм; его числа — в `NUMBERS_OUTSIDE_TABLE` с текстом «числа вне колонок + более у каждой колонки периода и числах всех колонок периода в строке его числа) — не колонка сумм; его числа — в `NUMBERS_OUTSIDE_TABLE` с текстом «числа вне колонок формы не учитываются» (AURORA-1735). ## 0.1.4 — 2026-11-06 diff --git a/arm_statements/columns.py b/arm_statements/columns.py index 451f196..f8061af 100644 --- a/arm_statements/columns.py +++ b/arm_statements/columns.py @@ -177,12 +177,16 @@ def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверк source: Source, rules: Rules, ) -> bool: - """Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3.""" + """Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3, и в строке его + числа у всех колонок периода тоже есть числа — число лишнее, а не сдвинутое OCR из пустой ячейки периода.""" 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 + hits = [r for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)] + if len(hits) > _SPARSE_MAX_NUMBERS: + return False + return all(_is_number(cell_at(rows, r, period), source, rules) for r in hits for period in dated) def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы diff --git a/arm_statements/split.py b/arm_statements/split.py index cd31192..cb0a2ee 100644 --- a/arm_statements/split.py +++ b/arm_statements/split.py @@ -8,10 +8,14 @@ from dataclasses import replace from arm_statements.codes import document_type_of, majority_section, section_of +from arm_statements.issues import WarningCode from arm_statements.loader import Rules, default_rules from arm_statements.model import DocumentType, Evidence, FormCandidate from arm_statements.segments import split_segments +# предупреждения источника, нужные каждой части: без числовых форматов xlsb шаг 4 иначе проверяет масштаб (ревью I-2) +_SOURCE_WARNINGS = frozenset({WarningCode.XLSB_NO_FORMATS}) + def _opening_type(codes: tuple[str, ...], rules: Rules) -> DocumentType | None: """Тип формы, которой сегмент открывается: первые `min_codes` кодов одной формы; иначе `None`.""" @@ -42,7 +46,8 @@ def split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple """Части кандидата по смене формы; без смены — `(candidate,)` тем же объектом. Первая часть сохраняет поля исходного кандидата (кроме строк и границ листов); часть `k` (с 1) — `locator` - с хвостом `;form:`, `type_hint = None`, пустые `evidence` и `warnings`: тип — только по кодам на шаге 6. + с хвостом `;form:`, `type_hint = None`, пустые `evidence`, из `warnings` — только предупреждения источника + (`XLSB_NO_FORMATS`): тип — только по кодам на шаге 6. """ rules = rules if rules is not None else default_rules() cuts = _cuts(candidate, rules) @@ -64,7 +69,7 @@ def split_by_form(candidate: FormCandidate, rules: Rules | None = None) -> tuple evidence=Evidence(okud=None, title=None), rows=rows, segment_breaks=breaks, - warnings=(), + warnings=tuple(w for w in candidate.warnings if w.code in _SOURCE_WARNINGS), ) ) return tuple(parts) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 940e74b..c53a911 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -257,3 +257,15 @@ def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None: 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"] + + +def test_shifted_value_in_sparse_column_keeps_column(rules: Rules) -> None: + """Единственное число столбца стоит в строке, где колонка периода пуста, — это сдвиг OCR, не мусор (ревью I-1).""" + rows = kaltan_rows() + rows[5][3] = None # без постороннего «$301»: единственное число столбца — сдвинутое значение 2460 + rows.insert(5, ["Прочее", "2460", "12", "55", None]) + 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"] + assert doc.status is DocStatus.NEEDS_CHECK diff --git a/tests/test_split.py b/tests/test_split.py index dd72914..dba9e1d 100644 --- a/tests/test_split.py +++ b/tests/test_split.py @@ -2,7 +2,7 @@ from datetime import date -from arm_statements import normalize_candidate, split_by_form +from arm_statements import WarningCode, make_warning, normalize_candidate, split_by_form from arm_statements.loader import Rules from arm_statements.model import DocumentType, Evidence from tests.builders import excel, hdr, ocr @@ -102,3 +102,12 @@ def test_excel_breaks_shifted(rules: Rules) -> None: assert inc.segment_breaks == (0, len(income_rows())) assert inc.locator == f"{cand.locator};form:1" assert len(inc.rows) == len(income_rows()) + len(continuation) + + +def test_xlsb_source_warning_kept_in_every_part(rules: Rules) -> None: + """`XLSB_NO_FORMATS` — свойство источника, нужно каждой части для проверки масштаба (ревью I-2).""" + no_formats = make_warning(WarningCode.XLSB_NO_FORMATS, "xlsb без числовых форматов", rules) + cand = excel([("Б", balance_rows()), ("О", income_rows())], header=HEADER, warnings=[no_formats]) + bal, inc = split_by_form(cand, rules) + assert bal.warnings == (no_formats,) + assert inc.warnings == (no_formats,)