Andrey Epifancev 663ba17716 fix(parser): inn_digits склеивает только группы цифр через пробел (T7.4)
ИНН из текста ячейки колонки ИНН принимается, только если в тексте одна запись цифр:
группы, разделённые пробелом/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
2026-10-05 14:07:33 +04:00

124 lines
7.7 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
__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