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:
2026-01-19 02:57:27 +00:00
parent 5d9cbd4812
commit ed1658eb2b
8 changed files with 1808 additions and 148 deletions

View File

@@ -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

View File

@@ -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
View 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')

View File

@@ -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', '', '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"

View File

@@ -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!'}

View File

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

View File

@@ -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]