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:
@@ -45,3 +45,40 @@ LOG_FILE=/app/logs/sales-bot.log
|
||||
# === ZONA HORARIA ===
|
||||
# México: -6, Cancún: -5, España: +1
|
||||
TZ_OFFSET=-6
|
||||
|
||||
# === SCHEDULER (Notificaciones Automáticas) ===
|
||||
# Habilitar/deshabilitar el scheduler de tareas programadas
|
||||
SCHEDULER_ENABLED=True
|
||||
|
||||
# Hora del recordatorio de mediodía (0-23)
|
||||
RECORDATORIO_MEDIODIA_HORA=12
|
||||
|
||||
# Hora del resumen diario (0-23)
|
||||
RESUMEN_DIARIO_HORA=18
|
||||
|
||||
# Día de la semana para resumen semanal (mon, tue, wed, thu, fri, sat, sun)
|
||||
RESUMEN_SEMANAL_DIA=mon
|
||||
|
||||
# Hora del resumen semanal (0-23)
|
||||
RESUMEN_SEMANAL_HORA=9
|
||||
|
||||
# === BONOS POR RACHA ===
|
||||
# Bonus en pesos por cumplir meta consecutivamente
|
||||
BONUS_3_DIAS=20
|
||||
BONUS_5_DIAS=50
|
||||
BONUS_10_DIAS=150
|
||||
|
||||
# === CONFIRMACIÓN DE VENTAS ===
|
||||
# Tiempo en minutos antes de que expire una venta pendiente de confirmación
|
||||
CONFIRMACION_TIMEOUT_MINUTOS=2
|
||||
|
||||
# === METAS Y COMISIONES (Defaults) ===
|
||||
# Meta diaria de tubos por vendedor (default)
|
||||
META_DIARIA_TUBOS_DEFAULT=3
|
||||
|
||||
# Comisión por tubo vendido después de cumplir meta (default)
|
||||
COMISION_POR_TUBO_DEFAULT=10
|
||||
|
||||
# === EXPORTACIÓN ===
|
||||
# Formato por defecto para exportación (excel o csv)
|
||||
EXPORTAR_FORMATO_DEFAULT=excel
|
||||
|
||||
408
sales-bot/app.py
408
sales-bot/app.py
@@ -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():
|
||||
"""
|
||||
|
||||
226
sales-bot/export_utils.py
Normal file
226
sales-bot/export_utils.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Utilidades de exportación para Sales Bot
|
||||
Genera archivos Excel y CSV con datos de ventas
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
OPENPYXL_DISPONIBLE = True
|
||||
except ImportError:
|
||||
OPENPYXL_DISPONIBLE = False
|
||||
|
||||
|
||||
def generar_excel_ventas(ventas, vendedor, stats):
|
||||
"""
|
||||
Genera archivo Excel con múltiples hojas:
|
||||
- Resumen: Estadísticas generales
|
||||
- Ventas: Detalle de todas las ventas
|
||||
- Comisiones: Desglose de comisiones
|
||||
"""
|
||||
if not OPENPYXL_DISPONIBLE:
|
||||
raise ImportError("openpyxl no está instalado")
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# Estilos
|
||||
header_font = Font(bold=True, color='FFFFFF')
|
||||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||||
header_alignment = Alignment(horizontal='center', vertical='center')
|
||||
currency_format = '$#,##0.00'
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
# ==================== HOJA 1: RESUMEN ====================
|
||||
ws_resumen = wb.active
|
||||
ws_resumen.title = "Resumen"
|
||||
|
||||
# Título
|
||||
ws_resumen['A1'] = f"Reporte de Ventas - {vendedor}"
|
||||
ws_resumen['A1'].font = Font(bold=True, size=16)
|
||||
ws_resumen.merge_cells('A1:D1')
|
||||
|
||||
ws_resumen['A2'] = f"Generado: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
ws_resumen['A2'].font = Font(italic=True, color='666666')
|
||||
|
||||
# Estadísticas
|
||||
resumen_data = [
|
||||
['Métrica', 'Valor'],
|
||||
['Total de Ventas', len(ventas)],
|
||||
['Monto Total', stats.get('monto_total', 0) if stats else 0],
|
||||
['Tubos Vendidos', stats.get('tubos_totales', 0) if stats else 0],
|
||||
['Comisión Total', stats.get('comision_total', 0) if stats else 0],
|
||||
['Días Activos', stats.get('dias_activos', 0) if stats else 0],
|
||||
['Días con Meta Cumplida', stats.get('dias_meta_cumplida', 0) if stats else 0],
|
||||
['Promedio Tubos/Día', round(stats.get('promedio_tubos_dia', 0), 1) if stats else 0],
|
||||
]
|
||||
|
||||
for row_idx, row_data in enumerate(resumen_data, start=4):
|
||||
for col_idx, value in enumerate(row_data, start=1):
|
||||
cell = ws_resumen.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = thin_border
|
||||
if row_idx == 4: # Header
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
elif col_idx == 2 and row_idx in [6, 8]: # Montos
|
||||
cell.number_format = currency_format
|
||||
|
||||
# Ajustar anchos
|
||||
ws_resumen.column_dimensions['A'].width = 25
|
||||
ws_resumen.column_dimensions['B'].width = 15
|
||||
|
||||
# ==================== HOJA 2: VENTAS ====================
|
||||
ws_ventas = wb.create_sheet("Ventas")
|
||||
|
||||
headers_ventas = ['ID', 'Fecha', 'Cliente', 'Monto', 'Estado', 'Canal']
|
||||
|
||||
for col_idx, header in enumerate(headers_ventas, start=1):
|
||||
cell = ws_ventas.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
cell.border = thin_border
|
||||
|
||||
for row_idx, venta in enumerate(ventas, start=2):
|
||||
fecha = venta.get('fecha_venta', '')
|
||||
if fecha:
|
||||
try:
|
||||
fecha = datetime.fromisoformat(fecha.replace('+00:00', '')).strftime('%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
row_data = [
|
||||
venta.get('Id', ''),
|
||||
fecha,
|
||||
venta.get('cliente', ''),
|
||||
venta.get('monto', 0),
|
||||
venta.get('estado', ''),
|
||||
venta.get('canal', '')
|
||||
]
|
||||
|
||||
for col_idx, value in enumerate(row_data, start=1):
|
||||
cell = ws_ventas.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = thin_border
|
||||
if col_idx == 4: # Monto
|
||||
cell.number_format = currency_format
|
||||
|
||||
# Ajustar anchos
|
||||
column_widths = [8, 18, 25, 12, 12, 15]
|
||||
for col_idx, width in enumerate(column_widths, start=1):
|
||||
ws_ventas.column_dimensions[get_column_letter(col_idx)].width = width
|
||||
|
||||
# Agregar filtros
|
||||
ws_ventas.auto_filter.ref = f"A1:F{len(ventas) + 1}"
|
||||
|
||||
# ==================== HOJA 3: COMISIONES ====================
|
||||
ws_comisiones = wb.create_sheet("Comisiones")
|
||||
|
||||
headers_comisiones = ['Descripción', 'Cantidad', 'Valor']
|
||||
|
||||
for col_idx, header in enumerate(headers_comisiones, start=1):
|
||||
cell = ws_comisiones.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
cell.border = thin_border
|
||||
|
||||
meta_diaria = 3
|
||||
comision_por_tubo = 10
|
||||
tubos_totales = stats.get('tubos_totales', 0) if stats else 0
|
||||
tubos_comisionables = max(0, tubos_totales - (meta_diaria * stats.get('dias_activos', 0))) if stats else 0
|
||||
|
||||
comisiones_data = [
|
||||
['Tubos vendidos', tubos_totales, ''],
|
||||
['Meta diaria', meta_diaria, 'tubos'],
|
||||
['Días activos', stats.get('dias_activos', 0) if stats else 0, 'días'],
|
||||
['Tubos comisionables', tubos_comisionables, ''],
|
||||
['Comisión por tubo', comision_por_tubo, ''],
|
||||
['', '', ''],
|
||||
['COMISIÓN TOTAL', stats.get('comision_total', 0) if stats else 0, ''],
|
||||
]
|
||||
|
||||
for row_idx, row_data in enumerate(comisiones_data, start=2):
|
||||
for col_idx, value in enumerate(row_data, start=1):
|
||||
cell = ws_comisiones.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = thin_border
|
||||
if row_idx == 8: # Total
|
||||
cell.font = Font(bold=True)
|
||||
if col_idx == 2:
|
||||
cell.number_format = currency_format
|
||||
elif col_idx == 2 and row_idx == 6:
|
||||
cell.number_format = currency_format
|
||||
|
||||
# Ajustar anchos
|
||||
ws_comisiones.column_dimensions['A'].width = 20
|
||||
ws_comisiones.column_dimensions['B'].width = 15
|
||||
ws_comisiones.column_dimensions['C'].width = 10
|
||||
|
||||
# Guardar a bytes
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def generar_csv_ventas(ventas):
|
||||
"""
|
||||
Genera archivo CSV con el detalle de ventas.
|
||||
Formato simple compatible con cualquier aplicación.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
|
||||
fieldnames = ['id', 'fecha', 'cliente', 'monto', 'estado', 'canal', 'vendedor']
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for venta in ventas:
|
||||
fecha = venta.get('fecha_venta', '')
|
||||
if fecha:
|
||||
try:
|
||||
fecha = datetime.fromisoformat(fecha.replace('+00:00', '')).strftime('%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
writer.writerow({
|
||||
'id': venta.get('Id', ''),
|
||||
'fecha': fecha,
|
||||
'cliente': venta.get('cliente', ''),
|
||||
'monto': venta.get('monto', 0),
|
||||
'estado': venta.get('estado', ''),
|
||||
'canal': venta.get('canal', ''),
|
||||
'vendedor': venta.get('vendedor_username', '')
|
||||
})
|
||||
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
|
||||
def generar_reporte_comisiones_csv(historial):
|
||||
"""
|
||||
Genera CSV con historial de comisiones.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
|
||||
fieldnames = ['mes', 'tubos', 'comision', 'ventas', 'dias_activos', 'monto_total']
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for h in historial:
|
||||
writer.writerow({
|
||||
'mes': h.get('mes', ''),
|
||||
'tubos': h.get('tubos_totales', 0),
|
||||
'comision': h.get('comision_total', 0),
|
||||
'ventas': h.get('cantidad_ventas', 0),
|
||||
'dias_activos': h.get('dias_activos', 0),
|
||||
'monto_total': h.get('monto_total', 0)
|
||||
})
|
||||
|
||||
return output.getvalue().encode('utf-8')
|
||||
@@ -1,20 +1,92 @@
|
||||
"""
|
||||
Handlers para procesamiento de ventas en Sales Bot
|
||||
Incluye sistema de confirmación interactiva obligatoria
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos
|
||||
from ocr_processor import OCRProcessor
|
||||
import os
|
||||
|
||||
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
|
||||
NUEVO: Sistema de comisiones por tubos vendidos
|
||||
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', 'sí', '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', '')
|
||||
@@ -29,80 +101,99 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
# Extraer información del texto
|
||||
monto = extraer_monto(text)
|
||||
cliente = extraer_cliente(text)
|
||||
tubos_manual = extraer_tubos(text) # NUEVO: tubos manuales
|
||||
tubos_manual = extraer_tubos(text)
|
||||
|
||||
# Procesar imágenes adjuntas
|
||||
imagen_url = None
|
||||
# ==================== 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")
|
||||
file_id = file_ids[0]
|
||||
|
||||
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}"
|
||||
|
||||
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
|
||||
|
||||
# Procesar con OCR
|
||||
for file_id in file_ids:
|
||||
try:
|
||||
ocr = OCRProcessor()
|
||||
resultado_ocr = ocr.procesar_ticket(imagen_data)
|
||||
imagen_data = mattermost.get_file(file_id)
|
||||
file_info = mattermost.get_file_info(file_id)
|
||||
|
||||
if resultado_ocr:
|
||||
monto_ocr = resultado_ocr.get('monto_detectado')
|
||||
fecha_ocr = resultado_ocr.get('fecha_detectada')
|
||||
productos_ocr = resultado_ocr.get('productos', [])
|
||||
if file_info and imagen_data:
|
||||
filename = file_info.get('name', 'ticket.jpg')
|
||||
file_size = file_info.get('size', 0)
|
||||
|
||||
if not monto and monto_ocr:
|
||||
monto = monto_ocr
|
||||
logger.info(f"Usando monto detectado por OCR: ${monto}")
|
||||
ocr_info += f"\n💡 Monto detectado: ${monto:,.2f}"
|
||||
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)
|
||||
|
||||
elif monto and monto_ocr:
|
||||
es_valido, mensaje = ocr.validar_monto_con_ocr(monto, monto_ocr, tolerancia=0.05)
|
||||
ocr_info += f"\n{mensaje}"
|
||||
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
|
||||
|
||||
if not es_valido:
|
||||
logger.warning(mensaje)
|
||||
# Procesar con OCR
|
||||
try:
|
||||
ocr = OCRProcessor()
|
||||
resultado_ocr = ocr.procesar_ticket(imagen_data)
|
||||
|
||||
if fecha_ocr:
|
||||
ocr_info += f"\n📅 Fecha: {fecha_ocr}"
|
||||
if resultado_ocr:
|
||||
monto_ocr = resultado_ocr.get('monto_detectado', 0)
|
||||
fecha_ocr = resultado_ocr.get('fecha_detectada')
|
||||
productos = resultado_ocr.get('productos', [])
|
||||
|
||||
if productos_ocr:
|
||||
# NUEVO: Contar tubos de tinte
|
||||
tubos_tinte = sum(
|
||||
p['cantidad'] for p in productos_ocr
|
||||
if 'tinte' in p['marca'].lower() or 'tinte' in p['producto'].lower()
|
||||
or 'cromatique' in p['marca'].lower()
|
||||
)
|
||||
ocr_info += f"\n🧪 Tubos de tinte: {tubos_tinte}"
|
||||
ocr_info += f"\n📦 Total productos: {len(productos_ocr)}"
|
||||
logger.info(f"Tubos de tinte detectados: {tubos_tinte}")
|
||||
if monto_ocr:
|
||||
monto_ocr_total += monto_ocr
|
||||
|
||||
except Exception as ocr_error:
|
||||
logger.error(f"Error en OCR: {str(ocr_error)}")
|
||||
ocr_info = "\n⚠️ No se pudo leer el ticket"
|
||||
productos_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)
|
||||
|
||||
logger.info(f"URL de imagen: {imagen_url}")
|
||||
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"
|
||||
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}
|
||||
@@ -110,6 +201,70 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
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:
|
||||
@@ -121,7 +276,7 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
username=user_name,
|
||||
nombre_completo=nombre,
|
||||
email=email,
|
||||
meta_diaria_tubos=3 # NUEVO: Meta de 3 tubos diarios
|
||||
meta_diaria_tubos=3
|
||||
)
|
||||
|
||||
if vendedor:
|
||||
@@ -134,6 +289,9 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
)
|
||||
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,
|
||||
@@ -154,7 +312,7 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
if productos_guardados:
|
||||
logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}")
|
||||
|
||||
# NUEVO: Guardar tubos manuales si se especificaron
|
||||
# Guardar tubos manuales si se especificaron
|
||||
elif tubos_manual and tubos_manual > 0:
|
||||
productos_manuales = [{
|
||||
'producto': 'Tinte (registro manual)',
|
||||
@@ -163,36 +321,38 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
|
||||
'importe': monto
|
||||
}]
|
||||
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_manuales)
|
||||
if productos_guardados:
|
||||
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
|
||||
nocodb.guardar_productos_venta(venta_id, productos_manuales)
|
||||
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
|
||||
|
||||
# NUEVO: Actualizar tabla de metas
|
||||
# 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')
|
||||
|
||||
# NUEVO: Obtener estadísticas del día
|
||||
# Obtener estadísticas del día
|
||||
stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name)
|
||||
|
||||
# Construir mensaje
|
||||
# Construir mensaje de confirmación
|
||||
mensaje_confirmacion = (
|
||||
f"✅ **Venta registrada**\n\n"
|
||||
f"✅ **Venta #{venta_id} registrada**\n\n"
|
||||
f"**Vendedor:** @{user_name}\n"
|
||||
f"**Monto:** {formatear_moneda(monto)}\n"
|
||||
f"**Cliente:** {cliente}\n"
|
||||
)
|
||||
|
||||
if imagen_url:
|
||||
mensaje_confirmacion += f"📸 **Ticket:** Guardado{ocr_info}\n"
|
||||
if imagenes_url:
|
||||
mensaje_confirmacion += f"📸 **Tickets:** {len(imagenes_url)} guardado(s){ocr_info}\n"
|
||||
|
||||
# NUEVO: Mostrar estadísticas de tubos y comisiones
|
||||
# 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)
|
||||
@@ -230,6 +390,14 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
|
||||
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,
|
||||
@@ -239,25 +407,22 @@ def handle_venta_message(data, mattermost, nocodb):
|
||||
|
||||
return {'text': mensaje_confirmacion}
|
||||
else:
|
||||
mensaje_error = f"❌ Error al registrar la venta. Intenta de nuevo."
|
||||
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 handle_venta_message: {str(e)}", exc_info=True)
|
||||
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
|
||||
NUEVO: Muestra tubos vendidos y comisiones ganadas
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
|
||||
hoy = datetime.now().strftime('%Y-%m-%d')
|
||||
mes_actual = datetime.now().strftime('%Y-%m')
|
||||
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||
|
||||
# Obtener todas las ventas del día
|
||||
ventas_hoy = nocodb.get_ventas_dia()
|
||||
@@ -287,7 +452,7 @@ def generar_reporte_diario(mattermost, nocodb):
|
||||
|
||||
# Construir mensaje
|
||||
mensaje = (
|
||||
f"📊 **Reporte Diario - {datetime.now().strftime('%d/%m/%Y')}**\n\n"
|
||||
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"
|
||||
@@ -313,20 +478,26 @@ def generar_reporte_diario(mattermost, nocodb):
|
||||
team_name = os.getenv('MATTERMOST_TEAM_NAME')
|
||||
channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES')
|
||||
|
||||
canal = mattermost.get_channel_by_name(team_name, 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'}
|
||||
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:
|
||||
logger.warning(f"Canal {channel_reportes} no encontrado")
|
||||
return {'status': 'error', 'message': 'Canal no encontrado'}
|
||||
# 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
|
||||
@@ -348,7 +519,7 @@ def comando_estadisticas(user_name, mattermost, nocodb):
|
||||
# Hoy
|
||||
if stats_hoy:
|
||||
mensaje += (
|
||||
f"**Hoy ({datetime.now().strftime('%d/%m')})**\n"
|
||||
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"
|
||||
@@ -373,4 +544,4 @@ def comando_estadisticas(user_name, mattermost, nocodb):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True)
|
||||
return f"❌ Error obteniendo estadísticas"
|
||||
return "❌ Error obteniendo estadísticas"
|
||||
|
||||
@@ -609,3 +609,354 @@ class NocoDBClient:
|
||||
def get_meta_vendedor(self, vendedor_username, mes=None):
|
||||
"""Obtiene las estadísticas del vendedor para el mes"""
|
||||
return self.get_estadisticas_vendedor_mes(vendedor_username, mes)
|
||||
|
||||
# ==================== NUEVOS MÉTODOS FASE 1 ====================
|
||||
|
||||
def get_venta_por_id(self, venta_id):
|
||||
"""Obtiene una venta específica por su ID"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records/{venta_id}",
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo venta {venta_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def cancelar_venta(self, venta_id, username_solicitante, motivo=None):
|
||||
"""
|
||||
Cancela una venta existente.
|
||||
Solo el vendedor dueño puede cancelar su propia venta.
|
||||
"""
|
||||
try:
|
||||
# Verificar que la venta existe
|
||||
venta = self.get_venta_por_id(venta_id)
|
||||
if not venta:
|
||||
return {'error': 'Venta no encontrada', 'success': False}
|
||||
|
||||
# Verificar permisos: solo el dueño puede cancelar
|
||||
if venta.get('vendedor_username') != username_solicitante:
|
||||
return {'error': 'Solo puedes cancelar tus propias ventas', 'success': False}
|
||||
|
||||
# Verificar que no esté ya cancelada
|
||||
if venta.get('estado') == 'cancelada':
|
||||
return {'error': 'Esta venta ya está cancelada', 'success': False}
|
||||
|
||||
# Actualizar estado
|
||||
payload = {
|
||||
'Id': venta_id,
|
||||
'estado': 'cancelada',
|
||||
'motivo_cancelacion': motivo or 'Sin motivo especificado',
|
||||
'fecha_cancelacion': datetime.now(TZ_MEXICO).isoformat()
|
||||
}
|
||||
|
||||
response = requests.patch(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
json=[payload],
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Recalcular metas del vendedor
|
||||
self.actualizar_meta_vendedor(username_solicitante)
|
||||
|
||||
logger.info(f"Venta {venta_id} cancelada por {username_solicitante}")
|
||||
return {
|
||||
'success': True,
|
||||
'venta_id': venta_id,
|
||||
'monto': venta.get('monto'),
|
||||
'cliente': venta.get('cliente')
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelando venta {venta_id}: {str(e)}")
|
||||
return {'error': f'Error al cancelar: {str(e)}', 'success': False}
|
||||
|
||||
def editar_venta(self, venta_id, username_solicitante, campos_actualizar):
|
||||
"""
|
||||
Edita una venta existente.
|
||||
Solo el vendedor dueño puede editar su propia venta.
|
||||
Guarda historial de cambios.
|
||||
"""
|
||||
try:
|
||||
# Verificar que la venta existe
|
||||
venta = self.get_venta_por_id(venta_id)
|
||||
if not venta:
|
||||
return {'error': 'Venta no encontrada', 'success': False}
|
||||
|
||||
# Verificar permisos
|
||||
if venta.get('vendedor_username') != username_solicitante:
|
||||
return {'error': 'Solo puedes editar tus propias ventas', 'success': False}
|
||||
|
||||
# Verificar que no esté cancelada
|
||||
if venta.get('estado') == 'cancelada':
|
||||
return {'error': 'No puedes editar una venta cancelada', 'success': False}
|
||||
|
||||
# Guardar historial de cambios
|
||||
cambios = []
|
||||
for campo, nuevo_valor in campos_actualizar.items():
|
||||
valor_anterior = venta.get(campo)
|
||||
if valor_anterior != nuevo_valor:
|
||||
cambios.append({
|
||||
'campo': campo,
|
||||
'anterior': valor_anterior,
|
||||
'nuevo': nuevo_valor
|
||||
})
|
||||
|
||||
if not cambios:
|
||||
return {'error': 'No hay cambios para aplicar', 'success': False}
|
||||
|
||||
# Actualizar venta
|
||||
payload = {'Id': venta_id, **campos_actualizar}
|
||||
|
||||
response = requests.patch(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
json=[payload],
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Recalcular metas
|
||||
self.actualizar_meta_vendedor(username_solicitante)
|
||||
|
||||
logger.info(f"Venta {venta_id} editada por {username_solicitante}: {cambios}")
|
||||
return {
|
||||
'success': True,
|
||||
'venta_id': venta_id,
|
||||
'cambios': cambios
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editando venta {venta_id}: {str(e)}")
|
||||
return {'error': f'Error al editar: {str(e)}', 'success': False}
|
||||
|
||||
def get_ultima_venta_usuario(self, username, minutos=5):
|
||||
"""
|
||||
Obtiene la última venta del usuario dentro de los últimos N minutos.
|
||||
Útil para el comando /deshacer.
|
||||
"""
|
||||
try:
|
||||
ahora = datetime.now(TZ_MEXICO)
|
||||
limite = ahora - timedelta(minutes=minutos)
|
||||
|
||||
# Obtener ventas recientes del usuario
|
||||
ventas = self.get_ventas_dia(username)
|
||||
|
||||
# Filtrar por tiempo y estado
|
||||
ventas_recientes = []
|
||||
for venta in ventas:
|
||||
if venta.get('estado') == 'cancelada':
|
||||
continue
|
||||
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
try:
|
||||
fecha_venta = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00'))
|
||||
if fecha_venta.tzinfo is None:
|
||||
fecha_venta = fecha_venta.replace(tzinfo=timezone.utc)
|
||||
fecha_mexico = fecha_venta.astimezone(TZ_MEXICO)
|
||||
|
||||
if fecha_mexico >= limite:
|
||||
ventas_recientes.append({
|
||||
**venta,
|
||||
'fecha_parseada': fecha_mexico
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
if not ventas_recientes:
|
||||
return None
|
||||
|
||||
# Ordenar por fecha y retornar la más reciente
|
||||
ventas_recientes.sort(key=lambda x: x['fecha_parseada'], reverse=True)
|
||||
return ventas_recientes[0]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo última venta de {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_clientes_frecuentes(self, username, query=None, limit=5):
|
||||
"""
|
||||
Obtiene los clientes más frecuentes de un vendedor.
|
||||
Útil para autocompletado.
|
||||
"""
|
||||
try:
|
||||
# Obtener todas las ventas del usuario
|
||||
ventas = self.get_ventas_mes(username)
|
||||
|
||||
# Contar frecuencia de clientes
|
||||
clientes = {}
|
||||
for venta in ventas:
|
||||
cliente = venta.get('cliente', '').strip()
|
||||
if cliente and cliente != 'Cliente sin nombre':
|
||||
if query and query.lower() not in cliente.lower():
|
||||
continue
|
||||
clientes[cliente] = clientes.get(cliente, 0) + 1
|
||||
|
||||
# Ordenar por frecuencia
|
||||
clientes_ordenados = sorted(clientes.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
return [c[0] for c in clientes_ordenados[:limit]]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo clientes frecuentes: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_historial_comisiones(self, username, meses=6):
|
||||
"""
|
||||
Obtiene el historial de comisiones de los últimos N meses.
|
||||
"""
|
||||
try:
|
||||
historial = []
|
||||
ahora = datetime.now(TZ_MEXICO)
|
||||
|
||||
for i in range(meses):
|
||||
# Calcular mes
|
||||
fecha = ahora - timedelta(days=30 * i)
|
||||
mes = fecha.strftime('%Y-%m')
|
||||
|
||||
# Obtener estadísticas del mes
|
||||
stats = self.get_estadisticas_vendedor_mes(username, mes)
|
||||
|
||||
if stats:
|
||||
historial.append({
|
||||
'mes': mes,
|
||||
'mes_nombre': fecha.strftime('%B %Y'),
|
||||
'tubos_totales': stats.get('tubos_totales', 0),
|
||||
'comision_total': stats.get('comision_total', 0),
|
||||
'monto_total': stats.get('monto_total', 0),
|
||||
'cantidad_ventas': stats.get('cantidad_ventas', 0),
|
||||
'dias_activos': stats.get('dias_activos', 0)
|
||||
})
|
||||
|
||||
return historial
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo historial de comisiones: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_vendedores_activos(self):
|
||||
"""Obtiene lista de vendedores activos"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 100},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
vendedores = response.json().get('list', [])
|
||||
|
||||
return [v for v in vendedores if v.get('activo', True)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo vendedores activos: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_ventas_semana(self, vendedor_username=None):
|
||||
"""Obtiene ventas de la semana actual (lunes a domingo)"""
|
||||
try:
|
||||
ahora = datetime.now(TZ_MEXICO)
|
||||
# Calcular inicio de semana (lunes)
|
||||
inicio_semana = ahora - timedelta(days=ahora.weekday())
|
||||
inicio_semana = inicio_semana.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
response = requests.get(
|
||||
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
|
||||
headers=self.headers,
|
||||
params={'limit': 1000},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
todas_ventas = response.json().get('list', [])
|
||||
|
||||
ventas_filtradas = []
|
||||
for venta in todas_ventas:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
vendedor = venta.get('vendedor_username', '')
|
||||
|
||||
try:
|
||||
fecha_utc = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00'))
|
||||
if fecha_utc.tzinfo is None:
|
||||
fecha_utc = fecha_utc.replace(tzinfo=timezone.utc)
|
||||
fecha_mexico = fecha_utc.astimezone(TZ_MEXICO)
|
||||
|
||||
if fecha_mexico >= inicio_semana:
|
||||
if vendedor_username is None or vendedor == vendedor_username:
|
||||
ventas_filtradas.append(venta)
|
||||
except:
|
||||
continue
|
||||
|
||||
return ventas_filtradas
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas de la semana: {str(e)}")
|
||||
return []
|
||||
|
||||
# ==================== MÉTODOS PARA RACHAS ====================
|
||||
|
||||
def verificar_racha(self, username):
|
||||
"""
|
||||
Verifica y actualiza la racha del vendedor.
|
||||
Retorna información de la racha y bonus si aplica.
|
||||
"""
|
||||
try:
|
||||
ahora = datetime.now(TZ_MEXICO)
|
||||
dias_consecutivos = 0
|
||||
fecha_check = ahora.date()
|
||||
|
||||
# Verificar días consecutivos hacia atrás
|
||||
while True:
|
||||
fecha_str = fecha_check.strftime('%Y-%m-%d')
|
||||
stats = self.get_estadisticas_vendedor_dia(username, fecha_str)
|
||||
|
||||
if stats and stats.get('tubos_vendidos', 0) >= self.META_DIARIA_TUBOS:
|
||||
dias_consecutivos += 1
|
||||
fecha_check -= timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
|
||||
# Límite de búsqueda
|
||||
if dias_consecutivos > 30:
|
||||
break
|
||||
|
||||
# Calcular bonus
|
||||
bonus = 0
|
||||
bonus_3 = float(os.getenv('BONUS_3_DIAS', 20))
|
||||
bonus_5 = float(os.getenv('BONUS_5_DIAS', 50))
|
||||
bonus_10 = float(os.getenv('BONUS_10_DIAS', 150))
|
||||
|
||||
if dias_consecutivos >= 10:
|
||||
bonus = bonus_10
|
||||
elif dias_consecutivos >= 5:
|
||||
bonus = bonus_5
|
||||
elif dias_consecutivos >= 3:
|
||||
bonus = bonus_3
|
||||
|
||||
return {
|
||||
'dias_consecutivos': dias_consecutivos,
|
||||
'bonus': bonus,
|
||||
'meta_diaria': self.META_DIARIA_TUBOS,
|
||||
'proximo_bonus': self._calcular_proximo_bonus(dias_consecutivos)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verificando racha de {username}: {str(e)}")
|
||||
return {'dias_consecutivos': 0, 'bonus': 0}
|
||||
|
||||
def _calcular_proximo_bonus(self, dias_actuales):
|
||||
"""Calcula cuántos días faltan para el próximo bonus"""
|
||||
if dias_actuales < 3:
|
||||
return {'dias_faltan': 3 - dias_actuales, 'bonus': float(os.getenv('BONUS_3_DIAS', 20))}
|
||||
elif dias_actuales < 5:
|
||||
return {'dias_faltan': 5 - dias_actuales, 'bonus': float(os.getenv('BONUS_5_DIAS', 50))}
|
||||
elif dias_actuales < 10:
|
||||
return {'dias_faltan': 10 - dias_actuales, 'bonus': float(os.getenv('BONUS_10_DIAS', 150))}
|
||||
else:
|
||||
return {'dias_faltan': 0, 'bonus': 0, 'mensaje': '¡Ya tienes el bonus máximo!'}
|
||||
|
||||
@@ -26,3 +26,9 @@ coloredlogs==15.0.1
|
||||
|
||||
# Utilidades
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Scheduler para tareas programadas
|
||||
APScheduler==3.10.4
|
||||
|
||||
# Exportación a Excel
|
||||
openpyxl==3.1.2
|
||||
|
||||
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}")
|
||||
@@ -164,3 +164,147 @@ def extraer_tubos(texto):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ==================== NUEVAS FUNCIONES FASE 1 ====================
|
||||
|
||||
def extraer_id_venta(texto):
|
||||
"""
|
||||
Extrae el ID de venta del texto del comando.
|
||||
Soporta formatos:
|
||||
- /cancelar 123
|
||||
- /editar 123 @monto 1500
|
||||
- #123
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
# Buscar número al inicio o después del comando
|
||||
patron_id = r'(?:^|\s)#?(\d+)'
|
||||
match = re.search(patron_id, texto.strip())
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extraer_motivo(texto):
|
||||
"""
|
||||
Extrae el motivo de cancelación del texto.
|
||||
Soporta formatos:
|
||||
- /cancelar 123 "cliente no pagó"
|
||||
- /cancelar 123 motivo: cliente canceló
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
# Buscar texto entre comillas
|
||||
patron_comillas = r'["\']([^"\']+)["\']'
|
||||
match = re.search(patron_comillas, texto)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Buscar después de "motivo:"
|
||||
patron_motivo = r'motivo[:\s]+(.+)$'
|
||||
match = re.search(patron_motivo, texto, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Si hay texto después del ID, usarlo como motivo
|
||||
patron_resto = r'^\d+\s+(.+)$'
|
||||
match = re.search(patron_resto, texto.strip())
|
||||
if match:
|
||||
motivo = match.group(1).strip()
|
||||
# Excluir si es otro comando
|
||||
if not motivo.startswith('@') and not motivo.startswith('/'):
|
||||
return motivo
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extraer_mes(texto):
|
||||
"""
|
||||
Extrae el mes del texto del comando.
|
||||
Soporta formatos:
|
||||
- 2026-01
|
||||
- 01-2026
|
||||
- enero 2026
|
||||
- enero
|
||||
"""
|
||||
if not texto:
|
||||
return None
|
||||
|
||||
texto = texto.strip().lower()
|
||||
|
||||
# Formato YYYY-MM
|
||||
patron_iso = r'(\d{4})-(\d{1,2})'
|
||||
match = re.search(patron_iso, texto)
|
||||
if match:
|
||||
return f"{match.group(1)}-{match.group(2).zfill(2)}"
|
||||
|
||||
# Formato MM-YYYY
|
||||
patron_inv = r'(\d{1,2})-(\d{4})'
|
||||
match = re.search(patron_inv, texto)
|
||||
if match:
|
||||
return f"{match.group(2)}-{match.group(1).zfill(2)}"
|
||||
|
||||
# Nombres de meses en español
|
||||
meses = {
|
||||
'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04',
|
||||
'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08',
|
||||
'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12'
|
||||
}
|
||||
|
||||
for nombre, num in meses.items():
|
||||
if nombre in texto:
|
||||
# Buscar año
|
||||
patron_anio = r'(\d{4})'
|
||||
match = re.search(patron_anio, texto)
|
||||
if match:
|
||||
return f"{match.group(1)}-{num}"
|
||||
# Si no hay año, usar el actual
|
||||
from datetime import datetime
|
||||
return f"{datetime.now().year}-{num}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parsear_formato_exportar(texto):
|
||||
"""
|
||||
Parsea los parámetros del comando /exportar.
|
||||
Retorna (formato, mes)
|
||||
"""
|
||||
if not texto:
|
||||
return 'excel', None
|
||||
|
||||
texto = texto.strip().lower()
|
||||
|
||||
# Detectar formato
|
||||
formato = 'excel'
|
||||
if 'csv' in texto:
|
||||
formato = 'csv'
|
||||
texto = texto.replace('csv', '').strip()
|
||||
|
||||
# Detectar mes
|
||||
mes = extraer_mes(texto)
|
||||
|
||||
return formato, mes
|
||||
|
||||
|
||||
def validar_tokens_comando(token, nombre_comando):
|
||||
"""
|
||||
Valida tokens para comandos slash.
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
|
||||
expected_tokens = [
|
||||
os.getenv(f'MATTERMOST_SLASH_TOKEN_{nombre_comando.upper()}'),
|
||||
os.getenv('MATTERMOST_OUTGOING_TOKEN'),
|
||||
os.getenv('MATTERMOST_WEBHOOK_SECRET'),
|
||||
]
|
||||
|
||||
return token in [t for t in expected_tokens if t]
|
||||
|
||||
Reference in New Issue
Block a user