Nuevas funcionalidades: - /cancelar: Cancelar ventas propias con motivo opcional - /deshacer: Deshacer última venta (dentro de 5 minutos) - /editar: Editar monto y cliente de ventas propias - /comisiones: Historial de comisiones de últimos 6 meses - /racha: Sistema de bonos por días consecutivos cumpliendo meta - /exportar: Exportar ventas a Excel o CSV Sistema de confirmación obligatoria: - Todas las ventas requieren confirmación explícita (si/no) - Preview de venta antes de registrar - Timeout de 2 minutos para ventas pendientes Scheduler de notificaciones: - Recordatorio de mediodía para vendedores sin meta - Resumen diario automático al final del día - Resumen semanal los lunes Otras mejoras: - Soporte para múltiples imágenes en una venta - Autocompletado de clientes frecuentes - Metas personalizadas por vendedor - Bonos por racha: $20 (3 días), $50 (5 días), $150 (10 días) Archivos nuevos: - export_utils.py: Generación de Excel y CSV - scheduler.py: Tareas programadas con APScheduler Dependencias nuevas: - APScheduler==3.10.4 - openpyxl==3.1.2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
311 lines
7.5 KiB
Python
311 lines
7.5 KiB
Python
"""
|
|
Utilidades para el Sales Bot
|
|
"""
|
|
import re
|
|
import os
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def validar_token_outgoing(token):
|
|
"""
|
|
Valida el token de webhooks salientes de Mattermost
|
|
"""
|
|
if not token:
|
|
return False
|
|
|
|
expected_tokens = [
|
|
os.getenv('MATTERMOST_WEBHOOK_SECRET'),
|
|
os.getenv('MATTERMOST_OUTGOING_TOKEN'),
|
|
]
|
|
|
|
return token in [t for t in expected_tokens if t]
|
|
|
|
|
|
def extraer_monto(texto):
|
|
"""
|
|
Extrae el monto de una venta del texto del mensaje.
|
|
Soporta formatos:
|
|
- @monto 1500
|
|
- $1,500.00
|
|
- $1500
|
|
- 1500 pesos
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
texto = texto.lower()
|
|
|
|
# Buscar formato @monto XXXX
|
|
patron_monto = r'@monto\s+\$?([\d,]+\.?\d*)'
|
|
match = re.search(patron_monto, texto)
|
|
if match:
|
|
monto_str = match.group(1).replace(',', '')
|
|
try:
|
|
return float(monto_str)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Buscar formato $X,XXX.XX o $XXXX
|
|
patron_dinero = r'\$\s*([\d,]+\.?\d*)'
|
|
match = re.search(patron_dinero, texto)
|
|
if match:
|
|
monto_str = match.group(1).replace(',', '')
|
|
try:
|
|
return float(monto_str)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Buscar formato XXXX pesos
|
|
patron_pesos = r'([\d,]+\.?\d*)\s*pesos'
|
|
match = re.search(patron_pesos, texto)
|
|
if match:
|
|
monto_str = match.group(1).replace(',', '')
|
|
try:
|
|
return float(monto_str)
|
|
except ValueError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def extraer_cliente(texto):
|
|
"""
|
|
Extrae el nombre del cliente del texto del mensaje.
|
|
Soporta formatos:
|
|
- @cliente Juan Pérez
|
|
- cliente: Juan Pérez
|
|
- a Juan Pérez
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
# Buscar formato @cliente NOMBRE
|
|
patron_cliente = r'@cliente\s+([^\n@$]+)'
|
|
match = re.search(patron_cliente, texto, re.IGNORECASE)
|
|
if match:
|
|
cliente = match.group(1).strip()
|
|
# Limpiar palabras clave que no son parte del nombre
|
|
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
|
|
return cliente.strip() if cliente.strip() else None
|
|
|
|
# Buscar formato "cliente: NOMBRE" o "cliente NOMBRE"
|
|
patron_cliente2 = r'cliente[:\s]+([^\n@$]+)'
|
|
match = re.search(patron_cliente2, texto, re.IGNORECASE)
|
|
if match:
|
|
cliente = match.group(1).strip()
|
|
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
|
|
return cliente.strip() if cliente.strip() else None
|
|
|
|
# Buscar formato "a NOMBRE" (después de un monto)
|
|
patron_a = r'\$[\d,\.]+\s+a\s+([^\n@$]+)'
|
|
match = re.search(patron_a, texto, re.IGNORECASE)
|
|
if match:
|
|
cliente = match.group(1).strip()
|
|
return cliente.strip() if cliente.strip() else None
|
|
|
|
return None
|
|
|
|
|
|
def formatear_moneda(valor, simbolo='$', decimales=2):
|
|
"""
|
|
Formatea un número como moneda.
|
|
Ejemplo: 1500.5 -> $1,500.50
|
|
"""
|
|
if valor is None:
|
|
return f"{simbolo}0.00"
|
|
|
|
try:
|
|
valor = float(valor)
|
|
return f"{simbolo}{valor:,.{decimales}f}"
|
|
except (ValueError, TypeError):
|
|
return f"{simbolo}0.00"
|
|
|
|
|
|
def extraer_tubos(texto):
|
|
"""
|
|
Extrae la cantidad de tubos del texto del mensaje.
|
|
Soporta formatos:
|
|
- @tubos 5
|
|
- tubos: 5
|
|
- 5 tubos
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
texto = texto.lower()
|
|
|
|
# Buscar formato @tubos XXXX
|
|
patron_tubos = r'@tubos\s+(\d+)'
|
|
match = re.search(patron_tubos, texto)
|
|
if match:
|
|
try:
|
|
return int(match.group(1))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Buscar formato "tubos: X" o "tubos X"
|
|
patron_tubos2 = r'tubos[:\s]+(\d+)'
|
|
match = re.search(patron_tubos2, texto)
|
|
if match:
|
|
try:
|
|
return int(match.group(1))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Buscar formato "X tubos"
|
|
patron_tubos3 = r'(\d+)\s*tubos?'
|
|
match = re.search(patron_tubos3, texto)
|
|
if match:
|
|
try:
|
|
return int(match.group(1))
|
|
except ValueError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
# ==================== NUEVAS FUNCIONES FASE 1 ====================
|
|
|
|
def extraer_id_venta(texto):
|
|
"""
|
|
Extrae el ID de venta del texto del comando.
|
|
Soporta formatos:
|
|
- /cancelar 123
|
|
- /editar 123 @monto 1500
|
|
- #123
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
# Buscar número al inicio o después del comando
|
|
patron_id = r'(?:^|\s)#?(\d+)'
|
|
match = re.search(patron_id, texto.strip())
|
|
if match:
|
|
try:
|
|
return int(match.group(1))
|
|
except ValueError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def extraer_motivo(texto):
|
|
"""
|
|
Extrae el motivo de cancelación del texto.
|
|
Soporta formatos:
|
|
- /cancelar 123 "cliente no pagó"
|
|
- /cancelar 123 motivo: cliente canceló
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
# Buscar texto entre comillas
|
|
patron_comillas = r'["\']([^"\']+)["\']'
|
|
match = re.search(patron_comillas, texto)
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
# Buscar después de "motivo:"
|
|
patron_motivo = r'motivo[:\s]+(.+)$'
|
|
match = re.search(patron_motivo, texto, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
# Si hay texto después del ID, usarlo como motivo
|
|
patron_resto = r'^\d+\s+(.+)$'
|
|
match = re.search(patron_resto, texto.strip())
|
|
if match:
|
|
motivo = match.group(1).strip()
|
|
# Excluir si es otro comando
|
|
if not motivo.startswith('@') and not motivo.startswith('/'):
|
|
return motivo
|
|
|
|
return None
|
|
|
|
|
|
def extraer_mes(texto):
|
|
"""
|
|
Extrae el mes del texto del comando.
|
|
Soporta formatos:
|
|
- 2026-01
|
|
- 01-2026
|
|
- enero 2026
|
|
- enero
|
|
"""
|
|
if not texto:
|
|
return None
|
|
|
|
texto = texto.strip().lower()
|
|
|
|
# Formato YYYY-MM
|
|
patron_iso = r'(\d{4})-(\d{1,2})'
|
|
match = re.search(patron_iso, texto)
|
|
if match:
|
|
return f"{match.group(1)}-{match.group(2).zfill(2)}"
|
|
|
|
# Formato MM-YYYY
|
|
patron_inv = r'(\d{1,2})-(\d{4})'
|
|
match = re.search(patron_inv, texto)
|
|
if match:
|
|
return f"{match.group(2)}-{match.group(1).zfill(2)}"
|
|
|
|
# Nombres de meses en español
|
|
meses = {
|
|
'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04',
|
|
'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08',
|
|
'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12'
|
|
}
|
|
|
|
for nombre, num in meses.items():
|
|
if nombre in texto:
|
|
# Buscar año
|
|
patron_anio = r'(\d{4})'
|
|
match = re.search(patron_anio, texto)
|
|
if match:
|
|
return f"{match.group(1)}-{num}"
|
|
# Si no hay año, usar el actual
|
|
from datetime import datetime
|
|
return f"{datetime.now().year}-{num}"
|
|
|
|
return None
|
|
|
|
|
|
def parsear_formato_exportar(texto):
|
|
"""
|
|
Parsea los parámetros del comando /exportar.
|
|
Retorna (formato, mes)
|
|
"""
|
|
if not texto:
|
|
return 'excel', None
|
|
|
|
texto = texto.strip().lower()
|
|
|
|
# Detectar formato
|
|
formato = 'excel'
|
|
if 'csv' in texto:
|
|
formato = 'csv'
|
|
texto = texto.replace('csv', '').strip()
|
|
|
|
# Detectar mes
|
|
mes = extraer_mes(texto)
|
|
|
|
return formato, mes
|
|
|
|
|
|
def validar_tokens_comando(token, nombre_comando):
|
|
"""
|
|
Valida tokens para comandos slash.
|
|
"""
|
|
if not token:
|
|
return False
|
|
|
|
expected_tokens = [
|
|
os.getenv(f'MATTERMOST_SLASH_TOKEN_{nombre_comando.upper()}'),
|
|
os.getenv('MATTERMOST_OUTGOING_TOKEN'),
|
|
os.getenv('MATTERMOST_WEBHOOK_SECRET'),
|
|
]
|
|
|
|
return token in [t for t in expected_tokens if t]
|