"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). Что делает разбор: пробелы долой (но пробел между цифрами вне схемы тысяч `1 234`, `12 345 678` — догадка, `verified` сбрасывается: «42831 1» → 428311, не чистое; AURORA-1511/1519); если текст целиком (а при скобках на всю строку — их содержимое) имеет вид `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`, `1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`, а также `1234.567` → `1234567`: ровно три цифры после точки, цифр до неё сколько угодно; scan-gateway#37); это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца 15.10.2026 (AURORA-1405, scan-gateway#35; для точки — 16.10.2026, #37), см. 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)» — прежний разбор # пробел между цифрами (AURORA-1511/1519, «42831 1»): допустим только как разделитель тысяч — первая группа 1–3 цифры, # дальше ровно по три; любой другой — догадка, сумма собирается из цифр, но verified = False _DIGIT_SPACE_RUN_RE = re.compile(r"\d+(?:\s+\d+)+") _SPACE_THOUSANDS_RE = re.compile(r"\d{1,3}(?:\s\d{3})+") _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") # то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные # «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке) # Для точки — шире, чем для запятой (решение владельца 16.10.2026): ровно три цифры после каждой точки, число цифр до # первой точки любое («1234.567», «12345.678»); первая цифра ненулевая. Запятая остаётся строго `\d{0,2}`. _DOT_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d*(?:\.\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})+` или `[+-]?[1-9]\\d*(\\.\\d{3})+` — удаляет запятые или точки (разделители тысяч: `4,200`, `4.200` → 4200) без потери verified. Затем заменяет запятую на точку, извлекает содержимое круглых скобок (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). verified=True только если в исходной строке не пришлось отбрасывать символы (после удаления скобок). """ spaces_ok = all(_SPACE_THOUSANDS_RE.fullmatch(run.group(0)) for run in _DIGIT_SPACE_RUN_RE.finditer(raw.strip())) s = raw.strip().replace(" ", "") inner = _BRACKET_RE.search(s) core = inner.group(1) if inner and inner.group(0) == s else s if _THOUSANDS_RE.fullmatch(core): s = s.replace(",", "") elif _DOT_THOUSANDS_RE.fullmatch(core): 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 and spaces_ok) except (InvalidOperation, ValueError): return ParsedAmount(None, verified and spaces_ok) def is_blank(text: str) -> bool: """Прочерк в отчётности означает ноль — считаем пустым значением (NULL, не 0).""" return not _BLANK_CHARS_RE.sub("", text)