Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
97 lines
7.1 KiB
Python
97 lines
7.1 KiB
Python
"""Суммы из 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)
|