"""Числа ячеек (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)