feat(normalizer): запятая — разделитель тысяч в суммах OCR (AURORA-1405, #35) #36
@ -169,7 +169,7 @@ ack (с `attempt` повторного запроса); файл в ИС вто
|
||||
`string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не
|
||||
отбрасывал символов`.
|
||||
- **`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`, остальные ячейки и документ целы). Сумма вида
|
||||
`1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
|
||||
с `is_recognized = false`.
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
|
||||
порядком вычисления `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`).
|
||||
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
|
||||
(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`.
|
||||
|
||||
Количество знаков после запятой здесь не проверяется: `1,234` → `1.234`; ограничение `Money` (не больше двух знаков) —
|
||||
в `document._value` (решение №30)."""
|
||||
Количество знаков после запятой здесь не проверяется: `0,125` → `0.125`, `12,3456` → `12.3456`;
|
||||
ограничение `Money` (не больше двух знаков) — в `document._value` (решение №30)."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@ -19,6 +23,9 @@ __all__ = ["ParsedAmount", "is_blank", "parse_amount"]
|
||||
_BLANK_CHARS_RE = re.compile(r"[\s\-–—]")
|
||||
_ALLOWED_RE = re.compile(r"[^\d+\-.]")
|
||||
_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)
|
||||
@ -30,14 +37,20 @@ class ParsedAmount:
|
||||
def parse_amount(raw: str) -> ParsedAmount:
|
||||
"""Парсит сумму из распознанного текста OCR-ячейки.
|
||||
|
||||
Заменяет запятую на точку, удаляет пробелы, извлекает содержимое круглых скобок
|
||||
Убирает пробелы; если строка целиком (или пара скобок на всю строку — её содержимое) вида
|
||||
`[+-]?[1-9]\\d{0,2}(,\\d{3})+` — удаляет запятые (разделители тысяч: `4,200` → 4200) без потери verified.
|
||||
Затем заменяет запятую на точку, извлекает содержимое круглых скобок
|
||||
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
|
||||
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
|
||||
|
||||
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
|
||||
if not stripped_raw:
|
||||
return ParsedAmount(None, stripped_raw == raw)
|
||||
|
||||
@ -142,8 +142,9 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu
|
||||
amount = parsed.amount
|
||||
verified = parsed.verified
|
||||
if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES:
|
||||
# решение №30: `Money` контракта — не больше двух знаков; «1,234» (пробел тысяч как запятая) — артефакт OCR,
|
||||
# сумма не принимается, текст остаётся в string_value, иначе ValidationError ронял бы всю таблицу
|
||||
# решение №30: `Money` контракта — не больше двух знаков; «1,234» теперь = 1234 (правило тысяч в parse_amount,
|
||||
# AURORA-1405), а «0,125», «12.3456» и т. п. — артефакт OCR: сумма не принимается, текст остаётся в
|
||||
# string_value, иначе ValidationError ронял бы всю таблицу
|
||||
amount, verified = None, False
|
||||
# DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана
|
||||
string_value = text if amount is None and text and not is_blank(text) else None
|
||||
|
||||
@ -24,11 +24,32 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
|
||||
# несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified
|
||||
("1.234,56", Decimal("123456"), False),
|
||||
("1,234.56", Decimal("123456"), False),
|
||||
(
|
||||
"1,234",
|
||||
Decimal("1.234"),
|
||||
True,
|
||||
), # знаков после запятой больше двух — `Money` отсекает `document._value` (№30)
|
||||
# запятая-разделитель тысяч (AURORA-1405, scan-gateway#35): удаляется до замены запятой на точку, verified=True
|
||||
("1,234", Decimal("1234"), True),
|
||||
("4,200", Decimal("4200"), True),
|
||||
("(4,200)", Decimal("-4200"), True),
|
||||
("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: скобки без содержимого — не сумма, но и не «неразобранный текст»
|
||||
("()", None, True),
|
||||
("( )", None, True),
|
||||
|
||||
@ -450,7 +450,7 @@ class TestKnownCodeConfidence:
|
||||
class TestMoneyPrecision:
|
||||
"""Решение №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:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
@ -467,17 +467,31 @@ class TestMoneyPrecision:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
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)),
|
||||
)
|
||||
built = _build(table)
|
||||
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 (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)
|
||||
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"])
|
||||
def test_up_to_two_decimals_is_kept(self, text: str) -> None:
|
||||
table = _table(
|
||||
|
||||
@ -238,8 +238,8 @@ SHALL отражать состояние.
|
||||
имени, иначе `confidence` ячейки наименования.
|
||||
- `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок —
|
||||
недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила
|
||||
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы
|
||||
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`1,234`) не помещается в `Money` контракта — `amount = null`,
|
||||
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы
|
||||
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`0,125`) не помещается в `Money` контракта — `amount = null`,
|
||||
`string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как
|
||||
распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано,
|
||||
если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `is_recognized = confidence == 100 и разбор не отбрасывал символов`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user