feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR

FASE 1 - PWA y Frontend:
- Crear templates/base.html, dashboard.html, analytics.html, executive.html
- Crear static/css/main.css con diseño responsivo
- Agregar static/js/app.js, pwa.js, camera.js, charts.js
- Implementar manifest.json y service-worker.js para PWA
- Soporte para captura de tickets desde cámara móvil

FASE 2 - Analytics:
- Crear módulo analytics/ con predictions.py, trends.py, comparisons.py
- Implementar predicción básica con promedio móvil + tendencia lineal
- Agregar endpoints /api/analytics/trends, predictions, comparisons
- Integrar Chart.js para gráficas interactivas

FASE 3 - Reportes PDF:
- Crear módulo reports/ con pdf_generator.py
- Implementar SalesReportPDF con generar_reporte_diario y ejecutivo
- Agregar comando /reporte [diario|semanal|ejecutivo]
- Agregar endpoints /api/reports/generate y /api/reports/download

FASE 4 - Mejoras OCR:
- Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py
- Implementar AmountDetector con patrones múltiples de montos
- Agregar preprocesador adaptativo con pipelines para diferentes condiciones
- Soporte para corrección de rotación (deskew) y threshold Otsu

Dependencias agregadas:
- reportlab, matplotlib (PDF)
- scipy, pandas (analytics)
- imutils, deskew, cachetools (OCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 03:26:16 +00:00
parent ed1658eb2b
commit 9936deaa90
25 changed files with 5501 additions and 282 deletions

View File

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

View File

@@ -0,0 +1,15 @@
"""
Analytics module for Sales Bot
Provides predictions, trends, and performance analysis
"""
from .predictions import prediccion_basica, calcular_tendencia
from .trends import TrendAnalyzer
from .comparisons import ComparisonAnalyzer
__all__ = [
'prediccion_basica',
'calcular_tendencia',
'TrendAnalyzer',
'ComparisonAnalyzer'
]

View File

@@ -0,0 +1,293 @@
"""
Comparison analysis for Sales Bot
Compares sales data across different time periods
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, List
logger = logging.getLogger(__name__)
# Mexico timezone
TZ_MEXICO = timezone(timedelta(hours=-6))
class ComparisonAnalyzer:
"""
Analyzes and compares sales data across different periods.
"""
def __init__(self, nocodb_client):
self.nocodb = nocodb_client
def comparar_semanas(self) -> Dict:
"""
Compara la semana actual con la semana anterior.
Returns:
dict con comparación semanal
"""
try:
hoy = datetime.now(TZ_MEXICO).date()
# Semana actual (lunes a hoy)
dias_desde_lunes = hoy.weekday()
inicio_semana_actual = hoy - timedelta(days=dias_desde_lunes)
# Semana anterior
inicio_semana_anterior = inicio_semana_actual - timedelta(days=7)
fin_semana_anterior = inicio_semana_actual - timedelta(days=1)
# Obtener ventas
ventas_actual = self._obtener_ventas_rango(inicio_semana_actual, hoy)
ventas_anterior = self._obtener_ventas_rango(inicio_semana_anterior, fin_semana_anterior)
monto_actual = sum(float(v.get('monto', 0) or 0) for v in ventas_actual)
monto_anterior = sum(float(v.get('monto', 0) or 0) for v in ventas_anterior)
# Calcular diferencia
if monto_anterior > 0:
diff_pct = ((monto_actual - monto_anterior) / monto_anterior) * 100
else:
diff_pct = 100 if monto_actual > 0 else 0
return {
'current_week': round(monto_actual, 2),
'previous_week': round(monto_anterior, 2),
'difference': round(monto_actual - monto_anterior, 2),
'diff_percent': round(diff_pct, 1),
'ventas_actual': len(ventas_actual),
'ventas_anterior': len(ventas_anterior),
'periodo_actual': {
'inicio': inicio_semana_actual.strftime('%Y-%m-%d'),
'fin': hoy.strftime('%Y-%m-%d')
},
'periodo_anterior': {
'inicio': inicio_semana_anterior.strftime('%Y-%m-%d'),
'fin': fin_semana_anterior.strftime('%Y-%m-%d')
}
}
except Exception as e:
logger.error(f"Error comparando semanas: {str(e)}")
return {
'current_week': 0,
'previous_week': 0,
'difference': 0,
'diff_percent': 0,
'error': str(e)
}
def comparar_meses(self, mes1: str = None, mes2: str = None) -> Dict:
"""
Compara dos meses.
Args:
mes1: Primer mes (formato YYYY-MM), default mes actual
mes2: Segundo mes (formato YYYY-MM), default mes anterior
Returns:
dict con comparación mensual
"""
try:
hoy = datetime.now(TZ_MEXICO).date()
# Determinar meses a comparar
if not mes1:
mes1 = hoy.strftime('%Y-%m')
if not mes2:
# Mes anterior
primer_dia_mes_actual = hoy.replace(day=1)
ultimo_dia_mes_anterior = primer_dia_mes_actual - timedelta(days=1)
mes2 = ultimo_dia_mes_anterior.strftime('%Y-%m')
# Obtener ventas de cada mes
ventas_mes1 = self._obtener_ventas_mes(mes1)
ventas_mes2 = self._obtener_ventas_mes(mes2)
monto_mes1 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes1)
monto_mes2 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes2)
# Calcular diferencia
if monto_mes2 > 0:
diff_pct = ((monto_mes1 - monto_mes2) / monto_mes2) * 100
else:
diff_pct = 100 if monto_mes1 > 0 else 0
# Calcular mes anterior al mes2 para tendencia de 3 meses
anio2, mes2_num = map(int, mes2.split('-'))
if mes2_num == 1:
mes3 = f"{anio2 - 1}-12"
else:
mes3 = f"{anio2}-{mes2_num - 1:02d}"
ventas_mes3 = self._obtener_ventas_mes(mes3)
monto_mes3 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes3)
return {
'current_month': round(monto_mes1, 2),
'previous_month': round(monto_mes2, 2),
'month_2': round(monto_mes3, 2),
'difference': round(monto_mes1 - monto_mes2, 2),
'diff_percent': round(diff_pct, 1),
'ventas_actual': len(ventas_mes1),
'ventas_anterior': len(ventas_mes2),
'months': [mes3, mes2, mes1],
'values': [round(monto_mes3, 2), round(monto_mes2, 2), round(monto_mes1, 2)],
'mes1': mes1,
'mes2': mes2
}
except Exception as e:
logger.error(f"Error comparando meses: {str(e)}")
return {
'current_month': 0,
'previous_month': 0,
'difference': 0,
'diff_percent': 0,
'error': str(e)
}
def comparar_anios(self, anio1: int = None, anio2: int = None) -> Dict:
"""
Compara ventas anuales (hasta la fecha actual del año).
Args:
anio1: Primer año, default año actual
anio2: Segundo año, default año anterior
Returns:
dict con comparación anual
"""
try:
hoy = datetime.now(TZ_MEXICO).date()
if not anio1:
anio1 = hoy.year
if not anio2:
anio2 = anio1 - 1
# Obtener ventas YTD de cada año
dia_mes = hoy.strftime('%m-%d')
# Para comparación justa, comparamos hasta el mismo día del año
ventas_anio1 = self._obtener_ventas_ytd(anio1, dia_mes)
ventas_anio2 = self._obtener_ventas_ytd(anio2, dia_mes)
monto_anio1 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio1)
monto_anio2 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio2)
# Calcular diferencia
if monto_anio2 > 0:
diff_pct = ((monto_anio1 - monto_anio2) / monto_anio2) * 100
else:
diff_pct = 100 if monto_anio1 > 0 else 0
return {
'anio_actual': anio1,
'anio_anterior': anio2,
'monto_actual': round(monto_anio1, 2),
'monto_anterior': round(monto_anio2, 2),
'difference': round(monto_anio1 - monto_anio2, 2),
'diff_percent': round(diff_pct, 1),
'ventas_actual': len(ventas_anio1),
'ventas_anterior': len(ventas_anio2),
'comparacion_hasta': hoy.strftime('%d de %B')
}
except Exception as e:
logger.error(f"Error comparando años: {str(e)}")
return {
'monto_actual': 0,
'monto_anterior': 0,
'difference': 0,
'diff_percent': 0,
'error': str(e)
}
def _obtener_ventas_rango(self, inicio: datetime.date, fin: datetime.date) -> List[Dict]:
"""Obtiene ventas en un rango de fechas."""
try:
# Obtener todas las ventas recientes
ventas = self.nocodb.get_ventas_mes() if hasattr(self.nocodb, 'get_ventas_mes') else []
ventas_filtradas = []
for venta in ventas:
try:
fecha_str = venta.get('fecha_venta', '')
if 'T' in fecha_str:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
else:
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
if fecha.tzinfo is None:
fecha = fecha.replace(tzinfo=timezone.utc)
fecha_local = fecha.astimezone(TZ_MEXICO).date()
if inicio <= fecha_local <= fin:
ventas_filtradas.append(venta)
except:
continue
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del rango: {str(e)}")
return []
def _obtener_ventas_mes(self, mes: str) -> List[Dict]:
"""Obtiene ventas de un mes específico."""
try:
# Usar método existente si está disponible
if hasattr(self.nocodb, 'get_ventas_mes'):
ventas = self.nocodb.get_ventas_mes()
else:
return []
# Filtrar por mes
ventas_filtradas = []
for venta in ventas:
fecha_str = venta.get('fecha_venta', '')
if fecha_str and fecha_str[:7] == mes:
ventas_filtradas.append(venta)
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del mes {mes}: {str(e)}")
return []
def _obtener_ventas_ytd(self, anio: int, hasta_dia_mes: str) -> List[Dict]:
"""Obtiene ventas del año hasta una fecha específica."""
try:
inicio = datetime(anio, 1, 1).date()
mes, dia = map(int, hasta_dia_mes.split('-'))
fin = datetime(anio, mes, dia).date()
return self._obtener_ventas_rango(inicio, fin)
except Exception as e:
logger.error(f"Error obteniendo ventas YTD {anio}: {str(e)}")
return []
def get_comparison_summary(self, tipo: str = 'weekly') -> Dict:
"""
Obtiene resumen de comparación según el tipo solicitado.
Args:
tipo: 'weekly', 'monthly', o 'yearly'
Returns:
dict con comparación
"""
if tipo == 'weekly':
return self.comparar_semanas()
elif tipo == 'monthly':
return self.comparar_meses()
elif tipo == 'yearly':
return self.comparar_anios()
else:
return {'error': f'Tipo de comparación no válido: {tipo}'}

View File

@@ -0,0 +1,241 @@
"""
Sales prediction algorithms for Sales Bot
Uses moving average and linear regression for basic predictions
"""
import logging
from typing import List, Dict, Optional
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# Try to import scipy for linear regression
try:
from scipy import stats
import numpy as np
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
logger.warning("scipy not available, using basic prediction methods")
def prediccion_basica(ventas_diarias: List[float], dias_prediccion: int = 7) -> Dict:
"""
Genera predicciones combinando promedio móvil con tendencia lineal.
Args:
ventas_diarias: Lista de montos de ventas por día
dias_prediccion: Número de días a predecir
Returns:
dict con predicciones, tendencia y confianza
"""
if not ventas_diarias or len(ventas_diarias) < 3:
return {
'predicciones': [],
'next_day': 0,
'next_week': 0,
'tendencia': 'stable',
'confidence': 0,
'error': 'Datos insuficientes para predicción'
}
try:
# Calcular promedio móvil (últimos 7 días o menos si no hay suficientes)
ventana = min(7, len(ventas_diarias))
promedio_movil = sum(ventas_diarias[-ventana:]) / ventana
# Calcular tendencia
if SCIPY_AVAILABLE:
# Usar regresión lineal con scipy
x = np.arange(len(ventas_diarias))
y = np.array(ventas_diarias)
slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
confianza = abs(r_value)
else:
# Método básico sin scipy
slope, intercept, confianza = _calcular_regresion_simple(ventas_diarias)
# Generar predicciones
predicciones = []
for i in range(1, dias_prediccion + 1):
# Predicción lineal
pred_lineal = slope * (len(ventas_diarias) + i) + intercept
# Combinar promedio móvil con tendencia lineal
pred_combinada = (promedio_movil + pred_lineal) / 2
# No permitir predicciones negativas
predicciones.append(max(0, pred_combinada))
# Determinar tendencia
if slope > 0.5:
tendencia = 'increasing'
elif slope < -0.5:
tendencia = 'decreasing'
else:
tendencia = 'stable'
return {
'predicciones': predicciones,
'next_day': round(predicciones[0], 2) if predicciones else 0,
'next_week': round(sum(predicciones[:7]), 2) if len(predicciones) >= 7 else round(sum(predicciones), 2),
'tendencia': tendencia,
'trend': tendencia, # Alias for frontend
'confidence': round(confianza, 2),
'promedio_movil': round(promedio_movil, 2),
'slope': round(slope, 4)
}
except Exception as e:
logger.error(f"Error en predicción: {str(e)}")
return {
'predicciones': [],
'next_day': 0,
'next_week': 0,
'tendencia': 'stable',
'trend': 'stable',
'confidence': 0,
'error': str(e)
}
def _calcular_regresion_simple(datos: List[float]) -> tuple:
"""
Calcula regresión lineal simple sin scipy.
Returns:
(slope, intercept, r_value)
"""
n = len(datos)
if n < 2:
return 0, datos[0] if datos else 0, 0
x = list(range(n))
y = datos
# Calcular medias
x_mean = sum(x) / n
y_mean = sum(y) / n
# Calcular slope
numerador = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
denominador = sum((x[i] - x_mean) ** 2 for i in range(n))
if denominador == 0:
return 0, y_mean, 0
slope = numerador / denominador
intercept = y_mean - slope * x_mean
# Calcular R-squared (coeficiente de determinación)
y_pred = [slope * xi + intercept for xi in x]
ss_res = sum((y[i] - y_pred[i]) ** 2 for i in range(n))
ss_tot = sum((y[i] - y_mean) ** 2 for i in range(n))
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
r_value = r_squared ** 0.5 if r_squared >= 0 else 0
return slope, intercept, r_value
def calcular_tendencia(ventas_diarias: List[float]) -> Dict:
"""
Analiza la tendencia de los datos de ventas.
Returns:
dict con información de tendencia
"""
if not ventas_diarias or len(ventas_diarias) < 2:
return {
'direccion': 'stable',
'cambio_porcentual': 0,
'volatilidad': 0
}
# Dividir en dos mitades
mitad = len(ventas_diarias) // 2
primera_mitad = ventas_diarias[:mitad] if mitad > 0 else ventas_diarias[:1]
segunda_mitad = ventas_diarias[mitad:] if mitad > 0 else ventas_diarias[1:]
promedio_primera = sum(primera_mitad) / len(primera_mitad) if primera_mitad else 0
promedio_segunda = sum(segunda_mitad) / len(segunda_mitad) if segunda_mitad else 0
# Calcular cambio porcentual
if promedio_primera > 0:
cambio_pct = ((promedio_segunda - promedio_primera) / promedio_primera) * 100
else:
cambio_pct = 0
# Determinar dirección
if cambio_pct > 5:
direccion = 'increasing'
elif cambio_pct < -5:
direccion = 'decreasing'
else:
direccion = 'stable'
# Calcular volatilidad (desviación estándar relativa)
media = sum(ventas_diarias) / len(ventas_diarias)
if media > 0:
varianza = sum((x - media) ** 2 for x in ventas_diarias) / len(ventas_diarias)
desviacion = varianza ** 0.5
volatilidad = (desviacion / media) * 100
else:
volatilidad = 0
return {
'direccion': direccion,
'cambio_porcentual': round(cambio_pct, 2),
'volatilidad': round(volatilidad, 2),
'promedio_anterior': round(promedio_primera, 2),
'promedio_actual': round(promedio_segunda, 2)
}
def generar_prediccion_extendida(
ventas_diarias: List[float],
dias_historico: int = 30,
dias_futuro: int = 14
) -> Dict:
"""
Genera predicción extendida con más detalles.
Args:
ventas_diarias: Datos históricos
dias_historico: Días de historia a considerar
dias_futuro: Días a predecir
Returns:
dict con predicción completa
"""
# Limitar datos al histórico solicitado
datos = ventas_diarias[-dias_historico:] if len(ventas_diarias) > dias_historico else ventas_diarias
# Obtener predicción básica
prediccion = prediccion_basica(datos, dias_futuro)
# Agregar análisis de tendencia
tendencia_info = calcular_tendencia(datos)
# Calcular estadísticas adicionales
if datos:
maximo = max(datos)
minimo = min(datos)
promedio = sum(datos) / len(datos)
mejor_dia_idx = datos.index(maximo)
else:
maximo = minimo = promedio = 0
mejor_dia_idx = 0
return {
**prediccion,
'tendencia_detalle': tendencia_info,
'estadisticas': {
'maximo': round(maximo, 2),
'minimo': round(minimo, 2),
'promedio': round(promedio, 2),
'mejor_dia_indice': mejor_dia_idx,
'dias_analizados': len(datos)
}
}

View File

@@ -0,0 +1,282 @@
"""
Trend analysis for Sales Bot
Aggregates and analyzes sales data over time
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Optional
from collections import defaultdict
logger = logging.getLogger(__name__)
# Mexico timezone
TZ_MEXICO = timezone(timedelta(hours=-6))
class TrendAnalyzer:
"""
Analyzes sales trends over different time periods.
"""
def __init__(self, nocodb_client):
self.nocodb = nocodb_client
def get_daily_trends(self, dias: int = 30, vendedor: str = None) -> Dict:
"""
Obtiene tendencias diarias de ventas.
Args:
dias: Número de días a analizar
vendedor: Username del vendedor (opcional, None para todos)
Returns:
dict con labels, ventas, tubos, y predicciones
"""
try:
# Obtener ventas del período
ventas = self._obtener_ventas_periodo(dias, vendedor)
# Agrupar por día
ventas_por_dia = self._agrupar_por_dia(ventas, dias)
# Generar labels (fechas)
hoy = datetime.now(TZ_MEXICO).date()
labels = []
valores_ventas = []
valores_tubos = []
for i in range(dias - 1, -1, -1):
fecha = hoy - timedelta(days=i)
fecha_str = fecha.strftime('%Y-%m-%d')
labels.append(fecha.strftime('%d/%m'))
dia_data = ventas_por_dia.get(fecha_str, {'monto': 0, 'tubos': 0})
valores_ventas.append(dia_data['monto'])
valores_tubos.append(dia_data['tubos'])
# Generar predicciones si hay datos
prediccion = []
if valores_ventas:
from .predictions import prediccion_basica
pred_result = prediccion_basica(valores_ventas, 7)
if pred_result.get('predicciones'):
# Añadir None para los días históricos y luego las predicciones
prediccion = [None] * len(valores_ventas)
prediccion.extend(pred_result['predicciones'])
return {
'labels': labels,
'ventas': valores_ventas,
'tubos': valores_tubos,
'prediccion': prediccion,
'periodo': {
'inicio': (hoy - timedelta(days=dias-1)).strftime('%Y-%m-%d'),
'fin': hoy.strftime('%Y-%m-%d'),
'dias': dias
}
}
except Exception as e:
logger.error(f"Error obteniendo tendencias diarias: {str(e)}")
return {
'labels': [],
'ventas': [],
'tubos': [],
'prediccion': [],
'error': str(e)
}
def _obtener_ventas_periodo(self, dias: int, vendedor: str = None) -> List[Dict]:
"""Obtiene ventas de los últimos N días."""
try:
# Usar método existente del cliente NocoDB
if hasattr(self.nocodb, 'get_ventas_historico'):
return self.nocodb.get_ventas_historico(dias, vendedor)
# Fallback: obtener ventas del mes y filtrar
ventas = self.nocodb.get_ventas_mes(vendedor) if vendedor else self.nocodb.get_ventas_mes()
# Filtrar por días
hoy = datetime.now(TZ_MEXICO).date()
fecha_inicio = hoy - timedelta(days=dias)
ventas_filtradas = []
for venta in ventas:
fecha_str = venta.get('fecha_venta', '')
try:
if 'T' in fecha_str:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
else:
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
if fecha.tzinfo is None:
fecha = fecha.replace(tzinfo=timezone.utc)
fecha_local = fecha.astimezone(TZ_MEXICO).date()
if fecha_local >= fecha_inicio:
ventas_filtradas.append(venta)
except:
continue
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del período: {str(e)}")
return []
def _agrupar_por_dia(self, ventas: List[Dict], dias: int) -> Dict[str, Dict]:
"""Agrupa ventas por día."""
ventas_por_dia = defaultdict(lambda: {'monto': 0, 'tubos': 0, 'count': 0})
for venta in ventas:
try:
fecha_str = venta.get('fecha_venta', '')
if 'T' in fecha_str:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
else:
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
if fecha.tzinfo is None:
fecha = fecha.replace(tzinfo=timezone.utc)
fecha_local = fecha.astimezone(TZ_MEXICO).date()
fecha_key = fecha_local.strftime('%Y-%m-%d')
monto = float(venta.get('monto', 0) or 0)
ventas_por_dia[fecha_key]['monto'] += monto
ventas_por_dia[fecha_key]['count'] += 1
# Contar tubos si hay información de productos
tubos = self._contar_tubos_venta(venta)
ventas_por_dia[fecha_key]['tubos'] += tubos
except Exception as e:
continue
return dict(ventas_por_dia)
def _contar_tubos_venta(self, venta: Dict) -> int:
"""Cuenta tubos de tinte en una venta."""
# Esto depende de la estructura de datos
# Por ahora retorna un valor estimado basado en el monto
# En producción, debería consultar la tabla de detalles
tubos = venta.get('tubos', 0)
if tubos:
return int(tubos)
# Estimación: ~$50 por tubo en promedio
monto = float(venta.get('monto', 0) or 0)
return max(1, int(monto / 50)) if monto > 0 else 0
def get_weekly_summary(self, semanas: int = 4) -> Dict:
"""
Obtiene resumen semanal de ventas.
Args:
semanas: Número de semanas a analizar
Returns:
dict con datos semanales
"""
try:
dias_totales = semanas * 7
ventas = self._obtener_ventas_periodo(dias_totales)
# Agrupar por semana
hoy = datetime.now(TZ_MEXICO).date()
semanas_data = []
for i in range(semanas):
fin_semana = hoy - timedelta(days=i * 7)
inicio_semana = fin_semana - timedelta(days=6)
# Filtrar ventas de esta semana
ventas_semana = []
for venta in ventas:
try:
fecha_str = venta.get('fecha_venta', '')
if 'T' in fecha_str:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
else:
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
if fecha.tzinfo is None:
fecha = fecha.replace(tzinfo=timezone.utc)
fecha_local = fecha.astimezone(TZ_MEXICO).date()
if inicio_semana <= fecha_local <= fin_semana:
ventas_semana.append(venta)
except:
continue
monto_total = sum(float(v.get('monto', 0) or 0) for v in ventas_semana)
semanas_data.append({
'semana': i + 1,
'inicio': inicio_semana.strftime('%Y-%m-%d'),
'fin': fin_semana.strftime('%Y-%m-%d'),
'label': f"{inicio_semana.strftime('%d/%m')} - {fin_semana.strftime('%d/%m')}",
'monto': round(monto_total, 2),
'ventas': len(ventas_semana)
})
# Invertir para orden cronológico
semanas_data.reverse()
return {
'semanas': semanas_data,
'total_semanas': semanas
}
except Exception as e:
logger.error(f"Error obteniendo resumen semanal: {str(e)}")
return {'semanas': [], 'error': str(e)}
def get_monthly_summary(self, meses: int = 6) -> Dict:
"""
Obtiene resumen mensual de ventas.
Args:
meses: Número de meses a analizar
Returns:
dict con datos mensuales
"""
try:
# Esto requeriría acceso a más datos históricos
# Por ahora retornamos estructura básica
hoy = datetime.now(TZ_MEXICO).date()
meses_data = []
for i in range(meses):
# Calcular mes
mes_actual = hoy.month - i
anio_actual = hoy.year
while mes_actual < 1:
mes_actual += 12
anio_actual -= 1
mes_str = f"{anio_actual}-{mes_actual:02d}"
mes_nombre = datetime(anio_actual, mes_actual, 1).strftime('%B %Y')
meses_data.append({
'mes': mes_str,
'nombre': mes_nombre,
'monto': 0, # Se llenaría con datos reales
'ventas': 0
})
meses_data.reverse()
return {
'meses': meses_data,
'total_meses': meses
}
except Exception as e:
logger.error(f"Error obteniendo resumen mensual: {str(e)}")
return {'meses': [], 'error': str(e)}

View File

@@ -1,13 +1,17 @@
from flask import Flask, request, jsonify, render_template_string
from flask import Flask, request, jsonify, render_template_string, render_template, send_from_directory
import os
import logging
from dotenv import load_dotenv
from datetime import datetime
from datetime import datetime, timedelta, timezone
import json
import base64
# Cargar variables de entorno
load_dotenv()
# Timezone México
TZ_MEXICO = timezone(timedelta(hours=-6))
# Importar módulos personalizados
from mattermost_client import MattermostClient
from nocodb_client import NocoDBClient
@@ -26,8 +30,10 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# Inicializar Flask
app = Flask(__name__)
# Inicializar Flask con templates y static folders
app = Flask(__name__,
template_folder='templates',
static_folder='static')
# Inicializar clientes
mattermost = MattermostClient(
@@ -801,305 +807,366 @@ def api_dashboard_metas():
@app.route('/dashboard')
def dashboard():
"""Dashboard principal de ventas"""
html = '''
try:
return render_template('dashboard.html')
except Exception as e:
logger.error(f"Error renderizando dashboard: {str(e)}")
# Fallback al HTML embebido si no hay template
return render_template_string('''
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sales Bot - Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
h1 { font-size: 28px; font-weight: 600; }
h1 span { color: #00d4ff; }
.fecha { color: #888; font-size: 14px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(0,212,255,0.1);
}
.stat-card .label { color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.stat-card .value { font-size: 32px; font-weight: 700; color: #00d4ff; }
.stat-card .subvalue { font-size: 14px; color: #666; margin-top: 4px; }
.stat-card.green .value { color: #00ff88; }
.stat-card.orange .value { color: #ffaa00; }
.stat-card.purple .value { color: #aa00ff; }
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.1);
}
.panel h2 {
font-size: 18px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel h2 .icon { font-size: 24px; }
.ranking-list { list-style: none; }
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.ranking-item:last-child { border-bottom: none; }
.ranking-position {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
margin-right: 12px;
}
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
.ranking-position.default { background: rgba(255,255,255,0.1); color: #888; }
.ranking-info { flex: 1; }
.ranking-name { font-weight: 600; margin-bottom: 2px; }
.ranking-stats { font-size: 12px; color: #888; }
.ranking-value { text-align: right; }
.ranking-tubos { font-size: 24px; font-weight: 700; color: #00d4ff; }
.ranking-comision { font-size: 12px; color: #00ff88; }
.ventas-list { max-height: 400px; overflow-y: auto; }
.venta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
}
.venta-info .vendedor { font-weight: 600; color: #00d4ff; }
.venta-info .cliente { font-size: 12px; color: #888; }
.venta-monto { font-size: 18px; font-weight: 700; color: #00ff88; }
.refresh-btn {
background: rgba(0,212,255,0.2);
border: 1px solid #00d4ff;
color: #00d4ff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.refresh-btn:hover { background: rgba(0,212,255,0.3); }
.loading { text-align: center; padding: 40px; color: #888; }
.meta-progress {
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.meta-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00ff88);
border-radius: 4px;
transition: width 0.5s;
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1><span>Sales</span> Bot Dashboard</h1>
<p class="fecha" id="fecha-actual"></p>
</div>
<button class="refresh-btn" onclick="cargarDatos()">🔄 Actualizar</button>
</header>
<html><head><title>Sales Bot Dashboard</title></head>
<body style="background:#1a1a2e;color:#fff;font-family:sans-serif;padding:40px;text-align:center;">
<h1>Sales Bot Dashboard</h1>
<p>Error cargando templates. Verifica que la carpeta templates/ existe.</p>
<p>Error: ''' + str(e) + '''</p>
</body></html>
''')
<div class="stats-grid">
<div class="stat-card">
<div class="label">Ventas Hoy</div>
<div class="value" id="ventas-hoy">-</div>
<div class="subvalue" id="monto-hoy">$0.00</div>
</div>
<div class="stat-card green">
<div class="label">Ventas del Mes</div>
<div class="value" id="ventas-mes">-</div>
<div class="subvalue" id="monto-mes">$0.00</div>
</div>
<div class="stat-card orange">
<div class="label">Vendedores Activos Hoy</div>
<div class="value" id="vendedores-activos">-</div>
</div>
<div class="stat-card purple">
<div class="label">Meta Diaria</div>
<div class="value">3</div>
<div class="subvalue">tubos por vendedor</div>
</div>
</div>
<div class="main-grid">
<div class="panel">
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
<ul class="ranking-list" id="ranking-list">
<li class="loading">Cargando...</li>
</ul>
</div>
@app.route('/dashboard/analytics')
def dashboard_analytics():
"""Dashboard de analytics con gráficas"""
try:
return render_template('analytics.html')
except Exception as e:
logger.error(f"Error renderizando analytics: {str(e)}")
return jsonify({'error': str(e)}), 500
<div class="panel">
<h2><span class="icon">📋</span> Ventas Recientes</h2>
<div class="ventas-list" id="ventas-list">
<div class="loading">Cargando...</div>
</div>
</div>
</div>
</div>
<script>
function formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
@app.route('/dashboard/executive')
def dashboard_executive():
"""Dashboard ejecutivo con KPIs"""
try:
return render_template('executive.html')
except Exception as e:
logger.error(f"Error renderizando executive: {str(e)}")
return jsonify({'error': str(e)}), 500
# ============== PWA ROUTES ==============
@app.route('/manifest.json')
def serve_manifest():
"""Servir manifest.json para PWA"""
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
@app.route('/service-worker.js')
def serve_service_worker():
"""Servir service worker para PWA"""
return send_from_directory('static', 'service-worker.js', mimetype='application/javascript')
# ============== ANALYTICS API ==============
@app.route('/api/analytics/trends', methods=['GET'])
def api_analytics_trends():
"""API: Tendencias de ventas"""
try:
from analytics.trends import TrendAnalyzer
dias = request.args.get('days', 30, type=int)
vendedor = request.args.get('vendedor', None)
analyzer = TrendAnalyzer(nocodb)
trends = analyzer.get_daily_trends(dias, vendedor)
return jsonify(trends), 200
except ImportError:
logger.warning("Módulo analytics.trends no disponible")
return jsonify({'error': 'Módulo de analytics no disponible', 'labels': [], 'ventas': []}), 200
except Exception as e:
logger.error(f"Error en API trends: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/analytics/predictions', methods=['GET'])
def api_analytics_predictions():
"""API: Predicciones de ventas"""
try:
from analytics.predictions import prediccion_basica
from analytics.trends import TrendAnalyzer
dias = request.args.get('days', 30, type=int)
dias_prediccion = request.args.get('predict', 7, type=int)
analyzer = TrendAnalyzer(nocodb)
trends = analyzer.get_daily_trends(dias)
ventas_diarias = trends.get('ventas', [])
prediccion = prediccion_basica(ventas_diarias, dias_prediccion)
return jsonify(prediccion), 200
except ImportError:
logger.warning("Módulo analytics.predictions no disponible")
return jsonify({
'error': 'Módulo de predicciones no disponible',
'next_day': 0,
'next_week': 0,
'tendencia': 'stable'
}), 200
except Exception as e:
logger.error(f"Error en API predictions: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/analytics/comparisons', methods=['GET'])
def api_analytics_comparisons():
"""API: Comparativas de períodos"""
try:
from analytics.comparisons import ComparisonAnalyzer
tipo = request.args.get('type', 'weekly')
analyzer = ComparisonAnalyzer(nocodb)
comparison = analyzer.get_comparison_summary(tipo)
return jsonify(comparison), 200
except ImportError:
logger.warning("Módulo analytics.comparisons no disponible")
return jsonify({'error': 'Módulo de comparaciones no disponible'}), 200
except Exception as e:
logger.error(f"Error en API comparisons: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/analytics/performance/<username>', methods=['GET'])
def api_analytics_performance(username):
"""API: Rendimiento de un vendedor específico"""
try:
# Obtener datos del vendedor
meta = nocodb.get_meta_vendedor(username)
racha = nocodb.verificar_racha(username)
ranking = nocodb.get_ranking_vendedores()
# Encontrar posición en ranking
posicion = 0
for i, v in enumerate(ranking, 1):
if v.get('vendedor_username') == username:
posicion = i
break
return jsonify({
'username': username,
'tubos_totales': meta.get('tubos_totales', 0) if meta else 0,
'total_vendido': meta.get('total_vendido', 0) if meta else 0,
'comision': meta.get('comision_total', 0) if meta else 0,
'ventas_realizadas': meta.get('ventas_realizadas', 0) if meta else 0,
'racha': racha.get('dias_consecutivos', 0),
'ranking': posicion,
'porcentaje_meta': meta.get('porcentaje_completado', 0) if meta else 0
}), 200
except Exception as e:
logger.error(f"Error en API performance: {str(e)}")
return jsonify({'error': str(e)}), 500
# ============== REPORTS API ==============
@app.route('/comando/reporte', methods=['POST'])
def comando_reporte():
"""
Endpoint para el comando slash /reporte en Mattermost
Uso: /reporte [diario|semanal|ejecutivo]
"""
try:
from utils import validar_tokens_comando
data = request.form.to_dict()
logger.info(f"Comando /reporte recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
if not validar_tokens_comando(token, 'reporte'):
return jsonify({'text': 'Token inválido'}), 403
user_name = data.get('user_name')
channel_id = data.get('channel_id')
texto = data.get('text', '').strip().lower()
# Determinar tipo de reporte
if 'ejecutivo' in texto or 'executive' in texto:
tipo = 'ejecutivo'
elif 'semanal' in texto or 'weekly' in texto:
tipo = 'semanal'
else:
tipo = 'diario'
# Generar reporte
try:
from reports.pdf_generator import SalesReportPDF, generar_reporte_diario, generar_reporte_ejecutivo
ventas = nocodb.get_ventas_dia() if tipo == 'diario' else nocodb.get_ventas_mes()
ranking = nocodb.get_ranking_vendedores()
# Calcular estadísticas
stats = {
'monto_total': sum(float(v.get('monto', 0) or 0) for v in ventas),
'cantidad_ventas': len(ventas),
'tubos_totales': sum(int(v.get('tubos', 0) or 0) for v in ventas),
'comision_total': sum(float(v.get('comision', 0) or 0) for v in ventas)
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
if tipo == 'ejecutivo':
pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats)
filename = f"reporte_ejecutivo_{datetime.now(TZ_MEXICO).strftime('%Y%m%d')}.pdf"
else:
pdf_content = generar_reporte_diario(ventas, ranking, stats)
filename = f"reporte_{tipo}_{datetime.now(TZ_MEXICO).strftime('%Y%m%d')}.pdf"
# Subir PDF a Mattermost
file_response = mattermost.upload_file(channel_id, pdf_content, filename)
if file_response:
mensaje = f"📊 **Reporte {tipo.capitalize()} generado**\n\nArchivo: `{filename}`"
else:
mensaje = "❌ Error al subir el reporte. Intenta de nuevo."
except ImportError as ie:
logger.warning(f"Módulo de reportes no disponible: {ie}")
mensaje = (
f"📊 **Reporte {tipo.capitalize()}** (texto)\n\n"
f"Instala `reportlab` para generar PDFs.\n\n"
f"**Resumen:**\n"
f"• Ventas: {len(ventas)}\n"
f"• Monto: ${stats['monto_total']:,.2f}\n"
)
return jsonify({
'response_type': 'in_channel',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /reporte: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/api/reports/generate', methods=['POST'])
def api_reports_generate():
"""API: Generar reporte PDF"""
try:
from reports.pdf_generator import generar_reporte_diario, generar_reporte_ejecutivo
data = request.json or {}
tipo = data.get('type', 'daily')
vendedor = data.get('vendedor', None)
# Obtener datos
if tipo == 'daily':
ventas = nocodb.get_ventas_dia()
else:
ventas = nocodb.get_ventas_mes()
if vendedor:
ventas = [v for v in ventas if v.get('vendedor_username') == vendedor]
ranking = nocodb.get_ranking_vendedores()
stats = {
'monto_total': sum(float(v.get('monto', 0) or 0) for v in ventas),
'cantidad_ventas': len(ventas),
'tubos_totales': sum(int(v.get('tubos', 0) or 0) for v in ventas),
'comision_total': sum(float(v.get('comision', 0) or 0) for v in ventas)
}
async function cargarResumen() {
try {
const res = await fetch('/api/dashboard/resumen');
const data = await res.json();
# Generar PDF
if tipo == 'executive':
pdf_content = generar_reporte_ejecutivo(ventas, ranking, stats)
else:
pdf_content = generar_reporte_diario(ventas, ranking, stats)
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
} catch (e) {
console.error('Error cargando resumen:', e);
}
}
# Guardar temporalmente y devolver ID
import hashlib
report_id = hashlib.md5(f"{tipo}_{datetime.now().isoformat()}".encode()).hexdigest()[:12]
async function cargarRanking() {
try {
const res = await fetch('/api/dashboard/ranking');
const data = await res.json();
const lista = document.getElementById('ranking-list');
# Guardar en directorio temporal
reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports')
os.makedirs(reports_dir, exist_ok=True)
report_path = os.path.join(reports_dir, f"{report_id}.pdf")
if (!data || data.length === 0) {
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
return;
}
with open(report_path, 'wb') as f:
f.write(pdf_content)
lista.innerHTML = data.slice(0, 10).map((v, i) => {
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
const tubos = v.tubos_totales || 0;
const comision = v.comision_total || 0;
const ventas = v.cantidad_ventas || 0;
return jsonify({
'report_id': report_id,
'status': 'generated',
'type': tipo,
'download_url': f'/api/reports/download/{report_id}'
}), 200
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
const username = v.vendedor_username || v.vendedor;
except ImportError:
return jsonify({'error': 'Módulo de reportes no disponible. Instala reportlab.'}), 500
except Exception as e:
logger.error(f"Error generando reporte: {str(e)}")
return jsonify({'error': str(e)}), 500
return `
<li class="ranking-item">
<div class="ranking-position ${posClass}">${i + 1}</div>
<div class="ranking-info">
<div class="ranking-name">${nombre}</div>
<div class="ranking-stats">@${username} • ${ventas} ventas • ${v.dias_activos || 0} días activos</div>
</div>
<div class="ranking-value">
<div class="ranking-tubos">${tubos} 🧪</div>
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
</div>
</li>
`;
}).join('');
} catch (e) {
console.error('Error cargando ranking:', e);
}
}
async function cargarVentasRecientes() {
try {
const res = await fetch('/api/dashboard/ventas-recientes');
const data = await res.json();
const lista = document.getElementById('ventas-list');
@app.route('/api/reports/download/<report_id>', methods=['GET'])
def api_reports_download(report_id):
"""API: Descargar reporte PDF"""
try:
from flask import send_file
if (!data || data.length === 0) {
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
return;
}
reports_dir = os.getenv('REPORTS_OUTPUT_DIR', '/tmp/salesbot_reports')
report_path = os.path.join(reports_dir, f"{report_id}.pdf")
lista.innerHTML = data.map(v => {
const nombre = v.nombre_completo || v.vendedor_username;
return `
<div class="venta-item">
<div class="venta-info">
<div class="vendedor">${nombre}</div>
<div class="cliente">${v.cliente || 'Sin cliente'} • ${formatDate(v.fecha_venta)}</div>
</div>
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
</div>
`}).join('');
} catch (e) {
console.error('Error cargando ventas:', e);
}
}
if not os.path.exists(report_path):
return jsonify({'error': 'Reporte no encontrado'}), 404
function cargarDatos() {
cargarResumen();
cargarRanking();
cargarVentasRecientes();
}
return send_file(
report_path,
mimetype='application/pdf',
as_attachment=True,
download_name=f"reporte_{report_id}.pdf"
)
except Exception as e:
logger.error(f"Error descargando reporte: {str(e)}")
return jsonify({'error': str(e)}), 500
// Cargar datos al inicio
cargarDatos();
// Actualizar cada 30 segundos
setInterval(cargarDatos, 30000);
</script>
</body>
</html>
'''
return render_template_string(html)
# ============== CAMERA/OCR API ==============
@app.route('/api/capture/ticket', methods=['POST'])
def api_capture_ticket():
"""API: Procesar imagen de ticket desde cámara (base64)"""
try:
data = request.json or {}
image_base64 = data.get('image')
user_name = data.get('user_name', 'anonymous')
if not image_base64:
return jsonify({'error': 'No se recibió imagen'}), 400
# Decodificar imagen base64
if ',' in image_base64:
image_base64 = image_base64.split(',')[1]
image_bytes = base64.b64decode(image_base64)
# Procesar con OCR
try:
from ocr.processor import procesar_ticket_imagen
resultado = procesar_ticket_imagen(image_bytes)
except ImportError:
# Fallback al procesador existente si el módulo OCR no existe
from handlers import procesar_imagen_ticket
resultado = procesar_imagen_ticket(image_bytes)
return jsonify({
'success': True,
'monto_detectado': resultado.get('monto', 0),
'cliente_detectado': resultado.get('cliente', ''),
'texto_extraido': resultado.get('texto', ''),
'confianza': resultado.get('confianza', 0)
}), 200
except Exception as e:
logger.error(f"Error procesando imagen de ticket: {str(e)}")
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
port = int(os.getenv('FLASK_PORT', 5000))

17
sales-bot/ocr/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
OCR Module for Sales Bot
Improved text extraction and amount detection from ticket images
"""
from .processor import procesar_ticket_imagen, OCRProcessor
from .amount_detector import AmountDetector, detectar_monto
from .patterns import detectar_formato_ticket, TICKET_FORMATS
__all__ = [
'procesar_ticket_imagen',
'OCRProcessor',
'AmountDetector',
'detectar_monto',
'detectar_formato_ticket',
'TICKET_FORMATS'
]

View File

@@ -0,0 +1,258 @@
"""
Amount detection for Sales Bot OCR
Improved detection of total amounts from ticket text
"""
import re
import logging
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Amount patterns in priority order
PATTERNS = [
# Explicit total patterns (highest priority)
(r'total\s*a\s*pagar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_a_pagar', 1),
(r'gran\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'gran_total', 2),
(r'total\s+final\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_final', 3),
(r'(?:^|\n)\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total', 4),
# Payment related
(r'a\s*cobrar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'a_cobrar', 5),
(r'importe\s*(?:total)?\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'importe', 6),
(r'monto\s*(?:total)?\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'monto', 7),
(r'suma\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'suma', 8),
(r'pago\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'pago', 9),
# Subtotal (lower priority - may need to add tax)
(r'subtotal\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'subtotal', 10),
# Generic currency patterns (lowest priority)
(r'\$\s*([\d,]+\.\d{2})\s*(?:\n|$)', 'monto_linea', 11),
]
# Words that indicate a line is NOT a total (negative patterns)
EXCLUSION_WORDS = [
'cambio', 'efectivo', 'pago con', 'tarjeta', 'recibido',
'iva', 'impuesto', 'descuento', 'ahorro', 'puntos'
]
class AmountDetector:
"""
Detects and extracts monetary amounts from ticket text.
Uses multiple patterns and heuristics to find the most likely total.
"""
def __init__(self):
self.patterns = PATTERNS
self.min_amount = 1 # Minimum valid amount
self.max_amount = 1000000 # Maximum valid amount
def detectar_monto(self, texto: str) -> Optional[Dict]:
"""
Detecta el monto total del ticket.
Args:
texto: Texto extraído del ticket
Returns:
dict con monto, tipo, patron, y confianza, o None si no se encuentra
"""
texto_lower = texto.lower()
resultados = []
for patron, tipo, prioridad in self.patterns:
matches = re.findall(patron, texto_lower, re.IGNORECASE | re.MULTILINE)
for match in matches:
# Skip if match is in an exclusion context
if self._is_excluded(texto_lower, match):
continue
monto = self._normalizar_monto(match)
if self.min_amount <= monto <= self.max_amount:
# Calculate confidence based on pattern type and context
confianza = self._calcular_confianza(texto_lower, match, tipo)
resultados.append({
'monto': monto,
'tipo': tipo,
'patron': patron,
'prioridad': prioridad,
'confianza': confianza
})
if not resultados:
# Try to find the largest amount as fallback
return self._fallback_detection(texto)
# Sort by priority (lower is better) then by confidence (higher is better)
resultados.sort(key=lambda x: (x['prioridad'], -x['confianza']))
# Return the best match
best = resultados[0]
return {
'monto': best['monto'],
'tipo': best['tipo'],
'patron': best['patron'],
'confianza': best['confianza']
}
def _normalizar_monto(self, monto_str: str) -> float:
"""
Normaliza string de monto a float.
Handles various formats:
- 1,234.56 (US/Mexico format)
- 1234.56
- 1 234.56 (space separator)
- 1234,56 (European format)
"""
if not monto_str:
return 0.0
# Remove currency symbols and whitespace
monto = monto_str.strip().replace('$', '').replace(' ', '')
# Handle different decimal separators
# If there's both comma and dot, determine which is decimal
if ',' in monto and '.' in monto:
# US/Mexico format: 1,234.56
monto = monto.replace(',', '')
elif ',' in monto:
# Could be European (1234,56) or thousand separator (1,234)
parts = monto.split(',')
if len(parts) == 2 and len(parts[1]) == 2:
# European format
monto = monto.replace(',', '.')
else:
# Thousand separator
monto = monto.replace(',', '')
try:
return float(monto)
except ValueError:
return 0.0
def _is_excluded(self, texto: str, match: str) -> bool:
"""
Checks if the match appears in an exclusion context.
"""
# Find the line containing this match
for linea in texto.split('\n'):
if match in linea:
linea_lower = linea.lower()
for exclusion in EXCLUSION_WORDS:
if exclusion in linea_lower:
return True
return False
def _calcular_confianza(self, texto: str, match: str, tipo: str) -> float:
"""
Calculates confidence score for a match.
Returns value between 0.0 and 1.0
"""
confianza = 0.5 # Base confidence
# Higher confidence for explicit total patterns
if tipo in ['total_a_pagar', 'gran_total', 'total_final']:
confianza += 0.3
elif tipo == 'total':
confianza += 0.2
# Higher confidence if near end of text
position = texto.find(match)
text_length = len(texto)
if position > text_length * 0.6: # In last 40% of text
confianza += 0.1
# Higher confidence if followed by payment info
after_match = texto[texto.find(match) + len(match):texto.find(match) + len(match) + 50]
if any(word in after_match.lower() for word in ['efectivo', 'tarjeta', 'cambio', 'gracias']):
confianza += 0.1
return min(confianza, 1.0)
def _fallback_detection(self, texto: str) -> Optional[Dict]:
"""
Fallback detection when standard patterns fail.
Looks for the largest reasonable amount in the text.
"""
# Find all currency-like numbers
all_amounts = re.findall(r'\$?\s*([\d,]+\.?\d{0,2})', texto)
valid_amounts = []
for amount_str in all_amounts:
amount = self._normalizar_monto(amount_str)
if self.min_amount <= amount <= self.max_amount:
valid_amounts.append(amount)
if valid_amounts:
# Return the largest amount (likely the total)
max_amount = max(valid_amounts)
return {
'monto': max_amount,
'tipo': 'fallback_max',
'patron': 'heuristic',
'confianza': 0.3
}
return None
def detectar_multiples_montos(self, texto: str) -> List[Dict]:
"""
Detecta todos los montos en el texto.
Useful for itemized receipts.
Returns:
Lista de diccionarios con monto y contexto
"""
texto_lower = texto.lower()
resultados = []
# Find all lines with amounts
lineas = texto.split('\n')
for linea in lineas:
matches = re.findall(r'\$?\s*([\d,]+\.?\d{0,2})', linea)
for match in matches:
monto = self._normalizar_monto(match)
if self.min_amount <= monto <= self.max_amount:
resultados.append({
'monto': monto,
'contexto': linea.strip(),
'es_total': 'total' in linea.lower()
})
return resultados
def detectar_monto(texto: str) -> Optional[Dict]:
"""
Convenience function to detect amount from text.
Args:
texto: Ticket text
Returns:
Dict with monto, tipo, patron, confianza or None
"""
detector = AmountDetector()
return detector.detectar_monto(texto)
def normalizar_monto(monto_str: str) -> float:
"""
Convenience function to normalize amount string.
Args:
monto_str: Amount as string
Returns:
Amount as float
"""
detector = AmountDetector()
return detector._normalizar_monto(monto_str)

223
sales-bot/ocr/patterns.py Normal file
View File

@@ -0,0 +1,223 @@
"""
Ticket format patterns for Sales Bot OCR
Supports multiple ticket formats from different stores
"""
import re
from typing import Dict, List, Optional
# Ticket format configurations
TICKET_FORMATS = {
'oxxo': {
'identificadores': ['oxxo', 'femsa', 'cadena comercial'],
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
'patron_hora': r'(\d{2}:\d{2}:\d{2})',
'prioridad': 1
},
'walmart': {
'identificadores': ['walmart', 'walmex', 'wal-mart', 'bodega aurrera'],
'patron_total': r'total\s*\$\s*([\d,]+\.\d{2})',
'patron_fecha': r'(\d{2}-\d{2}-\d{4})',
'prioridad': 2
},
'soriana': {
'identificadores': ['soriana', 'mega soriana', 'city club'],
'patron_total': r'total\s*a?\s*pagar\s*\$?\s*([\d,]+\.\d{2})',
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
'prioridad': 3
},
'tienda_pintura': {
'identificadores': ['tinte', 'cromatique', 'oxidante', 'distribuidora',
'colorante', 'pintura', 'tono', 'decolorante', 'revelador'],
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
'patron_productos': r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+)',
'patron_tubos': r'(\d+)\s*(?:tubos?|pzas?|piezas?|unid)',
'prioridad': 0 # Highest priority for paint stores
},
'farmacia': {
'identificadores': ['farmacia', 'guadalajara', 'benavides', 'similares', 'ahorro'],
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
'patron_fecha': r'(\d{2}/\d{2}/\d{2,4})',
'prioridad': 4
},
'seven_eleven': {
'identificadores': ['7-eleven', '7eleven', '7 eleven', 'iconn'],
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
'prioridad': 5
},
'generico': {
'identificadores': [], # Fallback - matches everything
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
'patron_fecha': r'(\d{2}[/-]\d{2}[/-]\d{2,4})',
'prioridad': 99
}
}
# Common patterns for amount extraction (in priority order)
AMOUNT_PATTERNS = [
# Explicit total patterns
(r'total\s*a\s*pagar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_a_pagar', 1),
(r'gran\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'gran_total', 2),
(r'total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total', 3),
# Payment related
(r'a\s*cobrar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'a_cobrar', 4),
(r'importe\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'importe', 5),
(r'monto\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'monto', 6),
(r'suma\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'suma', 7),
# Subtotal (lower priority)
(r'subtotal\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'subtotal', 8),
# Last resort - currency amounts at end of lines
(r'\$\s*([\d,]+\.\d{2})\s*$', 'monto_final', 9),
]
# Date patterns
DATE_PATTERNS = [
r'(\d{2}/\d{2}/\d{4})', # DD/MM/YYYY
r'(\d{2}-\d{2}-\d{4})', # DD-MM-YYYY
r'(\d{4}-\d{2}-\d{2})', # YYYY-MM-DD
r'(\d{2}/\d{2}/\d{2})', # DD/MM/YY
r'(\d{1,2}\s+de\s+\w+\s+de\s+\d{4})', # D de Mes de YYYY
]
# Client name patterns
CLIENT_PATTERNS = [
r'cliente\s*:?\s*(.+?)(?:\n|$)',
r'nombre\s*:?\s*(.+?)(?:\n|$)',
r'sr\.?\s*(.+?)(?:\n|$)',
r'sra\.?\s*(.+?)(?:\n|$)',
]
def detectar_formato_ticket(texto: str) -> str:
"""
Detecta el formato del ticket basado en identificadores.
Args:
texto: Texto extraído del ticket
Returns:
Nombre del formato detectado
"""
texto_lower = texto.lower()
# Check formats by priority (lower number = higher priority)
formatos_encontrados = []
for formato, config in TICKET_FORMATS.items():
if formato == 'generico':
continue
for identificador in config.get('identificadores', []):
if identificador in texto_lower:
formatos_encontrados.append((formato, config.get('prioridad', 99)))
break
if formatos_encontrados:
# Sort by priority and return highest priority match
formatos_encontrados.sort(key=lambda x: x[1])
return formatos_encontrados[0][0]
return 'generico'
def get_patron_total(formato: str) -> str:
"""
Obtiene el patrón de total para un formato específico.
Args:
formato: Nombre del formato
Returns:
Patrón regex para extraer el total
"""
config = TICKET_FORMATS.get(formato, TICKET_FORMATS['generico'])
return config.get('patron_total', TICKET_FORMATS['generico']['patron_total'])
def extraer_fecha_ticket(texto: str) -> Optional[str]:
"""
Extrae la fecha del ticket.
Args:
texto: Texto del ticket
Returns:
Fecha encontrada o None
"""
for patron in DATE_PATTERNS:
match = re.search(patron, texto, re.IGNORECASE)
if match:
return match.group(1)
return None
def extraer_cliente_ticket(texto: str) -> Optional[str]:
"""
Extrae el nombre del cliente del ticket.
Args:
texto: Texto del ticket
Returns:
Nombre del cliente o None
"""
for patron in CLIENT_PATTERNS:
match = re.search(patron, texto, re.IGNORECASE)
if match:
cliente = match.group(1).strip()
# Clean up common artifacts
cliente = re.sub(r'[^\w\s\-\.]', '', cliente)
if len(cliente) > 2: # Valid name should have at least 3 chars
return cliente
return None
def contar_tubos_texto(texto: str) -> int:
"""
Cuenta la cantidad de tubos mencionados en el ticket.
Args:
texto: Texto del ticket
Returns:
Cantidad de tubos detectados
"""
texto_lower = texto.lower()
total_tubos = 0
# Pattern for explicit tube counts
patrones_tubos = [
r'(\d+)\s*(?:tubos?|tbs?)',
r'(\d+)\s*(?:pzas?|piezas?)\s*(?:de\s+)?(?:tinte|color)',
r'(?:cantidad|qty|cant)\s*:?\s*(\d+)',
r'x\s*(\d+)\s*(?:tubos?)?',
]
for patron in patrones_tubos:
matches = re.findall(patron, texto_lower)
for match in matches:
try:
total_tubos += int(match)
except ValueError:
continue
# If no explicit count found, estimate from line items
if total_tubos == 0:
# Count lines that look like product entries
lineas = texto_lower.split('\n')
for linea in lineas:
if any(word in linea for word in ['tinte', 'color', 'tubo', 'cromatique']):
# Check for quantity at start of line or after product name
qty_match = re.search(r'^(\d+)\s+|x\s*(\d+)|(\d+)\s*pza', linea)
if qty_match:
qty = next((g for g in qty_match.groups() if g), '1')
total_tubos += int(qty)
else:
total_tubos += 1 # Assume 1 if no explicit quantity
return total_tubos

View File

@@ -0,0 +1,305 @@
"""
Image preprocessing for Sales Bot OCR
Adaptive preprocessing pipelines for different image conditions
"""
import logging
import os
from typing import Tuple, Optional, List
from io import BytesIO
logger = logging.getLogger(__name__)
# Try to import image processing libraries
try:
import cv2
import numpy as np
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
logger.warning("OpenCV not available. Image preprocessing will be limited.")
try:
from PIL import Image, ImageEnhance, ImageFilter
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
logger.warning("PIL not available. Image preprocessing will be limited.")
try:
from deskew import determine_skew
DESKEW_AVAILABLE = True
except ImportError:
DESKEW_AVAILABLE = False
logger.warning("deskew library not available. Rotation correction disabled.")
try:
import imutils
IMUTILS_AVAILABLE = True
except ImportError:
IMUTILS_AVAILABLE = False
logger.warning("imutils not available. Some rotations may not work.")
class ImagePreprocessor:
"""
Preprocesses ticket images for better OCR accuracy.
Supports multiple preprocessing pipelines for different image conditions.
"""
def __init__(self):
self.enable_deskew = os.getenv('OCR_ENABLE_DESKEW', 'true').lower() == 'true'
self.max_rotation = float(os.getenv('OCR_MAX_ROTATION_ANGLE', '15'))
self.use_adaptive = os.getenv('OCR_USE_ADAPTIVE_PIPELINE', 'true').lower() == 'true'
# Define preprocessing pipelines
self.pipelines = {
'standard': ['grayscale', 'contrast', 'otsu'],
'low_contrast': ['grayscale', 'clahe', 'adaptive_threshold'],
'noisy': ['grayscale', 'denoise', 'sharpen', 'otsu'],
'rotated': ['deskew', 'grayscale', 'contrast', 'otsu'],
'dark': ['grayscale', 'brighten', 'contrast', 'otsu'],
'light': ['grayscale', 'darken', 'contrast', 'otsu'],
}
def preprocess(self, image_bytes: bytes) -> bytes:
"""
Preprocess image bytes for OCR.
Args:
image_bytes: Raw image bytes
Returns:
Preprocessed image bytes
"""
if self.use_adaptive and CV2_AVAILABLE:
return self.preprocess_adaptive(image_bytes)
else:
return self.preprocess_basic(image_bytes)
def preprocess_basic(self, image_bytes: bytes) -> bytes:
"""
Basic preprocessing using PIL only.
"""
if not PIL_AVAILABLE:
return image_bytes
try:
# Load image
img = Image.open(BytesIO(image_bytes))
# Convert to grayscale
img = img.convert('L')
# Enhance contrast
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
# Sharpen
img = img.filter(ImageFilter.SHARPEN)
# Save to bytes
output = BytesIO()
img.save(output, format='PNG')
return output.getvalue()
except Exception as e:
logger.error(f"Error in basic preprocessing: {e}")
return image_bytes
def preprocess_adaptive(self, image_bytes: bytes) -> bytes:
"""
Adaptive preprocessing that tries multiple pipelines
and returns the best result.
"""
if not CV2_AVAILABLE:
return self.preprocess_basic(image_bytes)
try:
# Decode image
nparr = np.frombuffer(image_bytes, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if image is None:
logger.error("Could not decode image")
return image_bytes
# Analyze image to determine best pipeline
pipeline_name = self._determine_best_pipeline(image)
logger.info(f"Using preprocessing pipeline: {pipeline_name}")
# Apply pipeline
processed = self._apply_pipeline(image, pipeline_name)
# Encode back to bytes
_, buffer = cv2.imencode('.png', processed)
return buffer.tobytes()
except Exception as e:
logger.error(f"Error in adaptive preprocessing: {e}")
return self.preprocess_basic(image_bytes)
def _determine_best_pipeline(self, image: 'np.ndarray') -> str:
"""
Analyzes image to determine the best preprocessing pipeline.
"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Calculate image statistics
mean_brightness = np.mean(gray)
std_brightness = np.std(gray)
# Check for rotation if deskew is enabled
if self.enable_deskew and DESKEW_AVAILABLE:
try:
angle = determine_skew(gray)
if abs(angle) > 1.0 and abs(angle) <= self.max_rotation:
return 'rotated'
except Exception:
pass
# Determine based on brightness/contrast
if mean_brightness < 80:
return 'dark'
elif mean_brightness > 180:
return 'light'
elif std_brightness < 40:
return 'low_contrast'
elif std_brightness > 80:
return 'noisy'
else:
return 'standard'
def _apply_pipeline(self, image: 'np.ndarray', pipeline_name: str) -> 'np.ndarray':
"""
Applies a preprocessing pipeline to the image.
"""
pipeline = self.pipelines.get(pipeline_name, self.pipelines['standard'])
result = image.copy()
for step in pipeline:
try:
result = getattr(self, f'_step_{step}')(result)
except AttributeError:
logger.warning(f"Unknown preprocessing step: {step}")
except Exception as e:
logger.warning(f"Error in step {step}: {e}")
return result
# Pipeline steps
def _step_grayscale(self, image: 'np.ndarray') -> 'np.ndarray':
"""Convert to grayscale."""
if len(image.shape) == 3:
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
return image
def _step_contrast(self, image: 'np.ndarray') -> 'np.ndarray':
"""Enhance contrast using histogram equalization."""
if len(image.shape) == 3:
image = self._step_grayscale(image)
return cv2.equalizeHist(image)
def _step_otsu(self, image: 'np.ndarray') -> 'np.ndarray':
"""Apply Otsu's thresholding."""
if len(image.shape) == 3:
image = self._step_grayscale(image)
_, binary = cv2.threshold(image, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return binary
def _step_adaptive_threshold(self, image: 'np.ndarray') -> 'np.ndarray':
"""Apply adaptive thresholding."""
if len(image.shape) == 3:
image = self._step_grayscale(image)
return cv2.adaptiveThreshold(
image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
def _step_clahe(self, image: 'np.ndarray') -> 'np.ndarray':
"""Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)."""
if len(image.shape) == 3:
image = self._step_grayscale(image)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
return clahe.apply(image)
def _step_denoise(self, image: 'np.ndarray') -> 'np.ndarray':
"""Remove noise while preserving edges."""
if len(image.shape) == 3:
return cv2.fastNlMeansDenoisingColored(image, None, 10, 10, 7, 21)
return cv2.fastNlMeansDenoising(image, None, 10, 7, 21)
def _step_sharpen(self, image: 'np.ndarray') -> 'np.ndarray':
"""Sharpen the image."""
kernel = np.array([[-1, -1, -1],
[-1, 9, -1],
[-1, -1, -1]])
return cv2.filter2D(image, -1, kernel)
def _step_brighten(self, image: 'np.ndarray') -> 'np.ndarray':
"""Increase image brightness."""
return cv2.convertScaleAbs(image, alpha=1.2, beta=30)
def _step_darken(self, image: 'np.ndarray') -> 'np.ndarray':
"""Decrease image brightness."""
return cv2.convertScaleAbs(image, alpha=0.8, beta=-20)
def _step_deskew(self, image: 'np.ndarray') -> 'np.ndarray':
"""Detect and correct image rotation."""
if not DESKEW_AVAILABLE:
return image
try:
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image
angle = determine_skew(gray)
if abs(angle) > self.max_rotation:
logger.info(f"Rotation angle {angle} exceeds max {self.max_rotation}, skipping")
return image
if abs(angle) < 0.5:
return image # No significant rotation
logger.info(f"Correcting rotation: {angle} degrees")
if IMUTILS_AVAILABLE:
import imutils
return imutils.rotate_bound(image, -angle)
else:
# Manual rotation
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
return cv2.warpAffine(image, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
except Exception as e:
logger.error(f"Error in deskew: {e}")
return image
def preprocess_image(image_bytes: bytes) -> bytes:
"""
Convenience function to preprocess image bytes.
Args:
image_bytes: Raw image bytes
Returns:
Preprocessed image bytes
"""
preprocessor = ImagePreprocessor()
return preprocessor.preprocess(image_bytes)
def preprocess_for_ocr(image_bytes: bytes) -> bytes:
"""
Alias for preprocess_image.
"""
return preprocess_image(image_bytes)

294
sales-bot/ocr/processor.py Normal file
View File

@@ -0,0 +1,294 @@
"""
Main OCR processor for Sales Bot
Combines preprocessing, text extraction, and amount detection
"""
import logging
import os
from typing import Dict, Optional
from io import BytesIO
logger = logging.getLogger(__name__)
# Try to import OCR engine
try:
import pytesseract
from PIL import Image
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
logger.warning("pytesseract not available. OCR will not work.")
# Import local modules
from .preprocessor import ImagePreprocessor, preprocess_image
from .amount_detector import AmountDetector, detectar_monto
from .patterns import (
detectar_formato_ticket,
extraer_fecha_ticket,
extraer_cliente_ticket,
contar_tubos_texto,
get_patron_total
)
class OCRProcessor:
"""
Main OCR processor that coordinates image preprocessing,
text extraction, and data parsing.
"""
def __init__(self):
self.preprocessor = ImagePreprocessor()
self.amount_detector = AmountDetector()
self.confidence_threshold = float(os.getenv('OCR_CONFIDENCE_THRESHOLD', '0.6'))
# Tesseract configuration for Spanish
self.tesseract_config = '--oem 3 --psm 6 -l spa'
def process(self, image_bytes: bytes) -> Dict:
"""
Process a ticket image and extract relevant data.
Args:
image_bytes: Raw image bytes (JPEG, PNG, etc.)
Returns:
Dict with extracted data:
- texto: Full extracted text
- monto: Detected total amount
- cliente: Client name if found
- fecha: Date if found
- tubos: Number of tubes/items
- formato: Detected ticket format
- confianza: Confidence score
"""
if not TESSERACT_AVAILABLE:
return {
'error': 'Tesseract OCR not available',
'texto': '',
'monto': 0,
'confianza': 0
}
try:
# Preprocess image
processed_bytes = self.preprocessor.preprocess(image_bytes)
# Extract text
texto = self._extract_text(processed_bytes)
if not texto or len(texto.strip()) < 10:
# Try again with original image
texto = self._extract_text(image_bytes)
if not texto:
return {
'error': 'No text could be extracted',
'texto': '',
'monto': 0,
'confianza': 0
}
# Detect ticket format
formato = detectar_formato_ticket(texto)
# Extract amount
monto_result = self.amount_detector.detectar_monto(texto)
monto = monto_result.get('monto', 0) if monto_result else 0
monto_confianza = monto_result.get('confianza', 0) if monto_result else 0
monto_tipo = monto_result.get('tipo', 'unknown') if monto_result else 'unknown'
# Extract other data
cliente = extraer_cliente_ticket(texto)
fecha = extraer_fecha_ticket(texto)
tubos = contar_tubos_texto(texto)
# Calculate overall confidence
confianza = self._calculate_overall_confidence(
texto, monto, monto_confianza, cliente, fecha
)
return {
'texto': texto,
'monto': monto,
'monto_tipo': monto_tipo,
'cliente': cliente,
'fecha': fecha,
'tubos': tubos,
'formato': formato,
'confianza': confianza
}
except Exception as e:
logger.error(f"Error processing image: {e}", exc_info=True)
return {
'error': str(e),
'texto': '',
'monto': 0,
'confianza': 0
}
def _extract_text(self, image_bytes: bytes) -> str:
"""
Extract text from image bytes using Tesseract.
"""
try:
# Load image
img = Image.open(BytesIO(image_bytes))
# Convert to RGB if necessary
if img.mode != 'RGB' and img.mode != 'L':
img = img.convert('RGB')
# Run OCR
texto = pytesseract.image_to_string(img, config=self.tesseract_config)
# Clean up text
texto = self._clean_text(texto)
return texto
except Exception as e:
logger.error(f"Error extracting text: {e}")
return ''
def _clean_text(self, texto: str) -> str:
"""
Clean up OCR output text.
"""
if not texto:
return ''
# Remove excessive whitespace
import re
texto = re.sub(r'\s+', ' ', texto)
texto = re.sub(r'\n\s*\n', '\n', texto)
# Fix common OCR errors
replacements = {
'|': 'l',
'0': 'O', # Only in certain contexts
'1': 'I', # Only in certain contexts
'S': '$', # Only at start of amounts
}
# Apply selective replacements
# (Being careful not to corrupt actual numbers)
return texto.strip()
def _calculate_overall_confidence(
self,
texto: str,
monto: float,
monto_confianza: float,
cliente: Optional[str],
fecha: Optional[str]
) -> float:
"""
Calculate overall extraction confidence.
"""
confidence = 0.0
# Text quality (based on length and structure)
if len(texto) > 50:
confidence += 0.2
if len(texto) > 200:
confidence += 0.1
# Amount detection confidence
confidence += monto_confianza * 0.4
# Bonus for finding additional data
if cliente:
confidence += 0.1
if fecha:
confidence += 0.1
# Check for typical receipt keywords
keywords = ['total', 'cliente', 'fecha', 'ticket', 'venta', 'pago']
found_keywords = sum(1 for kw in keywords if kw in texto.lower())
confidence += min(found_keywords * 0.05, 0.2)
return min(confidence, 1.0)
def process_multiple(self, images: list) -> Dict:
"""
Process multiple images (e.g., multi-page receipt).
Combines results from all images.
Args:
images: List of image bytes
Returns:
Combined results
"""
all_texto = []
total_monto = 0
cliente = None
fecha = None
tubos = 0
formato = None
max_confianza = 0
for img_bytes in images:
result = self.process(img_bytes)
if result.get('texto'):
all_texto.append(result['texto'])
if result.get('monto', 0) > total_monto:
total_monto = result['monto']
if not cliente and result.get('cliente'):
cliente = result['cliente']
if not fecha and result.get('fecha'):
fecha = result['fecha']
tubos += result.get('tubos', 0)
if not formato and result.get('formato'):
formato = result['formato']
if result.get('confianza', 0) > max_confianza:
max_confianza = result['confianza']
return {
'texto': '\n---\n'.join(all_texto),
'monto': total_monto,
'cliente': cliente,
'fecha': fecha,
'tubos': tubos,
'formato': formato,
'confianza': max_confianza,
'paginas': len(images)
}
def procesar_ticket_imagen(image_bytes: bytes) -> Dict:
"""
Convenience function to process a ticket image.
Args:
image_bytes: Raw image bytes
Returns:
Dict with extracted data
"""
processor = OCRProcessor()
return processor.process(image_bytes)
def procesar_multiples_imagenes(images: list) -> Dict:
"""
Convenience function to process multiple images.
Args:
images: List of image bytes
Returns:
Combined results
"""
processor = OCRProcessor()
return processor.process_multiple(images)

View File

@@ -0,0 +1,12 @@
"""
Reports module for Sales Bot
Generates PDF reports for sales data
"""
from .pdf_generator import SalesReportPDF, generar_reporte_diario, generar_reporte_ejecutivo
__all__ = [
'SalesReportPDF',
'generar_reporte_diario',
'generar_reporte_ejecutivo'
]

View File

@@ -0,0 +1,532 @@
"""
PDF Report Generator for Sales Bot
Uses ReportLab for PDF generation and Matplotlib for charts
"""
import io
import os
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Try to import ReportLab
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch, cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
Image, PageBreak, HRFlowable
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
REPORTLAB_AVAILABLE = True
except ImportError:
REPORTLAB_AVAILABLE = False
logger.warning("ReportLab not available, PDF generation disabled")
# Try to import Matplotlib for charts
try:
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
logger.warning("Matplotlib not available, charts in PDF disabled")
# Mexico timezone
TZ_MEXICO = timezone(timedelta(hours=-6))
class SalesReportPDF:
"""
Generates PDF reports for sales data.
"""
# Color scheme matching the dashboard
COLORS = {
'primary': colors.HexColor('#00d4ff'),
'secondary': colors.HexColor('#00ff88'),
'warning': colors.HexColor('#ffaa00'),
'dark': colors.HexColor('#1a1a2e'),
'text': colors.HexColor('#333333'),
'muted': colors.HexColor('#666666'),
}
def __init__(self, ventas: List[Dict], stats: Dict, vendedor: str = None):
"""
Initialize the PDF generator.
Args:
ventas: List of sales data
stats: Statistics dictionary
vendedor: Optional vendor username (None for all)
"""
if not REPORTLAB_AVAILABLE:
raise ImportError("ReportLab is required for PDF generation")
self.ventas = ventas or []
self.stats = stats or {}
self.vendedor = vendedor
self.styles = getSampleStyleSheet()
self._setup_custom_styles()
def _setup_custom_styles(self):
"""Setup custom paragraph styles."""
# Title style
self.styles.add(ParagraphStyle(
'CustomTitle',
parent=self.styles['Heading1'],
fontSize=24,
textColor=self.COLORS['dark'],
spaceAfter=20,
alignment=TA_CENTER
))
# Subtitle style
self.styles.add(ParagraphStyle(
'CustomSubtitle',
parent=self.styles['Normal'],
fontSize=12,
textColor=self.COLORS['muted'],
spaceAfter=10,
alignment=TA_CENTER
))
# Section header
self.styles.add(ParagraphStyle(
'SectionHeader',
parent=self.styles['Heading2'],
fontSize=14,
textColor=self.COLORS['primary'],
spaceBefore=15,
spaceAfter=10
))
# KPI value style
self.styles.add(ParagraphStyle(
'KPIValue',
parent=self.styles['Normal'],
fontSize=18,
textColor=self.COLORS['dark'],
alignment=TA_CENTER
))
# KPI label style
self.styles.add(ParagraphStyle(
'KPILabel',
parent=self.styles['Normal'],
fontSize=10,
textColor=self.COLORS['muted'],
alignment=TA_CENTER
))
def generar_reporte_diario(self) -> bytes:
"""
Generates a daily sales report.
Returns:
PDF content as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=letter,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=0.75*inch,
bottomMargin=0.75*inch
)
elements = []
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
# Title
elements.append(Paragraph(
"Reporte Diario de Ventas",
self.styles['CustomTitle']
))
elements.append(Paragraph(
f"{fecha_hoy}",
self.styles['CustomSubtitle']
))
elements.append(Spacer(1, 20))
# KPIs Section
elements.append(self._create_kpi_section())
elements.append(Spacer(1, 20))
# Trend Chart (if matplotlib available)
if MATPLOTLIB_AVAILABLE and self.ventas:
chart_image = self._create_trend_chart()
if chart_image:
elements.append(Paragraph("Tendencia de Ventas (Últimos 7 días)", self.styles['SectionHeader']))
elements.append(Image(chart_image, width=6*inch, height=3*inch))
elements.append(Spacer(1, 20))
# Top Sellers Section
elements.append(Paragraph("Top Vendedores del Día", self.styles['SectionHeader']))
elements.append(self._create_top_sellers_table())
elements.append(Spacer(1, 20))
# Sales Detail
if len(self.ventas) <= 20: # Only include if not too many
elements.append(Paragraph("Detalle de Ventas", self.styles['SectionHeader']))
elements.append(self._create_sales_table())
# Footer
elements.append(Spacer(1, 30))
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
elements.append(Spacer(1, 10))
elements.append(Paragraph(
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
self.styles['CustomSubtitle']
))
doc.build(elements)
return buffer.getvalue()
def generar_reporte_ejecutivo(self) -> bytes:
"""
Generates an executive summary report.
Returns:
PDF content as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=letter,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=0.75*inch,
bottomMargin=0.75*inch
)
elements = []
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
mes_actual = datetime.now(TZ_MEXICO).strftime('%B %Y')
# Title
elements.append(Paragraph(
"Reporte Ejecutivo",
self.styles['CustomTitle']
))
elements.append(Paragraph(
f"{mes_actual}",
self.styles['CustomSubtitle']
))
elements.append(Spacer(1, 20))
# Executive KPIs
elements.append(self._create_executive_kpi_section())
elements.append(Spacer(1, 20))
# Monthly Trend Chart
if MATPLOTLIB_AVAILABLE and self.ventas:
chart_image = self._create_monthly_chart()
if chart_image:
elements.append(Paragraph("Tendencia Mensual", self.styles['SectionHeader']))
elements.append(Image(chart_image, width=6*inch, height=3*inch))
elements.append(Spacer(1, 20))
# Top Performers
elements.append(Paragraph("Top Performers del Mes", self.styles['SectionHeader']))
elements.append(self._create_top_performers_table())
elements.append(Spacer(1, 20))
# Comparison Section
elements.append(Paragraph("Comparativa", self.styles['SectionHeader']))
elements.append(self._create_comparison_section())
# Footer
elements.append(Spacer(1, 30))
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
elements.append(Spacer(1, 10))
elements.append(Paragraph(
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
self.styles['CustomSubtitle']
))
doc.build(elements)
return buffer.getvalue()
def _create_kpi_section(self) -> Table:
"""Creates KPI cards section."""
total_ventas = len(self.ventas)
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
tubos_total = self.stats.get('tubos_totales', 0)
comision_total = self.stats.get('comision_total', 0)
data = [
[
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
Paragraph(f"{tubos_total}", self.styles['KPIValue']),
Paragraph(f"${comision_total:,.2f}", self.styles['KPIValue']),
],
[
Paragraph("Ventas", self.styles['KPILabel']),
Paragraph("Monto Total", self.styles['KPILabel']),
Paragraph("Tubos", self.styles['KPILabel']),
Paragraph("Comisiones", self.styles['KPILabel']),
]
]
table = Table(data, colWidths=[1.5*inch]*4)
table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8f9fa')),
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e9ecef')),
('TOPPADDING', (0, 0), (-1, -1), 12),
('BOTTOMPADDING', (0, 0), (-1, -1), 12),
]))
return table
def _create_executive_kpi_section(self) -> Table:
"""Creates executive KPI section with more metrics."""
total_ventas = len(self.ventas)
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
promedio_ticket = monto_total / total_ventas if total_ventas > 0 else 0
vendedores_activos = len(set(v.get('vendedor_username') for v in self.ventas))
data = [
[
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
Paragraph(f"${promedio_ticket:,.2f}", self.styles['KPIValue']),
Paragraph(f"{vendedores_activos}", self.styles['KPIValue']),
],
[
Paragraph("Monto Total", self.styles['KPILabel']),
Paragraph("Total Ventas", self.styles['KPILabel']),
Paragraph("Ticket Promedio", self.styles['KPILabel']),
Paragraph("Vendedores", self.styles['KPILabel']),
]
]
table = Table(data, colWidths=[1.5*inch]*4)
table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e3f2fd')),
('BACKGROUND', (0, 1), (-1, 1), colors.HexColor('#f8f9fa')),
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
('TOPPADDING', (0, 0), (-1, -1), 15),
('BOTTOMPADDING', (0, 0), (-1, -1), 15),
]))
return table
def _create_top_sellers_table(self) -> Table:
"""Creates top sellers table."""
# Group sales by vendor
vendors = {}
for venta in self.ventas:
username = venta.get('vendedor_username', 'Desconocido')
if username not in vendors:
vendors[username] = {'ventas': 0, 'monto': 0}
vendors[username]['ventas'] += 1
vendors[username]['monto'] += float(venta.get('monto', 0) or 0)
# Sort by sales count
sorted_vendors = sorted(vendors.items(), key=lambda x: x[1]['monto'], reverse=True)[:5]
data = [['#', 'Vendedor', 'Ventas', 'Monto']]
for i, (username, stats) in enumerate(sorted_vendors, 1):
medal = ['🥇', '🥈', '🥉', '4.', '5.'][i-1]
data.append([
medal,
username,
str(stats['ventas']),
f"${stats['monto']:,.2f}"
])
if len(data) == 1:
data.append(['', 'Sin datos', '', ''])
table = Table(data, colWidths=[0.5*inch, 2.5*inch, 1*inch, 1.5*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.HexColor('#f8f9fa')),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
('TOPPADDING', (0, 1), (-1, -1), 8),
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
]))
return table
def _create_top_performers_table(self) -> Table:
"""Creates top performers table for executive report."""
# Similar to top sellers but with more metrics
return self._create_top_sellers_table()
def _create_sales_table(self) -> Table:
"""Creates detailed sales table."""
data = [['ID', 'Fecha', 'Vendedor', 'Cliente', 'Monto']]
for venta in self.ventas[:15]: # Limit to 15 rows
fecha_str = venta.get('fecha_venta', '')
try:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
fecha_formatted = fecha.strftime('%d/%m %H:%M')
except:
fecha_formatted = fecha_str[:16] if fecha_str else ''
data.append([
str(venta.get('Id', '')),
fecha_formatted,
venta.get('vendedor_username', '')[:15],
(venta.get('cliente', '') or 'N/A')[:20],
f"${float(venta.get('monto', 0) or 0):,.2f}"
])
table = Table(data, colWidths=[0.5*inch, 1*inch, 1.5*inch, 1.5*inch, 1*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f8f9fa')]),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
('TOPPADDING', (0, 1), (-1, -1), 6),
('BOTTOMPADDING', (0, 1), (-1, -1), 6),
]))
return table
def _create_comparison_section(self) -> Table:
"""Creates comparison section."""
# Placeholder data - would be filled with real comparison data
data = [
['Métrica', 'Período Actual', 'Período Anterior', 'Cambio'],
['Ventas', str(len(self.ventas)), '-', '-'],
['Monto', f"${sum(float(v.get('monto', 0) or 0) for v in self.ventas):,.2f}", '-', '-'],
['Vendedores', str(len(set(v.get('vendedor_username') for v in self.ventas))), '-', '-'],
]
table = Table(data, colWidths=[1.5*inch, 1.25*inch, 1.25*inch, 1*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['primary']),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
('TOPPADDING', (0, 1), (-1, -1), 8),
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
]))
return table
def _create_trend_chart(self) -> Optional[io.BytesIO]:
"""Creates a trend chart image."""
if not MATPLOTLIB_AVAILABLE:
return None
try:
# Group sales by date
sales_by_date = {}
for venta in self.ventas:
fecha_str = venta.get('fecha_venta', '')
try:
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
date_key = fecha.strftime('%Y-%m-%d')
if date_key not in sales_by_date:
sales_by_date[date_key] = 0
sales_by_date[date_key] += float(venta.get('monto', 0) or 0)
except:
continue
if not sales_by_date:
return None
# Sort by date
sorted_dates = sorted(sales_by_date.keys())[-7:] # Last 7 days
values = [sales_by_date.get(d, 0) for d in sorted_dates]
labels = [datetime.strptime(d, '%Y-%m-%d').strftime('%d/%m') for d in sorted_dates]
# Create chart
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(labels, values, color='#00d4ff', linewidth=2, marker='o')
ax.fill_between(labels, values, alpha=0.3, color='#00d4ff')
ax.set_xlabel('Fecha')
ax.set_ylabel('Monto ($)')
ax.set_title('Tendencia de Ventas')
ax.grid(True, alpha=0.3)
# Format y-axis as currency
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
plt.tight_layout()
# Save to buffer
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
buf.seek(0)
plt.close(fig)
return buf
except Exception as e:
logger.error(f"Error creating trend chart: {str(e)}")
return None
def _create_monthly_chart(self) -> Optional[io.BytesIO]:
"""Creates a monthly trend chart."""
return self._create_trend_chart() # Use same logic for now
# Convenience functions
def generar_reporte_diario(ventas: List[Dict], stats: Dict, vendedor: str = None) -> bytes:
"""
Genera un reporte diario en PDF.
Args:
ventas: Lista de ventas
stats: Estadísticas
vendedor: Username del vendedor (opcional)
Returns:
Contenido del PDF en bytes
"""
if not REPORTLAB_AVAILABLE:
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
report = SalesReportPDF(ventas, stats, vendedor)
return report.generar_reporte_diario()
def generar_reporte_ejecutivo(ventas: List[Dict], stats: Dict) -> bytes:
"""
Genera un reporte ejecutivo en PDF.
Args:
ventas: Lista de ventas
stats: Estadísticas
Returns:
Contenido del PDF en bytes
"""
if not REPORTLAB_AVAILABLE:
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
report = SalesReportPDF(ventas, stats)
return report.generar_reporte_ejecutivo()

View File

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

View File

@@ -0,0 +1,591 @@
/* ==================== RESET & BASE ==================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #00d4ff;
--secondary: #00ff88;
--warning: #ffaa00;
--purple: #aa00ff;
--bg-dark: #1a1a2e;
--bg-darker: #16213e;
--bg-card: rgba(255, 255, 255, 0.05);
--border-color: rgba(255, 255, 255, 0.1);
--text-primary: #ffffff;
--text-secondary: #888888;
--text-muted: #666666;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
min-height: 100vh;
color: var(--text-primary);
}
/* ==================== NAVBAR ==================== */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
}
.brand-text {
font-size: 20px;
font-weight: 600;
}
.nav-links {
display: flex;
gap: 5px;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
font-size: 14px;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-card);
}
.nav-link.active {
color: var(--primary);
background: rgba(0, 212, 255, 0.1);
}
.nav-actions {
display: flex;
gap: 10px;
}
/* ==================== CONTAINER ==================== */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* ==================== HEADER ==================== */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.page-header h1 {
font-size: 28px;
font-weight: 600;
}
.page-header h1 span {
color: var(--primary);
}
.fecha {
color: var(--text-secondary);
font-size: 14px;
}
/* ==================== BUTTONS ==================== */
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: none;
font-weight: 500;
}
.btn-primary {
background: var(--primary);
color: #000;
}
.btn-primary:hover {
background: #33ddff;
transform: translateY(-2px);
}
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-secondary:hover {
background: rgba(0, 212, 255, 0.2);
}
.refresh-btn {
background: rgba(0, 212, 255, 0.2);
border: 1px solid var(--primary);
color: var(--primary);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(0, 212, 255, 0.3);
}
/* ==================== STATS GRID ==================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border-color);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(0, 212, 255, 0.1);
}
.stat-card .label {
color: var(--text-secondary);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: var(--primary);
}
.stat-card .subvalue {
font-size: 14px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-card.green .value { color: var(--secondary); }
.stat-card.orange .value { color: var(--warning); }
.stat-card.purple .value { color: var(--purple); }
/* ==================== PANELS ==================== */
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-card);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border-color);
}
.panel h2 {
font-size: 18px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel h2 .icon {
font-size: 24px;
}
/* ==================== RANKING ==================== */
.ranking-list {
list-style: none;
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.ranking-item:last-child {
border-bottom: none;
}
.ranking-position {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
margin-right: 12px;
}
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
.ranking-position.default { background: rgba(255, 255, 255, 0.1); color: var(--text-secondary); }
.ranking-info {
flex: 1;
}
.ranking-name {
font-weight: 600;
margin-bottom: 2px;
}
.ranking-stats {
font-size: 12px;
color: var(--text-secondary);
}
.ranking-value {
text-align: right;
}
.ranking-tubos {
font-size: 24px;
font-weight: 700;
color: var(--primary);
}
.ranking-comision {
font-size: 12px;
color: var(--secondary);
}
/* ==================== VENTAS RECIENTES ==================== */
.ventas-list {
max-height: 400px;
overflow-y: auto;
}
.venta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.venta-info .vendedor {
font-weight: 600;
color: var(--primary);
}
.venta-info .cliente {
font-size: 12px;
color: var(--text-secondary);
}
.venta-monto {
font-size: 18px;
font-weight: 700;
color: var(--secondary);
}
/* ==================== CHARTS ==================== */
.chart-container {
position: relative;
height: 300px;
margin: 20px 0;
}
.chart-container canvas {
max-height: 100%;
}
/* ==================== LOADING ==================== */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.loading-spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ==================== PROGRESS BAR ==================== */
.meta-progress {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.meta-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--secondary));
border-radius: 4px;
transition: width 0.5s;
}
/* ==================== KPI CARDS ==================== */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.kpi-card {
background: var(--bg-card);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid var(--border-color);
}
.kpi-card .kpi-value {
font-size: 28px;
font-weight: 700;
color: var(--primary);
}
.kpi-card .kpi-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 4px;
}
.kpi-card .kpi-trend {
font-size: 12px;
margin-top: 8px;
}
.kpi-card .kpi-trend.up { color: var(--secondary); }
.kpi-card .kpi-trend.down { color: #ff4444; }
.kpi-card .kpi-trend.stable { color: var(--text-secondary); }
/* ==================== TABLES ==================== */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.data-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* ==================== CAMERA MODAL ==================== */
.camera-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
align-items: center;
justify-content: center;
}
.camera-modal.active {
display: flex;
}
.camera-container {
position: relative;
max-width: 90%;
max-height: 90%;
}
.camera-container video {
max-width: 100%;
max-height: 80vh;
border-radius: 12px;
}
.camera-controls {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
}
.camera-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
font-size: 24px;
cursor: pointer;
transition: transform 0.2s;
}
.camera-btn:hover {
transform: scale(1.1);
}
.camera-btn.capture {
background: var(--primary);
color: #000;
}
.camera-btn.close {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* ==================== MOBILE RESPONSIVE ==================== */
@media (max-width: 768px) {
.navbar {
flex-wrap: wrap;
gap: 10px;
}
.nav-links {
order: 3;
width: 100%;
justify-content: center;
}
.nav-link {
padding: 6px 12px;
font-size: 12px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-card .value {
font-size: 24px;
}
.page-header h1 {
font-size: 22px;
}
.panel {
padding: 16px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: 10px;
}
}
/* ==================== PWA INSTALL PROMPT ==================== */
.install-prompt {
display: none;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-darker);
border: 1px solid var(--primary);
border-radius: 12px;
padding: 16px 24px;
z-index: 1000;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.install-prompt.show {
display: flex;
align-items: center;
gap: 15px;
}
.install-prompt p {
margin: 0;
font-size: 14px;
}
.install-prompt .btn {
white-space: nowrap;
}

238
sales-bot/static/js/app.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* Sales Bot - Main Application JavaScript
*/
// Utility functions
const Utils = {
formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount || 0);
},
formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
formatTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
},
formatDateTime(dateStr) {
if (!dateStr) return '';
return `${this.formatDate(dateStr)} ${this.formatTime(dateStr)}`;
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease;
background: ${type === 'success' ? '#00ff88' : type === 'error' ? '#ff4444' : '#00d4ff'};
color: ${type === 'success' || type === 'info' ? '#000' : '#fff'};
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
};
// API Client
const API = {
async get(endpoint) {
try {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API GET ${endpoint}:`, error);
throw error;
}
},
async post(endpoint, data) {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API POST ${endpoint}:`, error);
throw error;
}
}
};
// Dashboard module
const Dashboard = {
async loadSummary() {
try {
const data = await API.get('/api/dashboard/resumen');
return data;
} catch (error) {
console.error('Error loading summary:', error);
return null;
}
},
async loadRanking() {
try {
const data = await API.get('/api/dashboard/ranking');
return data;
} catch (error) {
console.error('Error loading ranking:', error);
return [];
}
},
async loadRecentSales() {
try {
const data = await API.get('/api/dashboard/ventas-recientes');
return data;
} catch (error) {
console.error('Error loading recent sales:', error);
return [];
}
}
};
// Analytics module
const Analytics = {
async loadTrends(days = 30) {
try {
const data = await API.get(`/api/analytics/trends?days=${days}`);
return data;
} catch (error) {
console.error('Error loading trends:', error);
return null;
}
},
async loadPredictions(period = 30) {
try {
const data = await API.get(`/api/analytics/predictions?period=${period}`);
return data;
} catch (error) {
console.error('Error loading predictions:', error);
return null;
}
},
async loadComparisons(type = 'monthly') {
try {
const data = await API.get(`/api/analytics/comparisons?type=${type}`);
return data;
} catch (error) {
console.error('Error loading comparisons:', error);
return null;
}
}
};
// Offline support
const OfflineManager = {
isOnline: navigator.onLine,
init() {
window.addEventListener('online', () => {
this.isOnline = true;
Utils.showNotification('Conexion restaurada', 'success');
this.syncData();
});
window.addEventListener('offline', () => {
this.isOnline = false;
Utils.showNotification('Sin conexion - Modo offline', 'error');
});
},
async cacheData(key, data) {
try {
localStorage.setItem(`salesbot_${key}`, JSON.stringify({
data,
timestamp: Date.now()
}));
} catch (e) {
console.error('Error caching data:', e);
}
},
getCachedData(key, maxAge = 300000) { // 5 minutes default
try {
const cached = localStorage.getItem(`salesbot_${key}`);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > maxAge) return null;
return data;
} catch (e) {
return null;
}
},
syncData() {
// Sync any pending data when back online
console.log('Syncing data...');
}
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
OfflineManager.init();
});
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
// Export for use in templates
window.Utils = Utils;
window.API = API;
window.Dashboard = Dashboard;
window.Analytics = Analytics;

