feat(normalizer): разбор сумм OCR — перенос _parse_amount (§11.1 п. 7)

Переносим функции парсинга сумм и проверки пустоты из старого ядра:
замена запятой, удаление пробелов, извлечение круглых скобок (отрицательное),
множественные точки как разделители тысяч. Отличие: verified вычисляется
ДО постановки минуса (решение №13 design.md), чтобы не отмечать суммы
в скобках как неверифицированные.
This commit is contained in:
Andrey Epifancev 2026-10-07 00:10:20 +04:00
parent 5f56f62dbb
commit 60092b8293
2 changed files with 92 additions and 0 deletions

View File

@ -0,0 +1,57 @@
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра без изменений:
запятая → точка, пробелы долой, содержимое круглых скобок — отрицательное, прочие символы отбрасываются, несколько
точек — разделители тысяч (остаётся последняя). `verified` — разбор не отбрасывал символов."""
import re
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
__all__ = ["ParsedAmount", "is_blank", "parse_amount"]
_BLANK_CHARS_RE = re.compile(r"[\s\-–—]")
_ALLOWED_RE = re.compile(r"[^\d+\-.]")
_BRACKET_RE = re.compile(r"\(([^()]*)\)")
@dataclass(frozen=True)
class ParsedAmount:
amount: Decimal | None
verified: bool
def parse_amount(raw: str) -> ParsedAmount:
"""Парсит сумму из распознанного текста OCR-ячейки.
Заменяет запятую на точку, удаляет пробелы, извлекает содержимое круглых скобок
(означает отрицательное число), удаляет непотребные символы, обрабатывает множественные
точки как разделители тысяч (оставляет последнюю).
verified=True только если в исходной строке не пришлось отбрасывать символы
(после удаления скобок).
"""
s = raw.strip().replace(",", ".").replace(" ", "")
stripped_raw = s
if not stripped_raw:
return ParsedAmount(None, stripped_raw == raw)
match = _BRACKET_RE.search(s)
bracketed = match is not None
if match is not None:
s = match.group(1)
s = _ALLOWED_RE.sub("", s)
if s.count(".") > 1:
*head, tail = s.split(".")
s = "".join(head) + tail
# verified считается ДО постановки знака (отличие от источника, решение №13 design.md): в источнике сравнение шло
# после `s = "-" + s`, и любая сумма в скобках получала verified = False, то есть is_recognized = false
verified = s == stripped_raw.replace(")", "").replace("(", "")
if bracketed:
s = "-" + s.lstrip("+-")
try:
return ParsedAmount(Decimal(s), verified)
except (InvalidOperation, ValueError):
return ParsedAmount(None, verified)
def is_blank(text: str) -> bool:
"""Прочерк в отчётности означает ноль — считаем пустым значением (NULL, не 0)."""
return not _BLANK_CHARS_RE.sub("", text)

View File

@ -0,0 +1,35 @@
"""Суммы из OCR-ячеек (§11.1 п. 7): перенос `_parse_amount`/`_is_blank` старого ядра."""
from decimal import Decimal
import pytest
from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
@pytest.mark.parametrize(
("raw", "amount", "verified"),
[
("13 070", Decimal("13070"), True),
("(6032409)", Decimal("-6032409"), True),
("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12)
("1 234,56", Decimal("1234.56"), True),
("1.234.567", Decimal("1234567"), False), # несколько точек — разделители тысяч; текст изменён → не verified
("30127", Decimal("30127"), True),
("211(1 |", Decimal("2111"), False),
("не ****", None, False),
("", None, True),
("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL)
(":", None, False),
],
)
def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None:
assert parse_amount(raw) == ParsedAmount(amount, verified)
@pytest.mark.parametrize(
("raw", "expected"),
[("", True), ("-", True), ("—", True), (" – ", True), ("0", False), ("x", False)],
)
def test_is_blank(raw: str, expected: bool) -> None:
assert is_blank(raw) is expected