diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 0427cf0..22f6c7e 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -17,7 +17,17 @@ from src.parser.detect import INN_LENGTHS, inn_from_cell from src.parser.grid import Cell from src.parser.rules import Rules, source_offset -__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"] +__all__ = [ + "MONEY_Q", + "MoneyParse", + "column_inn", + "inn_checksum_ok", + "inn_digits", + "inn_only", + "row_inn", + "split_inn_suffix", + "to_money", +] MONEY_Q = Decimal("0.01") # ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при @@ -46,6 +56,15 @@ _MINUS_SIGN = "−" _SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$") _DIGIT_GROUP_SEPARATOR = re.compile(r"[\s\u00a0\u202f]") +# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 11, затем на 10 +_INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_FIRST = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_SECOND = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_CHECKSUM_MODULUS = 11 +_CHECK_DIGIT_MODULUS = 10 +_INN_LEGAL_LENGTH = 10 +_INN_PERSON_LENGTH = 12 + class MoneyParse(NamedTuple): value: Decimal | None @@ -177,3 +196,34 @@ def inn_only(text: str, rules: Rules) -> str | None: if match is not None: return match.group("inn") return None + + +def _check_digit(digits: str, weights: tuple[int, ...]) -> int: + total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False)) + return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS + + +def inn_checksum_ok(inn: str) -> bool: + """Контрольная сумма ИНН по алгоритму ФНС: у 10 цифр — последний разряд, у 12 — два последних (#35). + + Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки. + """ + if not inn.isdecimal(): # isdecimal: int() по каждому символу гарантированно сработает + return False + if len(inn) == _INN_LEGAL_LENGTH: + return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1]) + if len(inn) == _INN_PERSON_LENGTH: + first_ok = _check_digit(inn, _INN12_WEIGHTS_FIRST) == int(inn[-2]) + second_ok = _check_digit(inn, _INN12_WEIGHTS_SECOND) == int(inn[-1]) + return first_ok and second_ok + return False + + +def row_inn(cell: Cell, rules: Rules) -> str | None: + """ИНН строки, где написан только он (строка ИНН, `inn_rows`): число — 10/12 цифр или 9/11 с потерянным ведущим + нулём (R-24, #34, как у колонки ИНН); текст — по `inn_only_row` («7701234560», «ИНН 7701234560 / 770101001»).""" + if cell.kind == "number": + return inn_digits(cell) + if cell.kind != "text": + return None + return inn_only(cell.text, rules) diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 0440c7f..43179db 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -7,7 +7,8 @@ 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 +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 @@ -237,3 +238,50 @@ def test_only_extraction_restores_lost_leading_zero() -> None: 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 diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index f24ec4e..f7af876 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -135,3 +135,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста | | 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` | | 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | +| 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | +| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isdecimal()`, не `isdigit()` | `isdigit()` пропускает «²» и подобные, на которых `int()` падает с ValueError | Нет: вход — строки ИНН из `[0-9]`-выражений, на практике не встречается |