from flask import Flask, request, jsonify, render_template_string, render_template, send_from_directory import os import logging from dotenv import load_dotenv from datetime import datetime, timedelta, timezone import json import base64 # Cargar variables de entorno load_dotenv() # Timezone México TZ_MEXICO = timezone(timedelta(hours=-6)) # Importar módulos personalizados from mattermost_client import MattermostClient 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( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Inicializar Flask con templates y static folders app = Flask(__name__, template_folder='templates', static_folder='static') # Inicializar clientes mattermost = MattermostClient( url=os.getenv('MATTERMOST_URL'), token=os.getenv('MATTERMOST_BOT_TOKEN') ) nocodb = NocoDBClient( url=os.getenv('NOCODB_URL'), token=os.getenv('NOCODB_TOKEN') ) # Inicializar websocket listener ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_message) 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""" return jsonify({ 'status': 'healthy', 'timestamp': datetime.now().isoformat(), 'version': '1.0.0' }), 200 @app.route('/webhook/mattermost', methods=['POST']) def mattermost_webhook(): """ Recibe webhooks salientes de Mattermost cuando hay mensajes en el canal de ventas """ try: data = request.json logger.info(f"Webhook recibido: {json.dumps(data, indent=2)}") # Validar token token = data.get('token') if not validar_token_outgoing(token): logger.warning(f"Token inválido: {token}") return jsonify({'error': 'Token inválido'}), 403 # Ignorar mensajes del propio bot if data.get('user_name') == 'sales-bot': return jsonify({'status': 'ignored', 'reason': 'bot message'}), 200 # Obtener información del mensaje channel_name = data.get('channel_name') user_name = data.get('user_name') text = data.get('text', '').strip() logger.info(f"Procesando mensaje de {user_name} en #{channel_name}: {text}") # Verificar si es un mensaje de venta palabras_clave = ['venta', 'vendi', 'vendí', 'cliente', 'ticket'] es_venta = any(palabra in text.lower() for palabra in palabras_clave) if es_venta or data.get('file_ids'): respuesta = handle_venta_message(data, mattermost, nocodb) return jsonify(respuesta), 200 return jsonify({'status': 'ok', 'message': 'Mensaje procesado'}), 200 except Exception as e: logger.error(f"Error procesando webhook: {str(e)}", exc_info=True) return jsonify({'error': 'Error interno del servidor'}), 500 @app.route('/webhook/nocodb', methods=['POST']) def nocodb_webhook(): """ Recibe webhooks de NocoDB cuando se insertan/actualizan datos """ try: data = request.json logger.info(f"Webhook NocoDB recibido: {json.dumps(data, indent=2)}") # Aquí puedes agregar lógica para notificar en Mattermost # cuando se actualicen datos en NocoDB return jsonify({'status': 'ok'}), 200 except Exception as e: logger.error(f"Error procesando webhook NocoDB: {str(e)}", exc_info=True) return jsonify({'error': 'Error interno del servidor'}), 500 @app.route('/comando/metas', methods=['POST']) def comando_metas(): """ Endpoint para el comando slash /metas en Mattermost """ try: data = request.form.to_dict() logger.info(f"Comando /metas recibido de {data.get('user_name')}") # Validar token token = data.get('token') expected_tokens = [ os.getenv('MATTERMOST_SLASH_TOKEN_METAS'), os.getenv('MATTERMOST_OUTGOING_TOKEN') ] if token not in expected_tokens: return jsonify({'text': 'Token inválido'}), 403 user_name = data.get('user_name') # Obtener meta del vendedor meta = nocodb.get_meta_vendedor(user_name) if not meta: mensaje = ( f"@{user_name} Aún no tienes ventas registradas este mes.\n" "¡Empieza a vender y registra tus ventas!" ) else: porcentaje = meta.get('porcentaje_completado', 0) total_vendido = meta.get('total_vendido', 0) meta_establecida = meta.get('meta_establecida', 0) ventas_count = meta.get('ventas_realizadas', 0) falta = meta_establecida - total_vendido # Barra de progreso visual barra_length = 20 completado = int((porcentaje / 100) * barra_length) barra = '█' * completado + '░' * (barra_length - completado) mensaje = ( f"📊 **Reporte de {user_name}**\n\n" f"`{barra}` {porcentaje:.1f}%\n\n" f"**Total vendido:** ${total_vendido:,.2f} MXN\n" f"**Meta mensual:** ${meta_establecida:,.2f} MXN\n" f"**Falta:** ${falta:,.2f} MXN\n" f"**Ventas realizadas:** {ventas_count}\n" ) if porcentaje >= 100: mensaje += "\n🎉 **¡Felicidades! Meta completada**" elif porcentaje >= 75: mensaje += f"\n🔥 **¡Casi llegas! Solo faltan ${falta:,.2f}**" return jsonify({ 'response_type': 'ephemeral', 'text': mensaje }), 200 except Exception as e: logger.error(f"Error procesando comando /metas: {str(e)}", exc_info=True) return jsonify({ 'text': f'❌ Error procesando comando: {str(e)}' }), 500 @app.route('/comando/ranking', methods=['POST']) def comando_ranking(): """ Endpoint para el comando slash /ranking en Mattermost """ try: data = request.form.to_dict() logger.info(f"Comando /ranking recibido de {data.get('user_name')}") # Validar token token = data.get('token') expected_tokens = [ os.getenv('MATTERMOST_SLASH_TOKEN_RANKING'), os.getenv('MATTERMOST_OUTGOING_TOKEN') ] if token not in expected_tokens: return jsonify({'text': 'Token inválido'}), 403 # Obtener ranking ranking = nocodb.get_ranking_vendedores() if not ranking: mensaje = "No hay datos de ventas este mes." else: mensaje = "🏆 **Ranking de Vendedores - Mes Actual**\n\n" for i, vendedor in enumerate(ranking[:10], 1): username = vendedor.get('vendedor_username') total = vendedor.get('total_vendido', 0) porcentaje = vendedor.get('porcentaje_completado', 0) ventas = vendedor.get('ventas_realizadas', 0) # Medallas para top 3 if i == 1: emoji = '🥇' elif i == 2: emoji = '🥈' elif i == 3: emoji = '🥉' else: emoji = f'{i}.' mensaje += ( f"{emoji} **@{username}**\n" f" └ ${total:,.2f} MXN ({porcentaje:.1f}%) - {ventas} ventas\n" ) return jsonify({ 'response_type': 'in_channel', 'text': mensaje }), 200 except Exception as e: logger.error(f"Error procesando comando /ranking: {str(e)}", exc_info=True) return jsonify({ 'text': f'❌ Error procesando comando: {str(e)}' }), 500 @app.route('/comando/ayuda', methods=['POST']) def comando_ayuda(): """ Endpoint para el comando slash /ayuda en Mattermost """ try: data = request.form.to_dict() logger.info(f"Comando /ayuda recibido de {data.get('user_name')}") # Validar token token = data.get('token') expected_tokens = [ os.getenv('MATTERMOST_SLASH_TOKEN_AYUDA'), os.getenv('MATTERMOST_OUTGOING_TOKEN') ] if token not in expected_tokens: return jsonify({'text': 'Token inválido'}), 403 mensaje = ( "🤖 **Bot de Ventas - Guía de Uso**\n\n" "**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(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" "**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! 💪" ) return jsonify({ 'response_type': 'ephemeral', 'text': mensaje }), 200 except Exception as e: logger.error(f"Error procesando comando /ayuda: {str(e)}", exc_info=True) return jsonify({ '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(): """ Endpoint para generar reporte diario manualmente """ try: resultado = generar_reporte_diario(mattermost, nocodb) return jsonify(resultado), 200 except Exception as e: logger.error(f"Error generando reporte: {str(e)}", exc_info=True) return jsonify({'error': str(e)}), 500 @app.route('/test/mattermost', methods=['GET']) def test_mattermost(): """Test de conexión con Mattermost""" try: resultado = mattermost.test_connection() return jsonify(resultado), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/test/nocodb', methods=['GET']) def test_nocodb(): """Test de conexión con NocoDB""" try: resultado = nocodb.test_connection() return jsonify(resultado), 200 except Exception as e: return jsonify({'error': str(e)}), 500 # ============== DASHBOARD ============== @app.route('/api/dashboard/resumen', methods=['GET']) def api_dashboard_resumen(): """API: Resumen general del día y mes""" try: from datetime import datetime, timedelta, timezone TZ_MEXICO = timezone(timedelta(hours=-6)) hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d') mes = datetime.now(TZ_MEXICO).strftime('%Y-%m') # Ventas del día ventas_hoy = nocodb.get_ventas_dia() total_hoy = sum(float(v.get('monto', 0)) for v in ventas_hoy) # Ventas del mes ventas_mes = nocodb.get_ventas_mes() total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes) # Contar vendedores activos hoy vendedores_hoy = set(v.get('vendedor_username') for v in ventas_hoy) return jsonify({ 'fecha': hoy, 'mes': mes, 'ventas_hoy': len(ventas_hoy), 'monto_hoy': total_hoy, 'ventas_mes': len(ventas_mes), 'monto_mes': total_mes, 'vendedores_activos_hoy': len(vendedores_hoy) }), 200 except Exception as e: logger.error(f"Error en API resumen: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/dashboard/ranking', methods=['GET']) def api_dashboard_ranking(): """API: Ranking de vendedores del mes""" try: ranking = nocodb.get_ranking_vendedores() return jsonify(ranking), 200 except Exception as e: logger.error(f"Error en API ranking: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/dashboard/ventas-recientes', methods=['GET']) def api_dashboard_ventas_recientes(): """API: Últimas ventas registradas con nombres de vendedores""" try: import requests ventas = nocodb.get_ventas_dia() # Obtener lista de vendedores para mapear usernames a nombres vendedores_response = requests.get( f"{nocodb.url}/api/v2/tables/{nocodb.table_vendedores}/records", headers=nocodb.headers, params={'limit': 100}, timeout=10 ) vendedores_response.raise_for_status() vendedores = vendedores_response.json().get('list', []) # Crear mapa de username -> nombre_completo nombres_map = {v.get('username'): v.get('nombre_completo', v.get('username')) for v in vendedores} # Agregar nombre_completo a cada venta for venta in ventas: username = venta.get('vendedor_username', '') venta['nombre_completo'] = nombres_map.get(username, username) # Ordenar por fecha descendente y tomar las últimas 20 ventas_ordenadas = sorted(ventas, key=lambda x: x.get('fecha_venta', ''), reverse=True)[:20] return jsonify(ventas_ordenadas), 200 except Exception as e: logger.error(f"Error en API ventas recientes: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/dashboard/metas', methods=['GET']) def api_dashboard_metas(): """API: Estado de metas de todos los vendedores""" try: import requests response = requests.get( f"{nocodb.url}/api/v2/tables/{nocodb.table_metas}/records", headers=nocodb.headers, params={'limit': 100}, timeout=10 ) response.raise_for_status() metas = response.json().get('list', []) return jsonify(metas), 200 except Exception as e: logger.error(f"Error en API metas: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/dashboard') def dashboard(): """Dashboard principal de ventas""" try: return render_template('dashboard.html') except Exception as e: logger.error(f"Error renderizando dashboard: {str(e)}") # Fallback al HTML embebido si no hay template return render_template_string(''' Sales Bot Dashboard

