diff --git a/sales-bot/.env.example b/sales-bot/.env.example
index 7dca686..698eef7 100644
--- a/sales-bot/.env.example
+++ b/sales-bot/.env.example
@@ -82,3 +82,35 @@ COMISION_POR_TUBO_DEFAULT=10
# === EXPORTACIÓN ===
# Formato por defecto para exportación (excel o csv)
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
diff --git a/sales-bot/analytics/__init__.py b/sales-bot/analytics/__init__.py
new file mode 100644
index 0000000..d050369
--- /dev/null
+++ b/sales-bot/analytics/__init__.py
@@ -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'
+]
diff --git a/sales-bot/analytics/comparisons.py b/sales-bot/analytics/comparisons.py
new file mode 100644
index 0000000..03b9736
--- /dev/null
+++ b/sales-bot/analytics/comparisons.py
@@ -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}'}
diff --git a/sales-bot/analytics/predictions.py b/sales-bot/analytics/predictions.py
new file mode 100644
index 0000000..34de3ce
--- /dev/null
+++ b/sales-bot/analytics/predictions.py
@@ -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)
+ }
+ }
diff --git a/sales-bot/analytics/trends.py b/sales-bot/analytics/trends.py
new file mode 100644
index 0000000..9fce244
--- /dev/null
+++ b/sales-bot/analytics/trends.py
@@ -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)}
diff --git a/sales-bot/app.py b/sales-bot/app.py
index ae2acaa..e2806a5 100644
--- a/sales-bot/app.py
+++ b/sales-bot/app.py
@@ -1,13 +1,17 @@
-from flask import Flask, request, jsonify, render_template_string
+from flask import Flask, request, jsonify, render_template_string, render_template, send_from_directory
import os
import logging
from dotenv import load_dotenv
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
import json
+import base64
# Cargar variables de entorno
load_dotenv()
+# Timezone México
+TZ_MEXICO = timezone(timedelta(hours=-6))
+
# Importar módulos personalizados
from mattermost_client import MattermostClient
from nocodb_client import NocoDBClient
@@ -26,8 +30,10 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
-# Inicializar Flask
-app = Flask(__name__)
+# Inicializar Flask con templates y static folders
+app = Flask(__name__,
+ template_folder='templates',
+ static_folder='static')
# Inicializar clientes
mattermost = MattermostClient(
@@ -801,305 +807,366 @@ def api_dashboard_metas():
@app.route('/dashboard')
def dashboard():
"""Dashboard principal de ventas"""
- html = '''
+ try:
+ return render_template('dashboard.html')
+ except Exception as e:
+ logger.error(f"Error renderizando dashboard: {str(e)}")
+ # Fallback al HTML embebido si no hay template
+ return render_template_string('''
-
-
-
-
- Sales Bot - Dashboard
-
-
-
-
-
-
-
Sales Bot Dashboard
-
-
-
-
+
Sales Bot Dashboard
+
+
Sales Bot Dashboard
+
Error cargando templates. Verifica que la carpeta templates/ existe.
+
Error: ''' + str(e) + '''
+
+''')
-
-
-
Ventas Hoy
-
-
-
$0.00
-
-
-
Ventas del Mes
-
-
-
$0.00
-
-
-
Vendedores Activos Hoy
-
-
-
-
-
Meta Diaria
-
3
-
tubos por vendedor
-
-
-
-
-
🏆 Ranking del Mes (Tubos)
-
-
+@app.route('/dashboard/analytics')
+def dashboard_analytics():
+ """Dashboard de analytics con gráficas"""
+ try:
+ return render_template('analytics.html')
+ except Exception as e:
+ logger.error(f"Error renderizando analytics: {str(e)}")
+ return jsonify({'error': str(e)}), 500
-
-
-
-
-
-
-'''
- return render_template_string(html)
if __name__ == '__main__':
port = int(os.getenv('FLASK_PORT', 5000))
diff --git a/sales-bot/ocr/__init__.py b/sales-bot/ocr/__init__.py
new file mode 100644
index 0000000..2733edc
--- /dev/null
+++ b/sales-bot/ocr/__init__.py
@@ -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'
+]
diff --git a/sales-bot/ocr/amount_detector.py b/sales-bot/ocr/amount_detector.py
new file mode 100644
index 0000000..e82abc0
--- /dev/null
+++ b/sales-bot/ocr/amount_detector.py
@@ -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)
diff --git a/sales-bot/ocr/patterns.py b/sales-bot/ocr/patterns.py
new file mode 100644
index 0000000..ffd2cd6
--- /dev/null
+++ b/sales-bot/ocr/patterns.py
@@ -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
diff --git a/sales-bot/ocr/preprocessor.py b/sales-bot/ocr/preprocessor.py
new file mode 100644
index 0000000..620a571
--- /dev/null
+++ b/sales-bot/ocr/preprocessor.py
@@ -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)
diff --git a/sales-bot/ocr/processor.py b/sales-bot/ocr/processor.py
new file mode 100644
index 0000000..7f3f685
--- /dev/null
+++ b/sales-bot/ocr/processor.py
@@ -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)
diff --git a/sales-bot/reports/__init__.py b/sales-bot/reports/__init__.py
new file mode 100644
index 0000000..79a94a4
--- /dev/null
+++ b/sales-bot/reports/__init__.py
@@ -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'
+]
diff --git a/sales-bot/reports/pdf_generator.py b/sales-bot/reports/pdf_generator.py
new file mode 100644
index 0000000..b9bd6ca
--- /dev/null
+++ b/sales-bot/reports/pdf_generator.py
@@ -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()
diff --git a/sales-bot/requirements.txt b/sales-bot/requirements.txt
index ef454f1..16dcfe2 100644
--- a/sales-bot/requirements.txt
+++ b/sales-bot/requirements.txt
@@ -32,3 +32,20 @@ APScheduler==3.10.4
# Exportación a Excel
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
diff --git a/sales-bot/static/css/main.css b/sales-bot/static/css/main.css
new file mode 100644
index 0000000..ff2850d
--- /dev/null
+++ b/sales-bot/static/css/main.css
@@ -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;
+}
diff --git a/sales-bot/static/js/app.js b/sales-bot/static/js/app.js
new file mode 100644
index 0000000..9d3708b
--- /dev/null
+++ b/sales-bot/static/js/app.js
@@ -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;
diff --git a/sales-bot/static/js/camera.js b/sales-bot/static/js/camera.js
new file mode 100644
index 0000000..d27992f
--- /dev/null
+++ b/sales-bot/static/js/camera.js
@@ -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 = `
+
+
Resultado del Ticket
+
+
+
+
${monto}
+
+
+
+
+
${tubos}
+
+
+ ${productos.length > 0 ? `
+
+
+
+ ${productos.slice(0, 5).map(p => `
+ -
+ ${p.nombre || 'Producto'} - ${window.Utils.formatMoney(p.importe || 0)}
+
+ `).join('')}
+
+
+ ` : ''}
+
+
+
+
+
+
+ `;
+
+ 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;
diff --git a/sales-bot/static/js/charts.js b/sales-bot/static/js/charts.js
new file mode 100644
index 0000000..a2ac2e3
--- /dev/null
+++ b/sales-bot/static/js/charts.js
@@ -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;
diff --git a/sales-bot/static/js/pwa.js b/sales-bot/static/js/pwa.js
new file mode 100644
index 0000000..f2b3c8d
--- /dev/null
+++ b/sales-bot/static/js/pwa.js
@@ -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 = `
+ Instalar Sales Bot en tu dispositivo
+
+
+ `;
+ 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 = `
+ Nueva version disponible
+
+ `;
+ 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;
diff --git a/sales-bot/static/manifest.json b/sales-bot/static/manifest.json
new file mode 100644
index 0000000..5564663
--- /dev/null
+++ b/sales-bot/static/manifest.json
@@ -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
+}
diff --git a/sales-bot/static/service-worker.js b/sales-bot/static/service-worker.js
new file mode 100644
index 0000000..dbddd3f
--- /dev/null
+++ b/sales-bot/static/service-worker.js
@@ -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');
+ }
+ })
+ );
+});
diff --git a/sales-bot/templates/analytics.html b/sales-bot/templates/analytics.html
new file mode 100644
index 0000000..8602f24
--- /dev/null
+++ b/sales-bot/templates/analytics.html
@@ -0,0 +1,320 @@
+{% extends "base.html" %}
+
+{% block title %}Analytics - Sales Bot{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
-
+
Prediccion Prox. Semana
+
+
+
+
+
+
+
+
📈 Tendencia de Ventas
+
+
+
+
+
+
+
+
+
📊 Ventas por Vendedor
+
+
+
+
+
+
+
📅 Comparativa Semanal
+
+
+
+
+
+
+
+
+
🤖 Prediccion de Ventas
+
+
+
+ Basado en el promedio movil y tendencia lineal de los ultimos 30 dias:
+
+
+
+
+
Tendencia Detectada
+
+ -
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
diff --git a/sales-bot/templates/base.html b/sales-bot/templates/base.html
new file mode 100644
index 0000000..d1fc043
--- /dev/null
+++ b/sales-bot/templates/base.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+ {% block title %}Sales Bot{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block extra_css %}{% endblock %}
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+ {% block extra_js %}{% endblock %}
+
+
diff --git a/sales-bot/templates/dashboard.html b/sales-bot/templates/dashboard.html
new file mode 100644
index 0000000..d36baa9
--- /dev/null
+++ b/sales-bot/templates/dashboard.html
@@ -0,0 +1,183 @@
+{% extends "base.html" %}
+
+{% block title %}Dashboard - Sales Bot{% endblock %}
+
+{% block content %}
+
+
+
+
+
Ventas Hoy
+
-
+
$0.00
+
+
+
Ventas del Mes
+
-
+
$0.00
+
+
+
Vendedores Activos Hoy
+
-
+
+
+
Meta Diaria
+
3
+
tubos por vendedor
+
+
+
+
+
+
🏆 Ranking del Mes (Tubos)
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
diff --git a/sales-bot/templates/executive.html b/sales-bot/templates/executive.html
new file mode 100644
index 0000000..0c457fb
--- /dev/null
+++ b/sales-bot/templates/executive.html
@@ -0,0 +1,320 @@
+{% extends "base.html" %}
+
+{% block title %}Dashboard Ejecutivo - Sales Bot{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Comisiones Mes
+
-
+
total equipo
+
+
+
+
+
+
+
📈 Rendimiento Mensual
+
+
+
+
+
+
+
+
+
🏆 Top Performers del Mes
+
+
+
+ | # |
+ Vendedor |
+ Tubos |
+ Comision |
+ Racha |
+
+
+
+ | Cargando... |
+
+
+
+
+
+
+
📊 Metricas Clave
+
+
+
+
+
-
+
Vendedores Activos
+
+
+
+
+
Prediccion Proxima Semana
+
+
+
+
+
+
+
📅 Comparativa Mensual
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}