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 ===
|
# === ZONA HORARIA ===
|
||||||
# México: -6, Cancún: -5, España: +1
|
# México: -6, Cancún: -5, España: +1
|
||||||
TZ_OFFSET=-6
|
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 handlers import handle_venta_message, generar_reporte_diario
|
||||||
from utils import validar_token_outgoing
|
from utils import validar_token_outgoing
|
||||||
from websocket_listener import MattermostWebsocketListener
|
from websocket_listener import MattermostWebsocketListener
|
||||||
|
from scheduler import SalesBotScheduler
|
||||||
|
|
||||||
# Configurar logging
|
# Configurar logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -44,6 +45,11 @@ ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_messa
|
|||||||
ws_listener.start()
|
ws_listener.start()
|
||||||
logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost")
|
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'])
|
@app.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Endpoint para verificar que el bot está funcionando"""
|
"""Endpoint para verificar que el bot está funcionando"""
|
||||||
@@ -257,20 +263,28 @@ def comando_ayuda():
|
|||||||
"**Para registrar una venta:**\n"
|
"**Para registrar una venta:**\n"
|
||||||
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
||||||
"• `vendí $1500 a María García`\n"
|
"• `vendí $1500 a María García`\n"
|
||||||
"• También puedes adjuntar foto del ticket\n\n"
|
"• También puedes adjuntar foto(s) del ticket\n"
|
||||||
"**Comandos disponibles:**\n"
|
"• _Todas las ventas requieren confirmación_\n\n"
|
||||||
|
"**Comandos básicos:**\n"
|
||||||
"• `/metas` - Ver tu progreso del mes\n"
|
"• `/metas` - Ver tu progreso del mes\n"
|
||||||
"• `/ranking` - Ver ranking de vendedores\n"
|
"• `/ranking` - Ver ranking de vendedores\n"
|
||||||
"• `/ayuda` - Mostrar esta ayuda\n\n"
|
"• `/ayuda` - Mostrar esta ayuda\n\n"
|
||||||
"**Ejemplos de registro de ventas:**\n"
|
"**Comandos de gestión:**\n"
|
||||||
"✅ `venta @monto 2500 @cliente Empresa ABC`\n"
|
"• `/cancelar <id> [motivo]` - Cancelar una venta\n"
|
||||||
"✅ `vendí $1,200.50 a cliente Pedro`\n"
|
"• `/editar <id> @monto X @cliente Y` - Editar una venta\n"
|
||||||
"✅ `venta @monto 5000 @cliente Tienda XYZ`\n\n"
|
"• `/deshacer` - Deshacer última venta (5 min)\n\n"
|
||||||
"**Consejos:**\n"
|
"**Comandos de comisiones:**\n"
|
||||||
"• Registra tus ventas inmediatamente después de cerrarlas\n"
|
"• `/comisiones` - Ver historial de comisiones\n"
|
||||||
"• Incluye el nombre del cliente para mejor seguimiento\n"
|
"• `/racha` - Ver tu racha actual y bonos\n"
|
||||||
"• Revisa tu progreso regularmente con `/metas`\n"
|
"• `/exportar [csv] [mes]` - Exportar ventas a Excel/CSV\n\n"
|
||||||
"• Compite sanamente con tus compañeros en el `/ranking`\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! 💪"
|
"¡Sigue adelante! 💪"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,6 +299,378 @@ def comando_ayuda():
|
|||||||
'text': f'❌ Error procesando comando: {str(e)}'
|
'text': f'❌ Error procesando comando: {str(e)}'
|
||||||
}), 500
|
}), 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'])
|
@app.route('/reporte/diario', methods=['POST'])
|
||||||
def reporte_diario_manual():
|
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,129 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Handlers para procesamiento de ventas en Sales Bot
|
||||||
|
Incluye sistema de confirmación interactiva obligatoria
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import re
|
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 utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos
|
||||||
from ocr_processor import OCRProcessor
|
from ocr_processor import OCRProcessor
|
||||||
import os
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def handle_venta_message(data, mattermost, nocodb):
|
||||||
"""
|
"""
|
||||||
Maneja mensajes de venta en Mattermost
|
Maneja mensajes de venta en Mattermost con confirmación interactiva obligatoria.
|
||||||
NUEVO: Sistema de comisiones por tubos vendidos
|
|
||||||
|
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:
|
try:
|
||||||
user_name = data.get('user_name')
|
user_name = data.get('user_name')
|
||||||
text = data.get('text', '').strip()
|
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')
|
channel_name = data.get('channel_name')
|
||||||
post_id = data.get('post_id')
|
post_id = data.get('post_id')
|
||||||
file_ids = data.get('file_ids', '')
|
file_ids = data.get('file_ids', '')
|
||||||
|
|
||||||
if file_ids and isinstance(file_ids, str):
|
if file_ids and isinstance(file_ids, str):
|
||||||
file_ids = [f.strip() for f in file_ids.split(',') if f.strip()]
|
file_ids = [f.strip() for f in file_ids.split(',') if f.strip()]
|
||||||
elif not file_ids:
|
elif not file_ids:
|
||||||
file_ids = []
|
file_ids = []
|
||||||
|
|
||||||
logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}")
|
logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}")
|
||||||
|
|
||||||
# Extraer información del texto
|
# Extraer información del texto
|
||||||
monto = extraer_monto(text)
|
monto = extraer_monto(text)
|
||||||
cliente = extraer_cliente(text)
|
cliente = extraer_cliente(text)
|
||||||
tubos_manual = extraer_tubos(text) # NUEVO: tubos manuales
|
tubos_manual = extraer_tubos(text)
|
||||||
|
|
||||||
# Procesar imágenes adjuntas
|
# ==================== PROCESAR IMÁGENES (MÚLTIPLES) ====================
|
||||||
imagen_url = None
|
imagenes_url = []
|
||||||
ocr_info = ""
|
ocr_info = ""
|
||||||
productos_ocr = []
|
productos_ocr = []
|
||||||
|
monto_ocr_total = 0
|
||||||
|
tubos_ocr_total = 0
|
||||||
|
|
||||||
if file_ids:
|
if file_ids:
|
||||||
logger.info(f"Procesando {len(file_ids)} archivos adjuntos")
|
logger.info(f"Procesando {len(file_ids)} archivos adjuntos")
|
||||||
file_id = file_ids[0]
|
|
||||||
|
for file_id in file_ids:
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
ocr = OCRProcessor()
|
imagen_data = mattermost.get_file(file_id)
|
||||||
resultado_ocr = ocr.procesar_ticket(imagen_data)
|
file_info = mattermost.get_file_info(file_id)
|
||||||
|
|
||||||
if resultado_ocr:
|
if file_info and imagen_data:
|
||||||
monto_ocr = resultado_ocr.get('monto_detectado')
|
filename = file_info.get('name', 'ticket.jpg')
|
||||||
fecha_ocr = resultado_ocr.get('fecha_detectada')
|
file_size = file_info.get('size', 0)
|
||||||
productos_ocr = resultado_ocr.get('productos', [])
|
|
||||||
|
bot_token = os.getenv('MATTERMOST_BOT_TOKEN')
|
||||||
if not monto and monto_ocr:
|
mattermost_url = os.getenv('MATTERMOST_URL')
|
||||||
monto = monto_ocr
|
imagen_url = f"{mattermost_url}/api/v4/files/{file_id}?access_token={bot_token}"
|
||||||
logger.info(f"Usando monto detectado por OCR: ${monto}")
|
imagenes_url.append(imagen_url)
|
||||||
ocr_info += f"\n💡 Monto detectado: ${monto:,.2f}"
|
|
||||||
|
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
|
||||||
elif monto and monto_ocr:
|
|
||||||
es_valido, mensaje = ocr.validar_monto_con_ocr(monto, monto_ocr, tolerancia=0.05)
|
# Procesar con OCR
|
||||||
ocr_info += f"\n{mensaje}"
|
try:
|
||||||
|
ocr = OCRProcessor()
|
||||||
if not es_valido:
|
resultado_ocr = ocr.procesar_ticket(imagen_data)
|
||||||
logger.warning(mensaje)
|
|
||||||
|
if resultado_ocr:
|
||||||
if fecha_ocr:
|
monto_ocr = resultado_ocr.get('monto_detectado', 0)
|
||||||
ocr_info += f"\n📅 Fecha: {fecha_ocr}"
|
fecha_ocr = resultado_ocr.get('fecha_detectada')
|
||||||
|
productos = resultado_ocr.get('productos', [])
|
||||||
if productos_ocr:
|
|
||||||
# NUEVO: Contar tubos de tinte
|
if monto_ocr:
|
||||||
tubos_tinte = sum(
|
monto_ocr_total += monto_ocr
|
||||||
p['cantidad'] for p in productos_ocr
|
|
||||||
if 'tinte' in p['marca'].lower() or 'tinte' in p['producto'].lower()
|
if productos:
|
||||||
or 'cromatique' in p['marca'].lower()
|
productos_ocr.extend(productos)
|
||||||
)
|
# Contar tubos de tinte
|
||||||
ocr_info += f"\n🧪 Tubos de tinte: {tubos_tinte}"
|
for p in productos:
|
||||||
ocr_info += f"\n📦 Total productos: {len(productos_ocr)}"
|
marca = p.get('marca', '').lower()
|
||||||
logger.info(f"Tubos de tinte detectados: {tubos_tinte}")
|
producto = p.get('producto', '').lower()
|
||||||
|
if 'tinte' in marca or 'tinte' in producto or 'cromatique' in marca:
|
||||||
except Exception as ocr_error:
|
tubos_ocr_total += p.get('cantidad', 0)
|
||||||
logger.error(f"Error en OCR: {str(ocr_error)}")
|
|
||||||
ocr_info = "\n⚠️ No se pudo leer el ticket"
|
if fecha_ocr and not ocr_info:
|
||||||
productos_ocr = []
|
ocr_info += f"\n📅 Fecha ticket: {fecha_ocr}"
|
||||||
|
|
||||||
logger.info(f"URL de imagen: {imagen_url}")
|
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:
|
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 = (
|
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"
|
"**Formatos válidos:**\n"
|
||||||
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
"• `venta @monto 1500 @cliente Juan Pérez`\n"
|
||||||
"• `vendí $1500 a Juan Pérez`\n"
|
"• `vendí $1500 a Juan Pérez`\n"
|
||||||
|
"• `venta @monto 1500 @tubos 3`\n"
|
||||||
"• Adjunta foto del ticket"
|
"• Adjunta foto del ticket"
|
||||||
|
f"{sugerencias}"
|
||||||
)
|
)
|
||||||
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:')
|
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:')
|
||||||
return {'text': mensaje}
|
return {'text': mensaje}
|
||||||
|
|
||||||
if not cliente:
|
if not cliente:
|
||||||
cliente = "Cliente sin nombre"
|
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
|
# Verificar/crear vendedor
|
||||||
vendedor = nocodb.get_vendedor(user_name)
|
vendedor = nocodb.get_vendedor(user_name)
|
||||||
if not vendedor:
|
if not vendedor:
|
||||||
user_info = mattermost.get_user_by_username(user_name)
|
user_info = mattermost.get_user_by_username(user_name)
|
||||||
email = user_info.get('email', f"{user_name}@consultoria-as.com") if user_info else f"{user_name}@consultoria-as.com"
|
email = user_info.get('email', f"{user_name}@consultoria-as.com") if user_info else f"{user_name}@consultoria-as.com"
|
||||||
nombre = user_info.get('first_name', user_name) if user_info else user_name
|
nombre = user_info.get('first_name', user_name) if user_info else user_name
|
||||||
|
|
||||||
vendedor = nocodb.crear_vendedor(
|
vendedor = nocodb.crear_vendedor(
|
||||||
username=user_name,
|
username=user_name,
|
||||||
nombre_completo=nombre,
|
nombre_completo=nombre,
|
||||||
email=email,
|
email=email,
|
||||||
meta_diaria_tubos=3 # NUEVO: Meta de 3 tubos diarios
|
meta_diaria_tubos=3
|
||||||
)
|
)
|
||||||
|
|
||||||
if vendedor:
|
if vendedor:
|
||||||
mensaje_bienvenida = (
|
mensaje_bienvenida = (
|
||||||
f"👋 ¡Bienvenido @{user_name}!\n"
|
f"👋 ¡Bienvenido @{user_name}!\n"
|
||||||
@@ -133,7 +288,10 @@ def handle_venta_message(data, mattermost, nocodb):
|
|||||||
f"¡Empieza a registrar tus ventas!"
|
f"¡Empieza a registrar tus ventas!"
|
||||||
)
|
)
|
||||||
mattermost.post_message_webhook(mensaje_bienvenida, username='Sales Bot', icon_emoji=':wave:')
|
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
|
# Registrar venta
|
||||||
venta = nocodb.registrar_venta(
|
venta = nocodb.registrar_venta(
|
||||||
vendedor_username=user_name,
|
vendedor_username=user_name,
|
||||||
@@ -144,17 +302,17 @@ def handle_venta_message(data, mattermost, nocodb):
|
|||||||
canal=channel_name,
|
canal=channel_name,
|
||||||
imagen_url=imagen_url
|
imagen_url=imagen_url
|
||||||
)
|
)
|
||||||
|
|
||||||
if venta:
|
if venta:
|
||||||
venta_id = venta.get('Id')
|
venta_id = venta.get('Id')
|
||||||
|
|
||||||
# Guardar productos detectados por OCR
|
# Guardar productos detectados por OCR
|
||||||
if productos_ocr:
|
if productos_ocr:
|
||||||
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr)
|
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr)
|
||||||
if productos_guardados:
|
if productos_guardados:
|
||||||
logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}")
|
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:
|
elif tubos_manual and tubos_manual > 0:
|
||||||
productos_manuales = [{
|
productos_manuales = [{
|
||||||
'producto': 'Tinte (registro manual)',
|
'producto': 'Tinte (registro manual)',
|
||||||
@@ -163,42 +321,44 @@ def handle_venta_message(data, mattermost, nocodb):
|
|||||||
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
|
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
|
||||||
'importe': monto
|
'importe': monto
|
||||||
}]
|
}]
|
||||||
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_manuales)
|
nocodb.guardar_productos_venta(venta_id, productos_manuales)
|
||||||
if productos_guardados:
|
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
|
||||||
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
|
|
||||||
|
|
||||||
# NUEVO: Actualizar tabla de metas
|
# Actualizar tabla de metas
|
||||||
try:
|
try:
|
||||||
nocodb.actualizar_meta_vendedor(user_name)
|
nocodb.actualizar_meta_vendedor(user_name)
|
||||||
logger.info(f"Metas actualizadas para {user_name}")
|
logger.info(f"Metas actualizadas para {user_name}")
|
||||||
except Exception as meta_error:
|
except Exception as meta_error:
|
||||||
logger.error(f"Error actualizando metas: {str(meta_error)}")
|
logger.error(f"Error actualizando metas: {str(meta_error)}")
|
||||||
|
|
||||||
|
# Verificar racha
|
||||||
|
racha = nocodb.verificar_racha(user_name)
|
||||||
|
|
||||||
# Reacción de éxito
|
# Reacción de éxito
|
||||||
if post_id:
|
if post_id:
|
||||||
mattermost.add_reaction(post_id, 'white_check_mark')
|
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)
|
stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name)
|
||||||
|
|
||||||
# Construir mensaje
|
# Construir mensaje de confirmación
|
||||||
mensaje_confirmacion = (
|
mensaje_confirmacion = (
|
||||||
f"✅ **Venta registrada**\n\n"
|
f"✅ **Venta #{venta_id} registrada**\n\n"
|
||||||
f"**Vendedor:** @{user_name}\n"
|
f"**Vendedor:** @{user_name}\n"
|
||||||
f"**Monto:** {formatear_moneda(monto)}\n"
|
f"**Monto:** {formatear_moneda(monto)}\n"
|
||||||
f"**Cliente:** {cliente}\n"
|
f"**Cliente:** {cliente}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if imagen_url:
|
if imagenes_url:
|
||||||
mensaje_confirmacion += f"📸 **Ticket:** Guardado{ocr_info}\n"
|
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:
|
if stats_dia:
|
||||||
tubos_hoy = stats_dia.get('tubos_vendidos', 0)
|
tubos_hoy = stats_dia.get('tubos_vendidos', 0)
|
||||||
comision_hoy = stats_dia.get('comision', 0)
|
comision_hoy = stats_dia.get('comision', 0)
|
||||||
meta = stats_dia.get('meta_diaria', 3)
|
meta = stats_dia.get('meta_diaria', 3)
|
||||||
tubos_comisionables = stats_dia.get('tubos_comisionables', 0)
|
tubos_comisionables = stats_dia.get('tubos_comisionables', 0)
|
||||||
|
|
||||||
# Determinar emoji según progreso
|
# Determinar emoji según progreso
|
||||||
if tubos_hoy >= meta * 2:
|
if tubos_hoy >= meta * 2:
|
||||||
emoji = '🔥'
|
emoji = '🔥'
|
||||||
@@ -212,13 +372,13 @@ def handle_venta_message(data, mattermost, nocodb):
|
|||||||
else:
|
else:
|
||||||
emoji = '📊'
|
emoji = '📊'
|
||||||
mensaje_extra = '¡Sigue así!'
|
mensaje_extra = '¡Sigue así!'
|
||||||
|
|
||||||
mensaje_confirmacion += (
|
mensaje_confirmacion += (
|
||||||
f"\n**Resumen del día:** {emoji}\n"
|
f"\n**Resumen del día:** {emoji}\n"
|
||||||
f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n"
|
f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n"
|
||||||
f"• Meta diaria: {meta} tubos\n"
|
f"• Meta diaria: {meta} tubos\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if tubos_hoy > meta:
|
if tubos_hoy > meta:
|
||||||
mensaje_confirmacion += (
|
mensaje_confirmacion += (
|
||||||
f"• Tubos con comisión: {tubos_comisionables}\n"
|
f"• Tubos con comisión: {tubos_comisionables}\n"
|
||||||
@@ -227,41 +387,46 @@ def handle_venta_message(data, mattermost, nocodb):
|
|||||||
else:
|
else:
|
||||||
faltan = meta - tubos_hoy
|
faltan = meta - tubos_hoy
|
||||||
mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n"
|
mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n"
|
||||||
|
|
||||||
mensaje_confirmacion += f"• {mensaje_extra}"
|
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
|
# Enviar confirmación
|
||||||
mattermost.post_message_webhook(
|
mattermost.post_message_webhook(
|
||||||
mensaje_confirmacion,
|
mensaje_confirmacion,
|
||||||
username='Sales Bot',
|
username='Sales Bot',
|
||||||
icon_emoji=':moneybag:'
|
icon_emoji=':moneybag:'
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'text': mensaje_confirmacion}
|
return {'text': mensaje_confirmacion}
|
||||||
else:
|
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:')
|
mattermost.post_message_webhook(mensaje_error, username='Sales Bot', icon_emoji=':x:')
|
||||||
return {'text': mensaje_error}
|
return {'text': mensaje_error}
|
||||||
|
|
||||||
except Exception as e:
|
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)}"
|
mensaje_error = f"❌ Error: {str(e)}"
|
||||||
return {'text': mensaje_error}
|
return {'text': mensaje_error}
|
||||||
|
|
||||||
|
|
||||||
def generar_reporte_diario(mattermost, nocodb):
|
def generar_reporte_diario(mattermost, nocodb):
|
||||||
"""
|
"""
|
||||||
Genera reporte diario de ventas y comisiones
|
Genera reporte diario de ventas y comisiones
|
||||||
NUEVO: Muestra tubos vendidos y comisiones ganadas
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import os
|
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
hoy = datetime.now().strftime('%Y-%m-%d')
|
|
||||||
mes_actual = datetime.now().strftime('%Y-%m')
|
|
||||||
|
|
||||||
# Obtener todas las ventas del día
|
# Obtener todas las ventas del día
|
||||||
ventas_hoy = nocodb.get_ventas_dia()
|
ventas_hoy = nocodb.get_ventas_dia()
|
||||||
|
|
||||||
# Agrupar por vendedor
|
# Agrupar por vendedor
|
||||||
vendedores_hoy = {}
|
vendedores_hoy = {}
|
||||||
for venta in ventas_hoy:
|
for venta in ventas_hoy:
|
||||||
@@ -269,64 +434,70 @@ def generar_reporte_diario(mattermost, nocodb):
|
|||||||
if vendedor not in vendedores_hoy:
|
if vendedor not in vendedores_hoy:
|
||||||
vendedores_hoy[vendedor] = []
|
vendedores_hoy[vendedor] = []
|
||||||
vendedores_hoy[vendedor].append(venta)
|
vendedores_hoy[vendedor].append(venta)
|
||||||
|
|
||||||
# Calcular estadísticas por vendedor
|
# Calcular estadísticas por vendedor
|
||||||
stats_vendedores = []
|
stats_vendedores = []
|
||||||
for vendedor in vendedores_hoy.keys():
|
for vendedor in vendedores_hoy.keys():
|
||||||
stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy)
|
stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy)
|
||||||
if stats:
|
if stats:
|
||||||
stats_vendedores.append(stats)
|
stats_vendedores.append(stats)
|
||||||
|
|
||||||
# Ordenar por tubos vendidos
|
# Ordenar por tubos vendidos
|
||||||
stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True)
|
stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True)
|
||||||
|
|
||||||
# Calcular totales
|
# Calcular totales
|
||||||
total_tubos_dia = sum(s.get('tubos_vendidos', 0) for s in stats_vendedores)
|
total_tubos_dia = sum(s.get('tubos_vendidos', 0) for s in stats_vendedores)
|
||||||
total_comisiones = sum(s.get('comision', 0) for s in stats_vendedores)
|
total_comisiones = sum(s.get('comision', 0) for s in stats_vendedores)
|
||||||
total_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores)
|
total_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores)
|
||||||
|
|
||||||
# Construir mensaje
|
# Construir mensaje
|
||||||
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"**Resumen del día:**\n"
|
||||||
f"• Tubos vendidos: {total_tubos_dia} 🧪\n"
|
f"• Tubos vendidos: {total_tubos_dia} 🧪\n"
|
||||||
f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n"
|
f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n"
|
||||||
f"• Monto total: {formatear_moneda(total_monto)}\n"
|
f"• Monto total: {formatear_moneda(total_monto)}\n"
|
||||||
f"• Ventas: {len(ventas_hoy)}\n\n"
|
f"• Ventas: {len(ventas_hoy)}\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if stats_vendedores:
|
if stats_vendedores:
|
||||||
mensaje += "**Top Vendedores del Día:**\n"
|
mensaje += "**Top Vendedores del Día:**\n"
|
||||||
for i, stats in enumerate(stats_vendedores[:5], 1):
|
for i, stats in enumerate(stats_vendedores[:5], 1):
|
||||||
vendedor = stats.get('vendedor')
|
vendedor = stats.get('vendedor')
|
||||||
tubos = stats.get('tubos_vendidos', 0)
|
tubos = stats.get('tubos_vendidos', 0)
|
||||||
comision = stats.get('comision', 0)
|
comision = stats.get('comision', 0)
|
||||||
|
|
||||||
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
|
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
|
||||||
|
|
||||||
if comision > 0:
|
if comision > 0:
|
||||||
mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n"
|
mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n"
|
||||||
else:
|
else:
|
||||||
mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n"
|
mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n"
|
||||||
|
|
||||||
# Obtener canal de reportes
|
# Obtener canal de reportes
|
||||||
team_name = os.getenv('MATTERMOST_TEAM_NAME')
|
team_name = os.getenv('MATTERMOST_TEAM_NAME')
|
||||||
channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES')
|
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)
|
if canal:
|
||||||
logger.info("Reporte diario generado")
|
mattermost.post_message(canal['id'], mensaje)
|
||||||
return {'status': 'success', 'message': 'Reporte generado'}
|
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:
|
else:
|
||||||
logger.warning(f"Canal {channel_reportes} no encontrado")
|
# Enviar por webhook si no hay canal específico
|
||||||
return {'status': 'error', 'message': 'Canal no encontrado'}
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True)
|
logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True)
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
|
|
||||||
|
|
||||||
def comando_estadisticas(user_name, mattermost, nocodb):
|
def comando_estadisticas(user_name, mattermost, nocodb):
|
||||||
"""
|
"""
|
||||||
Muestra estadísticas personales del vendedor
|
Muestra estadísticas personales del vendedor
|
||||||
@@ -335,26 +506,26 @@ def comando_estadisticas(user_name, mattermost, nocodb):
|
|||||||
try:
|
try:
|
||||||
# Estadísticas del día
|
# Estadísticas del día
|
||||||
stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name)
|
stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name)
|
||||||
|
|
||||||
# Estadísticas del mes
|
# Estadísticas del mes
|
||||||
stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name)
|
stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name)
|
||||||
|
|
||||||
if not stats_hoy and not stats_mes:
|
if not stats_hoy and not stats_mes:
|
||||||
mensaje = f"@{user_name} Aún no tienes ventas registradas."
|
mensaje = f"@{user_name} Aún no tienes ventas registradas."
|
||||||
return mensaje
|
return mensaje
|
||||||
|
|
||||||
mensaje = f"📈 **Estadísticas de @{user_name}**\n\n"
|
mensaje = f"📈 **Estadísticas de @{user_name}**\n\n"
|
||||||
|
|
||||||
# Hoy
|
# Hoy
|
||||||
if stats_hoy:
|
if stats_hoy:
|
||||||
mensaje += (
|
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"• Tubos: {stats_hoy.get('tubos_vendidos', 0)} 🧪\n"
|
||||||
f"• Comisión: {formatear_moneda(stats_hoy.get('comision', 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"
|
f"• Monto: {formatear_moneda(stats_hoy.get('monto_total_dia', 0))}\n"
|
||||||
f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n"
|
f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mes
|
# Mes
|
||||||
if stats_mes:
|
if stats_mes:
|
||||||
mensaje += (
|
mensaje += (
|
||||||
@@ -367,10 +538,10 @@ def comando_estadisticas(user_name, mattermost, nocodb):
|
|||||||
f"• Días con meta: {stats_mes.get('dias_meta_cumplida', 0)}\n"
|
f"• Días con meta: {stats_mes.get('dias_meta_cumplida', 0)}\n"
|
||||||
f"• Promedio/día: {stats_mes.get('promedio_tubos_dia', 0):.1f} tubos\n"
|
f"• Promedio/día: {stats_mes.get('promedio_tubos_dia', 0):.1f} tubos\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':bar_chart:')
|
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':bar_chart:')
|
||||||
return mensaje
|
return mensaje
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True)
|
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):
|
def get_meta_vendedor(self, vendedor_username, mes=None):
|
||||||
"""Obtiene las estadísticas del vendedor para el mes"""
|
"""Obtiene las estadísticas del vendedor para el mes"""
|
||||||
return self.get_estadisticas_vendedor_mes(vendedor_username, 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
|
# Utilidades
|
||||||
python-dateutil==2.8.2
|
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
|
pass
|
||||||
|
|
||||||
return None
|
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