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:
339
sales-bot/scheduler.py
Normal file
339
sales-bot/scheduler.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Scheduler de tareas programadas para Sales Bot
|
||||
Maneja notificaciones automáticas y reportes periódicos
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Thread
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
APSCHEDULER_DISPONIBLE = True
|
||||
except ImportError:
|
||||
APSCHEDULER_DISPONIBLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Zona horaria de México
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
|
||||
class SalesBotScheduler:
|
||||
"""
|
||||
Programador de tareas automáticas para Sales Bot.
|
||||
|
||||
Tareas programadas:
|
||||
- Recordatorio de mediodía: Notifica a vendedores que no han cumplido meta
|
||||
- Resumen diario: Resumen de ventas al final del día
|
||||
- Resumen semanal: Estadísticas de la semana cada lunes
|
||||
"""
|
||||
|
||||
def __init__(self, mattermost_client, nocodb_client):
|
||||
if not APSCHEDULER_DISPONIBLE:
|
||||
logger.warning("APScheduler no está instalado. Scheduler deshabilitado.")
|
||||
self.scheduler = None
|
||||
return
|
||||
|
||||
self.mattermost = mattermost_client
|
||||
self.nocodb = nocodb_client
|
||||
self.scheduler = BackgroundScheduler(timezone='America/Mexico_City')
|
||||
|
||||
# Configuración desde variables de entorno
|
||||
self.enabled = os.getenv('SCHEDULER_ENABLED', 'True').lower() == 'true'
|
||||
self.hora_recordatorio = int(os.getenv('RECORDATORIO_MEDIODIA_HORA', 12))
|
||||
self.hora_resumen = int(os.getenv('RESUMEN_DIARIO_HORA', 18))
|
||||
self.dia_semanal = os.getenv('RESUMEN_SEMANAL_DIA', 'mon')
|
||||
self.hora_semanal = int(os.getenv('RESUMEN_SEMANAL_HORA', 9))
|
||||
|
||||
def iniciar(self):
|
||||
"""Inicia el scheduler con todas las tareas programadas"""
|
||||
if not self.scheduler or not self.enabled:
|
||||
logger.info("Scheduler deshabilitado")
|
||||
return
|
||||
|
||||
try:
|
||||
# Recordatorio de mediodía
|
||||
self.scheduler.add_job(
|
||||
self.recordatorio_mediodia,
|
||||
CronTrigger(hour=self.hora_recordatorio, minute=0),
|
||||
id='recordatorio_mediodia',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"Recordatorio de mediodía programado para las {self.hora_recordatorio}:00")
|
||||
|
||||
# Resumen diario
|
||||
self.scheduler.add_job(
|
||||
self.resumen_diario,
|
||||
CronTrigger(hour=self.hora_resumen, minute=0),
|
||||
id='resumen_diario',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"Resumen diario programado para las {self.hora_resumen}:00")
|
||||
|
||||
# Resumen semanal (lunes por defecto)
|
||||
self.scheduler.add_job(
|
||||
self.resumen_semanal,
|
||||
CronTrigger(day_of_week=self.dia_semanal, hour=self.hora_semanal, minute=0),
|
||||
id='resumen_semanal',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"Resumen semanal programado para {self.dia_semanal} a las {self.hora_semanal}:00")
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info("Scheduler iniciado correctamente")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error iniciando scheduler: {str(e)}")
|
||||
|
||||
def detener(self):
|
||||
"""Detiene el scheduler"""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Scheduler detenido")
|
||||
|
||||
def recordatorio_mediodia(self):
|
||||
"""
|
||||
Envía recordatorios a vendedores que no han cumplido su meta.
|
||||
Se ejecuta a mediodía para dar tiempo de reaccionar.
|
||||
"""
|
||||
try:
|
||||
logger.info("Ejecutando recordatorio de mediodía")
|
||||
|
||||
vendedores = self.nocodb.get_vendedores_activos()
|
||||
|
||||
for vendedor in vendedores:
|
||||
username = vendedor.get('username')
|
||||
meta = vendedor.get('meta_diaria_tubos', 3)
|
||||
|
||||
# Obtener estadísticas del día
|
||||
stats = self.nocodb.get_estadisticas_vendedor_dia(username)
|
||||
tubos = stats.get('tubos_vendidos', 0) if stats else 0
|
||||
|
||||
if tubos < meta:
|
||||
faltan = meta - tubos
|
||||
|
||||
# Determinar mensaje según progreso
|
||||
if tubos == 0:
|
||||
emoji = '⏰'
|
||||
mensaje_extra = '¡Aún no has registrado ventas hoy!'
|
||||
elif tubos == meta - 1:
|
||||
emoji = '💪'
|
||||
mensaje_extra = '¡Solo falta 1 tubo!'
|
||||
else:
|
||||
emoji = '📊'
|
||||
mensaje_extra = f'Llevas {tubos} de {meta} tubos.'
|
||||
|
||||
mensaje = (
|
||||
f"{emoji} **Recordatorio para @{username}**\n\n"
|
||||
f"{mensaje_extra}\n"
|
||||
f"**Faltan {faltan} tubos** para tu meta diaria.\n\n"
|
||||
f"_¡Tú puedes lograrlo!_ 💪"
|
||||
)
|
||||
|
||||
self.mattermost.post_message_webhook(
|
||||
mensaje,
|
||||
username='Sales Bot',
|
||||
icon_emoji=':alarm_clock:'
|
||||
)
|
||||
|
||||
logger.info("Recordatorio de mediodía completado")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en recordatorio de mediodía: {str(e)}")
|
||||
|
||||
def resumen_diario(self):
|
||||
"""
|
||||
Genera y envía el resumen diario de ventas.
|
||||
Se ejecuta al final del día laboral.
|
||||
"""
|
||||
try:
|
||||
logger.info("Ejecutando resumen diario")
|
||||
|
||||
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
|
||||
# Obtener todas las ventas del día
|
||||
ventas_hoy = self.nocodb.get_ventas_dia()
|
||||
|
||||
if not ventas_hoy:
|
||||
logger.info("No hay ventas hoy, no se envía resumen")
|
||||
return
|
||||
|
||||
# Calcular estadísticas
|
||||
total_monto = sum(float(v.get('monto', 0)) for v in ventas_hoy)
|
||||
total_ventas = len(ventas_hoy)
|
||||
|
||||
# Agrupar por vendedor
|
||||
vendedores_stats = {}
|
||||
for venta in ventas_hoy:
|
||||
username = venta.get('vendedor_username')
|
||||
if username not in vendedores_stats:
|
||||
vendedores_stats[username] = {
|
||||
'ventas': 0,
|
||||
'monto': 0
|
||||
}
|
||||
vendedores_stats[username]['ventas'] += 1
|
||||
vendedores_stats[username]['monto'] += float(venta.get('monto', 0))
|
||||
|
||||
# Obtener tubos y comisiones por vendedor
|
||||
for username in vendedores_stats:
|
||||
stats = self.nocodb.get_estadisticas_vendedor_dia(username, hoy)
|
||||
if stats:
|
||||
vendedores_stats[username]['tubos'] = stats.get('tubos_vendidos', 0)
|
||||
vendedores_stats[username]['comision'] = stats.get('comision', 0)
|
||||
|
||||
# Calcular totales de tubos y comisiones
|
||||
total_tubos = sum(v.get('tubos', 0) for v in vendedores_stats.values())
|
||||
total_comisiones = sum(v.get('comision', 0) for v in vendedores_stats.values())
|
||||
|
||||
# Ordenar vendedores por tubos
|
||||
vendedores_ordenados = sorted(
|
||||
vendedores_stats.items(),
|
||||
key=lambda x: x[1].get('tubos', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Construir mensaje
|
||||
mensaje = (
|
||||
f"📊 **Resumen del Día - {datetime.now(TZ_MEXICO).strftime('%d/%m/%Y')}**\n\n"
|
||||
f"**Totales:**\n"
|
||||
f"• Ventas: {total_ventas}\n"
|
||||
f"• Monto: ${total_monto:,.2f}\n"
|
||||
f"• Tubos: {total_tubos} 🧪\n"
|
||||
f"• Comisiones: ${total_comisiones:,.2f} 💰\n\n"
|
||||
)
|
||||
|
||||
if vendedores_ordenados:
|
||||
mensaje += "**Top del día:**\n"
|
||||
for i, (username, stats) in enumerate(vendedores_ordenados[:5], 1):
|
||||
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
|
||||
tubos = stats.get('tubos', 0)
|
||||
comision = stats.get('comision', 0)
|
||||
|
||||
mensaje += f"{emoji} @{username} - {tubos} tubos"
|
||||
if comision > 0:
|
||||
mensaje += f" (+${comision:,.2f})"
|
||||
mensaje += "\n"
|
||||
|
||||
mensaje += "\n_¡Excelente trabajo equipo!_ 🎉"
|
||||
|
||||
self.mattermost.post_message_webhook(
|
||||
mensaje,
|
||||
username='Sales Bot',
|
||||
icon_emoji=':chart_with_upwards_trend:'
|
||||
)
|
||||
|
||||
logger.info("Resumen diario enviado")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en resumen diario: {str(e)}")
|
||||
|
||||
def resumen_semanal(self):
|
||||
"""
|
||||
Genera y envía el resumen semanal de ventas.
|
||||
Se ejecuta los lunes por la mañana.
|
||||
"""
|
||||
try:
|
||||
logger.info("Ejecutando resumen semanal")
|
||||
|
||||
# Calcular fechas de la semana anterior
|
||||
ahora = datetime.now(TZ_MEXICO)
|
||||
fin_semana = ahora - timedelta(days=ahora.weekday()) # Lunes actual
|
||||
fin_semana = fin_semana.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
inicio_semana = fin_semana - timedelta(days=7) # Lunes anterior
|
||||
|
||||
# Obtener ventas de la semana anterior
|
||||
ventas_semana = self.nocodb.get_ventas_semana()
|
||||
|
||||
# Filtrar por semana anterior
|
||||
ventas_filtradas = []
|
||||
for venta in ventas_semana:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
try:
|
||||
fecha = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00'))
|
||||
if fecha.tzinfo is None:
|
||||
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||
fecha_mexico = fecha.astimezone(TZ_MEXICO)
|
||||
|
||||
if inicio_semana <= fecha_mexico < fin_semana:
|
||||
ventas_filtradas.append(venta)
|
||||
except:
|
||||
continue
|
||||
|
||||
if not ventas_filtradas:
|
||||
logger.info("No hay ventas de la semana anterior")
|
||||
return
|
||||
|
||||
# Calcular estadísticas
|
||||
total_monto = sum(float(v.get('monto', 0)) for v in ventas_filtradas)
|
||||
total_ventas = len(ventas_filtradas)
|
||||
|
||||
# Agrupar por vendedor
|
||||
vendedores_stats = {}
|
||||
for venta in ventas_filtradas:
|
||||
username = venta.get('vendedor_username')
|
||||
if username not in vendedores_stats:
|
||||
vendedores_stats[username] = {
|
||||
'ventas': 0,
|
||||
'monto': 0
|
||||
}
|
||||
vendedores_stats[username]['ventas'] += 1
|
||||
vendedores_stats[username]['monto'] += float(venta.get('monto', 0))
|
||||
|
||||
# Obtener ranking del mes para comparar
|
||||
ranking_mes = self.nocodb.get_ranking_vendedores()
|
||||
|
||||
# Ordenar por monto semanal
|
||||
vendedores_ordenados = sorted(
|
||||
vendedores_stats.items(),
|
||||
key=lambda x: x[1]['monto'],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Construir mensaje
|
||||
mensaje = (
|
||||
f"📅 **Resumen Semanal**\n"
|
||||
f"_{inicio_semana.strftime('%d/%m')} - {(fin_semana - timedelta(days=1)).strftime('%d/%m/%Y')}_\n\n"
|
||||
f"**Totales de la semana:**\n"
|
||||
f"• Ventas realizadas: {total_ventas}\n"
|
||||
f"• Monto total: ${total_monto:,.2f}\n"
|
||||
f"• Vendedores activos: {len(vendedores_stats)}\n\n"
|
||||
)
|
||||
|
||||
if vendedores_ordenados:
|
||||
mensaje += "🏆 **Top 3 de la semana:**\n"
|
||||
medallas = ['🥇', '🥈', '🥉']
|
||||
for i, (username, stats) in enumerate(vendedores_ordenados[:3]):
|
||||
monto = stats['monto']
|
||||
ventas = stats['ventas']
|
||||
mensaje += f"{medallas[i]} @{username} - ${monto:,.2f} ({ventas} ventas)\n"
|
||||
|
||||
mensaje += "\n_¡Excelente semana equipo! A seguir con todo._ 💪"
|
||||
|
||||
self.mattermost.post_message_webhook(
|
||||
mensaje,
|
||||
username='Sales Bot',
|
||||
icon_emoji=':calendar:'
|
||||
)
|
||||
|
||||
logger.info("Resumen semanal enviado")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en resumen semanal: {str(e)}")
|
||||
|
||||
def ejecutar_ahora(self, tarea):
|
||||
"""
|
||||
Ejecuta una tarea programada inmediatamente (útil para testing).
|
||||
|
||||
Args:
|
||||
tarea: 'recordatorio', 'diario', o 'semanal'
|
||||
"""
|
||||
if tarea == 'recordatorio':
|
||||
self.recordatorio_mediodia()
|
||||
elif tarea == 'diario':
|
||||
self.resumen_diario()
|
||||
elif tarea == 'semanal':
|
||||
self.resumen_semanal()
|
||||
else:
|
||||
logger.warning(f"Tarea desconocida: {tarea}")
|
||||
Reference in New Issue
Block a user