Andrey Epifancev 816719ca92 ОСВ: модуль строк ИНН — склейка в дереве и контрольная сумма (#35)
Модуль 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>
2026-10-13 16:27:02 +04:00

232 lines
14 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.

"""Числа ячеек (design D-5; `05-domain-rules.md` §1.0): суммы -> `Decimal` с двумя знаками, ИНН.
`inn_digits` — режим извлечения ИНН для ячеек выбранной колонки (строгое правило детектора + цифры из обрамляющего
текста); для `counterparty_inn` её вызывает классификатор строк.
`to_money` — единственное место парсера, где суммы квантуются до 0.01 (ROUND_HALF_UP) и где разбирается текст.
Читатели кладут число как `Decimal(str(value))` без квантования, поэтому float-хвосты (`0.30000000000000004`) приходят
сюда уже как точная десятичная запись и округляются предсказуемо. `float` в домене не используется: ни `float()`, ни
двоичной арифметики. Предупреждения — короткие русские строки без номера строки; номер строки добавляет вызывающий.
"""
import re
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import NamedTuple
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_checksum_ok",
"inn_digits",
"inn_only",
"row_inn",
"split_inn_suffix",
"to_money",
]
MONEY_Q = Decimal("0.01")
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
# извлечении из выбранной колонки ИНН, ruling R-24)
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
_ZERO = Decimal("0.00")
# Что остаётся на конце названия после отрезанного хвоста с ИНН: знаки-разделители («ООО Альфа (», «ООО Альфа,»)
_NAME_TRAILING = " ,;(/:-–—"
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
# числа с гигантской экспонентой само падает с InvalidOperation
_MAX_INTEGER_DIGITS = 16
# Порядок меньше -1000 — не шум округления (1e-12 от вычитания в Excel округляется до 0.00), а заведомо нечисловая
# запись; граница держит Decimal далеко от пределов контекста
_MIN_ADJUSTED = -1000
_WARNING_VALUE_LIMIT = 30
# Прочерки и пустота — «нет значения», не 0 и не ошибка. Это формат записи чисел, а не словарь синонимов
_NO_VALUE = frozenset({"-", "—", "–", ""})
_WHITESPACE = re.compile(r"[\s   ]+")
_PARENTHESES = re.compile(r"^\((.*)\)$")
_NUMBER = re.compile(r"^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$")
_MINUS_SIGN = "−"
# Одна запись цифр в тексте: группы цифр, разделённые только пробелом/NBSP/узким пробелом («3528 052718»), с нецифровым
# обрамлением («ИНН: …»). Две записи, разделённые иным знаком («№ 12 от 01.02.2025», «12 / 3528052718»), — не ИНН
_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
warning: str | None
def _skip(warning: str | None = None) -> MoneyParse:
return MoneyParse(None, warning)
def _shorten(raw: str) -> str:
return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
def _quantize(value: Decimal, shown: str) -> MoneyParse:
"""Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00."""
if not value.is_finite():
return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS:
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
try:
money = value.quantize(MONEY_Q, rounding=ROUND_HALF_UP)
except InvalidOperation:
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
if money.adjusted() >= _MAX_INTEGER_DIGITS: # 9999999999999999.999 округлилось до 10^16
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
return MoneyParse(_ZERO if money == 0 else money, None)
def _parse_text(raw: str) -> MoneyParse:
compact = _WHITESPACE.sub("", raw).replace(",", ".").replace(_MINUS_SIGN, "-")
wrapped = _PARENTHESES.match(compact)
if wrapped:
compact = "-" + wrapped.group(1)
if compact in _NO_VALUE:
return _skip()
if not _NUMBER.match(compact):
return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
try:
return _quantize(Decimal(compact), raw.strip())
except InvalidOperation:
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
def to_money(cell: Cell) -> MoneyParse:
"""Сумма из ячейки: `Decimal` с двумя знаками либо `None` (пусто, прочерк — без предупреждения; мусор — с ним)."""
if cell.kind == "empty":
return _skip()
if cell.kind == "number":
# str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке
return _quantize(Decimal(str(cell.value)), str(cell.value))
if cell.kind == "text":
return _parse_text(str(cell.value))
if cell.kind == "bool":
return _skip("логическое значение вместо суммы")
return _skip("дата вместо суммы")
def inn_digits(cell: Cell) -> str | None:
"""ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её).
1. Строгое правило детектора `detect.inn_from_cell` (ровно 10/12 цифр); целое число из 9/11 цифр — ИНН региона
01–09, записанный числом с потерянным ведущим нулём: дополняется нулём слева. Дополнение — только здесь, при
извлечении из колонки, уже выбранной по заголовку «ИНН» или по строгому правилу значений (ruling R-24).
2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): в тексте должна быть
ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
«№ 12 / 3528052718 / …»), — не ИНН (`None`). Ведущий ноль к склеенным цифрам не добавляется (11 цифр -> `None`).
Применяется только к ячейкам колонки, уже признанной колонкой ИНН.
Числовые ячейки во второй шаг не попадают (`3528052718.5` -> `None`). Мягкий второй шаг нельзя переносить в
`inn_from_cell`: она решает, является ли колонка колонкой ИНН, и обычные текстовые колонки (номера договоров,
телефоны) не должны под неё подпадать.
"""
strict = inn_from_cell(cell)
if strict is not None:
return strict
if cell.kind == "number":
return _restored_leading_zero(cell)
if cell.kind != "text":
return None
found = _SINGLE_DIGIT_RUN.match(cell.text)
if found is None:
return None
digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
return digits if len(digits) in INN_LENGTHS else None
def _restored_leading_zero(cell: Cell) -> str | None:
"""Целое неотрицательное число из 9/11 цифр → 10/12 цифр с ведущим нулём; иначе `None`."""
value = cell.value
if not isinstance(value, Decimal) or not value.is_finite() or value != value.to_integral_value() or value < 0:
return None
digits = str(int(value))
return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None
def column_inn(cell: Cell, rules: Rules) -> str | None:
"""ИНН из ячейки колонки ИНН: `inn_digits`, а склеенное «ИНН/КПП» («7701234567 / 770101001») — по `inn_kpp_cell`."""
found = inn_digits(cell)
if found is not None or cell.kind != "text":
return found
for pattern in rules.headers.inn_kpp_cell:
match = pattern.search(cell.text)
if match is not None:
return match.group("inn")
return None
def split_inn_suffix(text: str, rules: Rules) -> tuple[str, str | None]:
"""Название контрагента и ИНН из текста вида «Полиграф, 3528315710» / «ООО Альфа (ИНН 7701234567)».
ИНН — только в конце текста (`counterparty_inn_suffix`); название — текст до хвоста без разделителей по краям.
Нет ИНН (или от названия ничего не осталось) — текст как есть и `None`.
"""
for pattern in rules.headers.counterparty_inn_suffix:
match = pattern.search(text)
if match is None:
continue
name = text[: source_offset(text, match.start("tail"))].strip().rstrip(_NAME_TRAILING).rstrip()
if name:
return name, match.group("inn")
return text, None
def inn_only(text: str, rules: Rules) -> str | None:
"""ИНН строки, где написан только он (`inn_only_row`); иначе `None`."""
for pattern in rules.headers.inn_only_row:
match = pattern.search(text)
if match is not None:
return match.group("inn")
return None
def _check_digit(digits: str, weights: tuple[int, ...]) -> int:
# strict=False намеренно: zip обрезает цифры по длине весов (ИНН длиннее весов на 1–2 символа);
# последние разряды — контрольные, в сумму не входят
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.isascii() and inn.isdigit()): # ASCII-цифры: 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)