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

This commit is contained in:
aep 2026-10-15 17:37:17 +03:00
commit 18842e0796
6 changed files with 67 additions and 18 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,14 +1,18 @@
"""Суммы из 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`).
Что делает разбор: запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма Что делает разбор: пробелы долой; если текст целиком (а при скобках на всю строку — их содержимое) имеет вид
`[+-]?[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`). (`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого `verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`. (`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`.
Количество знаков после запятой здесь не проверяется: `1,234` → `1.234`; ограничение `Money` (не больше двух знаков) — Количество знаков после запятой здесь не проверяется: `0,125` → `0.125`, `12,3456` → `12.3456`;
в `document._value` (решение №30).""" ограничение `Money` (не больше двух знаков) — в `document._value` (решение №30)."""
import re import re
from dataclasses import dataclass from dataclasses import dataclass
@ -19,6 +23,9 @@ __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»; только если строка целиком
# (или содержимое пары скобок, занимающей всю строку) совпала; «(4,200) руб», «1,234(5,678)» — прежний разбор
_THOUSANDS_RE = re.compile(r"[+-]?[1-9]\d{0,2}(?:,\d{3})+")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -30,14 +37,20 @@ 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.234,56` → 123456). тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
verified=True только если в исходной строке не пришлось отбрасывать символы verified=True только если в исходной строке не пришлось отбрасывать символы
(после удаления скобок). (после удаления скобок).
""" """
s = raw.strip().replace(",", ".").replace(" ", "") 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 stripped_raw = s
if not stripped_raw: if not stripped_raw:
return ParsedAmount(None, stripped_raw == raw) return ParsedAmount(None, stripped_raw == raw)

View File

@ -142,8 +142,9 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu
amount = parsed.amount amount = parsed.amount
verified = parsed.verified verified = parsed.verified
if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES: if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES:
# решение №30: `Money` контракта — не больше двух знаков; «1,234» (пробел тысяч как запятая) — артефакт OCR, # решение №30: `Money` контракта — не больше двух знаков; «1,234» теперь = 1234 (правило тысяч в parse_amount,
# сумма не принимается, текст остаётся в string_value, иначе ValidationError ронял бы всю таблицу # AURORA-1405), а «0,125», «12.3456» и т. п. — артефакт OCR: сумма не принимается, текст остаётся в
# string_value, иначе ValidationError ронял бы всю таблицу
amount, verified = None, False amount, verified = None, False
# DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана
string_value = text if amount is None and text and not is_blank(text) else None string_value = text if amount is None and text and not is_blank(text) else None

View File

@ -24,11 +24,32 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
# несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified # несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified
("1.234,56", Decimal("123456"), False), ("1.234,56", Decimal("123456"), False),
("1,234.56", Decimal("123456"), False), ("1,234.56", Decimal("123456"), False),
( # запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): удаляется до замены запятой на точку, verified=True
"1,234", ("1,234", Decimal("1234"), True),
Decimal("1.234"), ("4,200", Decimal("4200"), True),
True, ("(4,200)", Decimal("-4200"), True),
), # знаков после запятой больше двух — `Money` отсекает `document._value` (№30) ("1,234,567", Decimal("1234567"), True),
("-4,200", Decimal("-4200"), True),
("+4,200", Decimal("4200"), True),
("(-4,200)", Decimal("-4200"), True),
("4,2", Decimal("4.2"), True),
("12,34", Decimal("12.34"), True),
("1,23", Decimal("1.23"), True),
# не тысячи: больше двух знаков после запятой остаётся десятичным — `Money` отсекает `document._value` (№30)
("0,125", Decimal("0.125"), True),
("12,3456", Decimal("12.3456"), True),
("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)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

@ -450,7 +450,7 @@ class TestKnownCodeConfidence:
class TestMoneyPrecision: class TestMoneyPrecision:
"""Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу.""" """Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу."""
@pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"]) @pytest.mark.parametrize("text", ["12,3456", "0,125", "12.3456", "(0,125)"])
def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None: def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None:
table = _table( table = _table(
"Бухгалтерский баланс", "Бухгалтерский баланс",
@ -467,17 +467,31 @@ class TestMoneyPrecision:
table = _table( table = _table(
"Бухгалтерский баланс", "Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0], CLEAN_BALANCE["rows"][0],
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "0,125", 100), (3, "56,78", 100)),
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)), _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)),
) )
built = _build(table) built = _build(table)
first, second = built.document.lines first, second = built.document.lines
assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234") assert (first.values[0].amount, first.values[0].string_value) == (None, "0,125")
assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True) assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True)
assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True) assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True)
assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True) assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True)
built.document.to_wire() # контрактная модель сериализуется без ValidationError built.document.to_wire() # контрактная модель сериализуется без ValidationError
@pytest.mark.parametrize(
("text", "amount"),
[("4,200", Decimal("4200")), ("(4,200)", Decimal("-4200")), ("1,234,567", Decimal("1234567"))],
)
def test_thousands_comma_gives_amount(self, text: str, amount: Decimal) -> None:
"""AURORA-1405, scan-gateway#35: «4,200» в PDF — это 4200, а не 4.200 (отсекаемое №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
@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,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 и разбор не отбрасывал символов`;