97 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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