Files
sales-bot-stacks/sales-bot/scheduler.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

340 lines
13 KiB
Python

"""
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}")