"""Суммы из 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)