Модуль inn_rows: строка с одним ИНН сопоставляется с наименованием в дереве (родитель или ребёнок), без пары остаётся с причиной; подсказка шапки InnHint отменяет контрольную сумму. row_data_type стал публичным. ASCII-проверка ИНН (ревью T1.2), комментарий про zip(strict=False) (T1.3), тест «770123456²» (T1.1). Решения 12–13 в design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""Числа: суммы -> 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_checksum_ok, inn_digits, row_inn, to_money
|
||
from src.parser.rules import Rules, load_rules
|
||
|
||
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"), # потерян ведущий ноль: восстанавливает извлечение (R-24)
|
||
(text("123"), None),
|
||
(num("123"), None),
|
||
(text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста
|
||
(text("ИНН: 3528052718"), "3528052718"),
|
||
(text("3528 052718"), "3528052718"),
|
||
(text("123 456"), None),
|
||
(text("ИНН 35280527181"), None), # 11 цифр: ведущий ноль к извлечённым цифрам не добавляется
|
||
(text("ИНН 352805271"), None), # 9 цифр
|
||
(text("№ 12/2025"), None),
|
||
(text("№ 12 от 01.02.2025"), None), # T7.4: 2+2+2+4 = 10 цифр из разных групп — не ИНН
|
||
(text("№ 12 / 3528052718 / …"), None), # T7.4: ИНН рядом с другими числами не склеивается
|
||
(text("ИНН/КПП 3528052718/352801001"), None),
|
||
(text("3528\u00a0052718"), "3528052718"), # группы через неразрывный пробел
|
||
(text("3528\u202f052718"), "3528052718"), # и через узкий неразрывный
|
||
(text("ИНН 3528 052718 (Вологда)"), "3528052718"),
|
||
(text("ИНН"), 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_is_superset_of_strict_detector_rule() -> None:
|
||
cells = (text("3528052718"), text(" 352819549855 "), num("3528052718.0"), num("352819549855"))
|
||
for cell in cells:
|
||
assert detect.inn_from_cell(cell) is not None
|
||
assert inn_digits(cell) == detect.inn_from_cell(cell)
|
||
|
||
|
||
def test_only_extraction_restores_lost_leading_zero() -> None:
|
||
"""R-24: ведущий ноль числа из 9/11 цифр восстанавливает извлечение из уже выбранной колонки ИНН, а не правило
|
||
детектора, которым колонка выбирается по значениям (иначе числовой КПП сошёл бы за ИНН)."""
|
||
for cell, padded in ((num("123456789"), "0123456789"), (num("12345678901"), "012345678901")):
|
||
assert detect.inn_from_cell(cell) is None
|
||
assert inn_digits(cell) == padded
|
||
|
||
|
||
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
|
||
assert detect.inn_from_cell(text("ИНН 352819549855")) is None
|
||
|
||
|
||
# --- Контрольная сумма ИНН и ИНН строки (#35) ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("inn", "ok"),
|
||
[
|
||
("7701234560", True),
|
||
("7707654321", True),
|
||
("2370012036", True),
|
||
("0123456788", True),
|
||
("500100732259", True),
|
||
("7701234567", False),
|
||
("5001234567", False),
|
||
("500100732258", False),
|
||
("770123456", False),
|
||
("77012345601", False),
|
||
("77012345a0", False),
|
||
("770123456²", False),
|
||
],
|
||
)
|
||
def test_inn_checksum_ok(inn: str, ok: bool) -> None:
|
||
assert inn_checksum_ok(inn) is ok
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("cell", "expected"),
|
||
[
|
||
(num(7701234560), "7701234560"),
|
||
(num(123456788), "0123456788"), # 9 цифр числом — потерянный ведущий ноль (R-24, #34)
|
||
(num(50010073225), "050010073225"), # 11 цифр числом
|
||
(text("7701234560"), "7701234560"),
|
||
(text("ИНН 7701234560"), "7701234560"),
|
||
(text("ИНН: 7701234560 / 770101001"), "7701234560"),
|
||
(text("123456788"), None), # текст из 9 цифр нулём не дополняется
|
||
(text("ООО Альфа 7701234560"), None), # не только ИНН
|
||
(num(Decimal("7701234560.5")), None),
|
||
(EMPTY_CELL, None),
|
||
],
|
||
)
|
||
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
|
||
assert row_inn(cell, rules) == expected
|