Sales Bot Dashboard

Error cargando templates. Verifica que la carpeta templates/ existe.

Error: ''' + str(e) + '''

''') @app.route('/dashboard/analytics') def dashboard_analytics(): """Dashboard de analytics con gráficas""" try: return render_template('analytics.html') except Exception as e: logger.error(f"Error renderizando analytics: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/dashboard/executive') def dashboard_executive(): """Dashboard ejecutivo con KPIs""" try: return render_template('executive.html') except Exception as e: logger.error(f"Error renderizando executive: {str(e)}") return jsonify({'error': str(e)}), 500 # ============== PWA ROUTES ============== @app.route('/manifest.json') def serve_manifest(): """Servir manifest.json para PWA""" return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json') @app.route('/service-worker.js') def serve_service_worker(): """Servir service worker para PWA""" return send_from_directory('static', 'service-worker.js', mimetype='application/javascript') # ============== ANALYTICS API ============== @app.route('/api/analytics/trends', methods=['GET']) def api_analytics_trends(): """API: Tendencias de ventas""" try: from analytics.trends import TrendAnalyzer dias = request.args.get('days', 30, type=int) vendedor = request.args.get('vendedor', None) analyzer = TrendAnalyzer(nocodb) trends = analyzer.get_daily_trends(dias, vendedor) return jsonify(trends), 200 except ImportError: logger.warning("Módulo analytics.trends no disponible") return jsonify({'error': 'Módulo de analytics no disponible', 'labels': [], 'ventas': []}), 200 except Exception as e: logger.error(f"Error en API trends: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/analytics/predictions', methods=['GET']) def api_analytics_predictions(): """API: Predicciones de ventas""" try: from analytics.predictions import prediccion_basica from analytics.trends import TrendAnalyzer dias = request.args.get('days', 30, type=int) dias_prediccion = request.args.get('predict', 7, type=int) analyzer = TrendAnalyzer(nocodb) trends = analyzer.get_daily_trends(dias) ventas_diarias = trends.get('ventas', []) prediccion = prediccion_basica(ventas_diarias, dias_prediccion) return jsonify(prediccion), 200 except ImportError: logger.warning("Módulo analytics.predictions no disponible") return jsonify({ 'error': 'Módulo de predicciones no disponible', 'next_day': 0, 'next_week': 0, 'tendencia': 'stable' }), 200 except Exception as e: logger.error(f"Error en API predictions: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/analytics/comparisons', methods=['GET']) def api_analytics_comparisons(): """API: Comparativas de períodos""" try: from analytics.comparisons import ComparisonAnalyzer tipo = request.args.get('type', 'weekly') analyzer = ComparisonAnalyzer(nocodb) comparison = analyzer.get_comparison_summary(tipo) return jsonify(comparison), 200 except ImportError: logger.warning("Módulo analytics.comparisons no disponible") return jsonify({'error': 'Módulo de comparaciones no disponible'}), 200 except Exception as e: logger.error(f"Error en API comparisons: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/analytics/performance/', methods=['GET']) def api_analytics_performance(username): """API: Rendimiento de un vendedor específico""" try: # Obtener datos del vendedor meta = nocodb.get_meta_vendedor(username) racha = nocodb.verificar_racha(username) ranking = nocodb.get_ranking_vendedores() # Encontrar posición en ranking posicion = 0 for i, v in enumerate(ranking, 1): if v.get('vendedor_username') == username: posicion = i break return jsonify({ 'username': username, 'tubos_totales': meta.get('tubos_totales', 0) if meta else 0, 'total_vendido': meta.get('total_vendido', 0) if meta else 0, 'comision': meta.get('comision_total', 0) if meta else 0, 'ventas_realizadas': meta.get('ventas_realizadas', 0) if meta else 0, 'racha': racha.get('dias_consecutivos', 0), 'ranking': posicion, 'porcentaje_meta': meta.get('porcentaje_completado', 0) if meta else 0 }), 200 except Exception as e: logger.error(f"Error en API performance: {str(e)}") return jsonify({'error': str(e)}), 500 # ============== REPORTS API ============== @app.route('/comando/reporte', methods=['POST']) def comando_reporte(): """ Endpoint para el comando slash /reporte en Mattermost Uso: /reporte [diario|semanal|ejecutivo] """ try: from utils import validar_tokens_comando data = request.form.to_dict() logger.info(f"Comando /reporte recibido de {data.get('user_name')}") # Validar token token = data.get('token') if not validar_tokens_comando(token, 'reporte'): return jsonify({'text': 'Token inválido'}), 403 user_name = data.get('user_name') channel_id = data.get('channel_id') texto = data.get('text', '').strip().lower() # Determinar tipo de reporte if 'ejecutivo' in texto or 'executive' in texto: tipo = 'ejecutivo' elif 'semanal' in texto or 'weekly' in texto: tipo = 'semanal' else: tipo = 'diario' # Generar reporte try: from reports.pdf_generator import SalesReportPDF, generar_reporte_diario, generar_reporte_ejecutivo ventas = nocodb.get_ventas_dia() if tipo == 'diario' else nocodb.get_ventas_mes() ranking = nocodb.get_ranking_vendedores() # Calcular estadísticas stats = { 'monto_total': sum(float(v.get('monto', 0) or 0) for v in ventas), 'cantidad_ventas': len(ventas), 'tubos_totales': sum(int(v.get('tubos', 0) or 0) for v in ventas), 'comision_total': sum(float(v.get('comision', 0) or 0) for v in ventas) } if tipo == 'ejecutivo': pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats) filename = f"reporte_ejecutivo_{datetime.now(TZ_MEXICO).strftime('%Y%m%d')}.pdf" else: pdf_content = generar_reporte_diario(ventas, ranking, stats) filename = f"reporte_{tipo}_{datetime.now(TZ_MEXICO).strftime('%Y%m%d')}.pdf" # Subir PDF a Mattermost file_response = mattermost.upload_file(channel_id, pdf_content, filename) if file_response: mensaje = f"📊 **Reporte {tipo.capitalize()} generado**\n\nArchivo: `{filename}`" else: mensaje = "❌ Error al subir el reporte. Intenta de nuevo." except ImportError as ie: logger.warning(f"Módulo de reportes no disponible: {ie}") mensaje = ( f"📊 **Reporte {tipo.capitalize()}** (texto)\n\n" f"Instala `reportlab` para generar PDFs.\n\n" f"**Resumen:**\n" f"• Ventas: {len(ventas)}\n" f"• Monto: ${stats['monto_total']:,.2f}\n" ) return jsonify({ 'response_type': 'in_channel', 'text': mensaje }), 200 except Exception as e: logger.error(f"Error procesando comando /reporte: {str(e)}", exc_info=True) return jsonify({ 'text': f'❌ Error procesando comando: {str(e)}' }), 500 @app.route('/api/reports/generate', methods=['POST']) def api_reports_generate(): """API: Generar reporte PDF""" try: from reports.pdf_generator import generar_reporte_diario, generar_reporte_ejecutivo data = request.json or {} tipo = data.get('type', 'daily') vendedor = data.get('vendedor', None) # Obtener datos if tipo == 'daily': ventas = nocodb.get_ventas_dia() else: ventas = nocodb.get_ventas_mes() if vendedor: ventas = [v for v in ventas if v.get('vendedor_username') == vendedor] ranking = nocodb.get_ranking_vendedores() stats = { 'monto_total': sum(float(v.get('monto', 0) or 0) for v in ventas), 'cantidad_ventas': len(ventas), 'tubos_totales': sum(int(v.get('tubos', 0) or 0) for v in ventas), 'comision_total': sum(float(v.get('comision', 0) or 0) for v in ventas) } # Generar PDF if tipo == 'executive': pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats) else: pdf_content = generar_reporte_diario(ventas, ranking, stats) # Guardar temporalmente y devolver ID import hashlib report_id = hashlib.md5(f"{tipo}_{datetime.now().isoformat()}".encode()).hexdigest()[:12] # Guardar en directorio temporal reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports') os.makedirs(reports_dir, exist_ok=True) report_path = os.path.join(reports_dir, f"{report_id}.pdf") with open(report_path, 'wb') as f: f.write(pdf_content) return jsonify({ 'report_id': report_id, 'status': 'generated', 'type': tipo, 'download_url': f'/api/reports/download/{report_id}' }), 200 except ImportError: return jsonify({'error': 'Módulo de reportes no disponible. Instala reportlab.'}), 500 except Exception as e: logger.error(f"Error generando reporte: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/api/reports/download/', methods=['GET']) def api_reports_download(report_id): """API: Descargar reporte PDF""" try: from flask import send_file reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports') report_path = os.path.join(reports_dir, f"{report_id}.pdf") if not os.path.exists(report_path): return jsonify({'error': 'Reporte no encontrado'}), 404 return send_file( report_path, mimetype='application/pdf', as_attachment=True, download_name=f"reporte_{report_id}.pdf" ) except Exception as e: logger.error(f"Error descargando reporte: {str(e)}") return jsonify({'error': str(e)}), 500 # ============== CAMERA/OCR API ============== @app.route('/api/capture/ticket', methods=['POST']) def api_capture_ticket(): """API: Procesar imagen de ticket desde cámara (base64)""" try: data = request.json or {} image_base64 = data.get('image') user_name = data.get('user_name', 'anonymous') if not image_base64: return jsonify({'error': 'No se recibió imagen'}), 400 # Decodificar imagen base64 if ',' in image_base64: image_base64 = image_base64.split(',')[1] image_bytes = base64.b64decode(image_base64) # Procesar con OCR try: from ocr.processor import procesar_ticket_imagen resultado = procesar_ticket_imagen(image_bytes) except ImportError: # Fallback al procesador existente si el módulo OCR no existe from handlers import procesar_imagen_ticket resultado = procesar_imagen_ticket(image_bytes) return jsonify({ 'success': True, 'monto_detectado': resultado.get('monto', 0), 'cliente_detectado': resultado.get('cliente', ''), 'texto_extraido': resultado.get('texto', ''), 'confianza': resultado.get('confianza', 0) }), 200 except Exception as e: logger.error(f"Error procesando imagen de ticket: {str(e)}") return jsonify({'error': str(e)}), 500 if __name__ == '__main__': port = int(os.getenv('FLASK_PORT', 5000)) host = os.getenv('FLASK_HOST', '0.0.0.0') debug = os.getenv('DEBUG', 'False').lower() == 'true' logger.info(f"Iniciando Sales Bot en {host}:{port}") app.run(host=host, port=port, debug=debug)