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>
340 lines
13 KiB
Python
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}")
|