feat(normalizer): точка — разделитель тысяч в суммах OCR (AURORA-1405, #37)

«4.200» разбирался как 4.200 с тремя знаками и отсекался решением №30
(amount = null, текст в string_value). Теперь точки строго через три цифры
([+-]?[1-9]\d{0,2}(\.\d{3})+, скобки на всю строку) — разделители тысяч,
как для запятой (#35): 4.200 -> 4200, (4.200) -> -4200, 1.234.567 -> 1234567,
verified не страдает. Дроби и смешанные записи (1.234,56) — как раньше.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 13:29:09 +04:00
parent 18842e0796
commit 68ffc5bc24
5 changed files with 88 additions and 9 deletions

View File

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

View File

@ -3,8 +3,9 @@
Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
`[+-]?[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`); это не отбрасывание символов, `verified` не страдает. Правило тысяч — решение владельца `1,234,567` → `1234567`); то же для точек (`4.200`, `837.994`, `(4.200)`, `1.234.567`; scan-gateway#37);
15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка; это не отбрасывание символов, `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`). (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
@ -26,6 +27,9 @@ _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)» — прежний разбор # (или содержимое пары скобок, занимающей всю строку) совпала; «(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})+")
# то же для точки (AURORA-1405, scan-gateway#37): ИС Сканирование иногда возвращает «4.200», «837.994»; смешанные
# «1.234,56» / «1,234.56» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке)
_DOT_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:\.\d{3})+")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -38,7 +42,8 @@ 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})+` или `[+-]?[1-9]\\d{0,2}(\\.\\d{3})+` — удаляет запятые или точки (разделители
тысяч: `4,200`, `4.200` → 4200) без потери verified.
Затем заменяет запятую на точку, извлекает содержимое круглых скобок Затем заменяет запятую на точку, извлекает содержимое круглых скобок
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
@ -48,8 +53,11 @@ 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 and inner.group(0) == s else s): core = inner.group(1) if inner and inner.group(0) == s else s
if _THOUSANDS_RE.fullmatch(core):
s = s.replace(",", "") s = s.replace(",", "")
elif _DOT_THOUSANDS_RE.fullmatch(core):
s = s.replace(".", "")
s = s.replace(",", ".") s = s.replace(",", ".")
stripped_raw = s stripped_raw = s
if not stripped_raw: if not stripped_raw:

View File

@ -14,7 +14,7 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("(6032409)", Decimal("-6032409"), True), ("(6032409)", Decimal("-6032409"), True),
("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12) ("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12)
("1 234,56", Decimal("1234.56"), True), ("1 234,56", Decimal("1234.56"), True),
("1.234.567", Decimal("1234567"), False), # несколько точек — разделители тысяч; текст изменён → не verified ("1.234.567", Decimal("1234567"), True), # точки строго через три цифры — разделители тысяч (AURORA-1405, #37)
("30127", Decimal("30127"), True), ("30127", Decimal("30127"), True),
("211(1 |", Decimal("2111"), False), ("211(1 |", Decimal("2111"), False),
("не ****", None, False), ("не ****", None, False),
@ -50,6 +50,34 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
("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), # пробелы убираются первыми
# точка-разделитель тысяч (AURORA-1405, scan-gateway#37): то же правило, что для запятой, verified=True
("4.200", Decimal("4200"), True),
("837.994", Decimal("837994"), True),
("(4.200)", Decimal("-4200"), True),
("-4.200", Decimal("-4200"), True),
("+4.200", Decimal("4200"), True),
("(-4.200)", Decimal("-4200"), True),
("1.234", Decimal("1234"), True),
("100.000", Decimal("100000"), True),
("1. 234", Decimal("1234"), True), # пробелы убираются первыми
# не тысячи: не три цифры после точки — десятичная дробь, как раньше
("4.2", Decimal("4.2"), True),
("4.20", Decimal("4.20"), True),
("12.34", Decimal("12.34"), True),
("1.2345", Decimal("1.2345"), True),
("0.125", Decimal("0.125"), True), # ведущий ноль — не тысячи
("01.234", Decimal("1.234"), True),
("1234.567", Decimal("1234.567"), True), # четыре цифры до точки — не группа тысяч
# смешанные разделители и прочий мусор — правило не применяется (R-1): прежний разбор
("1.234,567", Decimal("1234567"), False),
("1,234.567", Decimal("1234567"), False),
("1.234.56", Decimal("123456"), False),
("4.200.", Decimal("4200"), False), # хвостовая точка
("(4.200}", Decimal("4.200"), False), # нет пары скобок: правило не применяется, минус не ставится
("(4.200) руб", Decimal("-4.200"), False),
("abc(4.200)", Decimal("-4.200"), False),
("(4.200)(1.000)", Decimal("-4.200"), False),
("1.234(5.678)", Decimal("-5.678"), False),
# №13: скобки без содержимого — не сумма, но и не «неразобранный текст» # №13: скобки без содержимого — не сумма, но и не «неразобранный текст»
("()", None, True), ("()", None, True),
("( )", None, True), ("( )", None, True),

View File

@ -492,6 +492,48 @@ class TestMoneyPrecision:
value = _build(table).document.lines[0].values[0] value = _build(table).document.lines[0].values[0]
assert value.amount == amount and value.is_recognized is True and value.string_value is None assert value.amount == amount and value.is_recognized is True and value.string_value is None
@pytest.mark.parametrize(
("text", "amount"),
[
("4.200", Decimal("4200")),
("837.994", Decimal("837994")),
("(4.200)", Decimal("-4200")),
("1.234.567", Decimal("1234567")),
],
)
def test_thousands_dot_gives_amount(self, text: str, amount: Decimal) -> None:
"""AURORA-1405, scan-gateway#37: ИС вернула точку как разделитель тысяч — «4.200» это 4200, не текст (№30)."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount == amount and value.is_recognized is True and value.string_value is None
def test_thousands_dot_low_confidence_is_not_recognized(self) -> None:
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "4.200", 90), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
assert value.amount == Decimal("4200") and value.is_recognized is False
@pytest.mark.parametrize("text", ["4.20", "4.2", "1.2345"])
def test_dot_fraction_is_not_thousands(self, text: str) -> None:
"""Не три цифры после точки — дробь, как раньше: до двух знаков принимается, больше — текст (№30)."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
)
value = _build(table).document.lines[0].values[0]
if text == "1.2345":
assert value.amount is None and value.string_value == text
else:
assert value.amount == Decimal(text)
@pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"]) @pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"])
def test_up_to_two_decimals_is_kept(self, text: str) -> None: def test_up_to_two_decimals_is_kept(self, text: str) -> None:
table = _table( table = _table(

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) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35; так же точка строго через три цифры: `4.200`, `837.994` → 4200, 837994, #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: «как распознано,