Files
sales-bot-stacks/sales-bot/handlers.py
consultoria-as ed1658eb2b feat: Implementar mejoras de funcionalidad y UX del Sales Bot
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>
2026-01-19 02:57:27 +00:00

548 lines
22 KiB
Python

"""
Handlers para procesamiento de ventas en Sales Bot
Incluye sistema de confirmación interactiva obligatoria
"""
import logging
import re
import os
from datetime import datetime, timedelta, timezone
from utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos
from ocr_processor import OCRProcessor
logger = logging.getLogger(__name__)
# Zona horaria de México
TZ_MEXICO = timezone(timedelta(hours=-6))
# Cache de ventas pendientes de confirmación
# Formato: {username: {datos_venta, timestamp}}
VENTAS_PENDIENTES = {}
# Timeout para confirmación (en minutos)
CONFIRMACION_TIMEOUT = int(os.getenv('CONFIRMACION_TIMEOUT_MINUTOS', 2))
def limpiar_ventas_expiradas():
"""Elimina ventas pendientes que han expirado"""
ahora = datetime.now(TZ_MEXICO)
expiradas = []
for username, datos in VENTAS_PENDIENTES.items():
timestamp = datos.get('timestamp')
if timestamp:
if ahora - timestamp > timedelta(minutes=CONFIRMACION_TIMEOUT):
expiradas.append(username)
for username in expiradas:
del VENTAS_PENDIENTES[username]
logger.info(f"Venta pendiente de {username} expirada")
def handle_venta_message(data, mattermost, nocodb):
"""
Maneja mensajes de venta en Mattermost con confirmación interactiva obligatoria.
Flujo:
1. Usuario envía mensaje de venta
2. Bot muestra preview y pide confirmación
3. Usuario responde "si" o "no"
4. Bot registra la venta (si confirmada)
"""
try:
user_name = data.get('user_name')
text = data.get('text', '').strip()
text_lower = text.lower()
# Limpiar ventas expiradas
limpiar_ventas_expiradas()
# ==================== PROCESAR CONFIRMACIÓN ====================
# Si es confirmación de venta pendiente
if text_lower in ['si', '', 'confirmar', 'ok', 'yes'] and user_name in VENTAS_PENDIENTES:
venta_data = VENTAS_PENDIENTES.pop(user_name)
return registrar_venta_confirmada(venta_data, mattermost, nocodb)
# Si es cancelación de venta pendiente
if text_lower in ['no', 'cancelar', 'cancel'] and user_name in VENTAS_PENDIENTES:
VENTAS_PENDIENTES.pop(user_name)
mensaje = '❌ **Venta cancelada**\n\nPuedes registrar una nueva venta cuando quieras.'
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':x:')
return {'text': mensaje}
# Si ya tiene venta pendiente, recordar
if user_name in VENTAS_PENDIENTES:
venta_pendiente = VENTAS_PENDIENTES[user_name]
tiempo_restante = CONFIRMACION_TIMEOUT - (datetime.now(TZ_MEXICO) - venta_pendiente['timestamp']).seconds // 60
mensaje = (
f"⏳ **Ya tienes una venta pendiente**\n\n"
f"**Monto:** ${venta_pendiente.get('monto', 0):,.2f}\n"
f"**Cliente:** {venta_pendiente.get('cliente', 'Sin especificar')}\n\n"
f"Responde **si** para confirmar o **no** para cancelar.\n"
f"_Expira en {tiempo_restante} minuto(s)_"
)
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':hourglass:')
return {'text': mensaje}
# ==================== PROCESAR NUEVA VENTA ====================
channel_name = data.get('channel_name')
post_id = data.get('post_id')
file_ids = data.get('file_ids', '')
if file_ids and isinstance(file_ids, str):
file_ids = [f.strip() for f in file_ids.split(',') if f.strip()]
elif not file_ids:
file_ids = []
logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}")
# Extraer información del texto
monto = extraer_monto(text)
cliente = extraer_cliente(text)
tubos_manual = extraer_tubos(text)
# ==================== PROCESAR IMÁGENES (MÚLTIPLES) ====================
imagenes_url = []
ocr_info = ""
productos_ocr = []
monto_ocr_total = 0
tubos_ocr_total = 0
if file_ids:
logger.info(f"Procesando {len(file_ids)} archivos adjuntos")
for file_id in file_ids:
try:
imagen_data = mattermost.get_file(file_id)
file_info = mattermost.get_file_info(file_id)
if file_info and imagen_data:
filename = file_info.get('name', 'ticket.jpg')
file_size = file_info.get('size', 0)
bot_token = os.getenv('MATTERMOST_BOT_TOKEN')
mattermost_url = os.getenv('MATTERMOST_URL')
imagen_url = f"{mattermost_url}/api/v4/files/{file_id}?access_token={bot_token}"
imagenes_url.append(imagen_url)
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
# Procesar con OCR
try:
ocr = OCRProcessor()
resultado_ocr = ocr.procesar_ticket(imagen_data)
if resultado_ocr:
monto_ocr = resultado_ocr.get('monto_detectado', 0)
fecha_ocr = resultado_ocr.get('fecha_detectada')
productos = resultado_ocr.get('productos', [])
if monto_ocr:
monto_ocr_total += monto_ocr
if productos:
productos_ocr.extend(productos)
# Contar tubos de tinte
for p in productos:
marca = p.get('marca', '').lower()
producto = p.get('producto', '').lower()
if 'tinte' in marca or 'tinte' in producto or 'cromatique' in marca:
tubos_ocr_total += p.get('cantidad', 0)
if fecha_ocr and not ocr_info:
ocr_info += f"\n📅 Fecha ticket: {fecha_ocr}"
except Exception as ocr_error:
logger.error(f"Error en OCR para {file_id}: {str(ocr_error)}")
except Exception as file_error:
logger.error(f"Error procesando archivo {file_id}: {str(file_error)}")
# Resumen de OCR
if len(file_ids) > 1:
ocr_info += f"\n📷 Imágenes procesadas: {len(imagenes_url)}"
if monto_ocr_total > 0:
ocr_info += f"\n💡 Monto OCR: ${monto_ocr_total:,.2f}"
if not monto:
monto = monto_ocr_total
if tubos_ocr_total > 0:
ocr_info += f"\n🧪 Tubos detectados: {tubos_ocr_total}"
if productos_ocr:
ocr_info += f"\n📦 Productos: {len(productos_ocr)}"
# ==================== VALIDAR DATOS ====================
if not monto:
# Sugerir clientes frecuentes si no hay cliente
sugerencias = ""
if not cliente:
clientes_frecuentes = nocodb.get_clientes_frecuentes(user_name, limit=3)
if clientes_frecuentes:
lista = '\n'.join([f"{c}" for c in clientes_frecuentes])
sugerencias = f"\n\n💡 **Clientes frecuentes:**\n{lista}"
mensaje = (
f"@{user_name} Necesito el monto de la venta.\n\n"
"**Formatos válidos:**\n"
"• `venta @monto 1500 @cliente Juan Pérez`\n"
"• `vendí $1500 a Juan Pérez`\n"
"• `venta @monto 1500 @tubos 3`\n"
"• Adjunta foto del ticket"
f"{sugerencias}"
)
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:')
return {'text': mensaje}
if not cliente:
cliente = "Cliente sin nombre"
# Usar tubos manuales o de OCR
tubos_detectados = tubos_manual or tubos_ocr_total or 0
# ==================== CREAR PREVIEW Y PEDIR CONFIRMACIÓN ====================
# Guardar datos de la venta pendiente
VENTAS_PENDIENTES[user_name] = {
'user_name': user_name,
'monto': monto,
'cliente': cliente,
'tubos': tubos_detectados,
'tubos_manual': tubos_manual,
'productos_ocr': productos_ocr,
'imagenes_url': imagenes_url,
'ocr_info': ocr_info,
'channel_name': channel_name,
'post_id': post_id,
'text': text,
'timestamp': datetime.now(TZ_MEXICO)
}
# Construir mensaje de preview
mensaje_preview = f"📋 **Preview de Venta**\n\n"
mensaje_preview += f"**Vendedor:** @{user_name}\n"
mensaje_preview += f"**Monto:** ${monto:,.2f}\n"
mensaje_preview += f"**Cliente:** {cliente}\n"
if tubos_detectados > 0:
mensaje_preview += f"**Tubos:** {tubos_detectados} 🧪\n"
if imagenes_url:
mensaje_preview += f"**Tickets:** {len(imagenes_url)} imagen(es) 📸\n"
if ocr_info:
mensaje_preview += f"\n**Datos detectados:**{ocr_info}\n"
mensaje_preview += f"\n¿Confirmar esta venta? Responde **si** o **no**\n"
mensaje_preview += f"_(Expira en {CONFIRMACION_TIMEOUT} minutos)_"
mattermost.post_message_webhook(mensaje_preview, username='Sales Bot', icon_emoji=':clipboard:')
return {'text': mensaje_preview}
except Exception as e:
logger.error(f"Error en handle_venta_message: {str(e)}", exc_info=True)
mensaje_error = f"❌ Error: {str(e)}"
return {'text': mensaje_error}
def registrar_venta_confirmada(venta_data, mattermost, nocodb):
"""
Registra una venta después de ser confirmada por el usuario.
"""
try:
user_name = venta_data.get('user_name')
monto = venta_data.get('monto')
cliente = venta_data.get('cliente')
tubos_manual = venta_data.get('tubos_manual')
productos_ocr = venta_data.get('productos_ocr', [])
imagenes_url = venta_data.get('imagenes_url', [])
ocr_info = venta_data.get('ocr_info', '')
channel_name = venta_data.get('channel_name')
post_id = venta_data.get('post_id')
text = venta_data.get('text', '')
# Verificar/crear vendedor
vendedor = nocodb.get_vendedor(user_name)
if not vendedor:
user_info = mattermost.get_user_by_username(user_name)
email = user_info.get('email', f"{user_name}@consultoria-as.com") if user_info else f"{user_name}@consultoria-as.com"
nombre = user_info.get('first_name', user_name) if user_info else user_name
vendedor = nocodb.crear_vendedor(
username=user_name,
nombre_completo=nombre,
email=email,
meta_diaria_tubos=3
)
if vendedor:
mensaje_bienvenida = (
f"👋 ¡Bienvenido @{user_name}!\n"
f"**Sistema de comisiones:**\n"
f"• Meta diaria: {nocodb.META_DIARIA_TUBOS} tubos de tinte\n"
f"• Comisión: ${nocodb.COMISION_POR_TUBO:.0f} por tubo después del {nocodb.META_DIARIA_TUBOS}º\n"
f"¡Empieza a registrar tus ventas!"
)
mattermost.post_message_webhook(mensaje_bienvenida, username='Sales Bot', icon_emoji=':wave:')
# Usar primera imagen como principal
imagen_url = imagenes_url[0] if imagenes_url else None
# Registrar venta
venta = nocodb.registrar_venta(
vendedor_username=user_name,
monto=monto,
cliente=cliente,
descripcion=text,
mensaje_id=post_id,
canal=channel_name,
imagen_url=imagen_url
)
if venta:
venta_id = venta.get('Id')
# Guardar productos detectados por OCR
if productos_ocr:
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr)
if productos_guardados:
logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}")
# Guardar tubos manuales si se especificaron
elif tubos_manual and tubos_manual > 0:
productos_manuales = [{
'producto': 'Tinte (registro manual)',
'marca': 'Manual',
'cantidad': tubos_manual,
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
'importe': monto
}]
nocodb.guardar_productos_venta(venta_id, productos_manuales)
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
# Actualizar tabla de metas
try:
nocodb.actualizar_meta_vendedor(user_name)
logger.info(f"Metas actualizadas para {user_name}")
except Exception as meta_error:
logger.error(f"Error actualizando metas: {str(meta_error)}")
# Verificar racha
racha = nocodb.verificar_racha(user_name)
# Reacción de éxito
if post_id:
mattermost.add_reaction(post_id, 'white_check_mark')
# Obtener estadísticas del día
stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name)
# Construir mensaje de confirmación
mensaje_confirmacion = (
f"✅ **Venta #{venta_id} registrada**\n\n"
f"**Vendedor:** @{user_name}\n"
f"**Monto:** {formatear_moneda(monto)}\n"
f"**Cliente:** {cliente}\n"
)
if imagenes_url:
mensaje_confirmacion += f"📸 **Tickets:** {len(imagenes_url)} guardado(s){ocr_info}\n"
# Mostrar estadísticas de tubos y comisiones
if stats_dia:
tubos_hoy = stats_dia.get('tubos_vendidos', 0)
comision_hoy = stats_dia.get('comision', 0)
meta = stats_dia.get('meta_diaria', 3)
tubos_comisionables = stats_dia.get('tubos_comisionables', 0)
# Determinar emoji según progreso
if tubos_hoy >= meta * 2:
emoji = '🔥'
mensaje_extra = '¡Increíble día!'
elif tubos_hoy >= meta:
emoji = ''
mensaje_extra = '¡Meta cumplida!'
elif tubos_hoy >= meta - 1:
emoji = '💪'
mensaje_extra = '¡Casi llegas!'
else:
emoji = '📊'
mensaje_extra = '¡Sigue así!'
mensaje_confirmacion += (
f"\n**Resumen del día:** {emoji}\n"
f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n"
f"• Meta diaria: {meta} tubos\n"
)
if tubos_hoy > meta:
mensaje_confirmacion += (
f"• Tubos con comisión: {tubos_comisionables}\n"
f"• Comisión ganada hoy: {formatear_moneda(comision_hoy)} 💰\n"
)
else:
faltan = meta - tubos_hoy
mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n"
mensaje_confirmacion += f"{mensaje_extra}"
# Mostrar info de racha si aplica
if racha and racha.get('dias_consecutivos', 0) >= 3:
dias = racha['dias_consecutivos']
bonus = racha.get('bonus', 0)
mensaje_confirmacion += f"\n\n🔥 **Racha: {dias} días** "
if bonus > 0:
mensaje_confirmacion += f"(+${bonus:,.2f} bonus)"
# Enviar confirmación
mattermost.post_message_webhook(
mensaje_confirmacion,
username='Sales Bot',
icon_emoji=':moneybag:'
)
return {'text': mensaje_confirmacion}
else:
mensaje_error = "❌ Error al registrar la venta. Intenta de nuevo."
mattermost.post_message_webhook(mensaje_error, username='Sales Bot', icon_emoji=':x:')
return {'text': mensaje_error}
except Exception as e:
logger.error(f"Error en registrar_venta_confirmada: {str(e)}", exc_info=True)
mensaje_error = f"❌ Error: {str(e)}"
return {'text': mensaje_error}
def generar_reporte_diario(mattermost, nocodb):
"""
Genera reporte diario de ventas y comisiones
"""
try:
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
# Obtener todas las ventas del día
ventas_hoy = nocodb.get_ventas_dia()
# Agrupar por vendedor
vendedores_hoy = {}
for venta in ventas_hoy:
vendedor = venta.get('vendedor_username')
if vendedor not in vendedores_hoy:
vendedores_hoy[vendedor] = []
vendedores_hoy[vendedor].append(venta)
# Calcular estadísticas por vendedor
stats_vendedores = []
for vendedor in vendedores_hoy.keys():
stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy)
if stats:
stats_vendedores.append(stats)
# Ordenar por tubos vendidos
stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True)
# Calcular totales
total_tubos_dia = sum(s.get('tubos_vendidos', 0) for s in stats_vendedores)
total_comisiones = sum(s.get('comision', 0) for s in stats_vendedores)
total_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores)
# Construir mensaje
mensaje = (
f"📊 **Reporte Diario - {datetime.now(TZ_MEXICO).strftime('%d/%m/%Y')}**\n\n"
f"**Resumen del día:**\n"
f"• Tubos vendidos: {total_tubos_dia} 🧪\n"
f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n"
f"• Monto total: {formatear_moneda(total_monto)}\n"
f"• Ventas: {len(ventas_hoy)}\n\n"
)
if stats_vendedores:
mensaje += "**Top Vendedores del Día:**\n"
for i, stats in enumerate(stats_vendedores[:5], 1):
vendedor = stats.get('vendedor')
tubos = stats.get('tubos_vendidos', 0)
comision = stats.get('comision', 0)
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
if comision > 0:
mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n"
else:
mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n"
# Obtener canal de reportes
team_name = os.getenv('MATTERMOST_TEAM_NAME')
channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES')
if channel_reportes:
canal = mattermost.get_channel_by_name(team_name, channel_reportes)
if canal:
mattermost.post_message(canal['id'], mensaje)
logger.info("Reporte diario generado")
return {'status': 'success', 'message': 'Reporte generado'}
else:
logger.warning(f"Canal {channel_reportes} no encontrado")
return {'status': 'error', 'message': 'Canal no encontrado'}
else:
# Enviar por webhook si no hay canal específico
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':chart_with_upwards_trend:')
return {'status': 'success', 'message': 'Reporte enviado por webhook'}
except Exception as e:
logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True)
return {'status': 'error', 'message': str(e)}
def comando_estadisticas(user_name, mattermost, nocodb):
"""
Muestra estadísticas personales del vendedor
Comando: /stats o /estadisticas
"""
try:
# Estadísticas del día
stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name)
# Estadísticas del mes
stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name)
if not stats_hoy and not stats_mes:
mensaje = f"@{user_name} Aún no tienes ventas registradas."
return mensaje
mensaje = f"📈 **Estadísticas de @{user_name}**\n\n"
# Hoy
if stats_hoy:
mensaje += (
f"**Hoy ({datetime.now(TZ_MEXICO).strftime('%d/%m')})**\n"
f"• Tubos: {stats_hoy.get('tubos_vendidos', 0)} 🧪\n"
f"• Comisión: {formatear_moneda(stats_hoy.get('comision', 0))}\n"
f"• Monto: {formatear_moneda(stats_hoy.get('monto_total_dia', 0))}\n"
f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n"
)
# Mes
if stats_mes:
mensaje += (
f"**Este mes**\n"
f"• Tubos totales: {stats_mes.get('tubos_totales', 0)} 🧪\n"
f"• Comisión total: {formatear_moneda(stats_mes.get('comision_total', 0))} 💰\n"
f"• Monto total: {formatear_moneda(stats_mes.get('monto_total', 0))}\n"
f"• Ventas: {stats_mes.get('cantidad_ventas', 0)}\n"
f"• Días activos: {stats_mes.get('dias_activos', 0)}\n"
f"• Días con meta: {stats_mes.get('dias_meta_cumplida', 0)}\n"
f"• Promedio/día: {stats_mes.get('promedio_tubos_dia', 0):.1f} tubos\n"
)
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':bar_chart:')
return mensaje
except Exception as e:
logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True)
return "❌ Error obteniendo estadísticas"