Модуль 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>
232 lines
14 KiB
Python
232 lines
14 KiB
Python
"""Числа ячеек (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)
|