From 60092b82936c01b8289c8a75400fe7d9675842e8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 00:10:20 +0400 Subject: [PATCH] =?UTF-8?q?feat(normalizer):=20=D1=80=D0=B0=D0=B7=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D1=81=D1=83=D0=BC=D0=BC=20OCR=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=5Fparse=5Famount?= =?UTF-8?q?=20(=C2=A711.1=20=D0=BF.=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переносим функции парсинга сумм и проверки пустоты из старого ядра: замена запятой, удаление пробелов, извлечение круглых скобок (отрицательное), множественные точки как разделители тысяч. Отличие: verified вычисляется ДО постановки минуса (решение №13 design.md), чтобы не отмечать суммы в скобках как неверифицированные. --- api/src/normalizer/ocr/amounts.py | 57 +++++++++++++++++++++++ api/tests/unit/normalizer/test_amounts.py | 35 ++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 api/src/normalizer/ocr/amounts.py create mode 100644 api/tests/unit/normalizer/test_amounts.py 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