View File

@@ -0,0 +1,236 @@
/**
* Sales Bot - Camera Capture for Ticket Processing
*/
let cameraStream = null;
async function abrirCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (!modal || !video) {
console.error('Camera elements not found');
return;
}
try {
// Request camera access
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
video.srcObject = cameraStream;
modal.classList.add('active');
} catch (error) {
console.error('Error accessing camera:', error);
if (error.name === 'NotAllowedError') {
alert('Permiso de camara denegado. Por favor, permite el acceso a la camara en la configuracion del navegador.');
} else if (error.name === 'NotFoundError') {
alert('No se encontro una camara en este dispositivo.');
} else {
alert('Error al acceder a la camara: ' + error.message);
}
}
}
function cerrarCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (video) {
video.srcObject = null;
}
if (modal) {
modal.classList.remove('active');
}
}
async function capturarFoto() {
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
if (!video || !canvas) {
console.error('Video or canvas not found');
return;
}
// Set canvas size to video size
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw video frame to canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Get base64 image
const imageData = canvas.toDataURL('image/jpeg', 0.9);
// Close camera
cerrarCamara();
// Show loading
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
// Send to server for OCR processing
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: imageData
})
});
const result = await response.json();
if (result.success) {
if (window.Utils) {
window.Utils.showNotification('Ticket procesado correctamente', 'success');
}
// Show detected data
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando ticket: ' + (result.error || 'Error desconocido'), 'error');
}
}
} catch (error) {
console.error('Error sending image:', error);
if (window.Utils) {
window.Utils.showNotification('Error enviando imagen al servidor', 'error');
}
}
}
function mostrarResultadoOCR(result) {
// Create modal to show OCR results
const modal = document.createElement('div');
modal.className = 'camera-modal active';
modal.style.cssText = 'display: flex; align-items: center; justify-content: center;';
const monto = result.monto ? window.Utils.formatMoney(result.monto) : 'No detectado';
const productos = result.productos || [];
const tubos = productos.filter(p =>
p.nombre && p.nombre.toLowerCase().includes('tinte')
).length;
modal.innerHTML = `
<div style="background: #1a1a2e; padding: 30px; border-radius: 16px; max-width: 400px; width: 90%;">
<h2 style="margin-bottom: 20px; color: #00d4ff;">Resultado del Ticket</h2>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Monto Detectado</label>
<div style="font-size: 32px; font-weight: bold; color: #00ff88;">${monto}</div>
</div>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Tubos de Tinte</label>
<div style="font-size: 24px; font-weight: bold; color: #00d4ff;">${tubos}</div>
</div>
${productos.length > 0 ? `
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Productos (${productos.length})</label>
<ul style="list-style: none; margin-top: 10px; max-height: 150px; overflow-y: auto;">
${productos.slice(0, 5).map(p => `
<li style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
${p.nombre || 'Producto'} - ${window.Utils.formatMoney(p.importe || 0)}
</li>
`).join('')}
</ul>
</div>
` : ''}
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-secondary" onclick="this.closest('.camera-modal').remove()" style="flex: 1;">
Cerrar
</button>
<button class="btn btn-primary" onclick="confirmarVenta(${result.monto || 0}, ${tubos}); this.closest('.camera-modal').remove();" style="flex: 1;">
Registrar Venta
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
async function confirmarVenta(monto, tubos) {
// This would integrate with the main sales flow
// For now, just show a notification
if (window.Utils) {
window.Utils.showNotification(`Venta de ${window.Utils.formatMoney(monto)} lista para confirmar`, 'info');
}
// Here you could redirect to Mattermost or show a form
// to complete the sale registration
}
// File input fallback for devices without camera API
function createFileInput() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.style.display = 'none';
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const imageData = event.target.result;
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData })
});
const result = await response.json();
if (result.success) {
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando imagen', 'error');
}
}
} catch (error) {
console.error('Error:', error);
}
};
reader.readAsDataURL(file);
});
return input;
}
// Export functions
window.abrirCamara = abrirCamara;
window.cerrarCamara = cerrarCamara;
window.capturarFoto = capturarFoto;

View File

@@ -0,0 +1,266 @@
/**
* Sales Bot - Chart.js Integration and Chart Utilities
*/
// Chart default configuration
Chart.defaults.color = '#888';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
// Color palette
const ChartColors = {
primary: '#00d4ff',
secondary: '#00ff88',
warning: '#ffaa00',
danger: '#ff4444',
purple: '#aa00ff',
gradient: (ctx, color1, color2) => {
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
return gradient;
}
};
// Chart factory
const ChartFactory = {
// Line chart for trends
createTrendChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'line',
data: {
labels: data.labels || [],
datasets: [{
label: data.label || 'Datos',
data: data.values || [],
borderColor: options.color || ChartColors.primary,
backgroundColor: options.fill ?
ChartColors.gradient(ctx.getContext('2d'), 'rgba(0, 212, 255, 0.3)', 'rgba(0, 212, 255, 0)') :
'transparent',
fill: options.fill !== false,
tension: 0.4,
pointRadius: options.points ? 4 : 0,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: options.legend !== false,
labels: { color: '#888' }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: (ctx) => options.formatValue ?
options.formatValue(ctx.parsed.y) :
ctx.parsed.y
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888', maxRotation: 45 }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#888',
callback: options.formatYAxis || ((value) => value)
},
beginAtZero: options.beginAtZero !== false
}
}
}
});
},
// Bar chart for comparisons
createBarChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: options.horizontal ? 'bar' : 'bar',
data: {
labels: data.labels || [],
datasets: [{
label: data.label || 'Datos',
data: data.values || [],
backgroundColor: data.colors || [
ChartColors.primary,
ChartColors.secondary,
ChartColors.warning,
ChartColors.purple
],
borderRadius: 8,
barThickness: options.barThickness || 'flex'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: options.horizontal ? 'y' : 'x',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12,
callbacks: {
label: (ctx) => options.formatValue ?
options.formatValue(ctx.parsed[options.horizontal ? 'x' : 'y']) :
ctx.parsed[options.horizontal ? 'x' : 'y']
}
}
},
scales: {
x: {
grid: { display: !options.horizontal },
ticks: { color: '#888' }
},
y: {
grid: { color: options.horizontal ? 'transparent' : 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
},
// Doughnut chart for distribution
createDoughnutChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'doughnut',
data: {
labels: data.labels || [],
datasets: [{
data: data.values || [],
backgroundColor: data.colors || [
ChartColors.primary,
ChartColors.secondary,
ChartColors.warning,
ChartColors.purple,
ChartColors.danger
],
borderWidth: 0,
cutout: options.cutout || '70%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: options.legend !== false,
position: options.legendPosition || 'bottom',
labels: { color: '#888', padding: 15 }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12
}
}
}
});
},
// Multi-line chart for comparisons
createMultiLineChart(canvasId, datasets, labels, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const colors = [ChartColors.primary, ChartColors.secondary, ChartColors.warning, ChartColors.purple];
return new Chart(ctx.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: datasets.map((ds, i) => ({
label: ds.label,
data: ds.values,
borderColor: ds.color || colors[i % colors.length],
backgroundColor: 'transparent',
borderDash: ds.dashed ? [5, 5] : [],
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
labels: { color: '#888' }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' },
beginAtZero: true
}
}
}
});
}
};
// Helper to format currency in charts
function formatCurrency(value) {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
}
// Export
window.ChartFactory = ChartFactory;
window.ChartColors = ChartColors;
window.formatCurrency = formatCurrency;

122
sales-bot/static/js/pwa.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Sales Bot - PWA Registration and Install Prompt
*/
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('Service Worker registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
showUpdatePrompt();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
// Install prompt handling
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallPrompt();
});
function showInstallPrompt() {
// Create install prompt UI
const prompt = document.createElement('div');
prompt.id = 'install-prompt';
prompt.className = 'install-prompt show';
prompt.innerHTML = `
<span>Instalar Sales Bot en tu dispositivo</span>
<button class="btn btn-primary" onclick="installPWA()">Instalar</button>
<button class="btn btn-secondary" onclick="dismissInstallPrompt()">Ahora no</button>
`;
document.body.appendChild(prompt);
}
async function installPWA() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('Install prompt outcome:', outcome);
deferredPrompt = null;
dismissInstallPrompt();
if (outcome === 'accepted') {
if (window.Utils) {
window.Utils.showNotification('App instalada correctamente', 'success');
}
}
}
function dismissInstallPrompt() {
const prompt = document.getElementById('install-prompt');
if (prompt) {
prompt.remove();
}
}
function showUpdatePrompt() {
const updateBanner = document.createElement('div');
updateBanner.id = 'update-banner';
updateBanner.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #00d4ff;
color: #000;
padding: 15px;
text-align: center;
z-index: 9999;
`;
updateBanner.innerHTML = `
<span>Nueva version disponible</span>
<button onclick="updateApp()" style="margin-left: 15px; padding: 5px 15px; border: none; border-radius: 4px; cursor: pointer;">
Actualizar
</button>
`;
document.body.appendChild(updateBanner);
}
function updateApp() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(registration => {
if (registration && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
}
window.location.reload();
}
// Detect if running as PWA
function isPWA() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
// Add PWA class to body if running as installed app
if (isPWA()) {
document.body.classList.add('pwa-mode');
}
// Export
window.installPWA = installPWA;
window.dismissInstallPrompt = dismissInstallPrompt;
window.isPWA = isPWA;

View File

@@ -0,0 +1,83 @@
{
"name": "Sales Bot - Sistema de Ventas",
"short_name": "Sales Bot",
"description": "Sistema de seguimiento y automatizacion de ventas",
"start_url": "/dashboard",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1a1a2e",
"theme_color": "#00d4ff",
"scope": "/",
"lang": "es-MX",
"categories": ["business", "productivity"],
"icons": [
{
"src": "/static/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Dashboard",
"url": "/dashboard",
"description": "Ver dashboard principal"
},
{
"name": "Analytics",
"url": "/dashboard/analytics",
"description": "Ver analytics y graficas"
},
{
"name": "Ejecutivo",
"url": "/dashboard/executive",
"description": "Ver dashboard ejecutivo"
}
],
"related_applications": [],
"prefer_related_applications": false
}

View File

@@ -0,0 +1,217 @@
/**
* Sales Bot - Service Worker for PWA Offline Support
*/
const CACHE_NAME = 'salesbot-v1';
const RUNTIME_CACHE = 'salesbot-runtime-v1';
// Assets to cache on install
const PRECACHE_ASSETS = [
'/dashboard',
'/dashboard/analytics',
'/dashboard/executive',
'/static/css/main.css',
'/static/js/app.js',
'/static/js/pwa.js',
'/static/js/camera.js',
'/static/js/charts.js',
'/static/manifest.json'
];
// API endpoints to cache with network-first strategy
const API_ROUTES = [
'/api/dashboard/resumen',
'/api/dashboard/ranking',
'/api/dashboard/ventas-recientes',
'/api/analytics/trends',
'/api/analytics/predictions'
];
// Install event - cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Precaching assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => self.skipWaiting())
.catch(err => console.error('[SW] Precache failed:', err))
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - handle requests
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip external requests
if (url.origin !== location.origin) {
return;
}
// API requests - Network first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets - Cache first, fall back to network
if (url.pathname.startsWith('/static/')) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages - Network first with cache fallback
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// Default - Network first
event.respondWith(networkFirst(request));
});
// Cache first strategy
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache first failed:', error);
return new Response('Offline', { status: 503 });
}
}
// Network first strategy
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving from cache:', request.url);
return cachedResponse;
}
// No cache available
if (request.headers.get('Accept')?.includes('text/html')) {
return caches.match('/dashboard'); // Fallback to main page
}
if (request.headers.get('Accept')?.includes('application/json')) {
return new Response(JSON.stringify({
error: 'offline',
message: 'Sin conexion. Mostrando datos en cache.'
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Offline', { status: 503 });
}
}
// Handle messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync for offline actions (if supported)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-sales') {
event.waitUntil(syncSales());
}
});
async function syncSales() {
// Sync any pending sales when back online
console.log('[SW] Syncing pending sales...');
// Implementation would go here
}
// Push notifications (if implemented)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || 'Nueva notificacion de Sales Bot',
icon: '/static/icons/icon-192x192.png',
badge: '/static/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/dashboard'
}
};
event.waitUntil(
self.registration.showNotification(data.title || 'Sales Bot', options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window' }).then(clientList => {
// Focus existing window if available
for (const client of clientList) {
if (client.url.includes('/dashboard') && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data?.url || '/dashboard');
}
})
);
});

