Andrey Epifancev 9dd568ce44 fix(normalizer): правило тысяч только при скобках на всю строку, спека и README (AURORA-1405, #35)
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>
2026-10-15 16:59:32 +04:00

79 lines
5.2 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-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)