feat(normalizer): точка — тысячи при любом числе цифр до неё, если после неё ровно три (AURORA-1405, #37)

Решение владельца 16.10.2026 (R-3): шаблон точки [+-]?[1-9]\d*(\.\d{3})+ —
«1 234.567», «1234.567», «12345.678», «(1 234.567)» -> тысячи. Дробная часть
не из трёх цифр, ведущий ноль, смешанные записи — как раньше. Правило для
запятой не расширено.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 13:35:31 +04:00
parent 68ffc5bc24
commit dec3f5224f
5 changed files with 24 additions and 7 deletions

View File

@ -170,8 +170,10 @@ ack (с `attempt` повторного запроса); файл в ИС вто
отбрасывал символов`. отбрасывал символов`.
- **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками - **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками
(`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35; точка так же: `4.200`/`837.994` — 4200/837994, #37) не принимается: `amount = null`, `string_value` — текст (`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35; точка так же: `4.200`/`837.994` — 4200/837994, #37) не принимается: `amount = null`, `string_value` — текст
ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Точка-разделитель тысяч — только строго через три цифры (`[+-]?[1-9]\d{0,2}(\.\d{3})+`, скобки на всю ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Точка-разделитель тысяч — ровно три цифры после каждой точки, цифр до первой точки сколько угодно
строку; `4.200`, `(4.200)`, `1.234.567`), без потери `is_recognized`; `4.2`, `4.20` — дроби, `1.2345` — текст (№30). (`[+-]?[1-9]\d*(\.\d{3})+`, скобки на всю строку; `4.200`, `(4.200)`, `1.234.567`, `1234.567`, `1 234.567`), без
потери `is_recognized`; `4.2`, `4.20`, `1234.56` — дроби, `1.2345` — текст (№30); для запятой правило прежнее
(`\d{0,2}` до первой запятой, `1234,567` — не тысячи).
Смешанная сумма вида `1.234,56` (разделители обоих видов) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) — Смешанная сумма вида `1.234,56` (разделители обоих видов) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
с `is_recognized = false`. с `is_recognized = false`.
- `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает - `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает

View File

@ -3,7 +3,8 @@
Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
`[+-]?[1-9]\\d{0,2}(,\\d{3})+`, запятые — разделители тысяч и удаляются (`4,200` → `4200`, `(4,200)` → `-4200`, `[+-]?[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`; scan-gateway#37); `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, это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца 15.10.2026 (AURORA-1405,
scan-gateway#35; для точки — 16.10.2026, #37), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка; scan-gateway#35; для точки — 16.10.2026, #37), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка;
если есть круглые скобки — берётся содержимое первой пары, и сумма если есть круглые скобки — берётся содержимое первой пары, и сумма
@ -29,7 +30,9 @@ _BRACKET_RE = re.compile(r"\(([^()]*)\)")
_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+") _THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
# то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные # то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные
# «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке) # «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке)
_DOT_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:\.\d{3})+") # Для точки — шире, чем для запятой (решение владельца 16.10.2026): ровно три цифры после каждой точки, число цифр до
# первой точки любое («1234.567», «12345.678»); первая цифра ненулевая. Запятая остаётся строго `\d{0,2}`.
_DOT_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d*(?:\.\d{3})+")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -42,7 +45,7 @@ def parse_amount(raw: str) -> ParsedAmount:
"""Парсит сумму из распознанного текста OCR-ячейки. """Парсит сумму из распознанного текста OCR-ячейки.
Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида
`[+-]?[1-9]\\d{0,2}(,\\d{3})+` или `[+-]?[1-9]\\d{0,2}(\\.\\d{3})+` — удаляет запятые или точки (разделители `[+-]?[1-9]\\d{0,2}(,\\d{3})+` или `[+-]?[1-9]\\d*(\\.\\d{3})+` — удаляет запятые или точки (разделители
тысяч: `4,200`, `4.200` → 4200) без потери verified. тысяч: `4,200`, `4.200` → 4200) без потери verified.
Затем заменяет запятую на точку, извлекает содержимое круглых скобок Затем заменяет запятую на точку, извлекает содержимое круглых скобок
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями

View File

@ -47,6 +47,8 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("(4,200)(1,000)", Decimal("-4.200"), False), ("(4,200)(1,000)", Decimal("-4.200"), False),
("1,234(5,678)", Decimal("-5.678"), False), ("1,234(5,678)", Decimal("-5.678"), False),
("((4,200))", Decimal("-4.200"), True), ("((4,200))", Decimal("-4.200"), True),
("1234,567", Decimal("1234.567"), True), # запятая НЕ расширена (R-7): четыре цифры до неё — не тысячи
("1 234,567", Decimal("1234.567"), True),
("01,234", Decimal("1.234"), True), # ведущий ноль — не тысячи ("01,234", Decimal("1.234"), True), # ведущий ноль — не тысячи
("4,2000", Decimal("4.2000"), True), ("4,2000", Decimal("4.2000"), True),
("1, 234", Decimal("1234"), True), # пробелы убираются первыми ("1, 234", Decimal("1234"), True), # пробелы убираются первыми
@ -60,6 +62,13 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("1.234", Decimal("1234"), True), ("1.234", Decimal("1234"), True),
("100.000", Decimal("100000"), True), ("100.000", Decimal("100000"), True),
("1. 234", Decimal("1234"), True), # пробелы убираются первыми ("1. 234", Decimal("1234"), True), # пробелы убираются первыми
# ровно три цифры после точки — тысячи независимо от числа цифр до неё (R-3, решение владельца 16.10.2026)
("1 234.567", Decimal("1234567"), True),
("1234.567", Decimal("1234567"), True),
("12345.678", Decimal("12345678"), True),
("(1 234.567)", Decimal("-1234567"), True),
("-1234.567", Decimal("-1234567"), True),
("1234.567.890", Decimal("1234567890"), True),
# не тысячи: не три цифры после точки — десятичная дробь, как раньше # не тысячи: не три цифры после точки — десятичная дробь, как раньше
("4.2", Decimal("4.2"), True), ("4.2", Decimal("4.2"), True),
("4.20", Decimal("4.20"), True), ("4.20", Decimal("4.20"), True),
@ -67,7 +76,8 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("1.2345", Decimal("1.2345"), True), ("1.2345", Decimal("1.2345"), True),
("0.125", Decimal("0.125"), True), # ведущий ноль — не тысячи ("0.125", Decimal("0.125"), True), # ведущий ноль — не тысячи
("01.234", Decimal("1.234"), True), ("01.234", Decimal("1.234"), True),
("1234.567", Decimal("1234.567"), True), # четыре цифры до точки — не группа тысяч ("1234.56", Decimal("1234.56"), True), # дробная часть не из трёх цифр — дробь
("1234.5678", Decimal("1234.5678"), True),
# смешанные разделители и прочий мусор — правило не применяется (R-1): прежний разбор # смешанные разделители и прочий мусор — правило не применяется (R-1): прежний разбор
("1.234,567", Decimal("1234567"), False), ("1.234,567", Decimal("1234567"), False),
("1,234.567", Decimal("1234567"), False), ("1,234.567", Decimal("1234567"), False),

View File

@ -499,6 +499,8 @@ class TestMoneyPrecision:
("837.994", Decimal("837994")), ("837.994", Decimal("837994")),
("(4.200)", Decimal("-4200")), ("(4.200)", Decimal("-4200")),
("1.234.567", Decimal("1234567")), ("1.234.567", Decimal("1234567")),
("1234.567", Decimal("1234567")),
("1 234.567", Decimal("1234567")),
], ],
) )
def test_thousands_dot_gives_amount(self, text: str, amount: Decimal) -> None: def test_thousands_dot_gives_amount(self, text: str, amount: Decimal) -> None:

View File

@ -238,7 +238,7 @@ SHALL отражать состояние.
имени, иначе `confidence` ячейки наименования. имени, иначе `confidence` ячейки наименования.
- `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок — - `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок —
недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35; так же точка строго через три цифры: `4.200`, `837.994` → 4200, 837994, #37) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35; так же точка, после которой ровно три цифры, при любом числе цифр до неё: `4.200`, `837.994`, `1234.567` → 4200, 837994, 1234567, #37) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`0,125`) не помещается в `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: «как распознано,