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:
2026-01-19 03:26:16 +00:00
parent ed1658eb2b
commit 9936deaa90
25 changed files with 5501 additions and 282 deletions

View File

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