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>
This commit is contained in:
Andrey Epifancev 2026-10-15 16:59:32 +04:00
parent 1f308ddfe3
commit 9dd568ce44
4 changed files with 24 additions and 13 deletions

View File

@ -169,7 +169,7 @@ ack (с `attempt` повторного запроса); файл в ИС вто
`string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не `string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не
отбрасывал символов`. отбрасывал символов`.
- **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками - **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками
(`1,234`, `0,125`: пробел тысяч, распознанный как запятая) не принимается: `amount = null`, `string_value` — текст (`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35) не принимается: `amount = null`, `string_value` — текст
ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Сумма вида ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Сумма вида
`1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) — `1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
с `is_recognized = false`. с `is_recognized = false`.

View File

@ -1,11 +1,11 @@
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — """Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`).
Что делает разбор: если текст целиком (после strip, без пробелов, с учётом скобок — берётся содержимое первой пары — и Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
знака) имеет вид `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`,
`(4,200)` → `-4200`, `1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — `1,234,567` → `1234567`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца
решение владельца 15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. 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`). (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого `verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
@ -23,7 +23,8 @@ __all__ = ["ParsedAmount", "is_blank", "parse_amount"]
_BLANK_CHARS_RE = re.compile(r"[\s\-–—]") _BLANK_CHARS_RE = re.compile(r"[\s\-–—]")
_ALLOWED_RE = re.compile(r"[^\d+\-.]") _ALLOWED_RE = re.compile(r"[^\d+\-.]")
_BRACKET_RE = re.compile(r"\(([^()]*)\)") _BRACKET_RE = re.compile(r"\(([^()]*)\)")
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): «4,200», «-1,234,567»; проверяется на всей строке # запятая-разделитель тысяч (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})+") _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
@ -36,9 +37,9 @@ class ParsedAmount:
def parse_amount(raw: str) -> ParsedAmount: def parse_amount(raw: str) -> ParsedAmount:
"""Парсит сумму из распознанного текста OCR-ячейки. """Парсит сумму из распознанного текста OCR-ячейки.
Если содержимое (без пробелов, с учётом скобок) целиком вида `[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида
(разделители тысяч: `4,200` → 4200) без потери verified. Затем заменяет запятую на точку, удаляет пробелы, `[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые (разделители тысяч: `4,200` → 4200) без потери verified.
извлекает содержимое круглых скобок Затем заменяет запятую на точку, извлекает содержимое круглых скобок
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
@ -47,7 +48,7 @@ def parse_amount(raw: str) -> ParsedAmount:
""" """
s = raw.strip().replace(" ", "") s = raw.strip().replace(" ", "")
inner = _BRACKET_RE.search(s) inner = _BRACKET_RE.search(s)
if _THOUSANDS_RE.fullmatch(inner.group(1) if inner else s): if _THOUSANDS_RE.fullmatch(inner.group(1) if inner and inner.group(0) == s else s):
s = s.replace(",", "") s = s.replace(",", "")
s = s.replace(",", ".") s = s.replace(",", ".")
stripped_raw = s stripped_raw = s

View File

@ -40,6 +40,16 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("12,3456", Decimal("12.3456"), True), ("12,3456", Decimal("12.3456"), True),
("4,200,", Decimal("4200"), False), # хвостовая запятая: правило не применяется (две точки удалены, как раньше) ("4,200,", Decimal("4200"), False), # хвостовая запятая: правило не применяется (две точки удалены, как раньше)
("(4,200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится ("(4,200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится
# скобки не на всю строку — правило тысяч не применяется, прежний разбор (запятая → точка)
("(4,200) руб", Decimal("-4.200"), False),
("abc(4,200)", Decimal("-4.200"), False),
("(4,200)x", Decimal("-4.200"), False),
("(4,200)(1,000)", Decimal("-4.200"), False),
("1,234(5,678)", Decimal("-5.678"), False),
("((4,200))", Decimal("-4.200"), True),
("01,234", Decimal("1.234"), True), # ведущий ноль — не тысячи
("4,2000", Decimal("4.2000"), True),
("1, 234", Decimal("1234"), True), # пробелы убираются первыми
# №13: скобки без содержимого — не сумма, но и не «неразобранный текст» # №13: скобки без содержимого — не сумма, но и не «неразобранный текст»
("()", None, True), ("()", None, True),
("( )", None, True), ("( )", None, True),

View File

@ -238,8 +238,8 @@ SHALL отражать состояние.
имени, иначе `confidence` ячейки наименования. имени, иначе `confidence` ячейки наименования.
- `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок — - `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок —
недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`1,234`) не помещается в `Money` контракта — `amount = null`, отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`0,125`) не помещается в `Money` контракта — `amount = null`,
`string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как `string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как
распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано, распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано,
если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `is_recognized = confidence == 100 и разбор не отбрасывал символов`; если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `is_recognized = confidence == 100 и разбор не отбрасывал символов`;