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