Andrey Epifancev 1f2f050838 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
2026-10-05 13:45:59 +04:00

213 lines
6.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Числа: суммы -> 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)