diff --git a/sales-bot/.env.example b/sales-bot/.env.example index 7b81051..7dca686 100644 --- a/sales-bot/.env.example +++ b/sales-bot/.env.example @@ -45,3 +45,40 @@ LOG_FILE=/app/logs/sales-bot.log # === ZONA HORARIA === # México: -6, Cancún: -5, España: +1 TZ_OFFSET=-6 + +# === SCHEDULER (Notificaciones Automáticas) === +# Habilitar/deshabilitar el scheduler de tareas programadas +SCHEDULER_ENABLED=True + +# Hora del recordatorio de mediodía (0-23) +RECORDATORIO_MEDIODIA_HORA=12 + +# Hora del resumen diario (0-23) +RESUMEN_DIARIO_HORA=18 + +# Día de la semana para resumen semanal (mon, tue, wed, thu, fri, sat, sun) +RESUMEN_SEMANAL_DIA=mon + +# Hora del resumen semanal (0-23) +RESUMEN_SEMANAL_HORA=9 + +# === BONOS POR RACHA === +# Bonus en pesos por cumplir meta consecutivamente +BONUS_3_DIAS=20 +BONUS_5_DIAS=50 +BONUS_10_DIAS=150 + +# === CONFIRMACIÓN DE VENTAS === +# Tiempo en minutos antes de que expire una venta pendiente de confirmación +CONFIRMACION_TIMEOUT_MINUTOS=2 + +# === METAS Y COMISIONES (Defaults) === +# Meta diaria de tubos por vendedor (default) +META_DIARIA_TUBOS_DEFAULT=3 + +# Comisión por tubo vendido después de cumplir meta (default) +COMISION_POR_TUBO_DEFAULT=10 + +# === EXPORTACIÓN === +# Formato por defecto para exportación (excel o csv) +EXPORTAR_FORMATO_DEFAULT=excel diff --git a/sales-bot/app.py b/sales-bot/app.py index 926a379..ae2acaa 100644 --- a/sales-bot/app.py +++ b/sales-bot/app.py @@ -14,6 +14,7 @@ from nocodb_client import NocoDBClient from handlers import handle_venta_message, generar_reporte_diario from utils import validar_token_outgoing from websocket_listener import MattermostWebsocketListener +from scheduler import SalesBotScheduler # Configurar logging logging.basicConfig( @@ -44,6 +45,11 @@ ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_messa ws_listener.start() logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost") +# Inicializar scheduler de tareas programadas +scheduler = SalesBotScheduler(mattermost, nocodb) +scheduler.iniciar() +logger.info("Scheduler de tareas programadas iniciado") + @app.route('/health', methods=['GET']) def health_check(): """Endpoint para verificar que el bot está funcionando""" @@ -257,20 +263,28 @@ def comando_ayuda(): "**Para registrar una venta:**\n" "• `venta @monto 1500 @cliente Juan Pérez`\n" "• `vendí $1500 a María García`\n" - "• También puedes adjuntar foto del ticket\n\n" - "**Comandos disponibles:**\n" + "• También puedes adjuntar foto(s) del ticket\n" + "• _Todas las ventas requieren confirmación_\n\n" + "**Comandos básicos:**\n" "• `/metas` - Ver tu progreso del mes\n" "• `/ranking` - Ver ranking de vendedores\n" "• `/ayuda` - Mostrar esta ayuda\n\n" - "**Ejemplos de registro de ventas:**\n" - "✅ `venta @monto 2500 @cliente Empresa ABC`\n" - "✅ `vendí $1,200.50 a cliente Pedro`\n" - "✅ `venta @monto 5000 @cliente Tienda XYZ`\n\n" - "**Consejos:**\n" - "• Registra tus ventas inmediatamente después de cerrarlas\n" - "• Incluye el nombre del cliente para mejor seguimiento\n" - "• Revisa tu progreso regularmente con `/metas`\n" - "• Compite sanamente con tus compañeros en el `/ranking`\n\n" + "**Comandos de gestión:**\n" + "• `/cancelar [motivo]` - Cancelar una venta\n" + "• `/editar @monto X @cliente Y` - Editar una venta\n" + "• `/deshacer` - Deshacer última venta (5 min)\n\n" + "**Comandos de comisiones:**\n" + "• `/comisiones` - Ver historial de comisiones\n" + "• `/racha` - Ver tu racha actual y bonos\n" + "• `/exportar [csv] [mes]` - Exportar ventas a Excel/CSV\n\n" + "**Sistema de confirmación:**\n" + "1. Envía tu mensaje de venta o foto\n" + "2. El bot muestra un preview\n" + "3. Responde **si** para confirmar o **no** para cancelar\n\n" + "**Bonos por racha:**\n" + "• 3 días consecutivos: +$20\n" + "• 5 días consecutivos: +$50\n" + "• 10 días consecutivos: +$150\n\n" "¡Sigue adelante! 💪" ) @@ -285,6 +299,378 @@ def comando_ayuda(): 'text': f'❌ Error procesando comando: {str(e)}' }), 500 +# ============== NUEVOS COMANDOS FASE 1-3 ============== + +@app.route('/comando/cancelar', methods=['POST']) +def comando_cancelar(): + """ + Endpoint para el comando slash /cancelar en Mattermost + Uso: /cancelar [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 [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 @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 @monto 1600 @cliente NuevoCliente`\n\nEjemplo: `/editar 123 @monto 2000`' + }), 200 + + # Extraer campos a actualizar + campos = {} + nuevo_monto = extraer_monto(texto) + nuevo_cliente = extraer_cliente(texto) + + if nuevo_monto: + campos['monto'] = nuevo_monto + if nuevo_cliente: + campos['cliente'] = nuevo_cliente + + if not campos: + return jsonify({ + 'response_type': 'ephemeral', + 'text': '❌ **No se especificaron cambios**\n\nUsa `@monto` y/o `@cliente` para especificar los cambios.' + }), 200 + + # Editar venta + resultado = nocodb.editar_venta(venta_id, user_name, campos) + + if resultado.get('success'): + cambios_texto = '\n'.join([ + f"• **{c['campo']}:** {c['anterior']} → {c['nuevo']}" + for c in resultado.get('cambios', []) + ]) + mensaje = ( + f"✏️ **Venta #{venta_id} editada**\n\n" + f"**Cambios realizados:**\n{cambios_texto}\n\n" + "_Las metas han sido recalculadas._" + ) + else: + mensaje = f"❌ **Error:** {resultado.get('error')}" + + return jsonify({ + 'response_type': 'ephemeral', + 'text': mensaje + }), 200 + + except Exception as e: + logger.error(f"Error procesando comando /editar: {str(e)}", exc_info=True) + return jsonify({ + 'text': f'❌ Error procesando comando: {str(e)}' + }), 500 + + +@app.route('/comando/comisiones', methods=['POST']) +def comando_comisiones(): + """ + Endpoint para el comando slash /comisiones en Mattermost + Muestra historial de comisiones de los últimos meses + """ + try: + from utils import validar_tokens_comando + + data = request.form.to_dict() + logger.info(f"Comando /comisiones recibido de {data.get('user_name')}") + + # Validar token + token = data.get('token') + if not validar_tokens_comando(token, 'comisiones'): + return jsonify({'text': '❌ Token inválido'}), 403 + + user_name = data.get('user_name') + + # Obtener historial + historial = nocodb.get_historial_comisiones(user_name, meses=6) + + if not historial: + mensaje = f"📊 **Historial de Comisiones - @{user_name}**\n\nNo hay datos de comisiones." + else: + mensaje = f"📊 **Historial de Comisiones - @{user_name}**\n\n" + mensaje += "| Mes | Tubos | Comisión | Ventas |\n" + mensaje += "|-----|-------|----------|--------|\n" + + total_comision = 0 + for h in historial: + mensaje += f"| {h['mes']} | {h['tubos_totales']} | ${h['comision_total']:,.2f} | {h['cantidad_ventas']} |\n" + total_comision += h['comision_total'] + + mensaje += f"\n**Total acumulado:** ${total_comision:,.2f} 💰" + + return jsonify({ + 'response_type': 'ephemeral', + 'text': mensaje + }), 200 + + except Exception as e: + logger.error(f"Error procesando comando /comisiones: {str(e)}", exc_info=True) + return jsonify({ + 'text': f'❌ Error procesando comando: {str(e)}' + }), 500 + + +@app.route('/comando/racha', methods=['POST']) +def comando_racha(): + """ + Endpoint para el comando slash /racha en Mattermost + Muestra la racha actual del vendedor + """ + try: + from utils import validar_tokens_comando + + data = request.form.to_dict() + logger.info(f"Comando /racha recibido de {data.get('user_name')}") + + # Validar token + token = data.get('token') + if not validar_tokens_comando(token, 'racha'): + return jsonify({'text': '❌ Token inválido'}), 403 + + user_name = data.get('user_name') + + # Obtener racha + racha = nocodb.verificar_racha(user_name) + + dias = racha.get('dias_consecutivos', 0) + bonus = racha.get('bonus', 0) + proximo = racha.get('proximo_bonus', {}) + + # Determinar emoji según racha + if dias >= 10: + emoji = '🔥🔥🔥' + nivel = 'LEGENDARIO' + elif dias >= 5: + emoji = '🔥🔥' + nivel = 'EN FUEGO' + elif dias >= 3: + emoji = '🔥' + nivel = 'RACHA ACTIVA' + else: + emoji = '💪' + nivel = 'CONSTRUYENDO' + + mensaje = f"{emoji} **Racha de @{user_name}** {emoji}\n\n" + mensaje += f"**Nivel:** {nivel}\n" + mensaje += f"**Días consecutivos:** {dias}\n" + mensaje += f"**Meta diaria:** {racha.get('meta_diaria', 3)} tubos\n\n" + + if bonus > 0: + mensaje += f"**💰 Bonus ganado:** ${bonus:,.2f}\n\n" + + if proximo.get('dias_faltan', 0) > 0: + mensaje += f"**Próximo bonus:** ${proximo['bonus']:,.2f} en {proximo['dias_faltan']} día(s)\n" + elif proximo.get('mensaje'): + mensaje += f"**{proximo['mensaje']}**\n" + + mensaje += "\n_¡Sigue cumpliendo tu meta diaria para mantener la racha!_" + + return jsonify({ + 'response_type': 'ephemeral', + 'text': mensaje + }), 200 + + except Exception as e: + logger.error(f"Error procesando comando /racha: {str(e)}", exc_info=True) + return jsonify({ + 'text': f'❌ Error procesando comando: {str(e)}' + }), 500 + + +@app.route('/comando/exportar', methods=['POST']) +def comando_exportar(): + """ + Endpoint para el comando slash /exportar en Mattermost + Exporta ventas a Excel o CSV + """ + try: + from utils import validar_tokens_comando, parsear_formato_exportar + from export_utils import generar_excel_ventas, generar_csv_ventas + + data = request.form.to_dict() + logger.info(f"Comando /exportar recibido de {data.get('user_name')}") + + # Validar token + token = data.get('token') + if not validar_tokens_comando(token, 'exportar'): + return jsonify({'text': '❌ Token inválido'}), 403 + + user_name = data.get('user_name') + texto = data.get('text', '').strip() + channel_id = data.get('channel_id') + + # Parsear parámetros + formato, mes = parsear_formato_exportar(texto) + if not mes: + mes = datetime.now().strftime('%Y-%m') + + # Obtener datos + ventas = nocodb.get_ventas_mes(user_name, mes) + stats = nocodb.get_estadisticas_vendedor_mes(user_name, mes) + + if not ventas: + return jsonify({ + 'response_type': 'ephemeral', + 'text': f'📊 No hay ventas para {user_name} en {mes}' + }), 200 + + # Generar archivo + if formato == 'excel': + contenido = generar_excel_ventas(ventas, user_name, stats) + filename = f'ventas_{user_name}_{mes}.xlsx' + else: + contenido = generar_csv_ventas(ventas) + filename = f'ventas_{user_name}_{mes}.csv' + + # Subir a Mattermost + file_response = mattermost.upload_file(channel_id, contenido, filename) + + if file_response: + mensaje = f"📊 **Exportación completada**\n\nArchivo: `{filename}`\n\n_El archivo ha sido subido al canal._" + else: + mensaje = f"❌ Error al subir el archivo. Intenta de nuevo." + + return jsonify({ + 'response_type': 'ephemeral', + 'text': mensaje + }), 200 + + except ImportError: + return jsonify({ + 'response_type': 'ephemeral', + 'text': '❌ Módulo de exportación no disponible. Contacta al administrador.' + }), 200 + except Exception as e: + logger.error(f"Error procesando comando /exportar: {str(e)}", exc_info=True) + return jsonify({ + 'text': f'❌ Error procesando comando: {str(e)}' + }), 500 + + @app.route('/reporte/diario', methods=['POST']) def reporte_diario_manual(): """ diff --git a/sales-bot/export_utils.py b/sales-bot/export_utils.py new file mode 100644 index 0000000..b8d66b0 --- /dev/null +++ b/sales-bot/export_utils.py @@ -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') diff --git a/sales-bot/handlers.py b/sales-bot/handlers.py index e62a8e2..b9fb62b 100644 --- a/sales-bot/handlers.py +++ b/sales-bot/handlers.py @@ -1,129 +1,284 @@ +""" +Handlers para procesamiento de ventas en Sales Bot +Incluye sistema de confirmación interactiva obligatoria +""" import logging import re -from datetime import datetime +import os +from datetime import datetime, timedelta, timezone from utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos from ocr_processor import OCRProcessor -import os logger = logging.getLogger(__name__) +# Zona horaria de México +TZ_MEXICO = timezone(timedelta(hours=-6)) + +# Cache de ventas pendientes de confirmación +# Formato: {username: {datos_venta, timestamp}} +VENTAS_PENDIENTES = {} + +# Timeout para confirmación (en minutos) +CONFIRMACION_TIMEOUT = int(os.getenv('CONFIRMACION_TIMEOUT_MINUTOS', 2)) + + +def limpiar_ventas_expiradas(): + """Elimina ventas pendientes que han expirado""" + ahora = datetime.now(TZ_MEXICO) + expiradas = [] + + for username, datos in VENTAS_PENDIENTES.items(): + timestamp = datos.get('timestamp') + if timestamp: + if ahora - timestamp > timedelta(minutes=CONFIRMACION_TIMEOUT): + expiradas.append(username) + + for username in expiradas: + del VENTAS_PENDIENTES[username] + logger.info(f"Venta pendiente de {username} expirada") + + def handle_venta_message(data, mattermost, nocodb): """ - Maneja mensajes de venta en Mattermost - NUEVO: Sistema de comisiones por tubos vendidos + Maneja mensajes de venta en Mattermost con confirmación interactiva obligatoria. + + Flujo: + 1. Usuario envía mensaje de venta + 2. Bot muestra preview y pide confirmación + 3. Usuario responde "si" o "no" + 4. Bot registra la venta (si confirmada) """ try: user_name = data.get('user_name') text = data.get('text', '').strip() + text_lower = text.lower() + + # Limpiar ventas expiradas + limpiar_ventas_expiradas() + + # ==================== PROCESAR CONFIRMACIÓN ==================== + + # Si es confirmación de venta pendiente + if text_lower in ['si', 'sí', 'confirmar', 'ok', 'yes'] and user_name in VENTAS_PENDIENTES: + venta_data = VENTAS_PENDIENTES.pop(user_name) + return registrar_venta_confirmada(venta_data, mattermost, nocodb) + + # Si es cancelación de venta pendiente + if text_lower in ['no', 'cancelar', 'cancel'] and user_name in VENTAS_PENDIENTES: + VENTAS_PENDIENTES.pop(user_name) + mensaje = '❌ **Venta cancelada**\n\nPuedes registrar una nueva venta cuando quieras.' + mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':x:') + return {'text': mensaje} + + # Si ya tiene venta pendiente, recordar + if user_name in VENTAS_PENDIENTES: + venta_pendiente = VENTAS_PENDIENTES[user_name] + tiempo_restante = CONFIRMACION_TIMEOUT - (datetime.now(TZ_MEXICO) - venta_pendiente['timestamp']).seconds // 60 + + mensaje = ( + f"⏳ **Ya tienes una venta pendiente**\n\n" + f"**Monto:** ${venta_pendiente.get('monto', 0):,.2f}\n" + f"**Cliente:** {venta_pendiente.get('cliente', 'Sin especificar')}\n\n" + f"Responde **si** para confirmar o **no** para cancelar.\n" + f"_Expira en {tiempo_restante} minuto(s)_" + ) + mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':hourglass:') + return {'text': mensaje} + + # ==================== PROCESAR NUEVA VENTA ==================== + channel_name = data.get('channel_name') post_id = data.get('post_id') file_ids = data.get('file_ids', '') - + if file_ids and isinstance(file_ids, str): file_ids = [f.strip() for f in file_ids.split(',') if f.strip()] elif not file_ids: file_ids = [] - + logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}") - + # Extraer información del texto monto = extraer_monto(text) cliente = extraer_cliente(text) - tubos_manual = extraer_tubos(text) # NUEVO: tubos manuales - - # Procesar imágenes adjuntas - imagen_url = None + tubos_manual = extraer_tubos(text) + + # ==================== PROCESAR IMÁGENES (MÚLTIPLES) ==================== + imagenes_url = [] ocr_info = "" productos_ocr = [] - + monto_ocr_total = 0 + tubos_ocr_total = 0 + if file_ids: logger.info(f"Procesando {len(file_ids)} archivos adjuntos") - file_id = file_ids[0] - - imagen_data = mattermost.get_file(file_id) - file_info = mattermost.get_file_info(file_id) - - if file_info and imagen_data: - filename = file_info.get('name', 'ticket.jpg') - file_size = file_info.get('size', 0) - - bot_token = os.getenv('MATTERMOST_BOT_TOKEN') - mattermost_url = os.getenv('MATTERMOST_URL') - imagen_url = f"{mattermost_url}/api/v4/files/{file_id}?access_token={bot_token}" - - logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes") - - # Procesar con OCR + + for file_id in file_ids: try: - ocr = OCRProcessor() - resultado_ocr = ocr.procesar_ticket(imagen_data) - - if resultado_ocr: - monto_ocr = resultado_ocr.get('monto_detectado') - fecha_ocr = resultado_ocr.get('fecha_detectada') - productos_ocr = resultado_ocr.get('productos', []) - - if not monto and monto_ocr: - monto = monto_ocr - logger.info(f"Usando monto detectado por OCR: ${monto}") - ocr_info += f"\n💡 Monto detectado: ${monto:,.2f}" - - elif monto and monto_ocr: - es_valido, mensaje = ocr.validar_monto_con_ocr(monto, monto_ocr, tolerancia=0.05) - ocr_info += f"\n{mensaje}" - - if not es_valido: - logger.warning(mensaje) - - if fecha_ocr: - ocr_info += f"\n📅 Fecha: {fecha_ocr}" - - if productos_ocr: - # NUEVO: Contar tubos de tinte - tubos_tinte = sum( - p['cantidad'] for p in productos_ocr - if 'tinte' in p['marca'].lower() or 'tinte' in p['producto'].lower() - or 'cromatique' in p['marca'].lower() - ) - ocr_info += f"\n🧪 Tubos de tinte: {tubos_tinte}" - ocr_info += f"\n📦 Total productos: {len(productos_ocr)}" - logger.info(f"Tubos de tinte detectados: {tubos_tinte}") - - except Exception as ocr_error: - logger.error(f"Error en OCR: {str(ocr_error)}") - ocr_info = "\n⚠️ No se pudo leer el ticket" - productos_ocr = [] - - logger.info(f"URL de imagen: {imagen_url}") - + 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}" + imagenes_url.append(imagen_url) + + logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes") + + # Procesar con OCR + try: + ocr = OCRProcessor() + resultado_ocr = ocr.procesar_ticket(imagen_data) + + if resultado_ocr: + monto_ocr = resultado_ocr.get('monto_detectado', 0) + fecha_ocr = resultado_ocr.get('fecha_detectada') + productos = resultado_ocr.get('productos', []) + + if monto_ocr: + monto_ocr_total += monto_ocr + + if productos: + productos_ocr.extend(productos) + # Contar tubos de tinte + for p in productos: + marca = p.get('marca', '').lower() + producto = p.get('producto', '').lower() + if 'tinte' in marca or 'tinte' in producto or 'cromatique' in marca: + tubos_ocr_total += p.get('cantidad', 0) + + if fecha_ocr and not ocr_info: + ocr_info += f"\n📅 Fecha ticket: {fecha_ocr}" + + except Exception as ocr_error: + logger.error(f"Error en OCR para {file_id}: {str(ocr_error)}") + + except Exception as file_error: + logger.error(f"Error procesando archivo {file_id}: {str(file_error)}") + + # Resumen de OCR + if len(file_ids) > 1: + ocr_info += f"\n📷 Imágenes procesadas: {len(imagenes_url)}" + + if monto_ocr_total > 0: + ocr_info += f"\n💡 Monto OCR: ${monto_ocr_total:,.2f}" + if not monto: + monto = monto_ocr_total + + if tubos_ocr_total > 0: + ocr_info += f"\n🧪 Tubos detectados: {tubos_ocr_total}" + + if productos_ocr: + ocr_info += f"\n📦 Productos: {len(productos_ocr)}" + + # ==================== VALIDAR DATOS ==================== + if not monto: + # Sugerir clientes frecuentes si no hay cliente + sugerencias = "" + if not cliente: + clientes_frecuentes = nocodb.get_clientes_frecuentes(user_name, limit=3) + if clientes_frecuentes: + lista = '\n'.join([f" • {c}" for c in clientes_frecuentes]) + sugerencias = f"\n\n💡 **Clientes frecuentes:**\n{lista}" + mensaje = ( - f"@{user_name} Necesito el monto de la venta.\n" + f"@{user_name} Necesito el monto de la venta.\n\n" "**Formatos válidos:**\n" "• `venta @monto 1500 @cliente Juan Pérez`\n" "• `vendí $1500 a Juan Pérez`\n" + "• `venta @monto 1500 @tubos 3`\n" "• Adjunta foto del ticket" + f"{sugerencias}" ) mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:') return {'text': mensaje} - + if not cliente: cliente = "Cliente sin nombre" - + + # Usar tubos manuales o de OCR + tubos_detectados = tubos_manual or tubos_ocr_total or 0 + + # ==================== CREAR PREVIEW Y PEDIR CONFIRMACIÓN ==================== + + # Guardar datos de la venta pendiente + VENTAS_PENDIENTES[user_name] = { + 'user_name': user_name, + 'monto': monto, + 'cliente': cliente, + 'tubos': tubos_detectados, + 'tubos_manual': tubos_manual, + 'productos_ocr': productos_ocr, + 'imagenes_url': imagenes_url, + 'ocr_info': ocr_info, + 'channel_name': channel_name, + 'post_id': post_id, + 'text': text, + 'timestamp': datetime.now(TZ_MEXICO) + } + + # Construir mensaje de preview + mensaje_preview = f"📋 **Preview de Venta**\n\n" + mensaje_preview += f"**Vendedor:** @{user_name}\n" + mensaje_preview += f"**Monto:** ${monto:,.2f}\n" + mensaje_preview += f"**Cliente:** {cliente}\n" + + if tubos_detectados > 0: + mensaje_preview += f"**Tubos:** {tubos_detectados} 🧪\n" + + if imagenes_url: + mensaje_preview += f"**Tickets:** {len(imagenes_url)} imagen(es) 📸\n" + + if ocr_info: + mensaje_preview += f"\n**Datos detectados:**{ocr_info}\n" + + mensaje_preview += f"\n¿Confirmar esta venta? Responde **si** o **no**\n" + mensaje_preview += f"_(Expira en {CONFIRMACION_TIMEOUT} minutos)_" + + mattermost.post_message_webhook(mensaje_preview, username='Sales Bot', icon_emoji=':clipboard:') + return {'text': mensaje_preview} + + except Exception as e: + logger.error(f"Error en handle_venta_message: {str(e)}", exc_info=True) + mensaje_error = f"❌ Error: {str(e)}" + return {'text': mensaje_error} + + +def registrar_venta_confirmada(venta_data, mattermost, nocodb): + """ + Registra una venta después de ser confirmada por el usuario. + """ + try: + user_name = venta_data.get('user_name') + monto = venta_data.get('monto') + cliente = venta_data.get('cliente') + tubos_manual = venta_data.get('tubos_manual') + productos_ocr = venta_data.get('productos_ocr', []) + imagenes_url = venta_data.get('imagenes_url', []) + ocr_info = venta_data.get('ocr_info', '') + channel_name = venta_data.get('channel_name') + post_id = venta_data.get('post_id') + text = venta_data.get('text', '') + # Verificar/crear vendedor vendedor = nocodb.get_vendedor(user_name) if not vendedor: 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" nombre = user_info.get('first_name', user_name) if user_info else user_name - + vendedor = nocodb.crear_vendedor( username=user_name, nombre_completo=nombre, email=email, - meta_diaria_tubos=3 # NUEVO: Meta de 3 tubos diarios + meta_diaria_tubos=3 ) - + if vendedor: mensaje_bienvenida = ( f"👋 ¡Bienvenido @{user_name}!\n" @@ -133,7 +288,10 @@ def handle_venta_message(data, mattermost, nocodb): f"¡Empieza a registrar tus ventas!" ) mattermost.post_message_webhook(mensaje_bienvenida, username='Sales Bot', icon_emoji=':wave:') - + + # Usar primera imagen como principal + imagen_url = imagenes_url[0] if imagenes_url else None + # Registrar venta venta = nocodb.registrar_venta( vendedor_username=user_name, @@ -144,17 +302,17 @@ def handle_venta_message(data, mattermost, nocodb): canal=channel_name, imagen_url=imagen_url ) - + if venta: venta_id = venta.get('Id') - + # Guardar productos detectados por OCR if productos_ocr: productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr) if productos_guardados: logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}") - # NUEVO: Guardar tubos manuales si se especificaron + # Guardar tubos manuales si se especificaron elif tubos_manual and tubos_manual > 0: productos_manuales = [{ 'producto': 'Tinte (registro manual)', @@ -163,42 +321,44 @@ def handle_venta_message(data, mattermost, nocodb): 'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0, 'importe': monto }] - productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_manuales) - if productos_guardados: - logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}") + nocodb.guardar_productos_venta(venta_id, productos_manuales) + logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}") - # NUEVO: Actualizar tabla de metas + # Actualizar tabla de metas try: nocodb.actualizar_meta_vendedor(user_name) logger.info(f"Metas actualizadas para {user_name}") except Exception as meta_error: logger.error(f"Error actualizando metas: {str(meta_error)}") + # Verificar racha + racha = nocodb.verificar_racha(user_name) + # Reacción de éxito if post_id: mattermost.add_reaction(post_id, 'white_check_mark') - - # NUEVO: Obtener estadísticas del día + + # Obtener estadísticas del día stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name) - - # Construir mensaje + + # Construir mensaje de confirmación mensaje_confirmacion = ( - f"✅ **Venta registrada**\n\n" + f"✅ **Venta #{venta_id} registrada**\n\n" f"**Vendedor:** @{user_name}\n" f"**Monto:** {formatear_moneda(monto)}\n" f"**Cliente:** {cliente}\n" ) - - if imagen_url: - mensaje_confirmacion += f"📸 **Ticket:** Guardado{ocr_info}\n" - - # NUEVO: Mostrar estadísticas de tubos y comisiones + + if imagenes_url: + mensaje_confirmacion += f"📸 **Tickets:** {len(imagenes_url)} guardado(s){ocr_info}\n" + + # Mostrar estadísticas de tubos y comisiones if stats_dia: tubos_hoy = stats_dia.get('tubos_vendidos', 0) comision_hoy = stats_dia.get('comision', 0) meta = stats_dia.get('meta_diaria', 3) tubos_comisionables = stats_dia.get('tubos_comisionables', 0) - + # Determinar emoji según progreso if tubos_hoy >= meta * 2: emoji = '🔥' @@ -212,13 +372,13 @@ def handle_venta_message(data, mattermost, nocodb): else: emoji = '📊' mensaje_extra = '¡Sigue así!' - + mensaje_confirmacion += ( f"\n**Resumen del día:** {emoji}\n" f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n" f"• Meta diaria: {meta} tubos\n" ) - + if tubos_hoy > meta: mensaje_confirmacion += ( f"• Tubos con comisión: {tubos_comisionables}\n" @@ -227,41 +387,46 @@ def handle_venta_message(data, mattermost, nocodb): else: faltan = meta - tubos_hoy mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n" - + mensaje_confirmacion += f"• {mensaje_extra}" - + + # Mostrar info de racha si aplica + if racha and racha.get('dias_consecutivos', 0) >= 3: + dias = racha['dias_consecutivos'] + bonus = racha.get('bonus', 0) + mensaje_confirmacion += f"\n\n🔥 **Racha: {dias} días** " + if bonus > 0: + mensaje_confirmacion += f"(+${bonus:,.2f} bonus)" + # Enviar confirmación mattermost.post_message_webhook( mensaje_confirmacion, username='Sales Bot', icon_emoji=':moneybag:' ) - + return {'text': mensaje_confirmacion} else: - mensaje_error = f"❌ Error al registrar la venta. Intenta de nuevo." + mensaje_error = "❌ Error al registrar la venta. Intenta de nuevo." mattermost.post_message_webhook(mensaje_error, username='Sales Bot', icon_emoji=':x:') return {'text': mensaje_error} - + except Exception as e: - logger.error(f"Error en handle_venta_message: {str(e)}", exc_info=True) + logger.error(f"Error en registrar_venta_confirmada: {str(e)}", exc_info=True) mensaje_error = f"❌ Error: {str(e)}" return {'text': mensaje_error} + def generar_reporte_diario(mattermost, nocodb): """ Genera reporte diario de ventas y comisiones - NUEVO: Muestra tubos vendidos y comisiones ganadas """ try: - import os - - hoy = datetime.now().strftime('%Y-%m-%d') - mes_actual = datetime.now().strftime('%Y-%m') - + hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d') + # Obtener todas las ventas del día ventas_hoy = nocodb.get_ventas_dia() - + # Agrupar por vendedor vendedores_hoy = {} for venta in ventas_hoy: @@ -269,64 +434,70 @@ def generar_reporte_diario(mattermost, nocodb): if vendedor not in vendedores_hoy: vendedores_hoy[vendedor] = [] vendedores_hoy[vendedor].append(venta) - + # Calcular estadísticas por vendedor stats_vendedores = [] for vendedor in vendedores_hoy.keys(): stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy) if stats: stats_vendedores.append(stats) - + # Ordenar por tubos vendidos stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True) - + # Calcular totales 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_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores) - + # Construir mensaje mensaje = ( - f"📊 **Reporte Diario - {datetime.now().strftime('%d/%m/%Y')}**\n\n" + f"📊 **Reporte Diario - {datetime.now(TZ_MEXICO).strftime('%d/%m/%Y')}**\n\n" f"**Resumen del día:**\n" f"• Tubos vendidos: {total_tubos_dia} 🧪\n" f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n" f"• Monto total: {formatear_moneda(total_monto)}\n" f"• Ventas: {len(ventas_hoy)}\n\n" ) - + if stats_vendedores: mensaje += "**Top Vendedores del Día:**\n" for i, stats in enumerate(stats_vendedores[:5], 1): vendedor = stats.get('vendedor') tubos = stats.get('tubos_vendidos', 0) comision = stats.get('comision', 0) - + emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅' - + if comision > 0: mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n" else: mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n" - + # Obtener canal de reportes team_name = os.getenv('MATTERMOST_TEAM_NAME') channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES') - - canal = mattermost.get_channel_by_name(team_name, channel_reportes) - - if canal: - mattermost.post_message(canal['id'], mensaje) - logger.info("Reporte diario generado") - return {'status': 'success', 'message': 'Reporte generado'} + + if channel_reportes: + canal = mattermost.get_channel_by_name(team_name, channel_reportes) + + if canal: + mattermost.post_message(canal['id'], mensaje) + logger.info("Reporte diario generado") + return {'status': 'success', 'message': 'Reporte generado'} + else: + logger.warning(f"Canal {channel_reportes} no encontrado") + return {'status': 'error', 'message': 'Canal no encontrado'} else: - logger.warning(f"Canal {channel_reportes} no encontrado") - return {'status': 'error', 'message': 'Canal no encontrado'} - + # Enviar por webhook si no hay canal específico + mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':chart_with_upwards_trend:') + return {'status': 'success', 'message': 'Reporte enviado por webhook'} + except Exception as e: logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True) return {'status': 'error', 'message': str(e)} + def comando_estadisticas(user_name, mattermost, nocodb): """ Muestra estadísticas personales del vendedor @@ -335,26 +506,26 @@ def comando_estadisticas(user_name, mattermost, nocodb): try: # Estadísticas del día stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name) - + # Estadísticas del mes stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name) - + if not stats_hoy and not stats_mes: mensaje = f"@{user_name} Aún no tienes ventas registradas." return mensaje - + mensaje = f"📈 **Estadísticas de @{user_name}**\n\n" - + # Hoy if stats_hoy: mensaje += ( - f"**Hoy ({datetime.now().strftime('%d/%m')})**\n" + f"**Hoy ({datetime.now(TZ_MEXICO).strftime('%d/%m')})**\n" f"• Tubos: {stats_hoy.get('tubos_vendidos', 0)} 🧪\n" f"• Comisión: {formatear_moneda(stats_hoy.get('comision', 0))}\n" f"• Monto: {formatear_moneda(stats_hoy.get('monto_total_dia', 0))}\n" f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n" ) - + # Mes if stats_mes: 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"• 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:') return mensaje - + except Exception as e: logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True) - return f"❌ Error obteniendo estadísticas" + return "❌ Error obteniendo estadísticas" diff --git a/sales-bot/nocodb_client.py b/sales-bot/nocodb_client.py index 5fd861a..7c1170e 100644 --- a/sales-bot/nocodb_client.py +++ b/sales-bot/nocodb_client.py @@ -609,3 +609,354 @@ class NocoDBClient: def get_meta_vendedor(self, vendedor_username, mes=None): """Obtiene las estadísticas del vendedor para el mes""" return self.get_estadisticas_vendedor_mes(vendedor_username, mes) + + # ==================== NUEVOS MÉTODOS FASE 1 ==================== + + def get_venta_por_id(self, venta_id): + """Obtiene una venta específica por su ID""" + try: + response = requests.get( + f"{self.url}/api/v2/tables/{self.table_ventas}/records/{venta_id}", + headers=self.headers, + timeout=10 + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Error obteniendo venta {venta_id}: {str(e)}") + return None + + def cancelar_venta(self, venta_id, username_solicitante, motivo=None): + """ + Cancela una venta existente. + Solo el vendedor dueño puede cancelar su propia venta. + """ + try: + # Verificar que la venta existe + venta = self.get_venta_por_id(venta_id) + if not venta: + return {'error': 'Venta no encontrada', 'success': False} + + # Verificar permisos: solo el dueño puede cancelar + if venta.get('vendedor_username') != username_solicitante: + return {'error': 'Solo puedes cancelar tus propias ventas', 'success': False} + + # Verificar que no esté ya cancelada + if venta.get('estado') == 'cancelada': + return {'error': 'Esta venta ya está cancelada', 'success': False} + + # Actualizar estado + payload = { + 'Id': venta_id, + 'estado': 'cancelada', + 'motivo_cancelacion': motivo or 'Sin motivo especificado', + 'fecha_cancelacion': datetime.now(TZ_MEXICO).isoformat() + } + + response = requests.patch( + f"{self.url}/api/v2/tables/{self.table_ventas}/records", + headers=self.headers, + json=[payload], + timeout=10 + ) + response.raise_for_status() + + # Recalcular metas del vendedor + self.actualizar_meta_vendedor(username_solicitante) + + logger.info(f"Venta {venta_id} cancelada por {username_solicitante}") + return { + 'success': True, + 'venta_id': venta_id, + 'monto': venta.get('monto'), + 'cliente': venta.get('cliente') + } + + except Exception as e: + logger.error(f"Error cancelando venta {venta_id}: {str(e)}") + return {'error': f'Error al cancelar: {str(e)}', 'success': False} + + def editar_venta(self, venta_id, username_solicitante, campos_actualizar): + """ + Edita una venta existente. + Solo el vendedor dueño puede editar su propia venta. + Guarda historial de cambios. + """ + try: + # Verificar que la venta existe + venta = self.get_venta_por_id(venta_id) + if not venta: + return {'error': 'Venta no encontrada', 'success': False} + + # Verificar permisos + if venta.get('vendedor_username') != username_solicitante: + return {'error': 'Solo puedes editar tus propias ventas', 'success': False} + + # Verificar que no esté cancelada + if venta.get('estado') == 'cancelada': + return {'error': 'No puedes editar una venta cancelada', 'success': False} + + # Guardar historial de cambios + cambios = [] + for campo, nuevo_valor in campos_actualizar.items(): + valor_anterior = venta.get(campo) + if valor_anterior != nuevo_valor: + cambios.append({ + 'campo': campo, + 'anterior': valor_anterior, + 'nuevo': nuevo_valor + }) + + if not cambios: + return {'error': 'No hay cambios para aplicar', 'success': False} + + # Actualizar venta + payload = {'Id': venta_id, **campos_actualizar} + + response = requests.patch( + f"{self.url}/api/v2/tables/{self.table_ventas}/records", + headers=self.headers, + json=[payload], + timeout=10 + ) + response.raise_for_status() + + # Recalcular metas + self.actualizar_meta_vendedor(username_solicitante) + + logger.info(f"Venta {venta_id} editada por {username_solicitante}: {cambios}") + return { + 'success': True, + 'venta_id': venta_id, + 'cambios': cambios + } + + except Exception as e: + logger.error(f"Error editando venta {venta_id}: {str(e)}") + return {'error': f'Error al editar: {str(e)}', 'success': False} + + def get_ultima_venta_usuario(self, username, minutos=5): + """ + Obtiene la última venta del usuario dentro de los últimos N minutos. + Útil para el comando /deshacer. + """ + try: + ahora = datetime.now(TZ_MEXICO) + limite = ahora - timedelta(minutes=minutos) + + # Obtener ventas recientes del usuario + ventas = self.get_ventas_dia(username) + + # Filtrar por tiempo y estado + ventas_recientes = [] + for venta in ventas: + if venta.get('estado') == 'cancelada': + continue + + fecha_str = venta.get('fecha_venta', '') + try: + fecha_venta = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00')) + if fecha_venta.tzinfo is None: + fecha_venta = fecha_venta.replace(tzinfo=timezone.utc) + fecha_mexico = fecha_venta.astimezone(TZ_MEXICO) + + if fecha_mexico >= limite: + ventas_recientes.append({ + **venta, + 'fecha_parseada': fecha_mexico + }) + except: + continue + + if not ventas_recientes: + return None + + # Ordenar por fecha y retornar la más reciente + ventas_recientes.sort(key=lambda x: x['fecha_parseada'], reverse=True) + return ventas_recientes[0] + + except Exception as e: + logger.error(f"Error obteniendo última venta de {username}: {str(e)}") + return None + + def get_clientes_frecuentes(self, username, query=None, limit=5): + """ + Obtiene los clientes más frecuentes de un vendedor. + Útil para autocompletado. + """ + try: + # Obtener todas las ventas del usuario + ventas = self.get_ventas_mes(username) + + # Contar frecuencia de clientes + clientes = {} + for venta in ventas: + cliente = venta.get('cliente', '').strip() + if cliente and cliente != 'Cliente sin nombre': + if query and query.lower() not in cliente.lower(): + continue + clientes[cliente] = clientes.get(cliente, 0) + 1 + + # Ordenar por frecuencia + clientes_ordenados = sorted(clientes.items(), key=lambda x: x[1], reverse=True) + + return [c[0] for c in clientes_ordenados[:limit]] + + except Exception as e: + logger.error(f"Error obteniendo clientes frecuentes: {str(e)}") + return [] + + def get_historial_comisiones(self, username, meses=6): + """ + Obtiene el historial de comisiones de los últimos N meses. + """ + try: + historial = [] + ahora = datetime.now(TZ_MEXICO) + + for i in range(meses): + # Calcular mes + fecha = ahora - timedelta(days=30 * i) + mes = fecha.strftime('%Y-%m') + + # Obtener estadísticas del mes + stats = self.get_estadisticas_vendedor_mes(username, mes) + + if stats: + historial.append({ + 'mes': mes, + 'mes_nombre': fecha.strftime('%B %Y'), + 'tubos_totales': stats.get('tubos_totales', 0), + 'comision_total': stats.get('comision_total', 0), + 'monto_total': stats.get('monto_total', 0), + 'cantidad_ventas': stats.get('cantidad_ventas', 0), + 'dias_activos': stats.get('dias_activos', 0) + }) + + return historial + + except Exception as e: + logger.error(f"Error obteniendo historial de comisiones: {str(e)}") + return [] + + def get_vendedores_activos(self): + """Obtiene lista de vendedores activos""" + try: + response = requests.get( + f"{self.url}/api/v2/tables/{self.table_vendedores}/records", + headers=self.headers, + params={'limit': 100}, + timeout=10 + ) + response.raise_for_status() + vendedores = response.json().get('list', []) + + return [v for v in vendedores if v.get('activo', True)] + + except Exception as e: + logger.error(f"Error obteniendo vendedores activos: {str(e)}") + return [] + + def get_ventas_semana(self, vendedor_username=None): + """Obtiene ventas de la semana actual (lunes a domingo)""" + try: + ahora = datetime.now(TZ_MEXICO) + # Calcular inicio de semana (lunes) + inicio_semana = ahora - timedelta(days=ahora.weekday()) + inicio_semana = inicio_semana.replace(hour=0, minute=0, second=0, microsecond=0) + + response = requests.get( + f"{self.url}/api/v2/tables/{self.table_ventas}/records", + headers=self.headers, + params={'limit': 1000}, + timeout=10 + ) + response.raise_for_status() + todas_ventas = response.json().get('list', []) + + ventas_filtradas = [] + for venta in todas_ventas: + fecha_str = venta.get('fecha_venta', '') + vendedor = venta.get('vendedor_username', '') + + try: + fecha_utc = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00')) + if fecha_utc.tzinfo is None: + fecha_utc = fecha_utc.replace(tzinfo=timezone.utc) + fecha_mexico = fecha_utc.astimezone(TZ_MEXICO) + + if fecha_mexico >= inicio_semana: + if vendedor_username is None or vendedor == vendedor_username: + ventas_filtradas.append(venta) + except: + continue + + return ventas_filtradas + + except Exception as e: + logger.error(f"Error obteniendo ventas de la semana: {str(e)}") + return [] + + # ==================== MÉTODOS PARA RACHAS ==================== + + def verificar_racha(self, username): + """ + Verifica y actualiza la racha del vendedor. + Retorna información de la racha y bonus si aplica. + """ + try: + ahora = datetime.now(TZ_MEXICO) + dias_consecutivos = 0 + fecha_check = ahora.date() + + # Verificar días consecutivos hacia atrás + while True: + fecha_str = fecha_check.strftime('%Y-%m-%d') + stats = self.get_estadisticas_vendedor_dia(username, fecha_str) + + if stats and stats.get('tubos_vendidos', 0) >= self.META_DIARIA_TUBOS: + dias_consecutivos += 1 + fecha_check -= timedelta(days=1) + else: + break + + # Límite de búsqueda + if dias_consecutivos > 30: + break + + # Calcular bonus + bonus = 0 + bonus_3 = float(os.getenv('BONUS_3_DIAS', 20)) + bonus_5 = float(os.getenv('BONUS_5_DIAS', 50)) + bonus_10 = float(os.getenv('BONUS_10_DIAS', 150)) + + if dias_consecutivos >= 10: + bonus = bonus_10 + elif dias_consecutivos >= 5: + bonus = bonus_5 + elif dias_consecutivos >= 3: + bonus = bonus_3 + + return { + 'dias_consecutivos': dias_consecutivos, + 'bonus': bonus, + 'meta_diaria': self.META_DIARIA_TUBOS, + 'proximo_bonus': self._calcular_proximo_bonus(dias_consecutivos) + } + + except Exception as e: + logger.error(f"Error verificando racha de {username}: {str(e)}") + return {'dias_consecutivos': 0, 'bonus': 0} + + def _calcular_proximo_bonus(self, dias_actuales): + """Calcula cuántos días faltan para el próximo bonus""" + if dias_actuales < 3: + return {'dias_faltan': 3 - dias_actuales, 'bonus': float(os.getenv('BONUS_3_DIAS', 20))} + elif dias_actuales < 5: + return {'dias_faltan': 5 - dias_actuales, 'bonus': float(os.getenv('BONUS_5_DIAS', 50))} + elif dias_actuales < 10: + return {'dias_faltan': 10 - dias_actuales, 'bonus': float(os.getenv('BONUS_10_DIAS', 150))} + else: + return {'dias_faltan': 0, 'bonus': 0, 'mensaje': '¡Ya tienes el bonus máximo!'} diff --git a/sales-bot/requirements.txt b/sales-bot/requirements.txt index 67070a8..ef454f1 100644 --- a/sales-bot/requirements.txt +++ b/sales-bot/requirements.txt @@ -26,3 +26,9 @@ coloredlogs==15.0.1 # Utilidades python-dateutil==2.8.2 + +# Scheduler para tareas programadas +APScheduler==3.10.4 + +# Exportación a Excel +openpyxl==3.1.2 diff --git a/sales-bot/scheduler.py b/sales-bot/scheduler.py new file mode 100644 index 0000000..472eab3 --- /dev/null +++ b/sales-bot/scheduler.py @@ -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}") diff --git a/sales-bot/utils.py b/sales-bot/utils.py index d39bdbd..188927c 100644 --- a/sales-bot/utils.py +++ b/sales-bot/utils.py @@ -164,3 +164,147 @@ def extraer_tubos(texto): pass return None + + +# ==================== NUEVAS FUNCIONES FASE 1 ==================== + +def extraer_id_venta(texto): + """ + Extrae el ID de venta del texto del comando. + Soporta formatos: + - /cancelar 123 + - /editar 123 @monto 1500 + - #123 + """ + if not texto: + return None + + # Buscar número al inicio o después del comando + patron_id = r'(?:^|\s)#?(\d+)' + match = re.search(patron_id, texto.strip()) + if match: + try: + return int(match.group(1)) + except ValueError: + pass + + return None + + +def extraer_motivo(texto): + """ + Extrae el motivo de cancelación del texto. + Soporta formatos: + - /cancelar 123 "cliente no pagó" + - /cancelar 123 motivo: cliente canceló + """ + if not texto: + return None + + # Buscar texto entre comillas + patron_comillas = r'["\']([^"\']+)["\']' + match = re.search(patron_comillas, texto) + if match: + return match.group(1).strip() + + # Buscar después de "motivo:" + patron_motivo = r'motivo[:\s]+(.+)$' + match = re.search(patron_motivo, texto, re.IGNORECASE) + if match: + return match.group(1).strip() + + # Si hay texto después del ID, usarlo como motivo + patron_resto = r'^\d+\s+(.+)$' + match = re.search(patron_resto, texto.strip()) + if match: + motivo = match.group(1).strip() + # Excluir si es otro comando + if not motivo.startswith('@') and not motivo.startswith('/'): + return motivo + + return None + + +def extraer_mes(texto): + """ + Extrae el mes del texto del comando. + Soporta formatos: + - 2026-01 + - 01-2026 + - enero 2026 + - enero + """ + if not texto: + return None + + texto = texto.strip().lower() + + # Formato YYYY-MM + patron_iso = r'(\d{4})-(\d{1,2})' + match = re.search(patron_iso, texto) + if match: + return f"{match.group(1)}-{match.group(2).zfill(2)}" + + # Formato MM-YYYY + patron_inv = r'(\d{1,2})-(\d{4})' + match = re.search(patron_inv, texto) + if match: + return f"{match.group(2)}-{match.group(1).zfill(2)}" + + # Nombres de meses en español + meses = { + 'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04', + 'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08', + 'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12' + } + + for nombre, num in meses.items(): + if nombre in texto: + # Buscar año + patron_anio = r'(\d{4})' + match = re.search(patron_anio, texto) + if match: + return f"{match.group(1)}-{num}" + # Si no hay año, usar el actual + from datetime import datetime + return f"{datetime.now().year}-{num}" + + return None + + +def parsear_formato_exportar(texto): + """ + Parsea los parámetros del comando /exportar. + Retorna (formato, mes) + """ + if not texto: + return 'excel', None + + texto = texto.strip().lower() + + # Detectar formato + formato = 'excel' + if 'csv' in texto: + formato = 'csv' + texto = texto.replace('csv', '').strip() + + # Detectar mes + mes = extraer_mes(texto) + + return formato, mes + + +def validar_tokens_comando(token, nombre_comando): + """ + Valida tokens para comandos slash. + """ + if not token: + return False + + expected_tokens = [ + os.getenv(f'MATTERMOST_SLASH_TOKEN_{nombre_comando.upper()}'), + os.getenv('MATTERMOST_OUTGOING_TOKEN'), + os.getenv('MATTERMOST_WEBHOOK_SECRET'), + ] + + return token in [t for t in expected_tokens if t]