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>
This commit is contained in:
2026-01-19 02:57:27 +00:00
parent 5d9cbd4812
commit ed1658eb2b
8 changed files with 1808 additions and 148 deletions

View File

@@ -14,6 +14,7 @@ from nocodb_client import NocoDBClient
from handlers import handle_venta_message, generar_reporte_diario
from utils import validar_token_outgoing
from websocket_listener import MattermostWebsocketListener
from scheduler import SalesBotScheduler
# Configurar logging
logging.basicConfig(
@@ -44,6 +45,11 @@ ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_messa
ws_listener.start()
logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost")
# Inicializar scheduler de tareas programadas
scheduler = SalesBotScheduler(mattermost, nocodb)
scheduler.iniciar()
logger.info("Scheduler de tareas programadas iniciado")
@app.route('/health', methods=['GET'])
def health_check():
"""Endpoint para verificar que el bot está funcionando"""
@@ -257,20 +263,28 @@ def comando_ayuda():
"**Para registrar una venta:**\n"
"• `venta @monto 1500 @cliente Juan Pérez`\n"
"• `vendí $1500 a María García`\n"
"• También puedes adjuntar foto del ticket\n\n"
"**Comandos disponibles:**\n"
"• También puedes adjuntar foto(s) del ticket\n"
"• _Todas las ventas requieren confirmación_\n\n"
"**Comandos básicos:**\n"
"• `/metas` - Ver tu progreso del mes\n"
"• `/ranking` - Ver ranking de vendedores\n"
"• `/ayuda` - Mostrar esta ayuda\n\n"
"**Ejemplos de registro de ventas:**\n"
" `venta @monto 2500 @cliente Empresa ABC`\n"
" `vendí $1,200.50 a cliente Pedro`\n"
" `venta @monto 5000 @cliente Tienda XYZ`\n\n"
"**Consejos:**\n"
"Registra tus ventas inmediatamente después de cerrarlas\n"
"Incluye el nombre del cliente para mejor seguimiento\n"
"Revisa tu progreso regularmente con `/metas`\n"
"• Compite sanamente con tus compañeros en el `/ranking`\n\n"
"**Comandos de gestión:**\n"
" `/cancelar <id> [motivo]` - Cancelar una venta\n"
" `/editar <id> @monto X @cliente Y` - Editar una venta\n"
" `/deshacer` - Deshacer última venta (5 min)\n\n"
"**Comandos de comisiones:**\n"
"`/comisiones` - Ver historial de comisiones\n"
"`/racha` - Ver tu racha actual y bonos\n"
"`/exportar [csv] [mes]` - Exportar ventas a Excel/CSV\n\n"
"**Sistema de confirmación:**\n"
"1. Envía tu mensaje de venta o foto\n"
"2. El bot muestra un preview\n"
"3. Responde **si** para confirmar o **no** para cancelar\n\n"
"**Bonos por racha:**\n"
"• 3 días consecutivos: +$20\n"
"• 5 días consecutivos: +$50\n"
"• 10 días consecutivos: +$150\n\n"
"¡Sigue adelante! 💪"
)
@@ -285,6 +299,378 @@ def comando_ayuda():
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
# ============== NUEVOS COMANDOS FASE 1-3 ==============
@app.route('/comando/cancelar', methods=['POST'])
def comando_cancelar():
"""
Endpoint para el comando slash /cancelar en Mattermost
Uso: /cancelar <id_venta> [motivo]
"""
try:
from utils import extraer_id_venta, extraer_motivo, validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /cancelar recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'cancelar'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
texto = data.get('text', '').strip()
# Extraer ID de venta
venta_id = extraer_id_venta(texto)
if not venta_id:
return jsonify({
'response_type': 'ephemeral',
'text': '❌ **Uso incorrecto**\n\nFormato: `/cancelar <id_venta> [motivo]`\n\nEjemplo: `/cancelar 123 "cliente canceló"`'
}), 200
# Extraer motivo opcional
motivo = extraer_motivo(texto)
# Cancelar venta
resultado = nocodb.cancelar_venta(venta_id, user_name, motivo)
if resultado.get('success'):
mensaje = (
f"✅ **Venta #{venta_id} cancelada**\n\n"
f"**Monto:** ${resultado.get('monto', 0):,.2f}\n"
f"**Cliente:** {resultado.get('cliente', 'N/A')}\n"
)
if motivo:
mensaje += f"**Motivo:** {motivo}\n"
mensaje += "\n_Las metas han sido recalculadas._"
else:
mensaje = f"❌ **Error:** {resultado.get('error')}"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /cancelar: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/deshacer', methods=['POST'])
def comando_deshacer():
"""
Endpoint para el comando slash /deshacer en Mattermost
Cancela la última venta del usuario (dentro de los últimos 5 minutos)
"""
try:
from utils import validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /deshacer recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'deshacer'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
# Obtener última venta
ultima_venta = nocodb.get_ultima_venta_usuario(user_name, minutos=5)
if not ultima_venta:
return jsonify({
'response_type': 'ephemeral',
'text': '❌ **No hay ventas recientes para deshacer**\n\nSolo puedes deshacer ventas de los últimos 5 minutos.'
}), 200
venta_id = ultima_venta.get('Id')
# Cancelar la venta
resultado = nocodb.cancelar_venta(venta_id, user_name, 'Deshecho por usuario')
if resultado.get('success'):
mensaje = (
f"↩️ **Venta #{venta_id} deshecha**\n\n"
f"**Monto:** ${resultado.get('monto', 0):,.2f}\n"
f"**Cliente:** {resultado.get('cliente', 'N/A')}\n\n"
"_Las metas han sido recalculadas._"
)
else:
mensaje = f"❌ **Error:** {resultado.get('error')}"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /deshacer: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/editar', methods=['POST'])
def comando_editar():
"""
Endpoint para el comando slash /editar en Mattermost
Uso: /editar <id_venta> @monto 1600 @cliente Nuevo Cliente
"""
try:
from utils import extraer_id_venta, extraer_monto, extraer_cliente, validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /editar recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'editar'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
texto = data.get('text', '').strip()
# Extraer ID de venta
venta_id = extraer_id_venta(texto)
if not venta_id:
return jsonify({
'response_type': 'ephemeral',
'text': '❌ **Uso incorrecto**\n\nFormato: `/editar <id_venta> @monto 1600 @cliente NuevoCliente`\n\nEjemplo: `/editar 123 @monto 2000`'
}), 200
# Extraer campos a actualizar
campos = {}
nuevo_monto = extraer_monto(texto)
nuevo_cliente = extraer_cliente(texto)
if nuevo_monto:
campos['monto'] = nuevo_monto
if nuevo_cliente:
campos['cliente'] = nuevo_cliente
if not campos:
return jsonify({
'response_type': 'ephemeral',
'text': '❌ **No se especificaron cambios**\n\nUsa `@monto` y/o `@cliente` para especificar los cambios.'
}), 200
# Editar venta
resultado = nocodb.editar_venta(venta_id, user_name, campos)
if resultado.get('success'):
cambios_texto = '\n'.join([
f"• **{c['campo']}:** {c['anterior']}{c['nuevo']}"
for c in resultado.get('cambios', [])
])
mensaje = (
f"✏️ **Venta #{venta_id} editada**\n\n"
f"**Cambios realizados:**\n{cambios_texto}\n\n"
"_Las metas han sido recalculadas._"
)
else:
mensaje = f"❌ **Error:** {resultado.get('error')}"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /editar: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/comisiones', methods=['POST'])
def comando_comisiones():
"""
Endpoint para el comando slash /comisiones en Mattermost
Muestra historial de comisiones de los últimos meses
"""
try:
from utils import validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /comisiones recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'comisiones'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
# Obtener historial
historial = nocodb.get_historial_comisiones(user_name, meses=6)
if not historial:
mensaje = f"📊 **Historial de Comisiones - @{user_name}**\n\nNo hay datos de comisiones."
else:
mensaje = f"📊 **Historial de Comisiones - @{user_name}**\n\n"
mensaje += "| Mes | Tubos | Comisión | Ventas |\n"
mensaje += "|-----|-------|----------|--------|\n"
total_comision = 0
for h in historial:
mensaje += f"| {h['mes']} | {h['tubos_totales']} | ${h['comision_total']:,.2f} | {h['cantidad_ventas']} |\n"
total_comision += h['comision_total']
mensaje += f"\n**Total acumulado:** ${total_comision:,.2f} 💰"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /comisiones: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/racha', methods=['POST'])
def comando_racha():
"""
Endpoint para el comando slash /racha en Mattermost
Muestra la racha actual del vendedor
"""
try:
from utils import validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /racha recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'racha'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
# Obtener racha
racha = nocodb.verificar_racha(user_name)
dias = racha.get('dias_consecutivos', 0)
bonus = racha.get('bonus', 0)
proximo = racha.get('proximo_bonus', {})
# Determinar emoji según racha
if dias >= 10:
emoji = '🔥🔥🔥'
nivel = 'LEGENDARIO'
elif dias >= 5:
emoji = '🔥🔥'
nivel = 'EN FUEGO'
elif dias >= 3:
emoji = '🔥'
nivel = 'RACHA ACTIVA'
else:
emoji = '💪'
nivel = 'CONSTRUYENDO'
mensaje = f"{emoji} **Racha de @{user_name}** {emoji}\n\n"
mensaje += f"**Nivel:** {nivel}\n"
mensaje += f"**Días consecutivos:** {dias}\n"
mensaje += f"**Meta diaria:** {racha.get('meta_diaria', 3)} tubos\n\n"
if bonus > 0:
mensaje += f"**💰 Bonus ganado:** ${bonus:,.2f}\n\n"
if proximo.get('dias_faltan', 0) > 0:
mensaje += f"**Próximo bonus:** ${proximo['bonus']:,.2f} en {proximo['dias_faltan']} día(s)\n"
elif proximo.get('mensaje'):
mensaje += f"**{proximo['mensaje']}**\n"
mensaje += "\n_¡Sigue cumpliendo tu meta diaria para mantener la racha!_"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /racha: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/exportar', methods=['POST'])
def comando_exportar():
"""
Endpoint para el comando slash /exportar en Mattermost
Exporta ventas a Excel o CSV
"""
try:
from utils import validar_tokens_comando, parsear_formato_exportar
from export_utils import generar_excel_ventas, generar_csv_ventas
data = request.form.to_dict()
logger.info(f"Comando /exportar recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'exportar'):
return jsonify({'text': '❌ Token inválido'}), 403
user_name = data.get('user_name')
texto = data.get('text', '').strip()
channel_id = data.get('channel_id')
# Parsear parámetros
formato, mes = parsear_formato_exportar(texto)
if not mes:
mes = datetime.now().strftime('%Y-%m')
# Obtener datos
ventas = nocodb.get_ventas_mes(user_name, mes)
stats = nocodb.get_estadisticas_vendedor_mes(user_name, mes)
if not ventas:
return jsonify({
'response_type': 'ephemeral',
'text': f'📊 No hay ventas para {user_name} en {mes}'
}), 200
# Generar archivo
if formato == 'excel':
contenido = generar_excel_ventas(ventas, user_name, stats)
filename = f'ventas_{user_name}_{mes}.xlsx'
else:
contenido = generar_csv_ventas(ventas)
filename = f'ventas_{user_name}_{mes}.csv'
# Subir a Mattermost
file_response = mattermost.upload_file(channel_id, contenido, filename)
if file_response:
mensaje = f"📊 **Exportación completada**\n\nArchivo: `{filename}`\n\n_El archivo ha sido subido al canal._"
else:
mensaje = f"❌ Error al subir el archivo. Intenta de nuevo."
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except ImportError:
return jsonify({
'response_type': 'ephemeral',
'text': '❌ Módulo de exportación no disponible. Contacta al administrador.'
}), 200
except Exception as e:
logger.error(f"Error procesando comando /exportar: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/reporte/diario', methods=['POST'])
def reporte_diario_manual():
"""