feat(parser): нормализация сумм и ИНН (Decimal, текстовые форматы)
to_money — единственное место квантования сумм до 0.01 (ROUND_HALF_UP): число из ячейки (уже Decimal(str(value))) без float-хвостов, текст с пробелами/NBSP/узкими пробелами, запятой, (x), U+2212, экспонентой; прочерки и пусто — None без предупреждения; нечисловой текст, NaN/Infinity, значения вне numeric(18,2) — None с предупреждением; «-0» → 0.00. inn_digits делегирует detect.inn_from_cell (одно правило ИНН для детектора и классификатора). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
5a3d6f9c9e
commit
1f2f050838
96
api/src/parser/numbers.py
Normal file
96
api/src/parser/numbers.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Числа ячеек (design D-5; `05-domain-rules.md` §1.0): суммы -> `Decimal` с двумя знаками, ИНН.
|
||||||
|
|
||||||
|
`to_money` — единственное место парсера, где суммы квантуются до 0.01 (ROUND_HALF_UP) и где разбирается текст.
|
||||||
|
Читатели кладут число как `Decimal(str(value))` без квантования, поэтому float-хвосты (`0.30000000000000004`) приходят
|
||||||
|
сюда уже как точная десятичная запись и округляются предсказуемо. `float` в домене не используется: ни `float()`, ни
|
||||||
|
двоичной арифметики. Предупреждения — короткие русские строки без номера строки; номер строки добавляет вызывающий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from src.parser.detect import inn_from_cell
|
||||||
|
from src.parser.grid import Cell
|
||||||
|
|
||||||
|
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
|
||||||
|
|
||||||
|
MONEY_Q = Decimal("0.01")
|
||||||
|
_ZERO = Decimal("0.00")
|
||||||
|
|
||||||
|
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
||||||
|
# числа с гигантской экспонентой само падает с InvalidOperation
|
||||||
|
_MAX_INTEGER_DIGITS = 16
|
||||||
|
# Порядок меньше -1000 — не шум округления (1e-12 от вычитания в Excel округляется до 0.00), а заведомо нечисловая
|
||||||
|
# запись; граница держит Decimal далеко от пределов контекста
|
||||||
|
_MIN_ADJUSTED = -1000
|
||||||
|
_WARNING_VALUE_LIMIT = 30
|
||||||
|
|
||||||
|
# Прочерки и пустота — «нет значения», не 0 и не ошибка. Это формат записи чисел, а не словарь синонимов
|
||||||
|
_NO_VALUE = frozenset({"-", "—", "–", ""})
|
||||||
|
_WHITESPACE = re.compile(r"[\s ]+")
|
||||||
|
_PARENTHESES = re.compile(r"^\((.*)\)$")
|
||||||
|
_NUMBER = re.compile(r"^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$")
|
||||||
|
_MINUS_SIGN = "−"
|
||||||
|
|
||||||
|
|
||||||
|
class MoneyParse(NamedTuple):
|
||||||
|
value: Decimal | None
|
||||||
|
warning: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(warning: str | None = None) -> MoneyParse:
|
||||||
|
return MoneyParse(None, warning)
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten(raw: str) -> str:
|
||||||
|
return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _quantize(value: Decimal, shown: str) -> MoneyParse:
|
||||||
|
"""Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00."""
|
||||||
|
if not value.is_finite():
|
||||||
|
return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
|
||||||
|
if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS:
|
||||||
|
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
|
||||||
|
try:
|
||||||
|
money = value.quantize(MONEY_Q, rounding=ROUND_HALF_UP)
|
||||||
|
except InvalidOperation:
|
||||||
|
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
|
||||||
|
if money.adjusted() >= _MAX_INTEGER_DIGITS: # 9999999999999999.999 округлилось до 10^16
|
||||||
|
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
|
||||||
|
return MoneyParse(_ZERO if money == 0 else money, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_text(raw: str) -> MoneyParse:
|
||||||
|
compact = _WHITESPACE.sub("", raw).replace(",", ".").replace(_MINUS_SIGN, "-")
|
||||||
|
wrapped = _PARENTHESES.match(compact)
|
||||||
|
if wrapped:
|
||||||
|
compact = "-" + wrapped.group(1)
|
||||||
|
if compact in _NO_VALUE:
|
||||||
|
return _skip()
|
||||||
|
if not _NUMBER.match(compact):
|
||||||
|
return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
|
||||||
|
try:
|
||||||
|
return _quantize(Decimal(compact), raw.strip())
|
||||||
|
except InvalidOperation:
|
||||||
|
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
|
||||||
|
|
||||||
|
|
||||||
|
def to_money(cell: Cell) -> MoneyParse:
|
||||||
|
"""Сумма из ячейки: `Decimal` с двумя знаками либо `None` (пусто, прочерк — без предупреждения; мусор — с ним)."""
|
||||||
|
if cell.kind == "empty":
|
||||||
|
return _skip()
|
||||||
|
if cell.kind == "number":
|
||||||
|
# str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке
|
||||||
|
return _quantize(Decimal(str(cell.value)), str(cell.value))
|
||||||
|
if cell.kind == "text":
|
||||||
|
return _parse_text(str(cell.value))
|
||||||
|
if cell.kind == "bool":
|
||||||
|
return _skip("логическое значение вместо суммы")
|
||||||
|
return _skip("дата вместо суммы")
|
||||||
|
|
||||||
|
|
||||||
|
def inn_digits(cell: Cell) -> str | None:
|
||||||
|
"""ИНН из ячейки: 10 или 12 цифр, иначе `None`. Правило единое с детектором колонки ИНН — `detect.inn_from_cell`."""
|
||||||
|
return inn_from_cell(cell)
|
||||||
212
api/tests/unit/parser/test_numbers.py
Normal file
212
api/tests/unit/parser/test_numbers.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
"""Числа: суммы -> Decimal(0.01), текстовые форматы, пусто -> None; ИНН (design D-5, Review Focus 2)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.parser import detect
|
||||||
|
from src.parser.grid import EMPTY_CELL, Cell
|
||||||
|
from src.parser.numbers import MONEY_Q, MoneyParse, inn_digits, to_money
|
||||||
|
|
||||||
|
MAX_WARNING_LENGTH = 100
|
||||||
|
|
||||||
|
|
||||||
|
def num(value: object) -> Cell:
|
||||||
|
"""Числовая ячейка так, как её кладут читатели: Decimal(str(value))."""
|
||||||
|
return Cell(Decimal(str(value)), "number")
|
||||||
|
|
||||||
|
|
||||||
|
def text(value: str) -> Cell:
|
||||||
|
return Cell(value, "text")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_money(parsed: MoneyParse, expected: str) -> None:
|
||||||
|
assert parsed.warning is None
|
||||||
|
assert parsed.value is not None
|
||||||
|
assert str(parsed.value) == expected # сравнение строкой: точный вид (две цифры, без «-0.00», без хвостов)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Числовые ячейки ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("cell", "expected"),
|
||||||
|
[
|
||||||
|
(num(0.1 + 0.2), "0.30"), # float-хвост 0.30000000000000004 не попадает в результат
|
||||||
|
(num(5), "5.00"),
|
||||||
|
(num(1234567.891), "1234567.89"),
|
||||||
|
(num(2.675), "2.68"), # ROUND_HALF_UP по строковому представлению, а не по двоичному float
|
||||||
|
(num(-2.675), "-2.68"),
|
||||||
|
(num(1.005), "1.01"),
|
||||||
|
(num(0.1 * 3), "0.30"),
|
||||||
|
(num(1e-12), "0.00"), # шум вычитания в Excel
|
||||||
|
(num(123456789012345.67), "123456789012345.67"),
|
||||||
|
(Cell(Decimal("100"), "number"), "100.00"),
|
||||||
|
(Cell(Decimal("1E+3"), "number"), "1000.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_number_cells_are_quantized_half_up(cell: Cell, expected: str) -> None:
|
||||||
|
assert_money(to_money(cell), expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_money_quantum_is_one_kopeck() -> None:
|
||||||
|
assert MONEY_Q == Decimal("0.01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_is_decimal_never_float() -> None:
|
||||||
|
parsed = to_money(num(0.1 + 0.2))
|
||||||
|
|
||||||
|
assert isinstance(parsed.value, Decimal)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cell",
|
||||||
|
[num(0), num(-0.0), num("-0"), num(-0.001), text("-0"), text("(0,00)"), text("-0,00"), text("−0")],
|
||||||
|
)
|
||||||
|
def test_negative_zero_is_normalized(cell: Cell) -> None:
|
||||||
|
assert_money(to_money(cell), "0.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bool_is_none_with_warning() -> None:
|
||||||
|
parsed = to_money(Cell(True, "bool"))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_is_none_with_warning() -> None:
|
||||||
|
parsed = to_money(Cell(datetime.date(2025, 1, 31), "date"))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_cell_is_none_without_warning() -> None:
|
||||||
|
assert to_money(EMPTY_CELL) == MoneyParse(None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["NaN", "Infinity", "-Infinity", "sNaN"])
|
||||||
|
def test_non_finite_number_is_none_with_warning(raw: str) -> None:
|
||||||
|
parsed = to_money(Cell(Decimal(raw), "number"))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["1E+16", "-1E+16", "1E+300", "1E+999999", "-12345678901234567.89"])
|
||||||
|
def test_too_large_number_is_none_with_warning(raw: str) -> None:
|
||||||
|
parsed = to_money(Cell(Decimal(raw), "number"))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_largest_money_value_is_accepted() -> None:
|
||||||
|
assert_money(to_money(num("9999999999999999.99")), "9999999999999999.99")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extremely_small_number_is_none_with_warning() -> None:
|
||||||
|
parsed = to_money(Cell(Decimal("1E-999999"), "number"))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
# --- Текст ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("1 234 567,89", "1234567.89"),
|
||||||
|
("(1 234,00)", "-1234.00"),
|
||||||
|
("1 234 567,89", "1234567.89"), # неразрывный пробел
|
||||||
|
("1 234,5", "1234.50"), # узкий неразрывный
|
||||||
|
("1 234", "1234.00"), # тонкий
|
||||||
|
("1\t234", "1234.00"),
|
||||||
|
(" 12,5 ", "12.50"),
|
||||||
|
("1234.567", "1234.57"),
|
||||||
|
("−1 234,56", "-1234.56"), # U+2212
|
||||||
|
("-1234,56", "-1234.56"),
|
||||||
|
("(1234)", "-1234.00"),
|
||||||
|
("8.577e+00", "8.58"),
|
||||||
|
("8,577E+02", "857.70"),
|
||||||
|
("1.5e3", "1500.00"),
|
||||||
|
("2.675", "2.68"),
|
||||||
|
("0", "0.00"),
|
||||||
|
("1e-30", "0.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_text_formats(raw: str, expected: str) -> None:
|
||||||
|
assert_money(to_money(text(raw)), expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["-", "—", "–", "", " ", " ", " - ", "−"])
|
||||||
|
def test_dashes_and_blank_text_are_none_without_warning(raw: str) -> None:
|
||||||
|
assert to_money(text(raw)) == MoneyParse(None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw",
|
||||||
|
["abc", "12abc", "1.234,56", "1,2,3", "--5", "(-5)", "(1 234", "1 234)", "nan", "inf", "-inf", "Infinity", "NaN",
|
||||||
|
"1e", "e5", "0x10", "٣٤٥", "₽ 100", "100 руб.", "1e999999999999999999999", "1e99999", "1e400"],
|
||||||
|
) # fmt: skip
|
||||||
|
def test_non_numeric_text_is_none_with_warning(raw: str) -> None:
|
||||||
|
parsed = to_money(text(raw))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_is_russian_short_and_has_no_coordinates() -> None:
|
||||||
|
parsed = to_money(text("abc"))
|
||||||
|
|
||||||
|
assert parsed.warning is not None
|
||||||
|
assert "abc" in parsed.warning
|
||||||
|
assert any("а" <= ch.lower() <= "я" for ch in parsed.warning)
|
||||||
|
assert len(parsed.warning) < MAX_WARNING_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_garbage_is_truncated_in_warning() -> None:
|
||||||
|
parsed = to_money(text("x" * 500))
|
||||||
|
|
||||||
|
assert parsed.warning is not None
|
||||||
|
assert len(parsed.warning) < MAX_WARNING_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_large_text_is_none_with_warning() -> None:
|
||||||
|
parsed = to_money(text("1" * 40))
|
||||||
|
|
||||||
|
assert parsed.value is None
|
||||||
|
assert parsed.warning
|
||||||
|
|
||||||
|
|
||||||
|
# --- ИНН ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("cell", "expected"),
|
||||||
|
[
|
||||||
|
(text("3528052718"), "3528052718"),
|
||||||
|
(num("3528052718.0"), "3528052718"),
|
||||||
|
(text("352819549855"), "352819549855"),
|
||||||
|
(text(" 352819549855 "), "352819549855"),
|
||||||
|
(num("352819549855"), "352819549855"),
|
||||||
|
(num("123456789"), "0123456789"), # потерян ведущий ноль: общее правило detect.inn_from_cell
|
||||||
|
(text("123"), None),
|
||||||
|
(num("123"), None),
|
||||||
|
(text("ИНН 352819549855"), None), # текст не «чистится»: ИНН — колонка из одних цифр
|
||||||
|
(num("3528052718.5"), None),
|
||||||
|
(num("-3528052718"), None),
|
||||||
|
(EMPTY_CELL, None),
|
||||||
|
(Cell(True, "bool"), None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_inn_digits(cell: Cell, expected: str | None) -> None:
|
||||||
|
assert inn_digits(cell) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_inn_digits_delegates_to_detector_rule() -> None:
|
||||||
|
for cell in (text("3528052718"), num("123456789"), text("abc"), num("1.5")):
|
||||||
|
assert inn_digits(cell) == detect.inn_from_cell(cell)
|
||||||
Loading…
x
Reference in New Issue
Block a user