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:
parent
18842e0796
commit
68ffc5bc24
@ -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).
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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: «как распознано,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user