Andrey Epifancev 1e5f28bd50 ОСВ: контрольная сумма ИНН и ИНН строки (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:27:02 +04:00

288 lines
10 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_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),
],
)
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