feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR
FASE 1 - PWA y Frontend: - Crear templates/base.html, dashboard.html, analytics.html, executive.html - Crear static/css/main.css con diseño responsivo - Agregar static/js/app.js, pwa.js, camera.js, charts.js - Implementar manifest.json y service-worker.js para PWA - Soporte para captura de tickets desde cámara móvil FASE 2 - Analytics: - Crear módulo analytics/ con predictions.py, trends.py, comparisons.py - Implementar predicción básica con promedio móvil + tendencia lineal - Agregar endpoints /api/analytics/trends, predictions, comparisons - Integrar Chart.js para gráficas interactivas FASE 3 - Reportes PDF: - Crear módulo reports/ con pdf_generator.py - Implementar SalesReportPDF con generar_reporte_diario y ejecutivo - Agregar comando /reporte [diario|semanal|ejecutivo] - Agregar endpoints /api/reports/generate y /api/reports/download FASE 4 - Mejoras OCR: - Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py - Implementar AmountDetector con patrones múltiples de montos - Agregar preprocesador adaptativo con pipelines para diferentes condiciones - Soporte para corrección de rotación (deskew) y threshold Otsu Dependencias agregadas: - reportlab, matplotlib (PDF) - scipy, pandas (analytics) - imutils, deskew, cachetools (OCR) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
631
sales-bot/app.py
631
sales-bot/app.py
@@ -1,13 +1,17 @@
|
||||
from flask import Flask, request, jsonify, render_template_string
|
||||
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
|
||||
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
|
||||
@@ -26,8 +30,10 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Inicializar Flask
|
||||
app = Flask(__name__)
|
||||
# Inicializar Flask con templates y static folders
|
||||
app = Flask(__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static')
|
||||
|
||||
# Inicializar clientes
|
||||
mattermost = MattermostClient(
|
||||
@@ -801,305 +807,366 @@ def api_dashboard_metas():
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
"""Dashboard principal de ventas"""
|
||||
html = '''
|
||||
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('''
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sales Bot - Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
h1 { font-size: 28px; font-weight: 600; }
|
||||
h1 span { color: #00d4ff; }
|
||||
.fecha { color: #888; font-size: 14px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 40px rgba(0,212,255,0.1);
|
||||
}
|
||||
.stat-card .label { color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.stat-card .value { font-size: 32px; font-weight: 700; color: #00d4ff; }
|
||||
.stat-card .subvalue { font-size: 14px; color: #666; margin-top: 4px; }
|
||||
.stat-card.green .value { color: #00ff88; }
|
||||
.stat-card.orange .value { color: #ffaa00; }
|
||||
.stat-card.purple .value { color: #aa00ff; }
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
|
||||
.panel {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h2 .icon { font-size: 24px; }
|
||||
.ranking-list { list-style: none; }
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.ranking-item:last-child { border-bottom: none; }
|
||||
.ranking-position {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
|
||||
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
|
||||
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
|
||||
.ranking-position.default { background: rgba(255,255,255,0.1); color: #888; }
|
||||
.ranking-info { flex: 1; }
|
||||
.ranking-name { font-weight: 600; margin-bottom: 2px; }
|
||||
.ranking-stats { font-size: 12px; color: #888; }
|
||||
.ranking-value { text-align: right; }
|
||||
.ranking-tubos { font-size: 24px; font-weight: 700; color: #00d4ff; }
|
||||
.ranking-comision { font-size: 12px; color: #00ff88; }
|
||||
.ventas-list { max-height: 400px; overflow-y: auto; }
|
||||
.venta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.venta-info .vendedor { font-weight: 600; color: #00d4ff; }
|
||||
.venta-info .cliente { font-size: 12px; color: #888; }
|
||||
.venta-monto { font-size: 18px; font-weight: 700; color: #00ff88; }
|
||||
.refresh-btn {
|
||||
background: rgba(0,212,255,0.2);
|
||||
border: 1px solid #00d4ff;
|
||||
color: #00d4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.refresh-btn:hover { background: rgba(0,212,255,0.3); }
|
||||
.loading { text-align: center; padding: 40px; color: #888; }
|
||||
.meta-progress {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.meta-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div>
|
||||
<h1><span>Sales</span> Bot Dashboard</h1>
|
||||
<p class="fecha" id="fecha-actual"></p>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="cargarDatos()">🔄 Actualizar</button>
|
||||
</header>
|
||||
<html><head><title>Sales Bot Dashboard</title></head>
|
||||
<body style="background:#1a1a2e;color:#fff;font-family:sans-serif;padding:40px;text-align:center;">
|
||||
<h1>Sales Bot Dashboard</h1>
|
||||
<p>Error cargando templates. Verifica que la carpeta templates/ existe.</p>
|
||||
<p>Error: ''' + str(e) + '''</p>
|
||||
</body></html>
|
||||
''')
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Ventas Hoy</div>
|
||||
<div class="value" id="ventas-hoy">-</div>
|
||||
<div class="subvalue" id="monto-hoy">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card green">
|
||||
<div class="label">Ventas del Mes</div>
|
||||
<div class="value" id="ventas-mes">-</div>
|
||||
<div class="subvalue" id="monto-mes">$0.00</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="label">Vendedores Activos Hoy</div>
|
||||
<div class="value" id="vendedores-activos">-</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="label">Meta Diaria</div>
|
||||
<div class="value">3</div>
|
||||
<div class="subvalue">tubos por vendedor</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<div class="panel">
|
||||
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
|
||||
<ul class="ranking-list" id="ranking-list">
|
||||
<li class="loading">Cargando...</li>
|
||||
</ul>
|
||||
</div>
|
||||
@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
|
||||
|
||||
<div class="panel">
|
||||
<h2><span class="icon">📋</span> Ventas Recientes</h2>
|
||||
<div class="ventas-list" id="ventas-list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatMoney(amount) {
|
||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||
}
|
||||
@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
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function cargarResumen() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/resumen');
|
||||
const data = await res.json();
|
||||
# ============== PWA ROUTES ==============
|
||||
|
||||
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
|
||||
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
|
||||
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
|
||||
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
|
||||
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
|
||||
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error cargando resumen:', e);
|
||||
@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/<username>', 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)
|
||||
}
|
||||
|
||||
async function cargarRanking() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ranking');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ranking-list');
|
||||
# Generar PDF
|
||||
if tipo == 'executive':
|
||||
pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats)
|
||||
else:
|
||||
pdf_content = generar_reporte_diario(ventas, ranking, stats)
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
|
||||
return;
|
||||
}
|
||||
# Guardar temporalmente y devolver ID
|
||||
import hashlib
|
||||
report_id = hashlib.md5(f"{tipo}_{datetime.now().isoformat()}".encode()).hexdigest()[:12]
|
||||
|
||||
lista.innerHTML = data.slice(0, 10).map((v, i) => {
|
||||
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
|
||||
const tubos = v.tubos_totales || 0;
|
||||
const comision = v.comision_total || 0;
|
||||
const ventas = v.cantidad_ventas || 0;
|
||||
# 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")
|
||||
|
||||
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
|
||||
const username = v.vendedor_username || v.vendedor;
|
||||
with open(report_path, 'wb') as f:
|
||||
f.write(pdf_content)
|
||||
|
||||
return `
|
||||
<li class="ranking-item">
|
||||
<div class="ranking-position ${posClass}">${i + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${nombre}</div>
|
||||
<div class="ranking-stats">@${username} • ${ventas} ventas • ${v.dias_activos || 0} días activos</div>
|
||||
</div>
|
||||
<div class="ranking-value">
|
||||
<div class="ranking-tubos">${tubos} 🧪</div>
|
||||
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ranking:', e);
|
||||
}
|
||||
}
|
||||
return jsonify({
|
||||
'report_id': report_id,
|
||||
'status': 'generated',
|
||||
'type': tipo,
|
||||
'download_url': f'/api/reports/download/{report_id}'
|
||||
}), 200
|
||||
|
||||
async function cargarVentasRecientes() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/ventas-recientes');
|
||||
const data = await res.json();
|
||||
const lista = document.getElementById('ventas-list');
|
||||
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
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
lista.innerHTML = data.map(v => {
|
||||
const nombre = v.nombre_completo || v.vendedor_username;
|
||||
return `
|
||||
<div class="venta-item">
|
||||
<div class="venta-info">
|
||||
<div class="vendedor">${nombre}</div>
|
||||
<div class="cliente">${v.cliente || 'Sin cliente'} • ${formatDate(v.fecha_venta)}</div>
|
||||
</div>
|
||||
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
} catch (e) {
|
||||
console.error('Error cargando ventas:', e);
|
||||
}
|
||||
}
|
||||
@app.route('/api/reports/download/<report_id>', methods=['GET'])
|
||||
def api_reports_download(report_id):
|
||||
"""API: Descargar reporte PDF"""
|
||||
try:
|
||||
from flask import send_file
|
||||
|
||||
function cargarDatos() {
|
||||
cargarResumen();
|
||||
cargarRanking();
|
||||
cargarVentasRecientes();
|
||||
}
|
||||
reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports')
|
||||
report_path = os.path.join(reports_dir, f"{report_id}.pdf")
|
||||
|
||||
// Cargar datos al inicio
|
||||
cargarDatos();
|
||||
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
|
||||
|
||||
// Actualizar cada 30 segundos
|
||||
setInterval(cargarDatos, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return render_template_string(html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.getenv('FLASK_PORT', 5000))
|
||||
|
||||
Reference in New Issue
Block a user