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:
@@ -82,3 +82,35 @@ COMISION_POR_TUBO_DEFAULT=10
|
|||||||
# === EXPORTACIÓN ===
|
# === EXPORTACIÓN ===
|
||||||
# Formato por defecto para exportación (excel o csv)
|
# Formato por defecto para exportación (excel o csv)
|
||||||
EXPORTAR_FORMATO_DEFAULT=excel
|
EXPORTAR_FORMATO_DEFAULT=excel
|
||||||
|
|
||||||
|
# === PWA (Progressive Web App) ===
|
||||||
|
PWA_APP_NAME=Sales Bot
|
||||||
|
PWA_SHORT_NAME=SalesBot
|
||||||
|
PWA_THEME_COLOR=#00d4ff
|
||||||
|
PWA_BACKGROUND_COLOR=#1a1a2e
|
||||||
|
|
||||||
|
# === REPORTES PDF ===
|
||||||
|
# Directorio para guardar reportes generados
|
||||||
|
REPORTS_OUTPUT_DIR=/app/reports
|
||||||
|
# Días de retención de reportes
|
||||||
|
REPORTS_RETENTION_DAYS=30
|
||||||
|
# Habilitar envío automático de PDF diario
|
||||||
|
SCHEDULED_PDF_REPORT_ENABLED=true
|
||||||
|
# Hora de envío del reporte PDF diario
|
||||||
|
SCHEDULED_PDF_REPORT_HOUR=18
|
||||||
|
|
||||||
|
# === ANALYTICS ===
|
||||||
|
# Días de histórico para predicciones
|
||||||
|
PREDICTION_WINDOW_DAYS=30
|
||||||
|
# Meses de histórico para tendencias
|
||||||
|
TREND_HISTORY_MONTHS=12
|
||||||
|
|
||||||
|
# === OCR MEJORADO ===
|
||||||
|
# Habilitar corrección de rotación de imagen
|
||||||
|
OCR_ENABLE_DESKEW=true
|
||||||
|
# Ángulo máximo de rotación a corregir (grados)
|
||||||
|
OCR_MAX_ROTATION_ANGLE=15
|
||||||
|
# Umbral de confianza mínimo para aceptar OCR
|
||||||
|
OCR_CONFIDENCE_THRESHOLD=0.6
|
||||||
|
# Usar pipeline de preprocesamiento adaptativo
|
||||||
|
OCR_USE_ADAPTIVE_PIPELINE=true
|
||||||
|
|||||||
15
sales-bot/analytics/__init__.py
Normal file
15
sales-bot/analytics/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Analytics module for Sales Bot
|
||||||
|
Provides predictions, trends, and performance analysis
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .predictions import prediccion_basica, calcular_tendencia
|
||||||
|
from .trends import TrendAnalyzer
|
||||||
|
from .comparisons import ComparisonAnalyzer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'prediccion_basica',
|
||||||
|
'calcular_tendencia',
|
||||||
|
'TrendAnalyzer',
|
||||||
|
'ComparisonAnalyzer'
|
||||||
|
]
|
||||||
293
sales-bot/analytics/comparisons.py
Normal file
293
sales-bot/analytics/comparisons.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
Comparison analysis for Sales Bot
|
||||||
|
Compares sales data across different time periods
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mexico timezone
|
||||||
|
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
|
|
||||||
|
class ComparisonAnalyzer:
|
||||||
|
"""
|
||||||
|
Analyzes and compares sales data across different periods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, nocodb_client):
|
||||||
|
self.nocodb = nocodb_client
|
||||||
|
|
||||||
|
def comparar_semanas(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Compara la semana actual con la semana anterior.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con comparación semanal
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
# Semana actual (lunes a hoy)
|
||||||
|
dias_desde_lunes = hoy.weekday()
|
||||||
|
inicio_semana_actual = hoy - timedelta(days=dias_desde_lunes)
|
||||||
|
|
||||||
|
# Semana anterior
|
||||||
|
inicio_semana_anterior = inicio_semana_actual - timedelta(days=7)
|
||||||
|
fin_semana_anterior = inicio_semana_actual - timedelta(days=1)
|
||||||
|
|
||||||
|
# Obtener ventas
|
||||||
|
ventas_actual = self._obtener_ventas_rango(inicio_semana_actual, hoy)
|
||||||
|
ventas_anterior = self._obtener_ventas_rango(inicio_semana_anterior, fin_semana_anterior)
|
||||||
|
|
||||||
|
monto_actual = sum(float(v.get('monto', 0) or 0) for v in ventas_actual)
|
||||||
|
monto_anterior = sum(float(v.get('monto', 0) or 0) for v in ventas_anterior)
|
||||||
|
|
||||||
|
# Calcular diferencia
|
||||||
|
if monto_anterior > 0:
|
||||||
|
diff_pct = ((monto_actual - monto_anterior) / monto_anterior) * 100
|
||||||
|
else:
|
||||||
|
diff_pct = 100 if monto_actual > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'current_week': round(monto_actual, 2),
|
||||||
|
'previous_week': round(monto_anterior, 2),
|
||||||
|
'difference': round(monto_actual - monto_anterior, 2),
|
||||||
|
'diff_percent': round(diff_pct, 1),
|
||||||
|
'ventas_actual': len(ventas_actual),
|
||||||
|
'ventas_anterior': len(ventas_anterior),
|
||||||
|
'periodo_actual': {
|
||||||
|
'inicio': inicio_semana_actual.strftime('%Y-%m-%d'),
|
||||||
|
'fin': hoy.strftime('%Y-%m-%d')
|
||||||
|
},
|
||||||
|
'periodo_anterior': {
|
||||||
|
'inicio': inicio_semana_anterior.strftime('%Y-%m-%d'),
|
||||||
|
'fin': fin_semana_anterior.strftime('%Y-%m-%d')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error comparando semanas: {str(e)}")
|
||||||
|
return {
|
||||||
|
'current_week': 0,
|
||||||
|
'previous_week': 0,
|
||||||
|
'difference': 0,
|
||||||
|
'diff_percent': 0,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def comparar_meses(self, mes1: str = None, mes2: str = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Compara dos meses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mes1: Primer mes (formato YYYY-MM), default mes actual
|
||||||
|
mes2: Segundo mes (formato YYYY-MM), default mes anterior
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con comparación mensual
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
# Determinar meses a comparar
|
||||||
|
if not mes1:
|
||||||
|
mes1 = hoy.strftime('%Y-%m')
|
||||||
|
|
||||||
|
if not mes2:
|
||||||
|
# Mes anterior
|
||||||
|
primer_dia_mes_actual = hoy.replace(day=1)
|
||||||
|
ultimo_dia_mes_anterior = primer_dia_mes_actual - timedelta(days=1)
|
||||||
|
mes2 = ultimo_dia_mes_anterior.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# Obtener ventas de cada mes
|
||||||
|
ventas_mes1 = self._obtener_ventas_mes(mes1)
|
||||||
|
ventas_mes2 = self._obtener_ventas_mes(mes2)
|
||||||
|
|
||||||
|
monto_mes1 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes1)
|
||||||
|
monto_mes2 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes2)
|
||||||
|
|
||||||
|
# Calcular diferencia
|
||||||
|
if monto_mes2 > 0:
|
||||||
|
diff_pct = ((monto_mes1 - monto_mes2) / monto_mes2) * 100
|
||||||
|
else:
|
||||||
|
diff_pct = 100 if monto_mes1 > 0 else 0
|
||||||
|
|
||||||
|
# Calcular mes anterior al mes2 para tendencia de 3 meses
|
||||||
|
anio2, mes2_num = map(int, mes2.split('-'))
|
||||||
|
if mes2_num == 1:
|
||||||
|
mes3 = f"{anio2 - 1}-12"
|
||||||
|
else:
|
||||||
|
mes3 = f"{anio2}-{mes2_num - 1:02d}"
|
||||||
|
|
||||||
|
ventas_mes3 = self._obtener_ventas_mes(mes3)
|
||||||
|
monto_mes3 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes3)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'current_month': round(monto_mes1, 2),
|
||||||
|
'previous_month': round(monto_mes2, 2),
|
||||||
|
'month_2': round(monto_mes3, 2),
|
||||||
|
'difference': round(monto_mes1 - monto_mes2, 2),
|
||||||
|
'diff_percent': round(diff_pct, 1),
|
||||||
|
'ventas_actual': len(ventas_mes1),
|
||||||
|
'ventas_anterior': len(ventas_mes2),
|
||||||
|
'months': [mes3, mes2, mes1],
|
||||||
|
'values': [round(monto_mes3, 2), round(monto_mes2, 2), round(monto_mes1, 2)],
|
||||||
|
'mes1': mes1,
|
||||||
|
'mes2': mes2
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error comparando meses: {str(e)}")
|
||||||
|
return {
|
||||||
|
'current_month': 0,
|
||||||
|
'previous_month': 0,
|
||||||
|
'difference': 0,
|
||||||
|
'diff_percent': 0,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def comparar_anios(self, anio1: int = None, anio2: int = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Compara ventas anuales (hasta la fecha actual del año).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anio1: Primer año, default año actual
|
||||||
|
anio2: Segundo año, default año anterior
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con comparación anual
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
if not anio1:
|
||||||
|
anio1 = hoy.year
|
||||||
|
if not anio2:
|
||||||
|
anio2 = anio1 - 1
|
||||||
|
|
||||||
|
# Obtener ventas YTD de cada año
|
||||||
|
dia_mes = hoy.strftime('%m-%d')
|
||||||
|
|
||||||
|
# Para comparación justa, comparamos hasta el mismo día del año
|
||||||
|
ventas_anio1 = self._obtener_ventas_ytd(anio1, dia_mes)
|
||||||
|
ventas_anio2 = self._obtener_ventas_ytd(anio2, dia_mes)
|
||||||
|
|
||||||
|
monto_anio1 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio1)
|
||||||
|
monto_anio2 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio2)
|
||||||
|
|
||||||
|
# Calcular diferencia
|
||||||
|
if monto_anio2 > 0:
|
||||||
|
diff_pct = ((monto_anio1 - monto_anio2) / monto_anio2) * 100
|
||||||
|
else:
|
||||||
|
diff_pct = 100 if monto_anio1 > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'anio_actual': anio1,
|
||||||
|
'anio_anterior': anio2,
|
||||||
|
'monto_actual': round(monto_anio1, 2),
|
||||||
|
'monto_anterior': round(monto_anio2, 2),
|
||||||
|
'difference': round(monto_anio1 - monto_anio2, 2),
|
||||||
|
'diff_percent': round(diff_pct, 1),
|
||||||
|
'ventas_actual': len(ventas_anio1),
|
||||||
|
'ventas_anterior': len(ventas_anio2),
|
||||||
|
'comparacion_hasta': hoy.strftime('%d de %B')
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error comparando años: {str(e)}")
|
||||||
|
return {
|
||||||
|
'monto_actual': 0,
|
||||||
|
'monto_anterior': 0,
|
||||||
|
'difference': 0,
|
||||||
|
'diff_percent': 0,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _obtener_ventas_rango(self, inicio: datetime.date, fin: datetime.date) -> List[Dict]:
|
||||||
|
"""Obtiene ventas en un rango de fechas."""
|
||||||
|
try:
|
||||||
|
# Obtener todas las ventas recientes
|
||||||
|
ventas = self.nocodb.get_ventas_mes() if hasattr(self.nocodb, 'get_ventas_mes') else []
|
||||||
|
|
||||||
|
ventas_filtradas = []
|
||||||
|
for venta in ventas:
|
||||||
|
try:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
if 'T' in fecha_str:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||||
|
|
||||||
|
if fecha.tzinfo is None:
|
||||||
|
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
if inicio <= fecha_local <= fin:
|
||||||
|
ventas_filtradas.append(venta)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ventas_filtradas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo ventas del rango: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _obtener_ventas_mes(self, mes: str) -> List[Dict]:
|
||||||
|
"""Obtiene ventas de un mes específico."""
|
||||||
|
try:
|
||||||
|
# Usar método existente si está disponible
|
||||||
|
if hasattr(self.nocodb, 'get_ventas_mes'):
|
||||||
|
ventas = self.nocodb.get_ventas_mes()
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Filtrar por mes
|
||||||
|
ventas_filtradas = []
|
||||||
|
for venta in ventas:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
if fecha_str and fecha_str[:7] == mes:
|
||||||
|
ventas_filtradas.append(venta)
|
||||||
|
|
||||||
|
return ventas_filtradas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo ventas del mes {mes}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _obtener_ventas_ytd(self, anio: int, hasta_dia_mes: str) -> List[Dict]:
|
||||||
|
"""Obtiene ventas del año hasta una fecha específica."""
|
||||||
|
try:
|
||||||
|
inicio = datetime(anio, 1, 1).date()
|
||||||
|
mes, dia = map(int, hasta_dia_mes.split('-'))
|
||||||
|
fin = datetime(anio, mes, dia).date()
|
||||||
|
|
||||||
|
return self._obtener_ventas_rango(inicio, fin)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo ventas YTD {anio}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_comparison_summary(self, tipo: str = 'weekly') -> Dict:
|
||||||
|
"""
|
||||||
|
Obtiene resumen de comparación según el tipo solicitado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tipo: 'weekly', 'monthly', o 'yearly'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con comparación
|
||||||
|
"""
|
||||||
|
if tipo == 'weekly':
|
||||||
|
return self.comparar_semanas()
|
||||||
|
elif tipo == 'monthly':
|
||||||
|
return self.comparar_meses()
|
||||||
|
elif tipo == 'yearly':
|
||||||
|
return self.comparar_anios()
|
||||||
|
else:
|
||||||
|
return {'error': f'Tipo de comparación no válido: {tipo}'}
|
||||||
241
sales-bot/analytics/predictions.py
Normal file
241
sales-bot/analytics/predictions.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Sales prediction algorithms for Sales Bot
|
||||||
|
Uses moving average and linear regression for basic predictions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Try to import scipy for linear regression
|
||||||
|
try:
|
||||||
|
from scipy import stats
|
||||||
|
import numpy as np
|
||||||
|
SCIPY_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SCIPY_AVAILABLE = False
|
||||||
|
logger.warning("scipy not available, using basic prediction methods")
|
||||||
|
|
||||||
|
|
||||||
|
def prediccion_basica(ventas_diarias: List[float], dias_prediccion: int = 7) -> Dict:
|
||||||
|
"""
|
||||||
|
Genera predicciones combinando promedio móvil con tendencia lineal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ventas_diarias: Lista de montos de ventas por día
|
||||||
|
dias_prediccion: Número de días a predecir
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con predicciones, tendencia y confianza
|
||||||
|
"""
|
||||||
|
if not ventas_diarias or len(ventas_diarias) < 3:
|
||||||
|
return {
|
||||||
|
'predicciones': [],
|
||||||
|
'next_day': 0,
|
||||||
|
'next_week': 0,
|
||||||
|
'tendencia': 'stable',
|
||||||
|
'confidence': 0,
|
||||||
|
'error': 'Datos insuficientes para predicción'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calcular promedio móvil (últimos 7 días o menos si no hay suficientes)
|
||||||
|
ventana = min(7, len(ventas_diarias))
|
||||||
|
promedio_movil = sum(ventas_diarias[-ventana:]) / ventana
|
||||||
|
|
||||||
|
# Calcular tendencia
|
||||||
|
if SCIPY_AVAILABLE:
|
||||||
|
# Usar regresión lineal con scipy
|
||||||
|
x = np.arange(len(ventas_diarias))
|
||||||
|
y = np.array(ventas_diarias)
|
||||||
|
slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
|
||||||
|
confianza = abs(r_value)
|
||||||
|
else:
|
||||||
|
# Método básico sin scipy
|
||||||
|
slope, intercept, confianza = _calcular_regresion_simple(ventas_diarias)
|
||||||
|
|
||||||
|
# Generar predicciones
|
||||||
|
predicciones = []
|
||||||
|
for i in range(1, dias_prediccion + 1):
|
||||||
|
# Predicción lineal
|
||||||
|
pred_lineal = slope * (len(ventas_diarias) + i) + intercept
|
||||||
|
|
||||||
|
# Combinar promedio móvil con tendencia lineal
|
||||||
|
pred_combinada = (promedio_movil + pred_lineal) / 2
|
||||||
|
|
||||||
|
# No permitir predicciones negativas
|
||||||
|
predicciones.append(max(0, pred_combinada))
|
||||||
|
|
||||||
|
# Determinar tendencia
|
||||||
|
if slope > 0.5:
|
||||||
|
tendencia = 'increasing'
|
||||||
|
elif slope < -0.5:
|
||||||
|
tendencia = 'decreasing'
|
||||||
|
else:
|
||||||
|
tendencia = 'stable'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'predicciones': predicciones,
|
||||||
|
'next_day': round(predicciones[0], 2) if predicciones else 0,
|
||||||
|
'next_week': round(sum(predicciones[:7]), 2) if len(predicciones) >= 7 else round(sum(predicciones), 2),
|
||||||
|
'tendencia': tendencia,
|
||||||
|
'trend': tendencia, # Alias for frontend
|
||||||
|
'confidence': round(confianza, 2),
|
||||||
|
'promedio_movil': round(promedio_movil, 2),
|
||||||
|
'slope': round(slope, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error en predicción: {str(e)}")
|
||||||
|
return {
|
||||||
|
'predicciones': [],
|
||||||
|
'next_day': 0,
|
||||||
|
'next_week': 0,
|
||||||
|
'tendencia': 'stable',
|
||||||
|
'trend': 'stable',
|
||||||
|
'confidence': 0,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calcular_regresion_simple(datos: List[float]) -> tuple:
|
||||||
|
"""
|
||||||
|
Calcula regresión lineal simple sin scipy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(slope, intercept, r_value)
|
||||||
|
"""
|
||||||
|
n = len(datos)
|
||||||
|
if n < 2:
|
||||||
|
return 0, datos[0] if datos else 0, 0
|
||||||
|
|
||||||
|
x = list(range(n))
|
||||||
|
y = datos
|
||||||
|
|
||||||
|
# Calcular medias
|
||||||
|
x_mean = sum(x) / n
|
||||||
|
y_mean = sum(y) / n
|
||||||
|
|
||||||
|
# Calcular slope
|
||||||
|
numerador = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
|
||||||
|
denominador = sum((x[i] - x_mean) ** 2 for i in range(n))
|
||||||
|
|
||||||
|
if denominador == 0:
|
||||||
|
return 0, y_mean, 0
|
||||||
|
|
||||||
|
slope = numerador / denominador
|
||||||
|
intercept = y_mean - slope * x_mean
|
||||||
|
|
||||||
|
# Calcular R-squared (coeficiente de determinación)
|
||||||
|
y_pred = [slope * xi + intercept for xi in x]
|
||||||
|
ss_res = sum((y[i] - y_pred[i]) ** 2 for i in range(n))
|
||||||
|
ss_tot = sum((y[i] - y_mean) ** 2 for i in range(n))
|
||||||
|
|
||||||
|
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
||||||
|
r_value = r_squared ** 0.5 if r_squared >= 0 else 0
|
||||||
|
|
||||||
|
return slope, intercept, r_value
|
||||||
|
|
||||||
|
|
||||||
|
def calcular_tendencia(ventas_diarias: List[float]) -> Dict:
|
||||||
|
"""
|
||||||
|
Analiza la tendencia de los datos de ventas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con información de tendencia
|
||||||
|
"""
|
||||||
|
if not ventas_diarias or len(ventas_diarias) < 2:
|
||||||
|
return {
|
||||||
|
'direccion': 'stable',
|
||||||
|
'cambio_porcentual': 0,
|
||||||
|
'volatilidad': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dividir en dos mitades
|
||||||
|
mitad = len(ventas_diarias) // 2
|
||||||
|
primera_mitad = ventas_diarias[:mitad] if mitad > 0 else ventas_diarias[:1]
|
||||||
|
segunda_mitad = ventas_diarias[mitad:] if mitad > 0 else ventas_diarias[1:]
|
||||||
|
|
||||||
|
promedio_primera = sum(primera_mitad) / len(primera_mitad) if primera_mitad else 0
|
||||||
|
promedio_segunda = sum(segunda_mitad) / len(segunda_mitad) if segunda_mitad else 0
|
||||||
|
|
||||||
|
# Calcular cambio porcentual
|
||||||
|
if promedio_primera > 0:
|
||||||
|
cambio_pct = ((promedio_segunda - promedio_primera) / promedio_primera) * 100
|
||||||
|
else:
|
||||||
|
cambio_pct = 0
|
||||||
|
|
||||||
|
# Determinar dirección
|
||||||
|
if cambio_pct > 5:
|
||||||
|
direccion = 'increasing'
|
||||||
|
elif cambio_pct < -5:
|
||||||
|
direccion = 'decreasing'
|
||||||
|
else:
|
||||||
|
direccion = 'stable'
|
||||||
|
|
||||||
|
# Calcular volatilidad (desviación estándar relativa)
|
||||||
|
media = sum(ventas_diarias) / len(ventas_diarias)
|
||||||
|
if media > 0:
|
||||||
|
varianza = sum((x - media) ** 2 for x in ventas_diarias) / len(ventas_diarias)
|
||||||
|
desviacion = varianza ** 0.5
|
||||||
|
volatilidad = (desviacion / media) * 100
|
||||||
|
else:
|
||||||
|
volatilidad = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'direccion': direccion,
|
||||||
|
'cambio_porcentual': round(cambio_pct, 2),
|
||||||
|
'volatilidad': round(volatilidad, 2),
|
||||||
|
'promedio_anterior': round(promedio_primera, 2),
|
||||||
|
'promedio_actual': round(promedio_segunda, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generar_prediccion_extendida(
|
||||||
|
ventas_diarias: List[float],
|
||||||
|
dias_historico: int = 30,
|
||||||
|
dias_futuro: int = 14
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Genera predicción extendida con más detalles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ventas_diarias: Datos históricos
|
||||||
|
dias_historico: Días de historia a considerar
|
||||||
|
dias_futuro: Días a predecir
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con predicción completa
|
||||||
|
"""
|
||||||
|
# Limitar datos al histórico solicitado
|
||||||
|
datos = ventas_diarias[-dias_historico:] if len(ventas_diarias) > dias_historico else ventas_diarias
|
||||||
|
|
||||||
|
# Obtener predicción básica
|
||||||
|
prediccion = prediccion_basica(datos, dias_futuro)
|
||||||
|
|
||||||
|
# Agregar análisis de tendencia
|
||||||
|
tendencia_info = calcular_tendencia(datos)
|
||||||
|
|
||||||
|
# Calcular estadísticas adicionales
|
||||||
|
if datos:
|
||||||
|
maximo = max(datos)
|
||||||
|
minimo = min(datos)
|
||||||
|
promedio = sum(datos) / len(datos)
|
||||||
|
mejor_dia_idx = datos.index(maximo)
|
||||||
|
else:
|
||||||
|
maximo = minimo = promedio = 0
|
||||||
|
mejor_dia_idx = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
**prediccion,
|
||||||
|
'tendencia_detalle': tendencia_info,
|
||||||
|
'estadisticas': {
|
||||||
|
'maximo': round(maximo, 2),
|
||||||
|
'minimo': round(minimo, 2),
|
||||||
|
'promedio': round(promedio, 2),
|
||||||
|
'mejor_dia_indice': mejor_dia_idx,
|
||||||
|
'dias_analizados': len(datos)
|
||||||
|
}
|
||||||
|
}
|
||||||
282
sales-bot/analytics/trends.py
Normal file
282
sales-bot/analytics/trends.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""
|
||||||
|
Trend analysis for Sales Bot
|
||||||
|
Aggregates and analyzes sales data over time
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mexico timezone
|
||||||
|
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
|
|
||||||
|
class TrendAnalyzer:
|
||||||
|
"""
|
||||||
|
Analyzes sales trends over different time periods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, nocodb_client):
|
||||||
|
self.nocodb = nocodb_client
|
||||||
|
|
||||||
|
def get_daily_trends(self, dias: int = 30, vendedor: str = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Obtiene tendencias diarias de ventas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dias: Número de días a analizar
|
||||||
|
vendedor: Username del vendedor (opcional, None para todos)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con labels, ventas, tubos, y predicciones
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Obtener ventas del período
|
||||||
|
ventas = self._obtener_ventas_periodo(dias, vendedor)
|
||||||
|
|
||||||
|
# Agrupar por día
|
||||||
|
ventas_por_dia = self._agrupar_por_dia(ventas, dias)
|
||||||
|
|
||||||
|
# Generar labels (fechas)
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
labels = []
|
||||||
|
valores_ventas = []
|
||||||
|
valores_tubos = []
|
||||||
|
|
||||||
|
for i in range(dias - 1, -1, -1):
|
||||||
|
fecha = hoy - timedelta(days=i)
|
||||||
|
fecha_str = fecha.strftime('%Y-%m-%d')
|
||||||
|
labels.append(fecha.strftime('%d/%m'))
|
||||||
|
|
||||||
|
dia_data = ventas_por_dia.get(fecha_str, {'monto': 0, 'tubos': 0})
|
||||||
|
valores_ventas.append(dia_data['monto'])
|
||||||
|
valores_tubos.append(dia_data['tubos'])
|
||||||
|
|
||||||
|
# Generar predicciones si hay datos
|
||||||
|
prediccion = []
|
||||||
|
if valores_ventas:
|
||||||
|
from .predictions import prediccion_basica
|
||||||
|
pred_result = prediccion_basica(valores_ventas, 7)
|
||||||
|
if pred_result.get('predicciones'):
|
||||||
|
# Añadir None para los días históricos y luego las predicciones
|
||||||
|
prediccion = [None] * len(valores_ventas)
|
||||||
|
prediccion.extend(pred_result['predicciones'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'labels': labels,
|
||||||
|
'ventas': valores_ventas,
|
||||||
|
'tubos': valores_tubos,
|
||||||
|
'prediccion': prediccion,
|
||||||
|
'periodo': {
|
||||||
|
'inicio': (hoy - timedelta(days=dias-1)).strftime('%Y-%m-%d'),
|
||||||
|
'fin': hoy.strftime('%Y-%m-%d'),
|
||||||
|
'dias': dias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo tendencias diarias: {str(e)}")
|
||||||
|
return {
|
||||||
|
'labels': [],
|
||||||
|
'ventas': [],
|
||||||
|
'tubos': [],
|
||||||
|
'prediccion': [],
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _obtener_ventas_periodo(self, dias: int, vendedor: str = None) -> List[Dict]:
|
||||||
|
"""Obtiene ventas de los últimos N días."""
|
||||||
|
try:
|
||||||
|
# Usar método existente del cliente NocoDB
|
||||||
|
if hasattr(self.nocodb, 'get_ventas_historico'):
|
||||||
|
return self.nocodb.get_ventas_historico(dias, vendedor)
|
||||||
|
|
||||||
|
# Fallback: obtener ventas del mes y filtrar
|
||||||
|
ventas = self.nocodb.get_ventas_mes(vendedor) if vendedor else self.nocodb.get_ventas_mes()
|
||||||
|
|
||||||
|
# Filtrar por días
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
fecha_inicio = hoy - timedelta(days=dias)
|
||||||
|
|
||||||
|
ventas_filtradas = []
|
||||||
|
for venta in ventas:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
try:
|
||||||
|
if 'T' in fecha_str:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||||
|
|
||||||
|
if fecha.tzinfo is None:
|
||||||
|
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
if fecha_local >= fecha_inicio:
|
||||||
|
ventas_filtradas.append(venta)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ventas_filtradas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo ventas del período: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _agrupar_por_dia(self, ventas: List[Dict], dias: int) -> Dict[str, Dict]:
|
||||||
|
"""Agrupa ventas por día."""
|
||||||
|
ventas_por_dia = defaultdict(lambda: {'monto': 0, 'tubos': 0, 'count': 0})
|
||||||
|
|
||||||
|
for venta in ventas:
|
||||||
|
try:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
if 'T' in fecha_str:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||||
|
|
||||||
|
if fecha.tzinfo is None:
|
||||||
|
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||||
|
fecha_key = fecha_local.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
monto = float(venta.get('monto', 0) or 0)
|
||||||
|
ventas_por_dia[fecha_key]['monto'] += monto
|
||||||
|
ventas_por_dia[fecha_key]['count'] += 1
|
||||||
|
|
||||||
|
# Contar tubos si hay información de productos
|
||||||
|
tubos = self._contar_tubos_venta(venta)
|
||||||
|
ventas_por_dia[fecha_key]['tubos'] += tubos
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return dict(ventas_por_dia)
|
||||||
|
|
||||||
|
def _contar_tubos_venta(self, venta: Dict) -> int:
|
||||||
|
"""Cuenta tubos de tinte en una venta."""
|
||||||
|
# Esto depende de la estructura de datos
|
||||||
|
# Por ahora retorna un valor estimado basado en el monto
|
||||||
|
# En producción, debería consultar la tabla de detalles
|
||||||
|
tubos = venta.get('tubos', 0)
|
||||||
|
if tubos:
|
||||||
|
return int(tubos)
|
||||||
|
|
||||||
|
# Estimación: ~$50 por tubo en promedio
|
||||||
|
monto = float(venta.get('monto', 0) or 0)
|
||||||
|
return max(1, int(monto / 50)) if monto > 0 else 0
|
||||||
|
|
||||||
|
def get_weekly_summary(self, semanas: int = 4) -> Dict:
|
||||||
|
"""
|
||||||
|
Obtiene resumen semanal de ventas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
semanas: Número de semanas a analizar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con datos semanales
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dias_totales = semanas * 7
|
||||||
|
ventas = self._obtener_ventas_periodo(dias_totales)
|
||||||
|
|
||||||
|
# Agrupar por semana
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
semanas_data = []
|
||||||
|
|
||||||
|
for i in range(semanas):
|
||||||
|
fin_semana = hoy - timedelta(days=i * 7)
|
||||||
|
inicio_semana = fin_semana - timedelta(days=6)
|
||||||
|
|
||||||
|
# Filtrar ventas de esta semana
|
||||||
|
ventas_semana = []
|
||||||
|
for venta in ventas:
|
||||||
|
try:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
if 'T' in fecha_str:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||||
|
|
||||||
|
if fecha.tzinfo is None:
|
||||||
|
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||||
|
|
||||||
|
if inicio_semana <= fecha_local <= fin_semana:
|
||||||
|
ventas_semana.append(venta)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
monto_total = sum(float(v.get('monto', 0) or 0) for v in ventas_semana)
|
||||||
|
|
||||||
|
semanas_data.append({
|
||||||
|
'semana': i + 1,
|
||||||
|
'inicio': inicio_semana.strftime('%Y-%m-%d'),
|
||||||
|
'fin': fin_semana.strftime('%Y-%m-%d'),
|
||||||
|
'label': f"{inicio_semana.strftime('%d/%m')} - {fin_semana.strftime('%d/%m')}",
|
||||||
|
'monto': round(monto_total, 2),
|
||||||
|
'ventas': len(ventas_semana)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Invertir para orden cronológico
|
||||||
|
semanas_data.reverse()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'semanas': semanas_data,
|
||||||
|
'total_semanas': semanas
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo resumen semanal: {str(e)}")
|
||||||
|
return {'semanas': [], 'error': str(e)}
|
||||||
|
|
||||||
|
def get_monthly_summary(self, meses: int = 6) -> Dict:
|
||||||
|
"""
|
||||||
|
Obtiene resumen mensual de ventas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meses: Número de meses a analizar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con datos mensuales
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Esto requeriría acceso a más datos históricos
|
||||||
|
# Por ahora retornamos estructura básica
|
||||||
|
hoy = datetime.now(TZ_MEXICO).date()
|
||||||
|
meses_data = []
|
||||||
|
|
||||||
|
for i in range(meses):
|
||||||
|
# Calcular mes
|
||||||
|
mes_actual = hoy.month - i
|
||||||
|
anio_actual = hoy.year
|
||||||
|
|
||||||
|
while mes_actual < 1:
|
||||||
|
mes_actual += 12
|
||||||
|
anio_actual -= 1
|
||||||
|
|
||||||
|
mes_str = f"{anio_actual}-{mes_actual:02d}"
|
||||||
|
mes_nombre = datetime(anio_actual, mes_actual, 1).strftime('%B %Y')
|
||||||
|
|
||||||
|
meses_data.append({
|
||||||
|
'mes': mes_str,
|
||||||
|
'nombre': mes_nombre,
|
||||||
|
'monto': 0, # Se llenaría con datos reales
|
||||||
|
'ventas': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
meses_data.reverse()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'meses': meses_data,
|
||||||
|
'total_meses': meses
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo resumen mensual: {str(e)}")
|
||||||
|
return {'meses': [], 'error': str(e)}
|
||||||
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 os
|
||||||
import logging
|
import logging
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
# Cargar variables de entorno
|
# Cargar variables de entorno
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Timezone México
|
||||||
|
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
# Importar módulos personalizados
|
# Importar módulos personalizados
|
||||||
from mattermost_client import MattermostClient
|
from mattermost_client import MattermostClient
|
||||||
from nocodb_client import NocoDBClient
|
from nocodb_client import NocoDBClient
|
||||||
@@ -26,8 +30,10 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Inicializar Flask
|
# Inicializar Flask con templates y static folders
|
||||||
app = Flask(__name__)
|
app = Flask(__name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static')
|
||||||
|
|
||||||
# Inicializar clientes
|
# Inicializar clientes
|
||||||
mattermost = MattermostClient(
|
mattermost = MattermostClient(
|
||||||
@@ -801,305 +807,366 @@ def api_dashboard_metas():
|
|||||||
@app.route('/dashboard')
|
@app.route('/dashboard')
|
||||||
def dashboard():
|
def dashboard():
|
||||||
"""Dashboard principal de ventas"""
|
"""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>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html><head><title>Sales Bot Dashboard</title></head>
|
||||||
<head>
|
<body style="background:#1a1a2e;color:#fff;font-family:sans-serif;padding:40px;text-align:center;">
|
||||||
<meta charset="UTF-8">
|
<h1>Sales Bot Dashboard</h1>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<p>Error cargando templates. Verifica que la carpeta templates/ existe.</p>
|
||||||
<title>Sales Bot - Dashboard</title>
|
<p>Error: ''' + str(e) + '''</p>
|
||||||
<style>
|
</body></html>
|
||||||
* { 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>
|
|
||||||
|
|
||||||
<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">
|
@app.route('/dashboard/analytics')
|
||||||
<div class="panel">
|
def dashboard_analytics():
|
||||||
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
|
"""Dashboard de analytics con gráficas"""
|
||||||
<ul class="ranking-list" id="ranking-list">
|
try:
|
||||||
<li class="loading">Cargando...</li>
|
return render_template('analytics.html')
|
||||||
</ul>
|
except Exception as e:
|
||||||
</div>
|
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>
|
@app.route('/dashboard/executive')
|
||||||
function formatMoney(amount) {
|
def dashboard_executive():
|
||||||
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
"""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/<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)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
if tipo == 'ejecutivo':
|
||||||
if (!dateStr) return '';
|
pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats)
|
||||||
const date = new Date(dateStr);
|
filename = f"reporte_ejecutivo_{datetime.now(TZ_MEXICO).strftime('%Y%m%d')}.pdf"
|
||||||
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
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 cargarResumen() {
|
# Generar PDF
|
||||||
try {
|
if tipo == 'executive':
|
||||||
const res = await fetch('/api/dashboard/resumen');
|
pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats)
|
||||||
const data = await res.json();
|
else:
|
||||||
|
pdf_content = generar_reporte_diario(ventas, ranking, stats)
|
||||||
|
|
||||||
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
|
# Guardar temporalmente y devolver ID
|
||||||
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
|
import hashlib
|
||||||
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
|
report_id = hashlib.md5(f"{tipo}_{datetime.now().isoformat()}".encode()).hexdigest()[:12]
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cargarRanking() {
|
# Guardar en directorio temporal
|
||||||
try {
|
reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports')
|
||||||
const res = await fetch('/api/dashboard/ranking');
|
os.makedirs(reports_dir, exist_ok=True)
|
||||||
const data = await res.json();
|
report_path = os.path.join(reports_dir, f"{report_id}.pdf")
|
||||||
const lista = document.getElementById('ranking-list');
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
with open(report_path, 'wb') as f:
|
||||||
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
|
f.write(pdf_content)
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lista.innerHTML = data.slice(0, 10).map((v, i) => {
|
return jsonify({
|
||||||
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
|
'report_id': report_id,
|
||||||
const tubos = v.tubos_totales || 0;
|
'status': 'generated',
|
||||||
const comision = v.comision_total || 0;
|
'type': tipo,
|
||||||
const ventas = v.cantidad_ventas || 0;
|
'download_url': f'/api/reports/download/{report_id}'
|
||||||
|
}), 200
|
||||||
|
|
||||||
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
|
except ImportError:
|
||||||
const username = v.vendedor_username || v.vendedor;
|
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
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cargarVentasRecientes() {
|
@app.route('/api/reports/download/<report_id>', methods=['GET'])
|
||||||
try {
|
def api_reports_download(report_id):
|
||||||
const res = await fetch('/api/dashboard/ventas-recientes');
|
"""API: Descargar reporte PDF"""
|
||||||
const data = await res.json();
|
try:
|
||||||
const lista = document.getElementById('ventas-list');
|
from flask import send_file
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports')
|
||||||
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
|
report_path = os.path.join(reports_dir, f"{report_id}.pdf")
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lista.innerHTML = data.map(v => {
|
if not os.path.exists(report_path):
|
||||||
const nombre = v.nombre_completo || v.vendedor_username;
|
return jsonify({'error': 'Reporte no encontrado'}), 404
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cargarDatos() {
|
return send_file(
|
||||||
cargarResumen();
|
report_path,
|
||||||
cargarRanking();
|
mimetype='application/pdf',
|
||||||
cargarVentasRecientes();
|
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
|
||||||
|
|
||||||
// Cargar datos al inicio
|
|
||||||
cargarDatos();
|
|
||||||
|
|
||||||
// Actualizar cada 30 segundos
|
# ============== CAMERA/OCR API ==============
|
||||||
setInterval(cargarDatos, 30000);
|
|
||||||
</script>
|
@app.route('/api/capture/ticket', methods=['POST'])
|
||||||
</body>
|
def api_capture_ticket():
|
||||||
</html>
|
"""API: Procesar imagen de ticket desde cámara (base64)"""
|
||||||
'''
|
try:
|
||||||
return render_template_string(html)
|
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__':
|
if __name__ == '__main__':
|
||||||
port = int(os.getenv('FLASK_PORT', 5000))
|
port = int(os.getenv('FLASK_PORT', 5000))
|
||||||
|
|||||||
17
sales-bot/ocr/__init__.py
Normal file
17
sales-bot/ocr/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
OCR Module for Sales Bot
|
||||||
|
Improved text extraction and amount detection from ticket images
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .processor import procesar_ticket_imagen, OCRProcessor
|
||||||
|
from .amount_detector import AmountDetector, detectar_monto
|
||||||
|
from .patterns import detectar_formato_ticket, TICKET_FORMATS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'procesar_ticket_imagen',
|
||||||
|
'OCRProcessor',
|
||||||
|
'AmountDetector',
|
||||||
|
'detectar_monto',
|
||||||
|
'detectar_formato_ticket',
|
||||||
|
'TICKET_FORMATS'
|
||||||
|
]
|
||||||
258
sales-bot/ocr/amount_detector.py
Normal file
258
sales-bot/ocr/amount_detector.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Amount detection for Sales Bot OCR
|
||||||
|
Improved detection of total amounts from ticket text
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Amount patterns in priority order
|
||||||
|
PATTERNS = [
|
||||||
|
# Explicit total patterns (highest priority)
|
||||||
|
(r'total\s*a\s*pagar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_a_pagar', 1),
|
||||||
|
(r'gran\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'gran_total', 2),
|
||||||
|
(r'total\s+final\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_final', 3),
|
||||||
|
(r'(?:^|\n)\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total', 4),
|
||||||
|
|
||||||
|
# Payment related
|
||||||
|
(r'a\s*cobrar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'a_cobrar', 5),
|
||||||
|
(r'importe\s*(?:total)?\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'importe', 6),
|
||||||
|
(r'monto\s*(?:total)?\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'monto', 7),
|
||||||
|
(r'suma\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'suma', 8),
|
||||||
|
(r'pago\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'pago', 9),
|
||||||
|
|
||||||
|
# Subtotal (lower priority - may need to add tax)
|
||||||
|
(r'subtotal\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'subtotal', 10),
|
||||||
|
|
||||||
|
# Generic currency patterns (lowest priority)
|
||||||
|
(r'\$\s*([\d,]+\.\d{2})\s*(?:\n|$)', 'monto_linea', 11),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Words that indicate a line is NOT a total (negative patterns)
|
||||||
|
EXCLUSION_WORDS = [
|
||||||
|
'cambio', 'efectivo', 'pago con', 'tarjeta', 'recibido',
|
||||||
|
'iva', 'impuesto', 'descuento', 'ahorro', 'puntos'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AmountDetector:
|
||||||
|
"""
|
||||||
|
Detects and extracts monetary amounts from ticket text.
|
||||||
|
Uses multiple patterns and heuristics to find the most likely total.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.patterns = PATTERNS
|
||||||
|
self.min_amount = 1 # Minimum valid amount
|
||||||
|
self.max_amount = 1000000 # Maximum valid amount
|
||||||
|
|
||||||
|
def detectar_monto(self, texto: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Detecta el monto total del ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Texto extraído del ticket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con monto, tipo, patron, y confianza, o None si no se encuentra
|
||||||
|
"""
|
||||||
|
texto_lower = texto.lower()
|
||||||
|
resultados = []
|
||||||
|
|
||||||
|
for patron, tipo, prioridad in self.patterns:
|
||||||
|
matches = re.findall(patron, texto_lower, re.IGNORECASE | re.MULTILINE)
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
# Skip if match is in an exclusion context
|
||||||
|
if self._is_excluded(texto_lower, match):
|
||||||
|
continue
|
||||||
|
|
||||||
|
monto = self._normalizar_monto(match)
|
||||||
|
|
||||||
|
if self.min_amount <= monto <= self.max_amount:
|
||||||
|
# Calculate confidence based on pattern type and context
|
||||||
|
confianza = self._calcular_confianza(texto_lower, match, tipo)
|
||||||
|
|
||||||
|
resultados.append({
|
||||||
|
'monto': monto,
|
||||||
|
'tipo': tipo,
|
||||||
|
'patron': patron,
|
||||||
|
'prioridad': prioridad,
|
||||||
|
'confianza': confianza
|
||||||
|
})
|
||||||
|
|
||||||
|
if not resultados:
|
||||||
|
# Try to find the largest amount as fallback
|
||||||
|
return self._fallback_detection(texto)
|
||||||
|
|
||||||
|
# Sort by priority (lower is better) then by confidence (higher is better)
|
||||||
|
resultados.sort(key=lambda x: (x['prioridad'], -x['confianza']))
|
||||||
|
|
||||||
|
# Return the best match
|
||||||
|
best = resultados[0]
|
||||||
|
return {
|
||||||
|
'monto': best['monto'],
|
||||||
|
'tipo': best['tipo'],
|
||||||
|
'patron': best['patron'],
|
||||||
|
'confianza': best['confianza']
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalizar_monto(self, monto_str: str) -> float:
|
||||||
|
"""
|
||||||
|
Normaliza string de monto a float.
|
||||||
|
|
||||||
|
Handles various formats:
|
||||||
|
- 1,234.56 (US/Mexico format)
|
||||||
|
- 1234.56
|
||||||
|
- 1 234.56 (space separator)
|
||||||
|
- 1234,56 (European format)
|
||||||
|
"""
|
||||||
|
if not monto_str:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Remove currency symbols and whitespace
|
||||||
|
monto = monto_str.strip().replace('$', '').replace(' ', '')
|
||||||
|
|
||||||
|
# Handle different decimal separators
|
||||||
|
# If there's both comma and dot, determine which is decimal
|
||||||
|
if ',' in monto and '.' in monto:
|
||||||
|
# US/Mexico format: 1,234.56
|
||||||
|
monto = monto.replace(',', '')
|
||||||
|
elif ',' in monto:
|
||||||
|
# Could be European (1234,56) or thousand separator (1,234)
|
||||||
|
parts = monto.split(',')
|
||||||
|
if len(parts) == 2 and len(parts[1]) == 2:
|
||||||
|
# European format
|
||||||
|
monto = monto.replace(',', '.')
|
||||||
|
else:
|
||||||
|
# Thousand separator
|
||||||
|
monto = monto.replace(',', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
return float(monto)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _is_excluded(self, texto: str, match: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if the match appears in an exclusion context.
|
||||||
|
"""
|
||||||
|
# Find the line containing this match
|
||||||
|
for linea in texto.split('\n'):
|
||||||
|
if match in linea:
|
||||||
|
linea_lower = linea.lower()
|
||||||
|
for exclusion in EXCLUSION_WORDS:
|
||||||
|
if exclusion in linea_lower:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _calcular_confianza(self, texto: str, match: str, tipo: str) -> float:
|
||||||
|
"""
|
||||||
|
Calculates confidence score for a match.
|
||||||
|
|
||||||
|
Returns value between 0.0 and 1.0
|
||||||
|
"""
|
||||||
|
confianza = 0.5 # Base confidence
|
||||||
|
|
||||||
|
# Higher confidence for explicit total patterns
|
||||||
|
if tipo in ['total_a_pagar', 'gran_total', 'total_final']:
|
||||||
|
confianza += 0.3
|
||||||
|
elif tipo == 'total':
|
||||||
|
confianza += 0.2
|
||||||
|
|
||||||
|
# Higher confidence if near end of text
|
||||||
|
position = texto.find(match)
|
||||||
|
text_length = len(texto)
|
||||||
|
if position > text_length * 0.6: # In last 40% of text
|
||||||
|
confianza += 0.1
|
||||||
|
|
||||||
|
# Higher confidence if followed by payment info
|
||||||
|
after_match = texto[texto.find(match) + len(match):texto.find(match) + len(match) + 50]
|
||||||
|
if any(word in after_match.lower() for word in ['efectivo', 'tarjeta', 'cambio', 'gracias']):
|
||||||
|
confianza += 0.1
|
||||||
|
|
||||||
|
return min(confianza, 1.0)
|
||||||
|
|
||||||
|
def _fallback_detection(self, texto: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Fallback detection when standard patterns fail.
|
||||||
|
Looks for the largest reasonable amount in the text.
|
||||||
|
"""
|
||||||
|
# Find all currency-like numbers
|
||||||
|
all_amounts = re.findall(r'\$?\s*([\d,]+\.?\d{0,2})', texto)
|
||||||
|
|
||||||
|
valid_amounts = []
|
||||||
|
for amount_str in all_amounts:
|
||||||
|
amount = self._normalizar_monto(amount_str)
|
||||||
|
if self.min_amount <= amount <= self.max_amount:
|
||||||
|
valid_amounts.append(amount)
|
||||||
|
|
||||||
|
if valid_amounts:
|
||||||
|
# Return the largest amount (likely the total)
|
||||||
|
max_amount = max(valid_amounts)
|
||||||
|
return {
|
||||||
|
'monto': max_amount,
|
||||||
|
'tipo': 'fallback_max',
|
||||||
|
'patron': 'heuristic',
|
||||||
|
'confianza': 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detectar_multiples_montos(self, texto: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Detecta todos los montos en el texto.
|
||||||
|
|
||||||
|
Useful for itemized receipts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de diccionarios con monto y contexto
|
||||||
|
"""
|
||||||
|
texto_lower = texto.lower()
|
||||||
|
resultados = []
|
||||||
|
|
||||||
|
# Find all lines with amounts
|
||||||
|
lineas = texto.split('\n')
|
||||||
|
for linea in lineas:
|
||||||
|
matches = re.findall(r'\$?\s*([\d,]+\.?\d{0,2})', linea)
|
||||||
|
for match in matches:
|
||||||
|
monto = self._normalizar_monto(match)
|
||||||
|
if self.min_amount <= monto <= self.max_amount:
|
||||||
|
resultados.append({
|
||||||
|
'monto': monto,
|
||||||
|
'contexto': linea.strip(),
|
||||||
|
'es_total': 'total' in linea.lower()
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultados
|
||||||
|
|
||||||
|
|
||||||
|
def detectar_monto(texto: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Convenience function to detect amount from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Ticket text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with monto, tipo, patron, confianza or None
|
||||||
|
"""
|
||||||
|
detector = AmountDetector()
|
||||||
|
return detector.detectar_monto(texto)
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_monto(monto_str: str) -> float:
|
||||||
|
"""
|
||||||
|
Convenience function to normalize amount string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monto_str: Amount as string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Amount as float
|
||||||
|
"""
|
||||||
|
detector = AmountDetector()
|
||||||
|
return detector._normalizar_monto(monto_str)
|
||||||
223
sales-bot/ocr/patterns.py
Normal file
223
sales-bot/ocr/patterns.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Ticket format patterns for Sales Bot OCR
|
||||||
|
Supports multiple ticket formats from different stores
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
# Ticket format configurations
|
||||||
|
TICKET_FORMATS = {
|
||||||
|
'oxxo': {
|
||||||
|
'identificadores': ['oxxo', 'femsa', 'cadena comercial'],
|
||||||
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
||||||
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
||||||
|
'patron_hora': r'(\d{2}:\d{2}:\d{2})',
|
||||||
|
'prioridad': 1
|
||||||
|
},
|
||||||
|
'walmart': {
|
||||||
|
'identificadores': ['walmart', 'walmex', 'wal-mart', 'bodega aurrera'],
|
||||||
|
'patron_total': r'total\s*\$\s*([\d,]+\.\d{2})',
|
||||||
|
'patron_fecha': r'(\d{2}-\d{2}-\d{4})',
|
||||||
|
'prioridad': 2
|
||||||
|
},
|
||||||
|
'soriana': {
|
||||||
|
'identificadores': ['soriana', 'mega soriana', 'city club'],
|
||||||
|
'patron_total': r'total\s*a?\s*pagar\s*\$?\s*([\d,]+\.\d{2})',
|
||||||
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
||||||
|
'prioridad': 3
|
||||||
|
},
|
||||||
|
'tienda_pintura': {
|
||||||
|
'identificadores': ['tinte', 'cromatique', 'oxidante', 'distribuidora',
|
||||||
|
'colorante', 'pintura', 'tono', 'decolorante', 'revelador'],
|
||||||
|
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
|
||||||
|
'patron_productos': r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+)',
|
||||||
|
'patron_tubos': r'(\d+)\s*(?:tubos?|pzas?|piezas?|unid)',
|
||||||
|
'prioridad': 0 # Highest priority for paint stores
|
||||||
|
},
|
||||||
|
'farmacia': {
|
||||||
|
'identificadores': ['farmacia', 'guadalajara', 'benavides', 'similares', 'ahorro'],
|
||||||
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
||||||
|
'patron_fecha': r'(\d{2}/\d{2}/\d{2,4})',
|
||||||
|
'prioridad': 4
|
||||||
|
},
|
||||||
|
'seven_eleven': {
|
||||||
|
'identificadores': ['7-eleven', '7eleven', '7 eleven', 'iconn'],
|
||||||
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
||||||
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
||||||
|
'prioridad': 5
|
||||||
|
},
|
||||||
|
'generico': {
|
||||||
|
'identificadores': [], # Fallback - matches everything
|
||||||
|
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
|
||||||
|
'patron_fecha': r'(\d{2}[/-]\d{2}[/-]\d{2,4})',
|
||||||
|
'prioridad': 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Common patterns for amount extraction (in priority order)
|
||||||
|
AMOUNT_PATTERNS = [
|
||||||
|
# Explicit total patterns
|
||||||
|
(r'total\s*a\s*pagar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_a_pagar', 1),
|
||||||
|
(r'gran\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'gran_total', 2),
|
||||||
|
(r'total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total', 3),
|
||||||
|
|
||||||
|
# Payment related
|
||||||
|
(r'a\s*cobrar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'a_cobrar', 4),
|
||||||
|
(r'importe\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'importe', 5),
|
||||||
|
(r'monto\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'monto', 6),
|
||||||
|
(r'suma\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'suma', 7),
|
||||||
|
|
||||||
|
# Subtotal (lower priority)
|
||||||
|
(r'subtotal\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'subtotal', 8),
|
||||||
|
|
||||||
|
# Last resort - currency amounts at end of lines
|
||||||
|
(r'\$\s*([\d,]+\.\d{2})\s*$', 'monto_final', 9),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Date patterns
|
||||||
|
DATE_PATTERNS = [
|
||||||
|
r'(\d{2}/\d{2}/\d{4})', # DD/MM/YYYY
|
||||||
|
r'(\d{2}-\d{2}-\d{4})', # DD-MM-YYYY
|
||||||
|
r'(\d{4}-\d{2}-\d{2})', # YYYY-MM-DD
|
||||||
|
r'(\d{2}/\d{2}/\d{2})', # DD/MM/YY
|
||||||
|
r'(\d{1,2}\s+de\s+\w+\s+de\s+\d{4})', # D de Mes de YYYY
|
||||||
|
]
|
||||||
|
|
||||||
|
# Client name patterns
|
||||||
|
CLIENT_PATTERNS = [
|
||||||
|
r'cliente\s*:?\s*(.+?)(?:\n|$)',
|
||||||
|
r'nombre\s*:?\s*(.+?)(?:\n|$)',
|
||||||
|
r'sr\.?\s*(.+?)(?:\n|$)',
|
||||||
|
r'sra\.?\s*(.+?)(?:\n|$)',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def detectar_formato_ticket(texto: str) -> str:
|
||||||
|
"""
|
||||||
|
Detecta el formato del ticket basado en identificadores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Texto extraído del ticket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nombre del formato detectado
|
||||||
|
"""
|
||||||
|
texto_lower = texto.lower()
|
||||||
|
|
||||||
|
# Check formats by priority (lower number = higher priority)
|
||||||
|
formatos_encontrados = []
|
||||||
|
|
||||||
|
for formato, config in TICKET_FORMATS.items():
|
||||||
|
if formato == 'generico':
|
||||||
|
continue
|
||||||
|
|
||||||
|
for identificador in config.get('identificadores', []):
|
||||||
|
if identificador in texto_lower:
|
||||||
|
formatos_encontrados.append((formato, config.get('prioridad', 99)))
|
||||||
|
break
|
||||||
|
|
||||||
|
if formatos_encontrados:
|
||||||
|
# Sort by priority and return highest priority match
|
||||||
|
formatos_encontrados.sort(key=lambda x: x[1])
|
||||||
|
return formatos_encontrados[0][0]
|
||||||
|
|
||||||
|
return 'generico'
|
||||||
|
|
||||||
|
|
||||||
|
def get_patron_total(formato: str) -> str:
|
||||||
|
"""
|
||||||
|
Obtiene el patrón de total para un formato específico.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
formato: Nombre del formato
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Patrón regex para extraer el total
|
||||||
|
"""
|
||||||
|
config = TICKET_FORMATS.get(formato, TICKET_FORMATS['generico'])
|
||||||
|
return config.get('patron_total', TICKET_FORMATS['generico']['patron_total'])
|
||||||
|
|
||||||
|
|
||||||
|
def extraer_fecha_ticket(texto: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extrae la fecha del ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Texto del ticket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fecha encontrada o None
|
||||||
|
"""
|
||||||
|
for patron in DATE_PATTERNS:
|
||||||
|
match = re.search(patron, texto, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extraer_cliente_ticket(texto: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extrae el nombre del cliente del ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Texto del ticket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nombre del cliente o None
|
||||||
|
"""
|
||||||
|
for patron in CLIENT_PATTERNS:
|
||||||
|
match = re.search(patron, texto, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
cliente = match.group(1).strip()
|
||||||
|
# Clean up common artifacts
|
||||||
|
cliente = re.sub(r'[^\w\s\-\.]', '', cliente)
|
||||||
|
if len(cliente) > 2: # Valid name should have at least 3 chars
|
||||||
|
return cliente
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def contar_tubos_texto(texto: str) -> int:
|
||||||
|
"""
|
||||||
|
Cuenta la cantidad de tubos mencionados en el ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texto: Texto del ticket
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cantidad de tubos detectados
|
||||||
|
"""
|
||||||
|
texto_lower = texto.lower()
|
||||||
|
total_tubos = 0
|
||||||
|
|
||||||
|
# Pattern for explicit tube counts
|
||||||
|
patrones_tubos = [
|
||||||
|
r'(\d+)\s*(?:tubos?|tbs?)',
|
||||||
|
r'(\d+)\s*(?:pzas?|piezas?)\s*(?:de\s+)?(?:tinte|color)',
|
||||||
|
r'(?:cantidad|qty|cant)\s*:?\s*(\d+)',
|
||||||
|
r'x\s*(\d+)\s*(?:tubos?)?',
|
||||||
|
]
|
||||||
|
|
||||||
|
for patron in patrones_tubos:
|
||||||
|
matches = re.findall(patron, texto_lower)
|
||||||
|
for match in matches:
|
||||||
|
try:
|
||||||
|
total_tubos += int(match)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If no explicit count found, estimate from line items
|
||||||
|
if total_tubos == 0:
|
||||||
|
# Count lines that look like product entries
|
||||||
|
lineas = texto_lower.split('\n')
|
||||||
|
for linea in lineas:
|
||||||
|
if any(word in linea for word in ['tinte', 'color', 'tubo', 'cromatique']):
|
||||||
|
# Check for quantity at start of line or after product name
|
||||||
|
qty_match = re.search(r'^(\d+)\s+|x\s*(\d+)|(\d+)\s*pza', linea)
|
||||||
|
if qty_match:
|
||||||
|
qty = next((g for g in qty_match.groups() if g), '1')
|
||||||
|
total_tubos += int(qty)
|
||||||
|
else:
|
||||||
|
total_tubos += 1 # Assume 1 if no explicit quantity
|
||||||
|
|
||||||
|
return total_tubos
|
||||||
305
sales-bot/ocr/preprocessor.py
Normal file
305
sales-bot/ocr/preprocessor.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Image preprocessing for Sales Bot OCR
|
||||||
|
Adaptive preprocessing pipelines for different image conditions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Tuple, Optional, List
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Try to import image processing libraries
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
CV2_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CV2_AVAILABLE = False
|
||||||
|
logger.warning("OpenCV not available. Image preprocessing will be limited.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
PIL_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PIL_AVAILABLE = False
|
||||||
|
logger.warning("PIL not available. Image preprocessing will be limited.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from deskew import determine_skew
|
||||||
|
DESKEW_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
DESKEW_AVAILABLE = False
|
||||||
|
logger.warning("deskew library not available. Rotation correction disabled.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import imutils
|
||||||
|
IMUTILS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
IMUTILS_AVAILABLE = False
|
||||||
|
logger.warning("imutils not available. Some rotations may not work.")
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePreprocessor:
|
||||||
|
"""
|
||||||
|
Preprocesses ticket images for better OCR accuracy.
|
||||||
|
Supports multiple preprocessing pipelines for different image conditions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.enable_deskew = os.getenv('OCR_ENABLE_DESKEW', 'true').lower() == 'true'
|
||||||
|
self.max_rotation = float(os.getenv('OCR_MAX_ROTATION_ANGLE', '15'))
|
||||||
|
self.use_adaptive = os.getenv('OCR_USE_ADAPTIVE_PIPELINE', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Define preprocessing pipelines
|
||||||
|
self.pipelines = {
|
||||||
|
'standard': ['grayscale', 'contrast', 'otsu'],
|
||||||
|
'low_contrast': ['grayscale', 'clahe', 'adaptive_threshold'],
|
||||||
|
'noisy': ['grayscale', 'denoise', 'sharpen', 'otsu'],
|
||||||
|
'rotated': ['deskew', 'grayscale', 'contrast', 'otsu'],
|
||||||
|
'dark': ['grayscale', 'brighten', 'contrast', 'otsu'],
|
||||||
|
'light': ['grayscale', 'darken', 'contrast', 'otsu'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def preprocess(self, image_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Preprocess image bytes for OCR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: Raw image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Preprocessed image bytes
|
||||||
|
"""
|
||||||
|
if self.use_adaptive and CV2_AVAILABLE:
|
||||||
|
return self.preprocess_adaptive(image_bytes)
|
||||||
|
else:
|
||||||
|
return self.preprocess_basic(image_bytes)
|
||||||
|
|
||||||
|
def preprocess_basic(self, image_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Basic preprocessing using PIL only.
|
||||||
|
"""
|
||||||
|
if not PIL_AVAILABLE:
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
img = Image.open(BytesIO(image_bytes))
|
||||||
|
|
||||||
|
# Convert to grayscale
|
||||||
|
img = img.convert('L')
|
||||||
|
|
||||||
|
# Enhance contrast
|
||||||
|
enhancer = ImageEnhance.Contrast(img)
|
||||||
|
img = enhancer.enhance(1.5)
|
||||||
|
|
||||||
|
# Sharpen
|
||||||
|
img = img.filter(ImageFilter.SHARPEN)
|
||||||
|
|
||||||
|
# Save to bytes
|
||||||
|
output = BytesIO()
|
||||||
|
img.save(output, format='PNG')
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in basic preprocessing: {e}")
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
def preprocess_adaptive(self, image_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Adaptive preprocessing that tries multiple pipelines
|
||||||
|
and returns the best result.
|
||||||
|
"""
|
||||||
|
if not CV2_AVAILABLE:
|
||||||
|
return self.preprocess_basic(image_bytes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode image
|
||||||
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||||
|
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
logger.error("Could not decode image")
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
# Analyze image to determine best pipeline
|
||||||
|
pipeline_name = self._determine_best_pipeline(image)
|
||||||
|
logger.info(f"Using preprocessing pipeline: {pipeline_name}")
|
||||||
|
|
||||||
|
# Apply pipeline
|
||||||
|
processed = self._apply_pipeline(image, pipeline_name)
|
||||||
|
|
||||||
|
# Encode back to bytes
|
||||||
|
_, buffer = cv2.imencode('.png', processed)
|
||||||
|
return buffer.tobytes()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in adaptive preprocessing: {e}")
|
||||||
|
return self.preprocess_basic(image_bytes)
|
||||||
|
|
||||||
|
def _determine_best_pipeline(self, image: 'np.ndarray') -> str:
|
||||||
|
"""
|
||||||
|
Analyzes image to determine the best preprocessing pipeline.
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Calculate image statistics
|
||||||
|
mean_brightness = np.mean(gray)
|
||||||
|
std_brightness = np.std(gray)
|
||||||
|
|
||||||
|
# Check for rotation if deskew is enabled
|
||||||
|
if self.enable_deskew and DESKEW_AVAILABLE:
|
||||||
|
try:
|
||||||
|
angle = determine_skew(gray)
|
||||||
|
if abs(angle) > 1.0 and abs(angle) <= self.max_rotation:
|
||||||
|
return 'rotated'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Determine based on brightness/contrast
|
||||||
|
if mean_brightness < 80:
|
||||||
|
return 'dark'
|
||||||
|
elif mean_brightness > 180:
|
||||||
|
return 'light'
|
||||||
|
elif std_brightness < 40:
|
||||||
|
return 'low_contrast'
|
||||||
|
elif std_brightness > 80:
|
||||||
|
return 'noisy'
|
||||||
|
else:
|
||||||
|
return 'standard'
|
||||||
|
|
||||||
|
def _apply_pipeline(self, image: 'np.ndarray', pipeline_name: str) -> 'np.ndarray':
|
||||||
|
"""
|
||||||
|
Applies a preprocessing pipeline to the image.
|
||||||
|
"""
|
||||||
|
pipeline = self.pipelines.get(pipeline_name, self.pipelines['standard'])
|
||||||
|
result = image.copy()
|
||||||
|
|
||||||
|
for step in pipeline:
|
||||||
|
try:
|
||||||
|
result = getattr(self, f'_step_{step}')(result)
|
||||||
|
except AttributeError:
|
||||||
|
logger.warning(f"Unknown preprocessing step: {step}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error in step {step}: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Pipeline steps
|
||||||
|
|
||||||
|
def _step_grayscale(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Convert to grayscale."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
return image
|
||||||
|
|
||||||
|
def _step_contrast(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Enhance contrast using histogram equalization."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = self._step_grayscale(image)
|
||||||
|
return cv2.equalizeHist(image)
|
||||||
|
|
||||||
|
def _step_otsu(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Apply Otsu's thresholding."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = self._step_grayscale(image)
|
||||||
|
_, binary = cv2.threshold(image, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
return binary
|
||||||
|
|
||||||
|
def _step_adaptive_threshold(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Apply adaptive thresholding."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = self._step_grayscale(image)
|
||||||
|
return cv2.adaptiveThreshold(
|
||||||
|
image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY, 11, 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def _step_clahe(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
image = self._step_grayscale(image)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
return clahe.apply(image)
|
||||||
|
|
||||||
|
def _step_denoise(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Remove noise while preserving edges."""
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
return cv2.fastNlMeansDenoisingColored(image, None, 10, 10, 7, 21)
|
||||||
|
return cv2.fastNlMeansDenoising(image, None, 10, 7, 21)
|
||||||
|
|
||||||
|
def _step_sharpen(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Sharpen the image."""
|
||||||
|
kernel = np.array([[-1, -1, -1],
|
||||||
|
[-1, 9, -1],
|
||||||
|
[-1, -1, -1]])
|
||||||
|
return cv2.filter2D(image, -1, kernel)
|
||||||
|
|
||||||
|
def _step_brighten(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Increase image brightness."""
|
||||||
|
return cv2.convertScaleAbs(image, alpha=1.2, beta=30)
|
||||||
|
|
||||||
|
def _step_darken(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Decrease image brightness."""
|
||||||
|
return cv2.convertScaleAbs(image, alpha=0.8, beta=-20)
|
||||||
|
|
||||||
|
def _step_deskew(self, image: 'np.ndarray') -> 'np.ndarray':
|
||||||
|
"""Detect and correct image rotation."""
|
||||||
|
if not DESKEW_AVAILABLE:
|
||||||
|
return image
|
||||||
|
|
||||||
|
try:
|
||||||
|
if len(image.shape) == 3:
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
else:
|
||||||
|
gray = image
|
||||||
|
|
||||||
|
angle = determine_skew(gray)
|
||||||
|
|
||||||
|
if abs(angle) > self.max_rotation:
|
||||||
|
logger.info(f"Rotation angle {angle} exceeds max {self.max_rotation}, skipping")
|
||||||
|
return image
|
||||||
|
|
||||||
|
if abs(angle) < 0.5:
|
||||||
|
return image # No significant rotation
|
||||||
|
|
||||||
|
logger.info(f"Correcting rotation: {angle} degrees")
|
||||||
|
|
||||||
|
if IMUTILS_AVAILABLE:
|
||||||
|
import imutils
|
||||||
|
return imutils.rotate_bound(image, -angle)
|
||||||
|
else:
|
||||||
|
# Manual rotation
|
||||||
|
(h, w) = image.shape[:2]
|
||||||
|
center = (w // 2, h // 2)
|
||||||
|
M = cv2.getRotationMatrix2D(center, angle, 1.0)
|
||||||
|
return cv2.warpAffine(image, M, (w, h),
|
||||||
|
flags=cv2.INTER_CUBIC,
|
||||||
|
borderMode=cv2.BORDER_REPLICATE)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in deskew: {e}")
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_image(image_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Convenience function to preprocess image bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: Raw image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Preprocessed image bytes
|
||||||
|
"""
|
||||||
|
preprocessor = ImagePreprocessor()
|
||||||
|
return preprocessor.preprocess(image_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_for_ocr(image_bytes: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Alias for preprocess_image.
|
||||||
|
"""
|
||||||
|
return preprocess_image(image_bytes)
|
||||||
294
sales-bot/ocr/processor.py
Normal file
294
sales-bot/ocr/processor.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
Main OCR processor for Sales Bot
|
||||||
|
Combines preprocessing, text extraction, and amount detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Try to import OCR engine
|
||||||
|
try:
|
||||||
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
TESSERACT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
TESSERACT_AVAILABLE = False
|
||||||
|
logger.warning("pytesseract not available. OCR will not work.")
|
||||||
|
|
||||||
|
# Import local modules
|
||||||
|
from .preprocessor import ImagePreprocessor, preprocess_image
|
||||||
|
from .amount_detector import AmountDetector, detectar_monto
|
||||||
|
from .patterns import (
|
||||||
|
detectar_formato_ticket,
|
||||||
|
extraer_fecha_ticket,
|
||||||
|
extraer_cliente_ticket,
|
||||||
|
contar_tubos_texto,
|
||||||
|
get_patron_total
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OCRProcessor:
|
||||||
|
"""
|
||||||
|
Main OCR processor that coordinates image preprocessing,
|
||||||
|
text extraction, and data parsing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.preprocessor = ImagePreprocessor()
|
||||||
|
self.amount_detector = AmountDetector()
|
||||||
|
self.confidence_threshold = float(os.getenv('OCR_CONFIDENCE_THRESHOLD', '0.6'))
|
||||||
|
|
||||||
|
# Tesseract configuration for Spanish
|
||||||
|
self.tesseract_config = '--oem 3 --psm 6 -l spa'
|
||||||
|
|
||||||
|
def process(self, image_bytes: bytes) -> Dict:
|
||||||
|
"""
|
||||||
|
Process a ticket image and extract relevant data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: Raw image bytes (JPEG, PNG, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with extracted data:
|
||||||
|
- texto: Full extracted text
|
||||||
|
- monto: Detected total amount
|
||||||
|
- cliente: Client name if found
|
||||||
|
- fecha: Date if found
|
||||||
|
- tubos: Number of tubes/items
|
||||||
|
- formato: Detected ticket format
|
||||||
|
- confianza: Confidence score
|
||||||
|
"""
|
||||||
|
if not TESSERACT_AVAILABLE:
|
||||||
|
return {
|
||||||
|
'error': 'Tesseract OCR not available',
|
||||||
|
'texto': '',
|
||||||
|
'monto': 0,
|
||||||
|
'confianza': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Preprocess image
|
||||||
|
processed_bytes = self.preprocessor.preprocess(image_bytes)
|
||||||
|
|
||||||
|
# Extract text
|
||||||
|
texto = self._extract_text(processed_bytes)
|
||||||
|
|
||||||
|
if not texto or len(texto.strip()) < 10:
|
||||||
|
# Try again with original image
|
||||||
|
texto = self._extract_text(image_bytes)
|
||||||
|
|
||||||
|
if not texto:
|
||||||
|
return {
|
||||||
|
'error': 'No text could be extracted',
|
||||||
|
'texto': '',
|
||||||
|
'monto': 0,
|
||||||
|
'confianza': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect ticket format
|
||||||
|
formato = detectar_formato_ticket(texto)
|
||||||
|
|
||||||
|
# Extract amount
|
||||||
|
monto_result = self.amount_detector.detectar_monto(texto)
|
||||||
|
monto = monto_result.get('monto', 0) if monto_result else 0
|
||||||
|
monto_confianza = monto_result.get('confianza', 0) if monto_result else 0
|
||||||
|
monto_tipo = monto_result.get('tipo', 'unknown') if monto_result else 'unknown'
|
||||||
|
|
||||||
|
# Extract other data
|
||||||
|
cliente = extraer_cliente_ticket(texto)
|
||||||
|
fecha = extraer_fecha_ticket(texto)
|
||||||
|
tubos = contar_tubos_texto(texto)
|
||||||
|
|
||||||
|
# Calculate overall confidence
|
||||||
|
confianza = self._calculate_overall_confidence(
|
||||||
|
texto, monto, monto_confianza, cliente, fecha
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'texto': texto,
|
||||||
|
'monto': monto,
|
||||||
|
'monto_tipo': monto_tipo,
|
||||||
|
'cliente': cliente,
|
||||||
|
'fecha': fecha,
|
||||||
|
'tubos': tubos,
|
||||||
|
'formato': formato,
|
||||||
|
'confianza': confianza
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing image: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'error': str(e),
|
||||||
|
'texto': '',
|
||||||
|
'monto': 0,
|
||||||
|
'confianza': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_text(self, image_bytes: bytes) -> str:
|
||||||
|
"""
|
||||||
|
Extract text from image bytes using Tesseract.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
img = Image.open(BytesIO(image_bytes))
|
||||||
|
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if img.mode != 'RGB' and img.mode != 'L':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# Run OCR
|
||||||
|
texto = pytesseract.image_to_string(img, config=self.tesseract_config)
|
||||||
|
|
||||||
|
# Clean up text
|
||||||
|
texto = self._clean_text(texto)
|
||||||
|
|
||||||
|
return texto
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting text: {e}")
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _clean_text(self, texto: str) -> str:
|
||||||
|
"""
|
||||||
|
Clean up OCR output text.
|
||||||
|
"""
|
||||||
|
if not texto:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Remove excessive whitespace
|
||||||
|
import re
|
||||||
|
texto = re.sub(r'\s+', ' ', texto)
|
||||||
|
texto = re.sub(r'\n\s*\n', '\n', texto)
|
||||||
|
|
||||||
|
# Fix common OCR errors
|
||||||
|
replacements = {
|
||||||
|
'|': 'l',
|
||||||
|
'0': 'O', # Only in certain contexts
|
||||||
|
'1': 'I', # Only in certain contexts
|
||||||
|
'S': '$', # Only at start of amounts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply selective replacements
|
||||||
|
# (Being careful not to corrupt actual numbers)
|
||||||
|
|
||||||
|
return texto.strip()
|
||||||
|
|
||||||
|
def _calculate_overall_confidence(
|
||||||
|
self,
|
||||||
|
texto: str,
|
||||||
|
monto: float,
|
||||||
|
monto_confianza: float,
|
||||||
|
cliente: Optional[str],
|
||||||
|
fecha: Optional[str]
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate overall extraction confidence.
|
||||||
|
"""
|
||||||
|
confidence = 0.0
|
||||||
|
|
||||||
|
# Text quality (based on length and structure)
|
||||||
|
if len(texto) > 50:
|
||||||
|
confidence += 0.2
|
||||||
|
if len(texto) > 200:
|
||||||
|
confidence += 0.1
|
||||||
|
|
||||||
|
# Amount detection confidence
|
||||||
|
confidence += monto_confianza * 0.4
|
||||||
|
|
||||||
|
# Bonus for finding additional data
|
||||||
|
if cliente:
|
||||||
|
confidence += 0.1
|
||||||
|
if fecha:
|
||||||
|
confidence += 0.1
|
||||||
|
|
||||||
|
# Check for typical receipt keywords
|
||||||
|
keywords = ['total', 'cliente', 'fecha', 'ticket', 'venta', 'pago']
|
||||||
|
found_keywords = sum(1 for kw in keywords if kw in texto.lower())
|
||||||
|
confidence += min(found_keywords * 0.05, 0.2)
|
||||||
|
|
||||||
|
return min(confidence, 1.0)
|
||||||
|
|
||||||
|
def process_multiple(self, images: list) -> Dict:
|
||||||
|
"""
|
||||||
|
Process multiple images (e.g., multi-page receipt).
|
||||||
|
Combines results from all images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: List of image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined results
|
||||||
|
"""
|
||||||
|
all_texto = []
|
||||||
|
total_monto = 0
|
||||||
|
cliente = None
|
||||||
|
fecha = None
|
||||||
|
tubos = 0
|
||||||
|
formato = None
|
||||||
|
max_confianza = 0
|
||||||
|
|
||||||
|
for img_bytes in images:
|
||||||
|
result = self.process(img_bytes)
|
||||||
|
|
||||||
|
if result.get('texto'):
|
||||||
|
all_texto.append(result['texto'])
|
||||||
|
|
||||||
|
if result.get('monto', 0) > total_monto:
|
||||||
|
total_monto = result['monto']
|
||||||
|
|
||||||
|
if not cliente and result.get('cliente'):
|
||||||
|
cliente = result['cliente']
|
||||||
|
|
||||||
|
if not fecha and result.get('fecha'):
|
||||||
|
fecha = result['fecha']
|
||||||
|
|
||||||
|
tubos += result.get('tubos', 0)
|
||||||
|
|
||||||
|
if not formato and result.get('formato'):
|
||||||
|
formato = result['formato']
|
||||||
|
|
||||||
|
if result.get('confianza', 0) > max_confianza:
|
||||||
|
max_confianza = result['confianza']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'texto': '\n---\n'.join(all_texto),
|
||||||
|
'monto': total_monto,
|
||||||
|
'cliente': cliente,
|
||||||
|
'fecha': fecha,
|
||||||
|
'tubos': tubos,
|
||||||
|
'formato': formato,
|
||||||
|
'confianza': max_confianza,
|
||||||
|
'paginas': len(images)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def procesar_ticket_imagen(image_bytes: bytes) -> Dict:
|
||||||
|
"""
|
||||||
|
Convenience function to process a ticket image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: Raw image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with extracted data
|
||||||
|
"""
|
||||||
|
processor = OCRProcessor()
|
||||||
|
return processor.process(image_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def procesar_multiples_imagenes(images: list) -> Dict:
|
||||||
|
"""
|
||||||
|
Convenience function to process multiple images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: List of image bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined results
|
||||||
|
"""
|
||||||
|
processor = OCRProcessor()
|
||||||
|
return processor.process_multiple(images)
|
||||||
12
sales-bot/reports/__init__.py
Normal file
12
sales-bot/reports/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Reports module for Sales Bot
|
||||||
|
Generates PDF reports for sales data
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .pdf_generator import SalesReportPDF, generar_reporte_diario, generar_reporte_ejecutivo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'SalesReportPDF',
|
||||||
|
'generar_reporte_diario',
|
||||||
|
'generar_reporte_ejecutivo'
|
||||||
|
]
|
||||||
532
sales-bot/reports/pdf_generator.py
Normal file
532
sales-bot/reports/pdf_generator.py
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
"""
|
||||||
|
PDF Report Generator for Sales Bot
|
||||||
|
Uses ReportLab for PDF generation and Matplotlib for charts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Try to import ReportLab
|
||||||
|
try:
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import letter, A4
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import inch, cm
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||||
|
Image, PageBreak, HRFlowable
|
||||||
|
)
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
|
REPORTLAB_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
REPORTLAB_AVAILABLE = False
|
||||||
|
logger.warning("ReportLab not available, PDF generation disabled")
|
||||||
|
|
||||||
|
# Try to import Matplotlib for charts
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # Use non-interactive backend
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
MATPLOTLIB_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
MATPLOTLIB_AVAILABLE = False
|
||||||
|
logger.warning("Matplotlib not available, charts in PDF disabled")
|
||||||
|
|
||||||
|
# Mexico timezone
|
||||||
|
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||||
|
|
||||||
|
|
||||||
|
class SalesReportPDF:
|
||||||
|
"""
|
||||||
|
Generates PDF reports for sales data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Color scheme matching the dashboard
|
||||||
|
COLORS = {
|
||||||
|
'primary': colors.HexColor('#00d4ff'),
|
||||||
|
'secondary': colors.HexColor('#00ff88'),
|
||||||
|
'warning': colors.HexColor('#ffaa00'),
|
||||||
|
'dark': colors.HexColor('#1a1a2e'),
|
||||||
|
'text': colors.HexColor('#333333'),
|
||||||
|
'muted': colors.HexColor('#666666'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, ventas: List[Dict], stats: Dict, vendedor: str = None):
|
||||||
|
"""
|
||||||
|
Initialize the PDF generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ventas: List of sales data
|
||||||
|
stats: Statistics dictionary
|
||||||
|
vendedor: Optional vendor username (None for all)
|
||||||
|
"""
|
||||||
|
if not REPORTLAB_AVAILABLE:
|
||||||
|
raise ImportError("ReportLab is required for PDF generation")
|
||||||
|
|
||||||
|
self.ventas = ventas or []
|
||||||
|
self.stats = stats or {}
|
||||||
|
self.vendedor = vendedor
|
||||||
|
self.styles = getSampleStyleSheet()
|
||||||
|
self._setup_custom_styles()
|
||||||
|
|
||||||
|
def _setup_custom_styles(self):
|
||||||
|
"""Setup custom paragraph styles."""
|
||||||
|
# Title style
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
'CustomTitle',
|
||||||
|
parent=self.styles['Heading1'],
|
||||||
|
fontSize=24,
|
||||||
|
textColor=self.COLORS['dark'],
|
||||||
|
spaceAfter=20,
|
||||||
|
alignment=TA_CENTER
|
||||||
|
))
|
||||||
|
|
||||||
|
# Subtitle style
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
'CustomSubtitle',
|
||||||
|
parent=self.styles['Normal'],
|
||||||
|
fontSize=12,
|
||||||
|
textColor=self.COLORS['muted'],
|
||||||
|
spaceAfter=10,
|
||||||
|
alignment=TA_CENTER
|
||||||
|
))
|
||||||
|
|
||||||
|
# Section header
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
'SectionHeader',
|
||||||
|
parent=self.styles['Heading2'],
|
||||||
|
fontSize=14,
|
||||||
|
textColor=self.COLORS['primary'],
|
||||||
|
spaceBefore=15,
|
||||||
|
spaceAfter=10
|
||||||
|
))
|
||||||
|
|
||||||
|
# KPI value style
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
'KPIValue',
|
||||||
|
parent=self.styles['Normal'],
|
||||||
|
fontSize=18,
|
||||||
|
textColor=self.COLORS['dark'],
|
||||||
|
alignment=TA_CENTER
|
||||||
|
))
|
||||||
|
|
||||||
|
# KPI label style
|
||||||
|
self.styles.add(ParagraphStyle(
|
||||||
|
'KPILabel',
|
||||||
|
parent=self.styles['Normal'],
|
||||||
|
fontSize=10,
|
||||||
|
textColor=self.COLORS['muted'],
|
||||||
|
alignment=TA_CENTER
|
||||||
|
))
|
||||||
|
|
||||||
|
def generar_reporte_diario(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Generates a daily sales report.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF content as bytes
|
||||||
|
"""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=letter,
|
||||||
|
rightMargin=0.75*inch,
|
||||||
|
leftMargin=0.75*inch,
|
||||||
|
topMargin=0.75*inch,
|
||||||
|
bottomMargin=0.75*inch
|
||||||
|
)
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
|
||||||
|
|
||||||
|
# Title
|
||||||
|
elements.append(Paragraph(
|
||||||
|
"Reporte Diario de Ventas",
|
||||||
|
self.styles['CustomTitle']
|
||||||
|
))
|
||||||
|
elements.append(Paragraph(
|
||||||
|
f"{fecha_hoy}",
|
||||||
|
self.styles['CustomSubtitle']
|
||||||
|
))
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# KPIs Section
|
||||||
|
elements.append(self._create_kpi_section())
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Trend Chart (if matplotlib available)
|
||||||
|
if MATPLOTLIB_AVAILABLE and self.ventas:
|
||||||
|
chart_image = self._create_trend_chart()
|
||||||
|
if chart_image:
|
||||||
|
elements.append(Paragraph("Tendencia de Ventas (Últimos 7 días)", self.styles['SectionHeader']))
|
||||||
|
elements.append(Image(chart_image, width=6*inch, height=3*inch))
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Top Sellers Section
|
||||||
|
elements.append(Paragraph("Top Vendedores del Día", self.styles['SectionHeader']))
|
||||||
|
elements.append(self._create_top_sellers_table())
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Sales Detail
|
||||||
|
if len(self.ventas) <= 20: # Only include if not too many
|
||||||
|
elements.append(Paragraph("Detalle de Ventas", self.styles['SectionHeader']))
|
||||||
|
elements.append(self._create_sales_table())
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
elements.append(Spacer(1, 30))
|
||||||
|
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
|
||||||
|
elements.append(Spacer(1, 10))
|
||||||
|
elements.append(Paragraph(
|
||||||
|
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
self.styles['CustomSubtitle']
|
||||||
|
))
|
||||||
|
|
||||||
|
doc.build(elements)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def generar_reporte_ejecutivo(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Generates an executive summary report.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF content as bytes
|
||||||
|
"""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=letter,
|
||||||
|
rightMargin=0.75*inch,
|
||||||
|
leftMargin=0.75*inch,
|
||||||
|
topMargin=0.75*inch,
|
||||||
|
bottomMargin=0.75*inch
|
||||||
|
)
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
|
||||||
|
mes_actual = datetime.now(TZ_MEXICO).strftime('%B %Y')
|
||||||
|
|
||||||
|
# Title
|
||||||
|
elements.append(Paragraph(
|
||||||
|
"Reporte Ejecutivo",
|
||||||
|
self.styles['CustomTitle']
|
||||||
|
))
|
||||||
|
elements.append(Paragraph(
|
||||||
|
f"{mes_actual}",
|
||||||
|
self.styles['CustomSubtitle']
|
||||||
|
))
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Executive KPIs
|
||||||
|
elements.append(self._create_executive_kpi_section())
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Monthly Trend Chart
|
||||||
|
if MATPLOTLIB_AVAILABLE and self.ventas:
|
||||||
|
chart_image = self._create_monthly_chart()
|
||||||
|
if chart_image:
|
||||||
|
elements.append(Paragraph("Tendencia Mensual", self.styles['SectionHeader']))
|
||||||
|
elements.append(Image(chart_image, width=6*inch, height=3*inch))
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Top Performers
|
||||||
|
elements.append(Paragraph("Top Performers del Mes", self.styles['SectionHeader']))
|
||||||
|
elements.append(self._create_top_performers_table())
|
||||||
|
elements.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# Comparison Section
|
||||||
|
elements.append(Paragraph("Comparativa", self.styles['SectionHeader']))
|
||||||
|
elements.append(self._create_comparison_section())
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
elements.append(Spacer(1, 30))
|
||||||
|
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
|
||||||
|
elements.append(Spacer(1, 10))
|
||||||
|
elements.append(Paragraph(
|
||||||
|
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
self.styles['CustomSubtitle']
|
||||||
|
))
|
||||||
|
|
||||||
|
doc.build(elements)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def _create_kpi_section(self) -> Table:
|
||||||
|
"""Creates KPI cards section."""
|
||||||
|
total_ventas = len(self.ventas)
|
||||||
|
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
|
||||||
|
tubos_total = self.stats.get('tubos_totales', 0)
|
||||||
|
comision_total = self.stats.get('comision_total', 0)
|
||||||
|
|
||||||
|
data = [
|
||||||
|
[
|
||||||
|
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"{tubos_total}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"${comision_total:,.2f}", self.styles['KPIValue']),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph("Ventas", self.styles['KPILabel']),
|
||||||
|
Paragraph("Monto Total", self.styles['KPILabel']),
|
||||||
|
Paragraph("Tubos", self.styles['KPILabel']),
|
||||||
|
Paragraph("Comisiones", self.styles['KPILabel']),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[1.5*inch]*4)
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
|
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8f9fa')),
|
||||||
|
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
|
||||||
|
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e9ecef')),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 12),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 12),
|
||||||
|
]))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _create_executive_kpi_section(self) -> Table:
|
||||||
|
"""Creates executive KPI section with more metrics."""
|
||||||
|
total_ventas = len(self.ventas)
|
||||||
|
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
|
||||||
|
promedio_ticket = monto_total / total_ventas if total_ventas > 0 else 0
|
||||||
|
vendedores_activos = len(set(v.get('vendedor_username') for v in self.ventas))
|
||||||
|
|
||||||
|
data = [
|
||||||
|
[
|
||||||
|
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"${promedio_ticket:,.2f}", self.styles['KPIValue']),
|
||||||
|
Paragraph(f"{vendedores_activos}", self.styles['KPIValue']),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph("Monto Total", self.styles['KPILabel']),
|
||||||
|
Paragraph("Total Ventas", self.styles['KPILabel']),
|
||||||
|
Paragraph("Ticket Promedio", self.styles['KPILabel']),
|
||||||
|
Paragraph("Vendedores", self.styles['KPILabel']),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[1.5*inch]*4)
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e3f2fd')),
|
||||||
|
('BACKGROUND', (0, 1), (-1, 1), colors.HexColor('#f8f9fa')),
|
||||||
|
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 15),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 15),
|
||||||
|
]))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _create_top_sellers_table(self) -> Table:
|
||||||
|
"""Creates top sellers table."""
|
||||||
|
# Group sales by vendor
|
||||||
|
vendors = {}
|
||||||
|
for venta in self.ventas:
|
||||||
|
username = venta.get('vendedor_username', 'Desconocido')
|
||||||
|
if username not in vendors:
|
||||||
|
vendors[username] = {'ventas': 0, 'monto': 0}
|
||||||
|
vendors[username]['ventas'] += 1
|
||||||
|
vendors[username]['monto'] += float(venta.get('monto', 0) or 0)
|
||||||
|
|
||||||
|
# Sort by sales count
|
||||||
|
sorted_vendors = sorted(vendors.items(), key=lambda x: x[1]['monto'], reverse=True)[:5]
|
||||||
|
|
||||||
|
data = [['#', 'Vendedor', 'Ventas', 'Monto']]
|
||||||
|
for i, (username, stats) in enumerate(sorted_vendors, 1):
|
||||||
|
medal = ['🥇', '🥈', '🥉', '4.', '5.'][i-1]
|
||||||
|
data.append([
|
||||||
|
medal,
|
||||||
|
username,
|
||||||
|
str(stats['ventas']),
|
||||||
|
f"${stats['monto']:,.2f}"
|
||||||
|
])
|
||||||
|
|
||||||
|
if len(data) == 1:
|
||||||
|
data.append(['', 'Sin datos', '', ''])
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[0.5*inch, 2.5*inch, 1*inch, 1.5*inch])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||||
|
('BACKGROUND', (0, 1), (-1, -1), colors.HexColor('#f8f9fa')),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
||||||
|
('TOPPADDING', (0, 1), (-1, -1), 8),
|
||||||
|
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
|
||||||
|
]))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _create_top_performers_table(self) -> Table:
|
||||||
|
"""Creates top performers table for executive report."""
|
||||||
|
# Similar to top sellers but with more metrics
|
||||||
|
return self._create_top_sellers_table()
|
||||||
|
|
||||||
|
def _create_sales_table(self) -> Table:
|
||||||
|
"""Creates detailed sales table."""
|
||||||
|
data = [['ID', 'Fecha', 'Vendedor', 'Cliente', 'Monto']]
|
||||||
|
|
||||||
|
for venta in self.ventas[:15]: # Limit to 15 rows
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
try:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
fecha_formatted = fecha.strftime('%d/%m %H:%M')
|
||||||
|
except:
|
||||||
|
fecha_formatted = fecha_str[:16] if fecha_str else ''
|
||||||
|
|
||||||
|
data.append([
|
||||||
|
str(venta.get('Id', '')),
|
||||||
|
fecha_formatted,
|
||||||
|
venta.get('vendedor_username', '')[:15],
|
||||||
|
(venta.get('cliente', '') or 'N/A')[:20],
|
||||||
|
f"${float(venta.get('monto', 0) or 0):,.2f}"
|
||||||
|
])
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[0.5*inch, 1*inch, 1.5*inch, 1.5*inch, 1*inch])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
||||||
|
('BACKGROUND', (0, 1), (-1, -1), colors.white),
|
||||||
|
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f8f9fa')]),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
||||||
|
('TOPPADDING', (0, 1), (-1, -1), 6),
|
||||||
|
('BOTTOMPADDING', (0, 1), (-1, -1), 6),
|
||||||
|
]))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _create_comparison_section(self) -> Table:
|
||||||
|
"""Creates comparison section."""
|
||||||
|
# Placeholder data - would be filled with real comparison data
|
||||||
|
data = [
|
||||||
|
['Métrica', 'Período Actual', 'Período Anterior', 'Cambio'],
|
||||||
|
['Ventas', str(len(self.ventas)), '-', '-'],
|
||||||
|
['Monto', f"${sum(float(v.get('monto', 0) or 0) for v in self.ventas):,.2f}", '-', '-'],
|
||||||
|
['Vendedores', str(len(set(v.get('vendedor_username') for v in self.ventas))), '-', '-'],
|
||||||
|
]
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[1.5*inch, 1.25*inch, 1.25*inch, 1*inch])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['primary']),
|
||||||
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
||||||
|
('BACKGROUND', (0, 1), (-1, -1), colors.white),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
||||||
|
('TOPPADDING', (0, 1), (-1, -1), 8),
|
||||||
|
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
|
||||||
|
]))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
def _create_trend_chart(self) -> Optional[io.BytesIO]:
|
||||||
|
"""Creates a trend chart image."""
|
||||||
|
if not MATPLOTLIB_AVAILABLE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Group sales by date
|
||||||
|
sales_by_date = {}
|
||||||
|
for venta in self.ventas:
|
||||||
|
fecha_str = venta.get('fecha_venta', '')
|
||||||
|
try:
|
||||||
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||||
|
date_key = fecha.strftime('%Y-%m-%d')
|
||||||
|
if date_key not in sales_by_date:
|
||||||
|
sales_by_date[date_key] = 0
|
||||||
|
sales_by_date[date_key] += float(venta.get('monto', 0) or 0)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not sales_by_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort by date
|
||||||
|
sorted_dates = sorted(sales_by_date.keys())[-7:] # Last 7 days
|
||||||
|
values = [sales_by_date.get(d, 0) for d in sorted_dates]
|
||||||
|
labels = [datetime.strptime(d, '%Y-%m-%d').strftime('%d/%m') for d in sorted_dates]
|
||||||
|
|
||||||
|
# Create chart
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 4))
|
||||||
|
ax.plot(labels, values, color='#00d4ff', linewidth=2, marker='o')
|
||||||
|
ax.fill_between(labels, values, alpha=0.3, color='#00d4ff')
|
||||||
|
ax.set_xlabel('Fecha')
|
||||||
|
ax.set_ylabel('Monto ($)')
|
||||||
|
ax.set_title('Tendencia de Ventas')
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# Format y-axis as currency
|
||||||
|
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
# Save to buffer
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating trend chart: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_monthly_chart(self) -> Optional[io.BytesIO]:
|
||||||
|
"""Creates a monthly trend chart."""
|
||||||
|
return self._create_trend_chart() # Use same logic for now
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions
|
||||||
|
def generar_reporte_diario(ventas: List[Dict], stats: Dict, vendedor: str = None) -> bytes:
|
||||||
|
"""
|
||||||
|
Genera un reporte diario en PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ventas: Lista de ventas
|
||||||
|
stats: Estadísticas
|
||||||
|
vendedor: Username del vendedor (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Contenido del PDF en bytes
|
||||||
|
"""
|
||||||
|
if not REPORTLAB_AVAILABLE:
|
||||||
|
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
|
||||||
|
|
||||||
|
report = SalesReportPDF(ventas, stats, vendedor)
|
||||||
|
return report.generar_reporte_diario()
|
||||||
|
|
||||||
|
|
||||||
|
def generar_reporte_ejecutivo(ventas: List[Dict], stats: Dict) -> bytes:
|
||||||
|
"""
|
||||||
|
Genera un reporte ejecutivo en PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ventas: Lista de ventas
|
||||||
|
stats: Estadísticas
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Contenido del PDF en bytes
|
||||||
|
"""
|
||||||
|
if not REPORTLAB_AVAILABLE:
|
||||||
|
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
|
||||||
|
|
||||||
|
report = SalesReportPDF(ventas, stats)
|
||||||
|
return report.generar_reporte_ejecutivo()
|
||||||
@@ -32,3 +32,20 @@ APScheduler==3.10.4
|
|||||||
|
|
||||||
# Exportación a Excel
|
# Exportación a Excel
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
|
||||||
|
# === PDF Generation ===
|
||||||
|
reportlab==4.1.0
|
||||||
|
|
||||||
|
# === Charts for PDF ===
|
||||||
|
matplotlib==3.8.2
|
||||||
|
|
||||||
|
# === Analytics ===
|
||||||
|
scipy==1.12.0
|
||||||
|
pandas==2.1.4
|
||||||
|
|
||||||
|
# === OCR Improvements ===
|
||||||
|
imutils==0.5.4
|
||||||
|
deskew==1.1.0
|
||||||
|
|
||||||
|
# === Caching ===
|
||||||
|
cachetools==5.3.2
|
||||||
|
|||||||
591
sales-bot/static/css/main.css
Normal file
591
sales-bot/static/css/main.css
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/* ==================== RESET & BASE ==================== */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #00d4ff;
|
||||||
|
--secondary: #00ff88;
|
||||||
|
--warning: #ffaa00;
|
||||||
|
--purple: #aa00ff;
|
||||||
|
--bg-dark: #1a1a2e;
|
||||||
|
--bg-darker: #16213e;
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.05);
|
||||||
|
--border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #888888;
|
||||||
|
--text-muted: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== NAVBAR ==================== */
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== CONTAINER ==================== */
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== HEADER ==================== */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 span {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fecha {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== BUTTONS ==================== */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #33ddff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== STATS GRID ==================== */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
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: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .subvalue {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.green .value { color: var(--secondary); }
|
||||||
|
.stat-card.orange .value { color: var(--warning); }
|
||||||
|
.stat-card.purple .value { color: var(--purple); }
|
||||||
|
|
||||||
|
/* ==================== PANELS ==================== */
|
||||||
|
.main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 .icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== RANKING ==================== */
|
||||||
|
.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: var(--text-secondary); }
|
||||||
|
|
||||||
|
.ranking-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-tubos {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-comision {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== VENTAS RECIENTES ==================== */
|
||||||
|
.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: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.venta-info .cliente {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.venta-monto {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== CHARTS ==================== */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container canvas {
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== LOADING ==================== */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== PROGRESS BAR ==================== */
|
||||||
|
.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, var(--primary), var(--secondary));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== KPI CARDS ==================== */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-trend {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-trend.up { color: var(--secondary); }
|
||||||
|
.kpi-card .kpi-trend.down { color: #ff4444; }
|
||||||
|
.kpi-card .kpi-trend.stable { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ==================== TABLES ==================== */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== CAMERA MODAL ==================== */
|
||||||
|
.camera-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.capture {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-btn.close {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== MOBILE RESPONSIVE ==================== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== PWA INSTALL PROMPT ==================== */
|
||||||
|
.install-prompt {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-darker);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-prompt .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
238
sales-bot/static/js/app.js
Normal file
238
sales-bot/static/js/app.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Sales Bot - Main Application JavaScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
const Utils = {
|
||||||
|
formatMoney(amount) {
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDateTime(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return `${this.formatDate(dateStr)} ${this.formatTime(dateStr)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
// Create notification element
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification notification-${type}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
background: ${type === 'success' ? '#00ff88' : type === 'error' ? '#ff4444' : '#00d4ff'};
|
||||||
|
color: ${type === 'success' || type === 'info' ? '#000' : '#fff'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.animation = 'slideOut 0.3s ease';
|
||||||
|
setTimeout(() => notification.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Client
|
||||||
|
const API = {
|
||||||
|
async get(endpoint) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API GET ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async post(endpoint, data) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API POST ${endpoint}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard module
|
||||||
|
const Dashboard = {
|
||||||
|
async loadSummary() {
|
||||||
|
try {
|
||||||
|
const data = await API.get('/api/dashboard/resumen');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading summary:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadRanking() {
|
||||||
|
try {
|
||||||
|
const data = await API.get('/api/dashboard/ranking');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading ranking:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadRecentSales() {
|
||||||
|
try {
|
||||||
|
const data = await API.get('/api/dashboard/ventas-recientes');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent sales:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analytics module
|
||||||
|
const Analytics = {
|
||||||
|
async loadTrends(days = 30) {
|
||||||
|
try {
|
||||||
|
const data = await API.get(`/api/analytics/trends?days=${days}`);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading trends:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPredictions(period = 30) {
|
||||||
|
try {
|
||||||
|
const data = await API.get(`/api/analytics/predictions?period=${period}`);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading predictions:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadComparisons(type = 'monthly') {
|
||||||
|
try {
|
||||||
|
const data = await API.get(`/api/analytics/comparisons?type=${type}`);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading comparisons:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Offline support
|
||||||
|
const OfflineManager = {
|
||||||
|
isOnline: navigator.onLine,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
this.isOnline = true;
|
||||||
|
Utils.showNotification('Conexion restaurada', 'success');
|
||||||
|
this.syncData();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
this.isOnline = false;
|
||||||
|
Utils.showNotification('Sin conexion - Modo offline', 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async cacheData(key, data) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`salesbot_${key}`, JSON.stringify({
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error caching data:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getCachedData(key, maxAge = 300000) { // 5 minutes default
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(`salesbot_${key}`);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
if (Date.now() - timestamp > maxAge) return null;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncData() {
|
||||||
|
// Sync any pending data when back online
|
||||||
|
console.log('Syncing data...');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
OfflineManager.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add CSS animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideOut {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Export for use in templates
|
||||||
|
window.Utils = Utils;
|
||||||
|
window.API = API;
|
||||||
|
window.Dashboard = Dashboard;
|
||||||
|
window.Analytics = Analytics;
|
||||||
236
sales-bot/static/js/camera.js
Normal file
236
sales-bot/static/js/camera.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Sales Bot - Camera Capture for Ticket Processing
|
||||||
|
*/
|
||||||
|
|
||||||
|
let cameraStream = null;
|
||||||
|
|
||||||
|
async function abrirCamara() {
|
||||||
|
const modal = document.getElementById('camera-modal');
|
||||||
|
const video = document.getElementById('camera-video');
|
||||||
|
|
||||||
|
if (!modal || !video) {
|
||||||
|
console.error('Camera elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request camera access
|
||||||
|
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment', // Use back camera on mobile
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
|
||||||
|
video.srcObject = cameraStream;
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error);
|
||||||
|
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
alert('Permiso de camara denegado. Por favor, permite el acceso a la camara en la configuracion del navegador.');
|
||||||
|
} else if (error.name === 'NotFoundError') {
|
||||||
|
alert('No se encontro una camara en este dispositivo.');
|
||||||
|
} else {
|
||||||
|
alert('Error al acceder a la camara: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cerrarCamara() {
|
||||||
|
const modal = document.getElementById('camera-modal');
|
||||||
|
const video = document.getElementById('camera-video');
|
||||||
|
|
||||||
|
if (cameraStream) {
|
||||||
|
cameraStream.getTracks().forEach(track => track.stop());
|
||||||
|
cameraStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capturarFoto() {
|
||||||
|
const video = document.getElementById('camera-video');
|
||||||
|
const canvas = document.getElementById('camera-canvas');
|
||||||
|
|
||||||
|
if (!video || !canvas) {
|
||||||
|
console.error('Video or canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas size to video size
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
// Draw video frame to canvas
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
// Get base64 image
|
||||||
|
const imageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
|
||||||
|
// Close camera
|
||||||
|
cerrarCamara();
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Procesando imagen...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send to server for OCR processing
|
||||||
|
const response = await fetch('/api/capture/ticket', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image: imageData
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Ticket procesado correctamente', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detected data
|
||||||
|
mostrarResultadoOCR(result);
|
||||||
|
} else {
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Error procesando ticket: ' + (result.error || 'Error desconocido'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending image:', error);
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Error enviando imagen al servidor', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostrarResultadoOCR(result) {
|
||||||
|
// Create modal to show OCR results
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'camera-modal active';
|
||||||
|
modal.style.cssText = 'display: flex; align-items: center; justify-content: center;';
|
||||||
|
|
||||||
|
const monto = result.monto ? window.Utils.formatMoney(result.monto) : 'No detectado';
|
||||||
|
const productos = result.productos || [];
|
||||||
|
const tubos = productos.filter(p =>
|
||||||
|
p.nombre && p.nombre.toLowerCase().includes('tinte')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div style="background: #1a1a2e; padding: 30px; border-radius: 16px; max-width: 400px; width: 90%;">
|
||||||
|
<h2 style="margin-bottom: 20px; color: #00d4ff;">Resultado del Ticket</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Monto Detectado</label>
|
||||||
|
<div style="font-size: 32px; font-weight: bold; color: #00ff88;">${monto}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Tubos de Tinte</label>
|
||||||
|
<div style="font-size: 24px; font-weight: bold; color: #00d4ff;">${tubos}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${productos.length > 0 ? `
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Productos (${productos.length})</label>
|
||||||
|
<ul style="list-style: none; margin-top: 10px; max-height: 150px; overflow-y: auto;">
|
||||||
|
${productos.slice(0, 5).map(p => `
|
||||||
|
<li style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
${p.nombre || 'Producto'} - ${window.Utils.formatMoney(p.importe || 0)}
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||||
|
<button class="btn btn-secondary" onclick="this.closest('.camera-modal').remove()" style="flex: 1;">
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="confirmarVenta(${result.monto || 0}, ${tubos}); this.closest('.camera-modal').remove();" style="flex: 1;">
|
||||||
|
Registrar Venta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarVenta(monto, tubos) {
|
||||||
|
// This would integrate with the main sales flow
|
||||||
|
// For now, just show a notification
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification(`Venta de ${window.Utils.formatMoney(monto)} lista para confirmar`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here you could redirect to Mattermost or show a form
|
||||||
|
// to complete the sale registration
|
||||||
|
}
|
||||||
|
|
||||||
|
// File input fallback for devices without camera API
|
||||||
|
function createFileInput() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.capture = 'environment';
|
||||||
|
input.style.display = 'none';
|
||||||
|
|
||||||
|
input.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const imageData = event.target.result;
|
||||||
|
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Procesando imagen...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/capture/ticket', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: imageData })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
mostrarResultadoOCR(result);
|
||||||
|
} else {
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('Error procesando imagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions
|
||||||
|
window.abrirCamara = abrirCamara;
|
||||||
|
window.cerrarCamara = cerrarCamara;
|
||||||
|
window.capturarFoto = capturarFoto;
|
||||||
266
sales-bot/static/js/charts.js
Normal file
266
sales-bot/static/js/charts.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Sales Bot - Chart.js Integration and Chart Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Chart default configuration
|
||||||
|
Chart.defaults.color = '#888';
|
||||||
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
||||||
|
|
||||||
|
// Color palette
|
||||||
|
const ChartColors = {
|
||||||
|
primary: '#00d4ff',
|
||||||
|
secondary: '#00ff88',
|
||||||
|
warning: '#ffaa00',
|
||||||
|
danger: '#ff4444',
|
||||||
|
purple: '#aa00ff',
|
||||||
|
gradient: (ctx, color1, color2) => {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
||||||
|
gradient.addColorStop(0, color1);
|
||||||
|
gradient.addColorStop(1, color2);
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart factory
|
||||||
|
const ChartFactory = {
|
||||||
|
// Line chart for trends
|
||||||
|
createTrendChart(canvasId, data, options = {}) {
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
return new Chart(ctx.getContext('2d'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
label: data.label || 'Datos',
|
||||||
|
data: data.values || [],
|
||||||
|
borderColor: options.color || ChartColors.primary,
|
||||||
|
backgroundColor: options.fill ?
|
||||||
|
ChartColors.gradient(ctx.getContext('2d'), 'rgba(0, 212, 255, 0.3)', 'rgba(0, 212, 255, 0)') :
|
||||||
|
'transparent',
|
||||||
|
fill: options.fill !== false,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: options.points ? 4 : 0,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: options.legend !== false,
|
||||||
|
labels: { color: '#888' }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26, 26, 46, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#888',
|
||||||
|
borderColor: ChartColors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
displayColors: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => options.formatValue ?
|
||||||
|
options.formatValue(ctx.parsed.y) :
|
||||||
|
ctx.parsed.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888', maxRotation: 45 }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: {
|
||||||
|
color: '#888',
|
||||||
|
callback: options.formatYAxis || ((value) => value)
|
||||||
|
},
|
||||||
|
beginAtZero: options.beginAtZero !== false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bar chart for comparisons
|
||||||
|
createBarChart(canvasId, data, options = {}) {
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
return new Chart(ctx.getContext('2d'), {
|
||||||
|
type: options.horizontal ? 'bar' : 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
label: data.label || 'Datos',
|
||||||
|
data: data.values || [],
|
||||||
|
backgroundColor: data.colors || [
|
||||||
|
ChartColors.primary,
|
||||||
|
ChartColors.secondary,
|
||||||
|
ChartColors.warning,
|
||||||
|
ChartColors.purple
|
||||||
|
],
|
||||||
|
borderRadius: 8,
|
||||||
|
barThickness: options.barThickness || 'flex'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: options.horizontal ? 'y' : 'x',
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26, 26, 46, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#888',
|
||||||
|
borderColor: ChartColors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => options.formatValue ?
|
||||||
|
options.formatValue(ctx.parsed[options.horizontal ? 'x' : 'y']) :
|
||||||
|
ctx.parsed[options.horizontal ? 'x' : 'y']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: !options.horizontal },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: options.horizontal ? 'transparent' : 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Doughnut chart for distribution
|
||||||
|
createDoughnutChart(canvasId, data, options = {}) {
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
return new Chart(ctx.getContext('2d'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
data: data.values || [],
|
||||||
|
backgroundColor: data.colors || [
|
||||||
|
ChartColors.primary,
|
||||||
|
ChartColors.secondary,
|
||||||
|
ChartColors.warning,
|
||||||
|
ChartColors.purple,
|
||||||
|
ChartColors.danger
|
||||||
|
],
|
||||||
|
borderWidth: 0,
|
||||||
|
cutout: options.cutout || '70%'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: options.legend !== false,
|
||||||
|
position: options.legendPosition || 'bottom',
|
||||||
|
labels: { color: '#888', padding: 15 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26, 26, 46, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#888',
|
||||||
|
borderColor: ChartColors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Multi-line chart for comparisons
|
||||||
|
createMultiLineChart(canvasId, datasets, labels, options = {}) {
|
||||||
|
const ctx = document.getElementById(canvasId);
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
const colors = [ChartColors.primary, ChartColors.secondary, ChartColors.warning, ChartColors.purple];
|
||||||
|
|
||||||
|
return new Chart(ctx.getContext('2d'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: datasets.map((ds, i) => ({
|
||||||
|
label: ds.label,
|
||||||
|
data: ds.values,
|
||||||
|
borderColor: ds.color || colors[i % colors.length],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: ds.dashed ? [5, 5] : [],
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
labels: { color: '#888' }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26, 26, 46, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#888',
|
||||||
|
borderColor: ChartColors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' },
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format currency in charts
|
||||||
|
function formatCurrency(value) {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export
|
||||||
|
window.ChartFactory = ChartFactory;
|
||||||
|
window.ChartColors = ChartColors;
|
||||||
|
window.formatCurrency = formatCurrency;
|
||||||
122
sales-bot/static/js/pwa.js
Normal file
122
sales-bot/static/js/pwa.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Sales Bot - PWA Registration and Install Prompt
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Register Service Worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||||
|
console.log('Service Worker registered:', registration.scope);
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// New version available
|
||||||
|
showUpdatePrompt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install prompt handling
|
||||||
|
let deferredPrompt;
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
showInstallPrompt();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showInstallPrompt() {
|
||||||
|
// Create install prompt UI
|
||||||
|
const prompt = document.createElement('div');
|
||||||
|
prompt.id = 'install-prompt';
|
||||||
|
prompt.className = 'install-prompt show';
|
||||||
|
prompt.innerHTML = `
|
||||||
|
<span>Instalar Sales Bot en tu dispositivo</span>
|
||||||
|
<button class="btn btn-primary" onclick="installPWA()">Instalar</button>
|
||||||
|
<button class="btn btn-secondary" onclick="dismissInstallPrompt()">Ahora no</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPWA() {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
console.log('Install prompt outcome:', outcome);
|
||||||
|
deferredPrompt = null;
|
||||||
|
dismissInstallPrompt();
|
||||||
|
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
if (window.Utils) {
|
||||||
|
window.Utils.showNotification('App instalada correctamente', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissInstallPrompt() {
|
||||||
|
const prompt = document.getElementById('install-prompt');
|
||||||
|
if (prompt) {
|
||||||
|
prompt.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdatePrompt() {
|
||||||
|
const updateBanner = document.createElement('div');
|
||||||
|
updateBanner.id = 'update-banner';
|
||||||
|
updateBanner.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #00d4ff;
|
||||||
|
color: #000;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 9999;
|
||||||
|
`;
|
||||||
|
updateBanner.innerHTML = `
|
||||||
|
<span>Nueva version disponible</span>
|
||||||
|
<button onclick="updateApp()" style="margin-left: 15px; padding: 5px 15px; border: none; border-radius: 4px; cursor: pointer;">
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(updateBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateApp() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.getRegistration().then(registration => {
|
||||||
|
if (registration && registration.waiting) {
|
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if running as PWA
|
||||||
|
function isPWA() {
|
||||||
|
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
window.navigator.standalone === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add PWA class to body if running as installed app
|
||||||
|
if (isPWA()) {
|
||||||
|
document.body.classList.add('pwa-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export
|
||||||
|
window.installPWA = installPWA;
|
||||||
|
window.dismissInstallPrompt = dismissInstallPrompt;
|
||||||
|
window.isPWA = isPWA;
|
||||||
83
sales-bot/static/manifest.json
Normal file
83
sales-bot/static/manifest.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"name": "Sales Bot - Sistema de Ventas",
|
||||||
|
"short_name": "Sales Bot",
|
||||||
|
"description": "Sistema de seguimiento y automatizacion de ventas",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#1a1a2e",
|
||||||
|
"theme_color": "#00d4ff",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "es-MX",
|
||||||
|
"categories": ["business", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"url": "/dashboard",
|
||||||
|
"description": "Ver dashboard principal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Analytics",
|
||||||
|
"url": "/dashboard/analytics",
|
||||||
|
"description": "Ver analytics y graficas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ejecutivo",
|
||||||
|
"url": "/dashboard/executive",
|
||||||
|
"description": "Ver dashboard ejecutivo"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"related_applications": [],
|
||||||
|
"prefer_related_applications": false
|
||||||
|
}
|
||||||
217
sales-bot/static/service-worker.js
Normal file
217
sales-bot/static/service-worker.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Sales Bot - Service Worker for PWA Offline Support
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CACHE_NAME = 'salesbot-v1';
|
||||||
|
const RUNTIME_CACHE = 'salesbot-runtime-v1';
|
||||||
|
|
||||||
|
// Assets to cache on install
|
||||||
|
const PRECACHE_ASSETS = [
|
||||||
|
'/dashboard',
|
||||||
|
'/dashboard/analytics',
|
||||||
|
'/dashboard/executive',
|
||||||
|
'/static/css/main.css',
|
||||||
|
'/static/js/app.js',
|
||||||
|
'/static/js/pwa.js',
|
||||||
|
'/static/js/camera.js',
|
||||||
|
'/static/js/charts.js',
|
||||||
|
'/static/manifest.json'
|
||||||
|
];
|
||||||
|
|
||||||
|
// API endpoints to cache with network-first strategy
|
||||||
|
const API_ROUTES = [
|
||||||
|
'/api/dashboard/resumen',
|
||||||
|
'/api/dashboard/ranking',
|
||||||
|
'/api/dashboard/ventas-recientes',
|
||||||
|
'/api/analytics/trends',
|
||||||
|
'/api/analytics/predictions'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache core assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
console.log('[SW] Precaching assets');
|
||||||
|
return cache.addAll(PRECACHE_ASSETS);
|
||||||
|
})
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
.catch(err => console.error('[SW] Precache failed:', err))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then(cacheNames => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter(name => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
||||||
|
.map(name => {
|
||||||
|
console.log('[SW] Deleting old cache:', name);
|
||||||
|
return caches.delete(name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - handle requests
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip external requests
|
||||||
|
if (url.origin !== location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests - Network first, fall back to cache
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - Cache first, fall back to network
|
||||||
|
if (url.pathname.startsWith('/static/')) {
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages - Network first with cache fallback
|
||||||
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default - Network first
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache first strategy
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Cache first failed:', error);
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network first strategy
|
||||||
|
async function networkFirst(request) {
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(RUNTIME_CACHE);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('[SW] Serving from cache:', request.url);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache available
|
||||||
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||||
|
return caches.match('/dashboard'); // Fallback to main page
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.headers.get('Accept')?.includes('application/json')) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'offline',
|
||||||
|
message: 'Sin conexion. Mostrando datos en cache.'
|
||||||
|
}), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages from clients
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background sync for offline actions (if supported)
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'sync-sales') {
|
||||||
|
event.waitUntil(syncSales());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncSales() {
|
||||||
|
// Sync any pending sales when back online
|
||||||
|
console.log('[SW] Syncing pending sales...');
|
||||||
|
// Implementation would go here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push notifications (if implemented)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body || 'Nueva notificacion de Sales Bot',
|
||||||
|
icon: '/static/icons/icon-192x192.png',
|
||||||
|
badge: '/static/icons/icon-72x72.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
url: data.url || '/dashboard'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'Sales Bot', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification click
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window' }).then(clientList => {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes('/dashboard') && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(event.notification.data?.url || '/dashboard');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
320
sales-bot/templates/analytics.html
Normal file
320
sales-bot/templates/analytics.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Analytics - Sales Bot{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1><span>Analytics</span> Dashboard</h1>
|
||||||
|
<p class="fecha" id="fecha-actual"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select id="periodo-select" class="btn btn-secondary" onchange="cambiarPeriodo()">
|
||||||
|
<option value="7">Ultimos 7 dias</option>
|
||||||
|
<option value="30" selected>Ultimos 30 dias</option>
|
||||||
|
<option value="90">Ultimos 90 dias</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- KPIs Row -->
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="kpi-total-ventas">-</div>
|
||||||
|
<div class="kpi-label">Total Ventas</div>
|
||||||
|
<div class="kpi-trend" id="kpi-trend-ventas"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="kpi-monto-total">-</div>
|
||||||
|
<div class="kpi-label">Monto Total</div>
|
||||||
|
<div class="kpi-trend" id="kpi-trend-monto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="kpi-tubos">-</div>
|
||||||
|
<div class="kpi-label">Tubos Vendidos</div>
|
||||||
|
<div class="kpi-trend" id="kpi-trend-tubos"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="kpi-promedio">-</div>
|
||||||
|
<div class="kpi-label">Promedio/Dia</div>
|
||||||
|
<div class="kpi-trend" id="kpi-trend-promedio"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="kpi-prediccion">-</div>
|
||||||
|
<div class="kpi-label">Prediccion Prox. Semana</div>
|
||||||
|
<div class="kpi-trend" id="kpi-trend-prediccion"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="main-grid">
|
||||||
|
<div class="panel" style="grid-column: span 2;">
|
||||||
|
<h2><span class="icon">📈</span> Tendencia de Ventas</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="chart-tendencia"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-grid" style="margin-top: 20px;">
|
||||||
|
<div class="panel">
|
||||||
|
<h2><span class="icon">📊</span> Ventas por Vendedor</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="chart-vendedores"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2><span class="icon">📅</span> Comparativa Semanal</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="chart-comparativa"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prediction Info -->
|
||||||
|
<div class="panel" style="margin-top: 20px;">
|
||||||
|
<h2><span class="icon">🤖</span> Prediccion de Ventas</h2>
|
||||||
|
<div class="main-grid">
|
||||||
|
<div>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 15px;">
|
||||||
|
Basado en el promedio movil y tendencia lineal de los ultimos <span id="pred-dias">30</span> dias:
|
||||||
|
</p>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="pred-tomorrow">-</div>
|
||||||
|
<div class="kpi-label">Manana</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="pred-week">-</div>
|
||||||
|
<div class="kpi-label">Proxima Semana</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="pred-confidence">-</div>
|
||||||
|
<div class="kpi-label">Confianza</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="margin-bottom: 10px;">Tendencia Detectada</h3>
|
||||||
|
<div id="pred-trend-info" style="font-size: 48px; text-align: center; padding: 20px;">
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="/static/js/charts.js"></script>
|
||||||
|
<script>
|
||||||
|
let chartTendencia = null;
|
||||||
|
let chartVendedores = null;
|
||||||
|
let chartComparativa = null;
|
||||||
|
|
||||||
|
async function cargarAnalytics() {
|
||||||
|
const dias = document.getElementById('periodo-select').value;
|
||||||
|
document.getElementById('pred-dias').textContent = dias;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load trends
|
||||||
|
const resTrends = await fetch(`/api/analytics/trends?days=${dias}`);
|
||||||
|
const trends = await resTrends.json();
|
||||||
|
|
||||||
|
if (trends.labels && trends.ventas) {
|
||||||
|
renderTrendChart(trends);
|
||||||
|
updateKPIs(trends);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load predictions
|
||||||
|
const resPred = await fetch(`/api/analytics/predictions?period=${dias}`);
|
||||||
|
const predictions = await resPred.json();
|
||||||
|
|
||||||
|
if (predictions) {
|
||||||
|
document.getElementById('pred-tomorrow').textContent = formatMoney(predictions.next_day || 0);
|
||||||
|
document.getElementById('pred-week').textContent = formatMoney(predictions.next_week || 0);
|
||||||
|
document.getElementById('pred-confidence').textContent = Math.round((predictions.confidence || 0) * 100) + '%';
|
||||||
|
document.getElementById('kpi-prediccion').textContent = formatMoney(predictions.next_week || 0);
|
||||||
|
|
||||||
|
const trendIcon = predictions.trend === 'increasing' ? '↗ Subiendo' :
|
||||||
|
predictions.trend === 'decreasing' ? '↘ Bajando' : '→ Estable';
|
||||||
|
const trendColor = predictions.trend === 'increasing' ? 'var(--secondary)' :
|
||||||
|
predictions.trend === 'decreasing' ? '#ff4444' : 'var(--text-secondary)';
|
||||||
|
document.getElementById('pred-trend-info').innerHTML = `<span style="color: ${trendColor}">${trendIcon}</span>`;
|
||||||
|
|
||||||
|
const trendClass = predictions.trend === 'increasing' ? 'up' :
|
||||||
|
predictions.trend === 'decreasing' ? 'down' : 'stable';
|
||||||
|
document.getElementById('kpi-trend-prediccion').className = `kpi-trend ${trendClass}`;
|
||||||
|
document.getElementById('kpi-trend-prediccion').textContent = predictions.trend === 'increasing' ? '+' : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vendor comparison
|
||||||
|
const resRanking = await fetch('/api/dashboard/ranking');
|
||||||
|
const ranking = await resRanking.json();
|
||||||
|
|
||||||
|
if (ranking && ranking.length > 0) {
|
||||||
|
renderVendedoresChart(ranking);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load weekly comparison
|
||||||
|
const resComp = await fetch('/api/analytics/comparisons?type=weekly');
|
||||||
|
const comparisons = await resComp.json();
|
||||||
|
|
||||||
|
if (comparisons) {
|
||||||
|
renderComparativaChart(comparisons);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error cargando analytics:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKPIs(trends) {
|
||||||
|
const totalVentas = trends.ventas.reduce((a, b) => a + b, 0);
|
||||||
|
const promedio = totalVentas / trends.ventas.length;
|
||||||
|
|
||||||
|
document.getElementById('kpi-total-ventas').textContent = trends.ventas.length;
|
||||||
|
document.getElementById('kpi-monto-total').textContent = formatMoney(totalVentas);
|
||||||
|
document.getElementById('kpi-tubos').textContent = trends.tubos?.reduce((a, b) => a + b, 0) || '-';
|
||||||
|
document.getElementById('kpi-promedio').textContent = formatMoney(promedio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrendChart(data) {
|
||||||
|
const ctx = document.getElementById('chart-tendencia').getContext('2d');
|
||||||
|
|
||||||
|
if (chartTendencia) chartTendencia.destroy();
|
||||||
|
|
||||||
|
chartTendencia = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Ventas',
|
||||||
|
data: data.ventas,
|
||||||
|
borderColor: '#00d4ff',
|
||||||
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Prediccion',
|
||||||
|
data: data.prediccion || [],
|
||||||
|
borderColor: '#00ff88',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: '#888' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVendedoresChart(ranking) {
|
||||||
|
const ctx = document.getElementById('chart-vendedores').getContext('2d');
|
||||||
|
|
||||||
|
if (chartVendedores) chartVendedores.destroy();
|
||||||
|
|
||||||
|
const top5 = ranking.slice(0, 5);
|
||||||
|
|
||||||
|
chartVendedores = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: top5.map(v => v.vendedor_username || v.vendedor),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Tubos',
|
||||||
|
data: top5.map(v => v.tubos_totales || 0),
|
||||||
|
backgroundColor: ['#ffd700', '#c0c0c0', '#cd7f32', '#00d4ff', '#00ff88']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#fff' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComparativaChart(data) {
|
||||||
|
const ctx = document.getElementById('chart-comparativa').getContext('2d');
|
||||||
|
|
||||||
|
if (chartComparativa) chartComparativa.destroy();
|
||||||
|
|
||||||
|
chartComparativa = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: ['Semana Anterior', 'Esta Semana'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Monto',
|
||||||
|
data: [data.previous_week || 0, data.current_week || 0],
|
||||||
|
backgroundColor: ['rgba(255,255,255,0.2)', '#00d4ff']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cambiarPeriodo() {
|
||||||
|
cargarAnalytics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount) {
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
cargarAnalytics();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
55
sales-bot/templates/base.html
Normal file
55
sales-bot/templates/base.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#00d4ff">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Sales Bot">
|
||||||
|
<meta name="description" content="Sales Bot - Sistema de seguimiento de ventas">
|
||||||
|
|
||||||
|
<title>{% block title %}Sales Bot{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
|
||||||
|
|
||||||
|
<!-- Chart.js CDN -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-brand">
|
||||||
|
<span class="brand-icon">S</span>
|
||||||
|
<span class="brand-text">Sales Bot</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">Dashboard</a>
|
||||||
|
<a href="/dashboard/analytics" class="nav-link {% if active_page == 'analytics' %}active{% endif %}">Analytics</a>
|
||||||
|
<a href="/dashboard/executive" class="nav-link {% if active_page == 'executive' %}active{% endif %}">Ejecutivo</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<button class="refresh-btn" onclick="window.location.reload()">Actualizar</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script src="/static/js/pwa.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
183
sales-bot/templates/dashboard.html
Normal file
183
sales-bot/templates/dashboard.html
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Sales Bot{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-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>
|
||||||
|
|
||||||
|
<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"><div class="loading-spinner"></div></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2><span class="icon">📋</span> Ventas Recientes</h2>
|
||||||
|
<div class="ventas-list" id="ventas-list">
|
||||||
|
<div class="loading"><div class="loading-spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Button for Mobile -->
|
||||||
|
<button class="btn btn-primary" id="btn-camera" onclick="abrirCamara()" style="position: fixed; bottom: 20px; right: 20px; border-radius: 50%; width: 60px; height: 60px; font-size: 24px; display: none;">
|
||||||
|
📷
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Camera Modal -->
|
||||||
|
<div class="camera-modal" id="camera-modal">
|
||||||
|
<div class="camera-container">
|
||||||
|
<video id="camera-video" autoplay playsinline></video>
|
||||||
|
<div class="camera-controls">
|
||||||
|
<button class="camera-btn close" onclick="cerrarCamara()">✕</button>
|
||||||
|
<button class="camera-btn capture" onclick="capturarFoto()">📷</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="camera-canvas" style="display: none;"></canvas>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Helper functions
|
||||||
|
function formatMoney(amount) {
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data loading functions
|
||||||
|
async function cargarResumen() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/resumen');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargarRanking() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/ranking');
|
||||||
|
const data = await res.json();
|
||||||
|
const lista = document.getElementById('ranking-list');
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
|
||||||
|
const username = v.vendedor_username || v.vendedor;
|
||||||
|
|
||||||
|
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} dias 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargarVentasRecientes() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/ventas-recientes');
|
||||||
|
const data = await res.json();
|
||||||
|
const lista = document.getElementById('ventas-list');
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargarDatos() {
|
||||||
|
cargarResumen();
|
||||||
|
cargarRanking();
|
||||||
|
cargarVentasRecientes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
cargarDatos();
|
||||||
|
setInterval(cargarDatos, 30000);
|
||||||
|
|
||||||
|
// Show camera button on mobile
|
||||||
|
if ('mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices) {
|
||||||
|
document.getElementById('btn-camera').style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/camera.js"></script>
|
||||||
|
{% endblock %}
|
||||||
320
sales-bot/templates/executive.html
Normal file
320
sales-bot/templates/executive.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard Ejecutivo - Sales Bot{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1><span>Dashboard</span> Ejecutivo</h1>
|
||||||
|
<p class="fecha" id="fecha-actual"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" onclick="generarReportePDF()">Descargar PDF</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- KPIs Principales -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Ventas Hoy</div>
|
||||||
|
<div class="value" id="exec-ventas-hoy">-</div>
|
||||||
|
<div class="subvalue" id="exec-vs-ayer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card green">
|
||||||
|
<div class="label">Ventas Mes</div>
|
||||||
|
<div class="value" id="exec-ventas-mes">-</div>
|
||||||
|
<div class="subvalue" id="exec-vs-mes-ant"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card orange">
|
||||||
|
<div class="label">Comisiones Mes</div>
|
||||||
|
<div class="value" id="exec-comisiones">-</div>
|
||||||
|
<div class="subvalue">total equipo</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card purple">
|
||||||
|
<div class="label">Meta Cumplida</div>
|
||||||
|
<div class="value" id="exec-meta-pct">-</div>
|
||||||
|
<div class="subvalue" id="exec-meta-detalle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grafica Principal -->
|
||||||
|
<div class="panel" style="margin-bottom: 20px;">
|
||||||
|
<h2><span class="icon">📈</span> Rendimiento Mensual</h2>
|
||||||
|
<div class="chart-container" style="height: 350px;">
|
||||||
|
<canvas id="chart-mensual"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-grid">
|
||||||
|
<!-- Top Performers -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2><span class="icon">🏆</span> Top Performers del Mes</h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Vendedor</th>
|
||||||
|
<th>Tubos</th>
|
||||||
|
<th>Comision</th>
|
||||||
|
<th>Racha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="top-performers">
|
||||||
|
<tr><td colspan="5" class="loading">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metricas Adicionales -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2><span class="icon">📊</span> Metricas Clave</h2>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="metric-promedio-dia">-</div>
|
||||||
|
<div class="kpi-label">Promedio/Dia</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="metric-mejor-dia">-</div>
|
||||||
|
<div class="kpi-label">Mejor Dia</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="metric-vendedores">-</div>
|
||||||
|
<div class="kpi-label">Vendedores Activos</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-value" id="metric-ticket-prom">-</div>
|
||||||
|
<div class="kpi-label">Ticket Promedio</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin: 20px 0 15px;">Prediccion Proxima Semana</h3>
|
||||||
|
<div id="prediccion-exec" style="background: rgba(0,212,255,0.1); padding: 20px; border-radius: 12px; text-align: center;">
|
||||||
|
<div style="font-size: 36px; font-weight: bold; color: var(--primary);" id="pred-monto">-</div>
|
||||||
|
<div style="color: var(--text-secondary); margin-top: 5px;">Monto estimado</div>
|
||||||
|
<div style="margin-top: 10px;" id="pred-tendencia"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comparativa -->
|
||||||
|
<div class="panel" style="margin-top: 20px;">
|
||||||
|
<h2><span class="icon">📅</span> Comparativa Mensual</h2>
|
||||||
|
<div class="chart-container" style="height: 250px;">
|
||||||
|
<canvas id="chart-comparativa-meses"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
let chartMensual = null;
|
||||||
|
let chartComparativa = null;
|
||||||
|
|
||||||
|
async function cargarDashboardEjecutivo() {
|
||||||
|
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cargar resumen
|
||||||
|
const resResumen = await fetch('/api/dashboard/resumen');
|
||||||
|
const resumen = await resResumen.json();
|
||||||
|
|
||||||
|
document.getElementById('exec-ventas-hoy').textContent = formatMoney(resumen.monto_hoy || 0);
|
||||||
|
document.getElementById('exec-ventas-mes').textContent = formatMoney(resumen.monto_mes || 0);
|
||||||
|
|
||||||
|
// Cargar ranking
|
||||||
|
const resRanking = await fetch('/api/dashboard/ranking');
|
||||||
|
const ranking = await resRanking.json();
|
||||||
|
|
||||||
|
if (ranking && ranking.length > 0) {
|
||||||
|
renderTopPerformers(ranking);
|
||||||
|
calcularMetricas(ranking);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar tendencias
|
||||||
|
const resTrends = await fetch('/api/analytics/trends?days=30');
|
||||||
|
const trends = await resTrends.json();
|
||||||
|
|
||||||
|
if (trends.labels) {
|
||||||
|
renderChartMensual(trends);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar predicciones
|
||||||
|
const resPred = await fetch('/api/analytics/predictions?period=30');
|
||||||
|
const predictions = await resPred.json();
|
||||||
|
|
||||||
|
if (predictions) {
|
||||||
|
document.getElementById('pred-monto').textContent = formatMoney(predictions.next_week || 0);
|
||||||
|
|
||||||
|
const trendText = predictions.trend === 'increasing' ? '↗ Tendencia al alza' :
|
||||||
|
predictions.trend === 'decreasing' ? '↘ Tendencia a la baja' : '→ Tendencia estable';
|
||||||
|
const trendColor = predictions.trend === 'increasing' ? 'var(--secondary)' :
|
||||||
|
predictions.trend === 'decreasing' ? '#ff4444' : 'var(--text-secondary)';
|
||||||
|
|
||||||
|
document.getElementById('pred-tendencia').innerHTML = `<span style="color: ${trendColor}">${trendText}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar comparativas
|
||||||
|
const resComp = await fetch('/api/analytics/comparisons?type=monthly');
|
||||||
|
const comp = await resComp.json();
|
||||||
|
|
||||||
|
if (comp) {
|
||||||
|
renderChartComparativa(comp);
|
||||||
|
|
||||||
|
const diff = ((comp.current_month - comp.previous_month) / comp.previous_month * 100).toFixed(1);
|
||||||
|
const sign = diff > 0 ? '+' : '';
|
||||||
|
document.getElementById('exec-vs-mes-ant').textContent = `${sign}${diff}% vs mes anterior`;
|
||||||
|
document.getElementById('exec-vs-mes-ant').style.color = diff > 0 ? 'var(--secondary)' : '#ff4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error cargando dashboard ejecutivo:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopPerformers(ranking) {
|
||||||
|
const tbody = document.getElementById('top-performers');
|
||||||
|
const top5 = ranking.slice(0, 5);
|
||||||
|
|
||||||
|
tbody.innerHTML = top5.map((v, i) => {
|
||||||
|
const medalla = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1);
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${medalla}</td>
|
||||||
|
<td><strong>${v.vendedor_username || v.vendedor}</strong></td>
|
||||||
|
<td>${v.tubos_totales || 0}</td>
|
||||||
|
<td>${formatMoney(v.comision_total || 0)}</td>
|
||||||
|
<td>${v.racha || 0} dias</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Calcular comisiones totales
|
||||||
|
const totalComisiones = ranking.reduce((sum, v) => sum + (v.comision_total || 0), 0);
|
||||||
|
document.getElementById('exec-comisiones').textContent = formatMoney(totalComisiones);
|
||||||
|
|
||||||
|
// Calcular meta cumplida
|
||||||
|
const vendedoresConMeta = ranking.filter(v => (v.tubos_totales || 0) >= 3).length;
|
||||||
|
const pctMeta = Math.round((vendedoresConMeta / ranking.length) * 100);
|
||||||
|
document.getElementById('exec-meta-pct').textContent = `${pctMeta}%`;
|
||||||
|
document.getElementById('exec-meta-detalle').textContent = `${vendedoresConMeta}/${ranking.length} vendedores`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcularMetricas(ranking) {
|
||||||
|
const totalVendedores = ranking.length;
|
||||||
|
document.getElementById('metric-vendedores').textContent = totalVendedores;
|
||||||
|
|
||||||
|
// Estas metricas se calcularian con datos reales
|
||||||
|
document.getElementById('metric-promedio-dia').textContent = '-';
|
||||||
|
document.getElementById('metric-mejor-dia').textContent = '-';
|
||||||
|
document.getElementById('metric-ticket-prom').textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChartMensual(data) {
|
||||||
|
const ctx = document.getElementById('chart-mensual').getContext('2d');
|
||||||
|
|
||||||
|
if (chartMensual) chartMensual.destroy();
|
||||||
|
|
||||||
|
chartMensual = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Ventas',
|
||||||
|
data: data.ventas,
|
||||||
|
borderColor: '#00d4ff',
|
||||||
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChartComparativa(data) {
|
||||||
|
const ctx = document.getElementById('chart-comparativa-meses').getContext('2d');
|
||||||
|
|
||||||
|
if (chartComparativa) chartComparativa.destroy();
|
||||||
|
|
||||||
|
const meses = data.months || ['Mes -2', 'Mes -1', 'Mes Actual'];
|
||||||
|
const valores = data.values || [data.month_2 || 0, data.previous_month || 0, data.current_month || 0];
|
||||||
|
|
||||||
|
chartComparativa = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: meses,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Monto',
|
||||||
|
data: valores,
|
||||||
|
backgroundColor: ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.2)', '#00d4ff']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generarReportePDF() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reports/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'executive' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.download_url) {
|
||||||
|
window.open(data.download_url, '_blank');
|
||||||
|
} else if (data.error) {
|
||||||
|
alert('Error generando reporte: ' + data.error);
|
||||||
|
} else {
|
||||||
|
alert('Reporte generado. Se enviara al canal de Mattermost.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error generando PDF:', e);
|
||||||
|
alert('Error generando el reporte PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount) {
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
cargarDashboardEjecutivo();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user