I-1: «(4,200) руб», «1,234(5,678)» и т. п. разбираются как прежде; I-2/I-3: спека и README; M-1..M-3: docstring, комментарий регулярки, тесты крайних случаев. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
5.2 KiB
Python
79 lines
5.2 KiB
Python
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
|
||
порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`).
|
||
|
||
Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
|
||
`[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`,
|
||
`1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца
|
||
15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка;
|
||
если есть круглые скобки — берётся содержимое первой пары, и сумма
|
||
отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки
|
||
(`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
|
||
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
|
||
(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`.
|
||
|
||
Количество знаков после запятой здесь не проверяется: `0,125` → `0.125`, `12,3456` → `12.3456`;
|
||
ограничение `Money` (не больше двух знаков) — в `document._value` (решение №30)."""
|
||
|
||
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"\(([^()]*)\)")
|
||
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; только если строка целиком
|
||
# (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор
|
||
_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ParsedAmount:
|
||
amount: Decimal | None
|
||
verified: bool
|
||
|
||
|
||
def parse_amount(raw: str) -> ParsedAmount:
|
||
"""Парсит сумму из распознанного текста OCR-ячейки.
|
||
|
||
Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида
|
||
`[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые (разделители тысяч: `4,200` → 4200) без потери verified.
|
||
Затем заменяет запятую на точку, извлекает содержимое круглых скобок
|
||
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
|
||
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
|
||
|
||
verified=True только если в исходной строке не пришлось отбрасывать символы
|
||
(после удаления скобок).
|
||
"""
|
||
s = raw.strip().replace(" ", "")
|
||
inner = _BRACKET_RE.search(s)
|
||
if _THOUSANDS_RE.fullmatch(inner.group(1) if inner and inner.group(0) == s else s):
|
||
s = s.replace(",", "")
|
||
s = s.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)
|