diff --git a/api/src/normalizer/ocr/amounts.py b/api/src/normalizer/ocr/amounts.py new file mode 100644 index 0000000..c79d92d --- /dev/null +++ b/api/src/normalizer/ocr/amounts.py @@ -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) diff --git a/api/tests/unit/normalizer/test_amounts.py b/api/tests/unit/normalizer/test_amounts.py new file mode 100644 index 0000000..6f0e24f --- /dev/null +++ b/api/tests/unit/normalizer/test_amounts.py @@ -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