From 9b0f01ef1cf44669c39cb51ab738d9d16a0073a9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 00:31:58 +0400 Subject: [PATCH] =?UTF-8?q?fix(normalizer):=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=20=E2=80=94?= =?UTF-8?q?=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20=D1=88=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=20=D0=B2=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=80=D0=B8=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D1=8B=D1=81=D1=8F=D1=87=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D1=86=D0=B8=D1=80?= =?UTF-8?q?=D1=83=D0=B5=D1=82=D1=81=D1=8F=20(AURORA-1517,=201511,=201519,?= =?UTF-8?q?=20#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/normalizer/ocr/amounts.py | 13 ++++++-- api/src/normalizer/ocr/rows.py | 18 ++++++++++++ api/tests/unit/normalizer/test_amounts.py | 12 ++++++++ api/tests/unit/normalizer/test_rows.py | 36 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/api/src/normalizer/ocr/amounts.py b/api/src/normalizer/ocr/amounts.py index 8ea24a0..84401a5 100644 --- a/api/src/normalizer/ocr/amounts.py +++ b/api/src/normalizer/ocr/amounts.py @@ -1,7 +1,9 @@ """Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). -Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид +Что делает разбор: пробелы долой (но пробел между цифрами вне схемы тысяч `1 234`, `12 345 678` — догадка, `verified` +сбрасывается: «42831 1» → 428311, не чистое; AURORA-1511/1519); +если текст целиком (а при скобках на всю строку — их содержимое) имеет вид `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`, `1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`, а также `1234.567` → `1234567`: ровно три цифры после точки, цифр до неё сколько угодно; scan-gateway#37); @@ -27,6 +29,10 @@ _ALLOWED_RE = re.compile(r"[^\d+\-.]") _BRACKET_RE = re.compile(r"\(([^()]*)\)") # запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком # (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор +# пробел между цифрами (AURORA-1511/1519, «42831 1»): допустим только как разделитель тысяч — первая группа 1–3 цифры, +# дальше ровно по три; любой другой — догадка, сумма собирается из цифр, но verified = False +_DIGIT_SPACE_RUN_RE = re.compile(r"\d+(?:\s+\d+)+") +_SPACE_THOUSANDS_RE = re.compile(r"\d{1,3}(?:\s\d{3})+") _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") # то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные # «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке) @@ -54,6 +60,7 @@ def parse_amount(raw: str) -> ParsedAmount: verified=True только если в исходной строке не пришлось отбрасывать символы (после удаления скобок). """ + spaces_ok = all(_SPACE_THOUSANDS_RE.fullmatch(run.group(0)) for run in _DIGIT_SPACE_RUN_RE.finditer(raw.strip())) s = raw.strip().replace(" ", "") inner = _BRACKET_RE.search(s) core = inner.group(1) if inner and inner.group(0) == s else s @@ -79,9 +86,9 @@ def parse_amount(raw: str) -> ParsedAmount: if bracketed: s = "-" + s.lstrip("+-") try: - return ParsedAmount(Decimal(s), verified) + return ParsedAmount(Decimal(s), verified and spaces_ok) except (InvalidOperation, ValueError): - return ParsedAmount(None, verified) + return ParsedAmount(None, verified and spaces_ok) def is_blank(text: str) -> bool: diff --git a/api/src/normalizer/ocr/rows.py b/api/src/normalizer/ocr/rows.py index ecc866b..5b79ead 100644 --- a/api/src/normalizer/ocr/rows.py +++ b/api/src/normalizer/ocr/rows.py @@ -3,6 +3,7 @@ выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке. Расхождение с источником — склеенные строки ИС (`1500\n1700`) разлепляются до классификации (OQ-156, решение владельца 08.10.2026).""" +import re from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass from enum import StrEnum @@ -16,6 +17,7 @@ __all__ = [ "ParsedTable", "RowKind", "classify_row", + "is_numbering_row", "parse_table_rows", "split_merged_row", "table_type_by_codes", @@ -71,6 +73,20 @@ def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None: return RowKind.ROW +_NUMBERING_CELL_RE = re.compile(r"\d{1,2}") +_MIN_NUMBERING_CELLS = 3 + + +def is_numbering_row(cells: Cells) -> bool: + """AURORA-1517: строка нумерации колонок «1 2 3 4 5» под шапкой — часть заголовка. Все непустые ячейки — короткие + (1–2 цифры) целые строго по возрастанию, ячеек не меньше трёх (как `is_numbering_row` в tabular-parser).""" + texts = [cell.value.strip() for _, cell in sorted(cells.items()) if cell.value.strip()] + if len(texts) < _MIN_NUMBERING_CELLS or not all(_NUMBERING_CELL_RE.fullmatch(t) for t in texts): + return False + numbers = [int(t) for t in texts] + return all(a < b for a, b in zip(numbers, numbers[1:], strict=False)) + + def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None: kind = classify_row(cells, layout) if kind is None or kind is RowKind.HEADER: @@ -119,6 +135,8 @@ def split_merged_row(cells: Cells, layout: ColumnLayout) -> list[Cells] | None: def _data_rows(cells: Cells, layout: ColumnLayout, ordinal: int, skipped: _Skipped) -> list[ParsedRow]: """Строки данных из одной строки OCR: склеенная разлепляется (OQ-156), отброшенная — учитывается в `skipped`.""" + if is_numbering_row(cells): # часть заголовка: в данные не попадает и в счётчики отброшенного не идёт + return [] split = split_merged_row(cells, layout) if split is not None: skipped.split_merged_codes += 1 diff --git a/api/tests/unit/normalizer/test_amounts.py b/api/tests/unit/normalizer/test_amounts.py index b01d616..dacbcd9 100644 --- a/api/tests/unit/normalizer/test_amounts.py +++ b/api/tests/unit/normalizer/test_amounts.py @@ -88,6 +88,18 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount ("abc(4.200)", Decimal("-4.200"), False), ("(4.200)(1.000)", Decimal("-4.200"), False), ("1.234(5.678)", Decimal("-5.678"), False), + # пробел внутри суммы допустим только как разделитель тысяч (AURORA-1511/1519): иначе догадка, verified=False + ("42831 1", Decimal("428311"), False), + ("1 2", Decimal("12"), False), + ("1234 567", Decimal("1234567"), False), + ("12 34", Decimal("1234"), False), + ("1 2345", Decimal("12345"), False), + ("(42831 1)", Decimal("-428311"), False), + ("1 234,5 6", Decimal("1234.56"), False), + ("12 345 678", Decimal("12345678"), True), + ("(12 345)", Decimal("-12345"), True), + ("-1 234", Decimal("-1234"), True), + ("123 456", Decimal("123456"), True), # №13: скобки без содержимого — не сумма, но и не «неразобранный текст» ("()", None, True), ("( )", None, True), diff --git a/api/tests/unit/normalizer/test_rows.py b/api/tests/unit/normalizer/test_rows.py index a05715d..543882d 100644 --- a/api/tests/unit/normalizer/test_rows.py +++ b/api/tests/unit/normalizer/test_rows.py @@ -10,6 +10,7 @@ from src.normalizer.ocr.rows import ( ParsedTable, RowKind, classify_row, + is_numbering_row, parse_table_rows, table_type_by_codes, table_type_by_unanimous_codes, @@ -71,6 +72,41 @@ class TestParseTableRows: assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4)) assert parsed.rows[1].ordinal == 1 + def test_numbering_row_is_not_data(self) -> None: + """AURORA-1517: строка «1 2 3 4 5» под шапкой — часть заголовка, не строка данных с кодом «3».""" + rows = [ + HEADER, + _row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (4, "4", 100)), + _row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None + assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW] + assert parsed.rows[1].ordinal == 2 + assert parsed.skipped_non_numeric_code == 0 and parsed.skipped_no_code_with_amounts == 0 + + def test_numbering_row_with_empty_cells(self) -> None: + rows = [ + HEADER, + _row(1, (0, "1", 100), (1, "2", 100), (2, "3", 100), (3, "", 100), (4, "5", 100)), + _row(2, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW] + + @pytest.mark.parametrize( + "values", + [ + ("1", "2"), # меньше трёх ячеек + ("5", "3", "4"), # не по возрастанию + ("1", "2", "2"), + ("1105", "2", "3"), # не короткие + ("Выручка", "2110", "5"), + ], + ) + def test_not_numbering_row(self, values: tuple[str, ...]) -> None: + assert not is_numbering_row(cells_by_index(_row(1, *((i, v, 100) for i, v in enumerate(values))))) + def test_second_header_overrides_layout(self) -> None: """Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке.""" rows = [