ОСВ: контрольная сумма ИНН и ИНН строки (#35)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 14:33:22 +04:00
parent a2744b3e40
commit 1e5f28bd50
3 changed files with 102 additions and 2 deletions

View File

@ -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)

View File

@ -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

View File

@ -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]`-выражений, на практике не встречается |