From 9936deaa90b5c84557568cb7fccf58ed2bae8027 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 19 Jan 2026 03:26:16 +0000 Subject: [PATCH] feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FASE 1 - PWA y Frontend: - Crear templates/base.html, dashboard.html, analytics.html, executive.html - Crear static/css/main.css con diseño responsivo - Agregar static/js/app.js, pwa.js, camera.js, charts.js - Implementar manifest.json y service-worker.js para PWA - Soporte para captura de tickets desde cámara móvil FASE 2 - Analytics: - Crear módulo analytics/ con predictions.py, trends.py, comparisons.py - Implementar predicción básica con promedio móvil + tendencia lineal - Agregar endpoints /api/analytics/trends, predictions, comparisons - Integrar Chart.js para gráficas interactivas FASE 3 - Reportes PDF: - Crear módulo reports/ con pdf_generator.py - Implementar SalesReportPDF con generar_reporte_diario y ejecutivo - Agregar comando /reporte [diario|semanal|ejecutivo] - Agregar endpoints /api/reports/generate y /api/reports/download FASE 4 - Mejoras OCR: - Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py - Implementar AmountDetector con patrones múltiples de montos - Agregar preprocesador adaptativo con pipelines para diferentes condiciones - Soporte para corrección de rotación (deskew) y threshold Otsu Dependencias agregadas: - reportlab, matplotlib (PDF) - scipy, pandas (analytics) - imutils, deskew, cachetools (OCR) Co-Authored-By: Claude Opus 4.5 --- sales-bot/.env.example | 32 ++ sales-bot/analytics/__init__.py | 15 + sales-bot/analytics/comparisons.py | 293 ++++++++++++++ sales-bot/analytics/predictions.py | 241 +++++++++++ sales-bot/analytics/trends.py | 282 +++++++++++++ sales-bot/app.py | 631 ++++++++++++++++------------- sales-bot/ocr/__init__.py | 17 + sales-bot/ocr/amount_detector.py | 258 ++++++++++++ sales-bot/ocr/patterns.py | 223 ++++++++++ sales-bot/ocr/preprocessor.py | 305 ++++++++++++++ sales-bot/ocr/processor.py | 294 ++++++++++++++ sales-bot/reports/__init__.py | 12 + sales-bot/reports/pdf_generator.py | 532 ++++++++++++++++++++++++ sales-bot/requirements.txt | 17 + sales-bot/static/css/main.css | 591 +++++++++++++++++++++++++++ sales-bot/static/js/app.js | 238 +++++++++++ sales-bot/static/js/camera.js | 236 +++++++++++ sales-bot/static/js/charts.js | 266 ++++++++++++ sales-bot/static/js/pwa.js | 122 ++++++ sales-bot/static/manifest.json | 83 ++++ sales-bot/static/service-worker.js | 217 ++++++++++ sales-bot/templates/analytics.html | 320 +++++++++++++++ sales-bot/templates/base.html | 55 +++ sales-bot/templates/dashboard.html | 183 +++++++++ sales-bot/templates/executive.html | 320 +++++++++++++++ 25 files changed, 5501 insertions(+), 282 deletions(-) create mode 100644 sales-bot/analytics/__init__.py create mode 100644 sales-bot/analytics/comparisons.py create mode 100644 sales-bot/analytics/predictions.py create mode 100644 sales-bot/analytics/trends.py create mode 100644 sales-bot/ocr/__init__.py create mode 100644 sales-bot/ocr/amount_detector.py create mode 100644 sales-bot/ocr/patterns.py create mode 100644 sales-bot/ocr/preprocessor.py create mode 100644 sales-bot/ocr/processor.py create mode 100644 sales-bot/reports/__init__.py create mode 100644 sales-bot/reports/pdf_generator.py create mode 100644 sales-bot/static/css/main.css create mode 100644 sales-bot/static/js/app.js create mode 100644 sales-bot/static/js/camera.js create mode 100644 sales-bot/static/js/charts.js create mode 100644 sales-bot/static/js/pwa.js create mode 100644 sales-bot/static/manifest.json create mode 100644 sales-bot/static/service-worker.js create mode 100644 sales-bot/templates/analytics.html create mode 100644 sales-bot/templates/base.html create mode 100644 sales-bot/templates/dashboard.html create mode 100644 sales-bot/templates/executive.html 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)

-
    -
  • Cargando...
  • -
-
+@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 -
-

📋 Ventas Recientes

-
-
Cargando...
-
-
-
-
- - - -''' - 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 %} + + + +
+
+
-
+
Total Ventas
+
+
+
+
-
+
Monto Total
+
+
+
+
-
+
Tubos Vendidos
+
+
+
+
-
+
Promedio/Dia
+
+
+
+
-
+
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: +

+
+
+
-
+
Manana
+
+
+
-
+
Proxima Semana
+
+
+
-
+
Confianza
+
+
+
+
+

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)

+
    +
  • +
+
+ +
+

📋 Ventas Recientes

+
+
+
+
+
+ + + + + +
+
+ +
+ + +
+
+
+ +{% 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 %} + + + +
+
+
Ventas Hoy
+
-
+
+
+
+
Ventas Mes
+
-
+
+
+
+
Comisiones Mes
+
-
+
total equipo
+
+
+
Meta Cumplida
+
-
+
+
+
+ + +
+

📈 Rendimiento Mensual

+
+ +
+
+ +
+ +
+

🏆 Top Performers del Mes

+ + + + + + + + + + + + + +
#VendedorTubosComisionRacha
Cargando...
+
+ + +
+

📊 Metricas Clave

+
+
+
-
+
Promedio/Dia
+
+
+
-
+
Mejor Dia
+
+
+
-
+
Vendedores Activos
+
+
+
-
+
Ticket Promedio
+
+
+ +

Prediccion Proxima Semana

+
+
-
+
Monto estimado
+
+
+
+
+ + +
+

📅 Comparativa Mensual

+
+ +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %}