feat(normalizer): разбор сумм OCR — перенос _parse_amount (§11.1 п. 7)
Переносим функции парсинга сумм и проверки пустоты из старого ядра: замена запятой, удаление пробелов, извлечение круглых скобок (отрицательное), множественные точки как разделители тысяч. Отличие: verified вычисляется ДО постановки минуса (решение №13 design.md), чтобы не отмечать суммы в скобках как неверифицированные.
This commit is contained in:
parent
5f56f62dbb
commit
60092b8293
57
api/src/normalizer/ocr/amounts.py
Normal file
57
api/src/normalizer/ocr/amounts.py
Normal 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)
|
||||||
35
api/tests/unit/normalizer/test_amounts.py
Normal file
35
api/tests/unit/normalizer/test_amounts.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user