Merge pull request 'feat(normalizer): точка — разделитель тысяч в суммах OCR (AURORA-1405, #37)' (#38) from feat/37-dot-thousands into main

This commit is contained in:
aep 2026-10-16 12:36:19 +03:00
commit 36d3f5fbf0
5 changed files with 105 additions and 9 deletions

View File

@ -169,9 +169,12 @@ 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.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) — (`[+-]?[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` — все точки считаются разделителями тысяч (дословный перенос) —
с `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,10 @@
Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
`[+-]?[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`, а также `1234.567` →
15.10.2026 (AURORA-1405, scan-gateway#35), см. docs/context/05-domain-rules.md §11.1 п. 7. Затем запятая → точка; `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`). (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
@ -26,6 +28,11 @@ _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» не подходят ни под одну из двух регулярок (разделитель один и тот же по всей строке)
# Для точки — шире, чем для запятой (решение владельца 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)
@ -38,7 +45,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*(\\.\\d{3})+` — удаляет запятые или точки (разделители
тысяч: `4,200`, `4.200` → 4200) без потери verified.
Затем заменяет запятую на точку, извлекает содержимое круглых скобок Затем заменяет запятую на точку, извлекает содержимое круглых скобок
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
@ -48,8 +56,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),
@ -47,9 +47,47 @@ 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), # пробелы убираются первыми
# точка-разделитель тысяч (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), # пробелы убираются первыми
# ровно три цифры после точки — тысячи независимо от числа цифр до неё (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.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.56", Decimal("1234.56"), True), # дробная часть не из трёх цифр — дробь
("1234.5678", Decimal("1234.5678"), 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,50 @@ 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")),
("1234.567", Decimal("1234567")),
("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`, `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: «как распознано,