Merge PR #40: строка нумерации колонок и пробелы внутри суммы (AURORA-1517, 1511, 1519)
This commit is contained in:
commit
f095535250
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user