ИНН из текста ячейки колонки ИНН принимается, только если в тексте одна запись цифр: группы, разделённые пробелом/NBSP/узким пробелом («3528 052718», «ИНН 352819549855»). «№ 12 от 01.02.2025» и «№ 12 / 3528052718 / …» больше не дают ложный ИНН. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
124 lines
7.7 KiB
Python
124 lines
7.7 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
|
||
|
||
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
|
||
|
||
MONEY_Q = Decimal("0.01")
|
||
_ZERO = Decimal("0.00")
|
||
|
||
# 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]")
|
||
|
||
|
||
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 цифр; число с потерянным ведущим нулём дополняется).
|
||
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 != "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
|