fix(normalizer): строка нумерации колонок и пробелы внутри суммы (AURORA-1517, 1511, 1519) #40

Merged
aep merged 1 commits from feat/39-ocr-numbering-and-spaces into main 2026-10-18 00:19:09 +03:00
4 changed files with 76 additions and 3 deletions

View File

@ -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:

View File

@ -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

View File

@ -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),

View File

@ -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 = [