View File

@@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Analytics - Sales Bot{% endblock %}
{% block content %}
<header class="page-header">
<div>
<h1><span>Analytics</span> Dashboard</h1>
<p class="fecha" id="fecha-actual"></p>
</div>
<div>
<select id="periodo-select" class="btn btn-secondary" onchange="cambiarPeriodo()">
<option value="7">Ultimos 7 dias</option>
<option value="30" selected>Ultimos 30 dias</option>
<option value="90">Ultimos 90 dias</option>
</select>
</div>
</header>
<!-- KPIs Row -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value" id="kpi-total-ventas">-</div>
<div class="kpi-label">Total Ventas</div>
<div class="kpi-trend" id="kpi-trend-ventas"></div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-monto-total">-</div>
<div class="kpi-label">Monto Total</div>
<div class="kpi-trend" id="kpi-trend-monto"></div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-tubos">-</div>
<div class="kpi-label">Tubos Vendidos</div>
<div class="kpi-trend" id="kpi-trend-tubos"></div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-promedio">-</div>
<div class="kpi-label">Promedio/Dia</div>
<div class="kpi-trend" id="kpi-trend-promedio"></div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-prediccion">-</div>
<div class="kpi-label">Prediccion Prox. Semana</div>
<div class="kpi-trend" id="kpi-trend-prediccion"></div>
</div>
</div>
<!-- Charts -->
<div class="main-grid">
<div class="panel" style="grid-column: span 2;">
<h2><span class="icon">&#128200;</span> Tendencia de Ventas</h2>
<div class="chart-container">
<canvas id="chart-tendencia"></canvas>
</div>
</div>
</div>
<div class="main-grid" style="margin-top: 20px;">
<div class="panel">
<h2><span class="icon">&#128202;</span> Ventas por Vendedor</h2>
<div class="chart-container">
<canvas id="chart-vendedores"></canvas>
</div>
</div>
<div class="panel">
<h2><span class="icon">&#128197;</span> Comparativa Semanal</h2>
<div class="chart-container">
<canvas id="chart-comparativa"></canvas>
</div>
</div>
</div>
<!-- Prediction Info -->
<div class="panel" style="margin-top: 20px;">
<h2><span class="icon">&#129302;</span> Prediccion de Ventas</h2>
<div class="main-grid">
<div>
<p style="color: var(--text-secondary); margin-bottom: 15px;">
Basado en el promedio movil y tendencia lineal de los ultimos <span id="pred-dias">30</span> dias:
</p>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value" id="pred-tomorrow">-</div>
<div class="kpi-label">Manana</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="pred-week">-</div>
<div class="kpi-label">Proxima Semana</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="pred-confidence">-</div>
<div class="kpi-label">Confianza</div>
</div>
</div>
</div>
<div>
<h3 style="margin-bottom: 10px;">Tendencia Detectada</h3>
<div id="pred-trend-info" style="font-size: 48px; text-align: center; padding: 20px;">
-
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="/static/js/charts.js"></script>
<script>
let chartTendencia = null;
let chartVendedores = null;
let chartComparativa = null;
async function cargarAnalytics() {
const dias = document.getElementById('periodo-select').value;
document.getElementById('pred-dias').textContent = dias;
try {
// Load trends
const resTrends = await fetch(`/api/analytics/trends?days=${dias}`);
const trends = await resTrends.json();
if (trends.labels && trends.ventas) {
renderTrendChart(trends);
updateKPIs(trends);
}
// Load predictions
const resPred = await fetch(`/api/analytics/predictions?period=${dias}`);
const predictions = await resPred.json();
if (predictions) {
document.getElementById('pred-tomorrow').textContent = formatMoney(predictions.next_day || 0);
document.getElementById('pred-week').textContent = formatMoney(predictions.next_week || 0);
document.getElementById('pred-confidence').textContent = Math.round((predictions.confidence || 0) * 100) + '%';
document.getElementById('kpi-prediccion').textContent = formatMoney(predictions.next_week || 0);
const trendIcon = predictions.trend === 'increasing' ? '&#8599; Subiendo' :
predictions.trend === 'decreasing' ? '&#8600; Bajando' : '&#8594; Estable';
const trendColor = predictions.trend === 'increasing' ? 'var(--secondary)' :
predictions.trend === 'decreasing' ? '#ff4444' : 'var(--text-secondary)';
document.getElementById('pred-trend-info').innerHTML = `<span style="color: ${trendColor}">${trendIcon}</span>`;
const trendClass = predictions.trend === 'increasing' ? 'up' :
predictions.trend === 'decreasing' ? 'down' : 'stable';
document.getElementById('kpi-trend-prediccion').className = `kpi-trend ${trendClass}`;
document.getElementById('kpi-trend-prediccion').textContent = predictions.trend === 'increasing' ? '+' : '-';
}
// Load vendor comparison
const resRanking = await fetch('/api/dashboard/ranking');
const ranking = await resRanking.json();
if (ranking && ranking.length > 0) {
renderVendedoresChart(ranking);
}
// Load weekly comparison
const resComp = await fetch('/api/analytics/comparisons?type=weekly');
const comparisons = await resComp.json();
if (comparisons) {
renderComparativaChart(comparisons);
}
} catch (e) {
console.error('Error cargando analytics:', e);
}
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
}
function updateKPIs(trends) {
const totalVentas = trends.ventas.reduce((a, b) => a + b, 0);
const promedio = totalVentas / trends.ventas.length;
document.getElementById('kpi-total-ventas').textContent = trends.ventas.length;
document.getElementById('kpi-monto-total').textContent = formatMoney(totalVentas);
document.getElementById('kpi-tubos').textContent = trends.tubos?.reduce((a, b) => a + b, 0) || '-';
document.getElementById('kpi-promedio').textContent = formatMoney(promedio);
}
function renderTrendChart(data) {
const ctx = document.getElementById('chart-tendencia').getContext('2d');
if (chartTendencia) chartTendencia.destroy();
chartTendencia = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [
{
label: 'Ventas',
data: data.ventas,
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Prediccion',
data: data.prediccion || [],
borderColor: '#00ff88',
borderDash: [5, 5],
fill: false,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#888' }
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
}
function renderVendedoresChart(ranking) {
const ctx = document.getElementById('chart-vendedores').getContext('2d');
if (chartVendedores) chartVendedores.destroy();
const top5 = ranking.slice(0, 5);
chartVendedores = new Chart(ctx, {
type: 'bar',
data: {
labels: top5.map(v => v.vendedor_username || v.vendedor),
datasets: [{
label: 'Tubos',
data: top5.map(v => v.tubos_totales || 0),
backgroundColor: ['#ffd700', '#c0c0c0', '#cd7f32', '#00d4ff', '#00ff88']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
},
y: {
grid: { display: false },
ticks: { color: '#fff' }
}
}
}
});
}
function renderComparativaChart(data) {
const ctx = document.getElementById('chart-comparativa').getContext('2d');
if (chartComparativa) chartComparativa.destroy();
chartComparativa = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Semana Anterior', 'Esta Semana'],
datasets: [{
label: 'Monto',
data: [data.previous_week || 0, data.current_week || 0],
backgroundColor: ['rgba(255,255,255,0.2)', '#00d4ff']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
}
function cambiarPeriodo() {
cargarAnalytics();
}
function formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
}
// Initialize
cargarAnalytics();
</script>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#00d4ff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Sales Bot">
<meta name="description" content="Sales Bot - Sistema de seguimiento de ventas">
<title>{% block title %}Sales Bot{% endblock %}</title>
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<!-- Styles -->
<link rel="stylesheet" href="/static/css/main.css">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar">
<div class="nav-brand">
<span class="brand-icon">S</span>
<span class="brand-text">Sales Bot</span>
</div>
<div class="nav-links">
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="/dashboard/analytics" class="nav-link {% if active_page == 'analytics' %}active{% endif %}">Analytics</a>
<a href="/dashboard/executive" class="nav-link {% if active_page == 'executive' %}active{% endif %}">Ejecutivo</a>
</div>
<div class="nav-actions">
<button class="refresh-btn" onclick="window.location.reload()">Actualizar</button>
</div>
</nav>
<!-- Main Content -->
<main class="container">
{% block content %}{% endblock %}
</main>
<!-- Scripts -->
<script src="/static/js/app.js"></script>
<script src="/static/js/pwa.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Dashboard - Sales Bot{% endblock %}
{% block content %}
<header class="page-header">
<div>
<h1><span>Sales</span> Bot Dashboard</h1>
<p class="fecha" id="fecha-actual"></p>
</div>
<button class="refresh-btn" onclick="cargarDatos()">Actualizar</button>
</header>
<div class="stats-grid">
<div class="stat-card">
<div class="label">Ventas Hoy</div>
<div class="value" id="ventas-hoy">-</div>
<div class="subvalue" id="monto-hoy">$0.00</div>
</div>
<div class="stat-card green">
<div class="label">Ventas del Mes</div>
<div class="value" id="ventas-mes">-</div>
<div class="subvalue" id="monto-mes">$0.00</div>
</div>
<div class="stat-card orange">
<div class="label">Vendedores Activos Hoy</div>
<div class="value" id="vendedores-activos">-</div>
</div>
<div class="stat-card purple">
<div class="label">Meta Diaria</div>
<div class="value">3</div>
<div class="subvalue">tubos por vendedor</div>
</div>
</div>
<div class="main-grid">
<div class="panel">
<h2><span class="icon">&#127942;</span> Ranking del Mes (Tubos)</h2>
<ul class="ranking-list" id="ranking-list">
<li class="loading"><div class="loading-spinner"></div></li>
</ul>
</div>
<div class="panel">
<h2><span class="icon">&#128203;</span> Ventas Recientes</h2>
<div class="ventas-list" id="ventas-list">
<div class="loading"><div class="loading-spinner"></div></div>
</div>
</div>
</div>
<!-- Camera Button for Mobile -->
<button class="btn btn-primary" id="btn-camera" onclick="abrirCamara()" style="position: fixed; bottom: 20px; right: 20px; border-radius: 50%; width: 60px; height: 60px; font-size: 24px; display: none;">
&#128247;
</button>
<!-- Camera Modal -->
<div class="camera-modal" id="camera-modal">
<div class="camera-container">
<video id="camera-video" autoplay playsinline></video>
<div class="camera-controls">
<button class="camera-btn close" onclick="cerrarCamara()">&#10005;</button>
<button class="camera-btn capture" onclick="capturarFoto()">&#128247;</button>
</div>
</div>
</div>
<canvas id="camera-canvas" style="display: none;"></canvas>
{% endblock %}
{% block extra_js %}
<script>
// Helper functions
function formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
// Data loading functions
async function cargarResumen() {
try {
const res = await fetch('/api/dashboard/resumen');
const data = await res.json();
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
} catch (e) {
console.error('Error cargando resumen:', e);
}
}
async function cargarRanking() {
try {
const res = await fetch('/api/dashboard/ranking');
const data = await res.json();
const lista = document.getElementById('ranking-list');
if (!data || data.length === 0) {
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
return;
}
lista.innerHTML = data.slice(0, 10).map((v, i) => {
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
const tubos = v.tubos_totales || 0;
const comision = v.comision_total || 0;
const ventas = v.cantidad_ventas || 0;
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
const username = v.vendedor_username || v.vendedor;
return `
<li class="ranking-item">
<div class="ranking-position ${posClass}">${i + 1}</div>
<div class="ranking-info">
<div class="ranking-name">${nombre}</div>
<div class="ranking-stats">@${username} - ${ventas} ventas - ${v.dias_activos || 0} dias activos</div>
</div>
<div class="ranking-value">
<div class="ranking-tubos">${tubos}</div>
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
</div>
</li>
`;
}).join('');
} catch (e) {
console.error('Error cargando ranking:', e);
}
}
async function cargarVentasRecientes() {
try {
const res = await fetch('/api/dashboard/ventas-recientes');
const data = await res.json();
const lista = document.getElementById('ventas-list');
if (!data || data.length === 0) {
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
return;
}
lista.innerHTML = data.map(v => {
const nombre = v.nombre_completo || v.vendedor_username;
return `
<div class="venta-item">
<div class="venta-info">
<div class="vendedor">${nombre}</div>
<div class="cliente">${v.cliente || 'Sin cliente'} - ${formatDate(v.fecha_venta)}</div>
</div>
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
</div>
`}).join('');
} catch (e) {
console.error('Error cargando ventas:', e);
}
}
function cargarDatos() {
cargarResumen();
cargarRanking();
cargarVentasRecientes();
}
// Initialize
cargarDatos();
setInterval(cargarDatos, 30000);
// Show camera button on mobile
if ('mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices) {
document.getElementById('btn-camera').style.display = 'block';
}
</script>
<script src="/static/js/camera.js"></script>
{% endblock %}

View File

@@ -0,0 +1,320 @@
{% extends "base.html" %}
{% block title %}Dashboard Ejecutivo - Sales Bot{% endblock %}
{% block content %}
<header class="page-header">
<div>
<h1><span>Dashboard</span> Ejecutivo</h1>
<p class="fecha" id="fecha-actual"></p>
</div>
<div>
<button class="btn btn-primary" onclick="generarReportePDF()">Descargar PDF</button>
</div>
</header>
<!-- KPIs Principales -->
<div class="stats-grid">
<div class="stat-card">
<div class="label">Ventas Hoy</div>
<div class="value" id="exec-ventas-hoy">-</div>
<div class="subvalue" id="exec-vs-ayer"></div>
</div>
<div class="stat-card green">
<div class="label">Ventas Mes</div>
<div class="value" id="exec-ventas-mes">-</div>
<div class="subvalue" id="exec-vs-mes-ant"></div>
</div>
<div class="stat-card orange">
<div class="label">Comisiones Mes</div>
<div class="value" id="exec-comisiones">-</div>
<div class="subvalue">total equipo</div>
</div>
<div class="stat-card purple">
<div class="label">Meta Cumplida</div>
<div class="value" id="exec-meta-pct">-</div>
<div class="subvalue" id="exec-meta-detalle"></div>
</div>
</div>
<!-- Grafica Principal -->
<div class="panel" style="margin-bottom: 20px;">
<h2><span class="icon">&#128200;</span> Rendimiento Mensual</h2>
<div class="chart-container" style="height: 350px;">
<canvas id="chart-mensual"></canvas>
</div>
</div>
<div class="main-grid">
<!-- Top Performers -->
<div class="panel">
<h2><span class="icon">&#127942;</span> Top Performers del Mes</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Vendedor</th>
<th>Tubos</th>
<th>Comision</th>
<th>Racha</th>
</tr>
</thead>
<tbody id="top-performers">
<tr><td colspan="5" class="loading">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Metricas Adicionales -->
<div class="panel">
<h2><span class="icon">&#128202;</span> Metricas Clave</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-value" id="metric-promedio-dia">-</div>
<div class="kpi-label">Promedio/Dia</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="metric-mejor-dia">-</div>
<div class="kpi-label">Mejor Dia</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="metric-vendedores">-</div>
<div class="kpi-label">Vendedores Activos</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="metric-ticket-prom">-</div>
<div class="kpi-label">Ticket Promedio</div>
</div>
</div>
<h3 style="margin: 20px 0 15px;">Prediccion Proxima Semana</h3>
<div id="prediccion-exec" style="background: rgba(0,212,255,0.1); padding: 20px; border-radius: 12px; text-align: center;">
<div style="font-size: 36px; font-weight: bold; color: var(--primary);" id="pred-monto">-</div>
<div style="color: var(--text-secondary); margin-top: 5px;">Monto estimado</div>
<div style="margin-top: 10px;" id="pred-tendencia"></div>
</div>
</div>
</div>
<!-- Comparativa -->
<div class="panel" style="margin-top: 20px;">
<h2><span class="icon">&#128197;</span> Comparativa Mensual</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="chart-comparativa-meses"></canvas>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let chartMensual = null;
let chartComparativa = null;
async function cargarDashboardEjecutivo() {
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
try {
// Cargar resumen
const resResumen = await fetch('/api/dashboard/resumen');
const resumen = await resResumen.json();
document.getElementById('exec-ventas-hoy').textContent = formatMoney(resumen.monto_hoy || 0);
document.getElementById('exec-ventas-mes').textContent = formatMoney(resumen.monto_mes || 0);
// Cargar ranking
const resRanking = await fetch('/api/dashboard/ranking');
const ranking = await resRanking.json();
if (ranking && ranking.length > 0) {
renderTopPerformers(ranking);
calcularMetricas(ranking);
}
// Cargar tendencias
const resTrends = await fetch('/api/analytics/trends?days=30');
const trends = await resTrends.json();
if (trends.labels) {
renderChartMensual(trends);
}
// Cargar predicciones
const resPred = await fetch('/api/analytics/predictions?period=30');
const predictions = await resPred.json();
if (predictions) {
document.getElementById('pred-monto').textContent = formatMoney(predictions.next_week || 0);
const trendText = predictions.trend === 'increasing' ? '&#8599; Tendencia al alza' :
predictions.trend === 'decreasing' ? '&#8600; Tendencia a la baja' : '&#8594; Tendencia estable';
const trendColor = predictions.trend === 'increasing' ? 'var(--secondary)' :
predictions.trend === 'decreasing' ? '#ff4444' : 'var(--text-secondary)';
document.getElementById('pred-tendencia').innerHTML = `<span style="color: ${trendColor}">${trendText}</span>`;
}
// Cargar comparativas
const resComp = await fetch('/api/analytics/comparisons?type=monthly');
const comp = await resComp.json();
if (comp) {
renderChartComparativa(comp);
const diff = ((comp.current_month - comp.previous_month) / comp.previous_month * 100).toFixed(1);
const sign = diff > 0 ? '+' : '';
document.getElementById('exec-vs-mes-ant').textContent = `${sign}${diff}% vs mes anterior`;
document.getElementById('exec-vs-mes-ant').style.color = diff > 0 ? 'var(--secondary)' : '#ff4444';
}
} catch (e) {
console.error('Error cargando dashboard ejecutivo:', e);
}
}
function renderTopPerformers(ranking) {
const tbody = document.getElementById('top-performers');
const top5 = ranking.slice(0, 5);
tbody.innerHTML = top5.map((v, i) => {
const medalla = i === 0 ? '&#129351;' : i === 1 ? '&#129352;' : i === 2 ? '&#129353;' : (i + 1);
return `
<tr>
<td>${medalla}</td>
<td><strong>${v.vendedor_username || v.vendedor}</strong></td>
<td>${v.tubos_totales || 0}</td>
<td>${formatMoney(v.comision_total || 0)}</td>
<td>${v.racha || 0} dias</td>
</tr>
`;
}).join('');
// Calcular comisiones totales
const totalComisiones = ranking.reduce((sum, v) => sum + (v.comision_total || 0), 0);
document.getElementById('exec-comisiones').textContent = formatMoney(totalComisiones);
// Calcular meta cumplida
const vendedoresConMeta = ranking.filter(v => (v.tubos_totales || 0) >= 3).length;
const pctMeta = Math.round((vendedoresConMeta / ranking.length) * 100);
document.getElementById('exec-meta-pct').textContent = `${pctMeta}%`;
document.getElementById('exec-meta-detalle').textContent = `${vendedoresConMeta}/${ranking.length} vendedores`;
}
function calcularMetricas(ranking) {
const totalVendedores = ranking.length;
document.getElementById('metric-vendedores').textContent = totalVendedores;
// Estas metricas se calcularian con datos reales
document.getElementById('metric-promedio-dia').textContent = '-';
document.getElementById('metric-mejor-dia').textContent = '-';
document.getElementById('metric-ticket-prom').textContent = '-';
}
function renderChartMensual(data) {
const ctx = document.getElementById('chart-mensual').getContext('2d');
if (chartMensual) chartMensual.destroy();
chartMensual = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Ventas',
data: data.ventas,
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
}
function renderChartComparativa(data) {
const ctx = document.getElementById('chart-comparativa-meses').getContext('2d');
if (chartComparativa) chartComparativa.destroy();
const meses = data.months || ['Mes -2', 'Mes -1', 'Mes Actual'];
const valores = data.values || [data.month_2 || 0, data.previous_month || 0, data.current_month || 0];
chartComparativa = new Chart(ctx, {
type: 'bar',
data: {
labels: meses,
datasets: [{
label: 'Monto',
data: valores,
backgroundColor: ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.2)', '#00d4ff']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
}
async function generarReportePDF() {
try {
const res = await fetch('/api/reports/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'executive' })
});
const data = await res.json();
if (data.download_url) {
window.open(data.download_url, '_blank');
} else if (data.error) {
alert('Error generando reporte: ' + data.error);
} else {
alert('Reporte generado. Se enviara al canal de Mattermost.');
}
} catch (e) {
console.error('Error generando PDF:', e);
alert('Error generando el reporte PDF');
}
}
function formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
}
// Initialize
cargarDashboardEjecutivo();
</script>
{% endblock %}