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:
15
sales-bot/analytics/__init__.py
Normal file
15
sales-bot/analytics/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Analytics module for Sales Bot
|
||||
Provides predictions, trends, and performance analysis
|
||||
"""
|
||||
|
||||
from .predictions import prediccion_basica, calcular_tendencia
|
||||
from .trends import TrendAnalyzer
|
||||
from .comparisons import ComparisonAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'prediccion_basica',
|
||||
'calcular_tendencia',
|
||||
'TrendAnalyzer',
|
||||
'ComparisonAnalyzer'
|
||||
]
|
||||
293
sales-bot/analytics/comparisons.py
Normal file
293
sales-bot/analytics/comparisons.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Comparison analysis for Sales Bot
|
||||
Compares sales data across different time periods
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mexico timezone
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
|
||||
class ComparisonAnalyzer:
|
||||
"""
|
||||
Analyzes and compares sales data across different periods.
|
||||
"""
|
||||
|
||||
def __init__(self, nocodb_client):
|
||||
self.nocodb = nocodb_client
|
||||
|
||||
def comparar_semanas(self) -> Dict:
|
||||
"""
|
||||
Compara la semana actual con la semana anterior.
|
||||
|
||||
Returns:
|
||||
dict con comparación semanal
|
||||
"""
|
||||
try:
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
|
||||
# Semana actual (lunes a hoy)
|
||||
dias_desde_lunes = hoy.weekday()
|
||||
inicio_semana_actual = hoy - timedelta(days=dias_desde_lunes)
|
||||
|
||||
# Semana anterior
|
||||
inicio_semana_anterior = inicio_semana_actual - timedelta(days=7)
|
||||
fin_semana_anterior = inicio_semana_actual - timedelta(days=1)
|
||||
|
||||
# Obtener ventas
|
||||
ventas_actual = self._obtener_ventas_rango(inicio_semana_actual, hoy)
|
||||
ventas_anterior = self._obtener_ventas_rango(inicio_semana_anterior, fin_semana_anterior)
|
||||
|
||||
monto_actual = sum(float(v.get('monto', 0) or 0) for v in ventas_actual)
|
||||
monto_anterior = sum(float(v.get('monto', 0) or 0) for v in ventas_anterior)
|
||||
|
||||
# Calcular diferencia
|
||||
if monto_anterior > 0:
|
||||
diff_pct = ((monto_actual - monto_anterior) / monto_anterior) * 100
|
||||
else:
|
||||
diff_pct = 100 if monto_actual > 0 else 0
|
||||
|
||||
return {
|
||||
'current_week': round(monto_actual, 2),
|
||||
'previous_week': round(monto_anterior, 2),
|
||||
'difference': round(monto_actual - monto_anterior, 2),
|
||||
'diff_percent': round(diff_pct, 1),
|
||||
'ventas_actual': len(ventas_actual),
|
||||
'ventas_anterior': len(ventas_anterior),
|
||||
'periodo_actual': {
|
||||
'inicio': inicio_semana_actual.strftime('%Y-%m-%d'),
|
||||
'fin': hoy.strftime('%Y-%m-%d')
|
||||
},
|
||||
'periodo_anterior': {
|
||||
'inicio': inicio_semana_anterior.strftime('%Y-%m-%d'),
|
||||
'fin': fin_semana_anterior.strftime('%Y-%m-%d')
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparando semanas: {str(e)}")
|
||||
return {
|
||||
'current_week': 0,
|
||||
'previous_week': 0,
|
||||
'difference': 0,
|
||||
'diff_percent': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def comparar_meses(self, mes1: str = None, mes2: str = None) -> Dict:
|
||||
"""
|
||||
Compara dos meses.
|
||||
|
||||
Args:
|
||||
mes1: Primer mes (formato YYYY-MM), default mes actual
|
||||
mes2: Segundo mes (formato YYYY-MM), default mes anterior
|
||||
|
||||
Returns:
|
||||
dict con comparación mensual
|
||||
"""
|
||||
try:
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
|
||||
# Determinar meses a comparar
|
||||
if not mes1:
|
||||
mes1 = hoy.strftime('%Y-%m')
|
||||
|
||||
if not mes2:
|
||||
# Mes anterior
|
||||
primer_dia_mes_actual = hoy.replace(day=1)
|
||||
ultimo_dia_mes_anterior = primer_dia_mes_actual - timedelta(days=1)
|
||||
mes2 = ultimo_dia_mes_anterior.strftime('%Y-%m')
|
||||
|
||||
# Obtener ventas de cada mes
|
||||
ventas_mes1 = self._obtener_ventas_mes(mes1)
|
||||
ventas_mes2 = self._obtener_ventas_mes(mes2)
|
||||
|
||||
monto_mes1 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes1)
|
||||
monto_mes2 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes2)
|
||||
|
||||
# Calcular diferencia
|
||||
if monto_mes2 > 0:
|
||||
diff_pct = ((monto_mes1 - monto_mes2) / monto_mes2) * 100
|
||||
else:
|
||||
diff_pct = 100 if monto_mes1 > 0 else 0
|
||||
|
||||
# Calcular mes anterior al mes2 para tendencia de 3 meses
|
||||
anio2, mes2_num = map(int, mes2.split('-'))
|
||||
if mes2_num == 1:
|
||||
mes3 = f"{anio2 - 1}-12"
|
||||
else:
|
||||
mes3 = f"{anio2}-{mes2_num - 1:02d}"
|
||||
|
||||
ventas_mes3 = self._obtener_ventas_mes(mes3)
|
||||
monto_mes3 = sum(float(v.get('monto', 0) or 0) for v in ventas_mes3)
|
||||
|
||||
return {
|
||||
'current_month': round(monto_mes1, 2),
|
||||
'previous_month': round(monto_mes2, 2),
|
||||
'month_2': round(monto_mes3, 2),
|
||||
'difference': round(monto_mes1 - monto_mes2, 2),
|
||||
'diff_percent': round(diff_pct, 1),
|
||||
'ventas_actual': len(ventas_mes1),
|
||||
'ventas_anterior': len(ventas_mes2),
|
||||
'months': [mes3, mes2, mes1],
|
||||
'values': [round(monto_mes3, 2), round(monto_mes2, 2), round(monto_mes1, 2)],
|
||||
'mes1': mes1,
|
||||
'mes2': mes2
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparando meses: {str(e)}")
|
||||
return {
|
||||
'current_month': 0,
|
||||
'previous_month': 0,
|
||||
'difference': 0,
|
||||
'diff_percent': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def comparar_anios(self, anio1: int = None, anio2: int = None) -> Dict:
|
||||
"""
|
||||
Compara ventas anuales (hasta la fecha actual del año).
|
||||
|
||||
Args:
|
||||
anio1: Primer año, default año actual
|
||||
anio2: Segundo año, default año anterior
|
||||
|
||||
Returns:
|
||||
dict con comparación anual
|
||||
"""
|
||||
try:
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
|
||||
if not anio1:
|
||||
anio1 = hoy.year
|
||||
if not anio2:
|
||||
anio2 = anio1 - 1
|
||||
|
||||
# Obtener ventas YTD de cada año
|
||||
dia_mes = hoy.strftime('%m-%d')
|
||||
|
||||
# Para comparación justa, comparamos hasta el mismo día del año
|
||||
ventas_anio1 = self._obtener_ventas_ytd(anio1, dia_mes)
|
||||
ventas_anio2 = self._obtener_ventas_ytd(anio2, dia_mes)
|
||||
|
||||
monto_anio1 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio1)
|
||||
monto_anio2 = sum(float(v.get('monto', 0) or 0) for v in ventas_anio2)
|
||||
|
||||
# Calcular diferencia
|
||||
if monto_anio2 > 0:
|
||||
diff_pct = ((monto_anio1 - monto_anio2) / monto_anio2) * 100
|
||||
else:
|
||||
diff_pct = 100 if monto_anio1 > 0 else 0
|
||||
|
||||
return {
|
||||
'anio_actual': anio1,
|
||||
'anio_anterior': anio2,
|
||||
'monto_actual': round(monto_anio1, 2),
|
||||
'monto_anterior': round(monto_anio2, 2),
|
||||
'difference': round(monto_anio1 - monto_anio2, 2),
|
||||
'diff_percent': round(diff_pct, 1),
|
||||
'ventas_actual': len(ventas_anio1),
|
||||
'ventas_anterior': len(ventas_anio2),
|
||||
'comparacion_hasta': hoy.strftime('%d de %B')
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparando años: {str(e)}")
|
||||
return {
|
||||
'monto_actual': 0,
|
||||
'monto_anterior': 0,
|
||||
'difference': 0,
|
||||
'diff_percent': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _obtener_ventas_rango(self, inicio: datetime.date, fin: datetime.date) -> List[Dict]:
|
||||
"""Obtiene ventas en un rango de fechas."""
|
||||
try:
|
||||
# Obtener todas las ventas recientes
|
||||
ventas = self.nocodb.get_ventas_mes() if hasattr(self.nocodb, 'get_ventas_mes') else []
|
||||
|
||||
ventas_filtradas = []
|
||||
for venta in ventas:
|
||||
try:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
if 'T' in fecha_str:
|
||||
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||
|
||||
if fecha.tzinfo is None:
|
||||
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||
|
||||
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||
|
||||
if inicio <= fecha_local <= fin:
|
||||
ventas_filtradas.append(venta)
|
||||
except:
|
||||
continue
|
||||
|
||||
return ventas_filtradas
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas del rango: {str(e)}")
|
||||
return []
|
||||
|
||||
def _obtener_ventas_mes(self, mes: str) -> List[Dict]:
|
||||
"""Obtiene ventas de un mes específico."""
|
||||
try:
|
||||
# Usar método existente si está disponible
|
||||
if hasattr(self.nocodb, 'get_ventas_mes'):
|
||||
ventas = self.nocodb.get_ventas_mes()
|
||||
else:
|
||||
return []
|
||||
|
||||
# Filtrar por mes
|
||||
ventas_filtradas = []
|
||||
for venta in ventas:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
if fecha_str and fecha_str[:7] == mes:
|
||||
ventas_filtradas.append(venta)
|
||||
|
||||
return ventas_filtradas
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas del mes {mes}: {str(e)}")
|
||||
return []
|
||||
|
||||
def _obtener_ventas_ytd(self, anio: int, hasta_dia_mes: str) -> List[Dict]:
|
||||
"""Obtiene ventas del año hasta una fecha específica."""
|
||||
try:
|
||||
inicio = datetime(anio, 1, 1).date()
|
||||
mes, dia = map(int, hasta_dia_mes.split('-'))
|
||||
fin = datetime(anio, mes, dia).date()
|
||||
|
||||
return self._obtener_ventas_rango(inicio, fin)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas YTD {anio}: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_comparison_summary(self, tipo: str = 'weekly') -> Dict:
|
||||
"""
|
||||
Obtiene resumen de comparación según el tipo solicitado.
|
||||
|
||||
Args:
|
||||
tipo: 'weekly', 'monthly', o 'yearly'
|
||||
|
||||
Returns:
|
||||
dict con comparación
|
||||
"""
|
||||
if tipo == 'weekly':
|
||||
return self.comparar_semanas()
|
||||
elif tipo == 'monthly':
|
||||
return self.comparar_meses()
|
||||
elif tipo == 'yearly':
|
||||
return self.comparar_anios()
|
||||
else:
|
||||
return {'error': f'Tipo de comparación no válido: {tipo}'}
|
||||
241
sales-bot/analytics/predictions.py
Normal file
241
sales-bot/analytics/predictions.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Sales prediction algorithms for Sales Bot
|
||||
Uses moving average and linear regression for basic predictions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import scipy for linear regression
|
||||
try:
|
||||
from scipy import stats
|
||||
import numpy as np
|
||||
SCIPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
SCIPY_AVAILABLE = False
|
||||
logger.warning("scipy not available, using basic prediction methods")
|
||||
|
||||
|
||||
def prediccion_basica(ventas_diarias: List[float], dias_prediccion: int = 7) -> Dict:
|
||||
"""
|
||||
Genera predicciones combinando promedio móvil con tendencia lineal.
|
||||
|
||||
Args:
|
||||
ventas_diarias: Lista de montos de ventas por día
|
||||
dias_prediccion: Número de días a predecir
|
||||
|
||||
Returns:
|
||||
dict con predicciones, tendencia y confianza
|
||||
"""
|
||||
if not ventas_diarias or len(ventas_diarias) < 3:
|
||||
return {
|
||||
'predicciones': [],
|
||||
'next_day': 0,
|
||||
'next_week': 0,
|
||||
'tendencia': 'stable',
|
||||
'confidence': 0,
|
||||
'error': 'Datos insuficientes para predicción'
|
||||
}
|
||||
|
||||
try:
|
||||
# Calcular promedio móvil (últimos 7 días o menos si no hay suficientes)
|
||||
ventana = min(7, len(ventas_diarias))
|
||||
promedio_movil = sum(ventas_diarias[-ventana:]) / ventana
|
||||
|
||||
# Calcular tendencia
|
||||
if SCIPY_AVAILABLE:
|
||||
# Usar regresión lineal con scipy
|
||||
x = np.arange(len(ventas_diarias))
|
||||
y = np.array(ventas_diarias)
|
||||
slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
|
||||
confianza = abs(r_value)
|
||||
else:
|
||||
# Método básico sin scipy
|
||||
slope, intercept, confianza = _calcular_regresion_simple(ventas_diarias)
|
||||
|
||||
# Generar predicciones
|
||||
predicciones = []
|
||||
for i in range(1, dias_prediccion + 1):
|
||||
# Predicción lineal
|
||||
pred_lineal = slope * (len(ventas_diarias) + i) + intercept
|
||||
|
||||
# Combinar promedio móvil con tendencia lineal
|
||||
pred_combinada = (promedio_movil + pred_lineal) / 2
|
||||
|
||||
# No permitir predicciones negativas
|
||||
predicciones.append(max(0, pred_combinada))
|
||||
|
||||
# Determinar tendencia
|
||||
if slope > 0.5:
|
||||
tendencia = 'increasing'
|
||||
elif slope < -0.5:
|
||||
tendencia = 'decreasing'
|
||||
else:
|
||||
tendencia = 'stable'
|
||||
|
||||
return {
|
||||
'predicciones': predicciones,
|
||||
'next_day': round(predicciones[0], 2) if predicciones else 0,
|
||||
'next_week': round(sum(predicciones[:7]), 2) if len(predicciones) >= 7 else round(sum(predicciones), 2),
|
||||
'tendencia': tendencia,
|
||||
'trend': tendencia, # Alias for frontend
|
||||
'confidence': round(confianza, 2),
|
||||
'promedio_movil': round(promedio_movil, 2),
|
||||
'slope': round(slope, 4)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en predicción: {str(e)}")
|
||||
return {
|
||||
'predicciones': [],
|
||||
'next_day': 0,
|
||||
'next_week': 0,
|
||||
'tendencia': 'stable',
|
||||
'trend': 'stable',
|
||||
'confidence': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def _calcular_regresion_simple(datos: List[float]) -> tuple:
|
||||
"""
|
||||
Calcula regresión lineal simple sin scipy.
|
||||
|
||||
Returns:
|
||||
(slope, intercept, r_value)
|
||||
"""
|
||||
n = len(datos)
|
||||
if n < 2:
|
||||
return 0, datos[0] if datos else 0, 0
|
||||
|
||||
x = list(range(n))
|
||||
y = datos
|
||||
|
||||
# Calcular medias
|
||||
x_mean = sum(x) / n
|
||||
y_mean = sum(y) / n
|
||||
|
||||
# Calcular slope
|
||||
numerador = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
|
||||
denominador = sum((x[i] - x_mean) ** 2 for i in range(n))
|
||||
|
||||
if denominador == 0:
|
||||
return 0, y_mean, 0
|
||||
|
||||
slope = numerador / denominador
|
||||
intercept = y_mean - slope * x_mean
|
||||
|
||||
# Calcular R-squared (coeficiente de determinación)
|
||||
y_pred = [slope * xi + intercept for xi in x]
|
||||
ss_res = sum((y[i] - y_pred[i]) ** 2 for i in range(n))
|
||||
ss_tot = sum((y[i] - y_mean) ** 2 for i in range(n))
|
||||
|
||||
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
||||
r_value = r_squared ** 0.5 if r_squared >= 0 else 0
|
||||
|
||||
return slope, intercept, r_value
|
||||
|
||||
|
||||
def calcular_tendencia(ventas_diarias: List[float]) -> Dict:
|
||||
"""
|
||||
Analiza la tendencia de los datos de ventas.
|
||||
|
||||
Returns:
|
||||
dict con información de tendencia
|
||||
"""
|
||||
if not ventas_diarias or len(ventas_diarias) < 2:
|
||||
return {
|
||||
'direccion': 'stable',
|
||||
'cambio_porcentual': 0,
|
||||
'volatilidad': 0
|
||||
}
|
||||
|
||||
# Dividir en dos mitades
|
||||
mitad = len(ventas_diarias) // 2
|
||||
primera_mitad = ventas_diarias[:mitad] if mitad > 0 else ventas_diarias[:1]
|
||||
segunda_mitad = ventas_diarias[mitad:] if mitad > 0 else ventas_diarias[1:]
|
||||
|
||||
promedio_primera = sum(primera_mitad) / len(primera_mitad) if primera_mitad else 0
|
||||
promedio_segunda = sum(segunda_mitad) / len(segunda_mitad) if segunda_mitad else 0
|
||||
|
||||
# Calcular cambio porcentual
|
||||
if promedio_primera > 0:
|
||||
cambio_pct = ((promedio_segunda - promedio_primera) / promedio_primera) * 100
|
||||
else:
|
||||
cambio_pct = 0
|
||||
|
||||
# Determinar dirección
|
||||
if cambio_pct > 5:
|
||||
direccion = 'increasing'
|
||||
elif cambio_pct < -5:
|
||||
direccion = 'decreasing'
|
||||
else:
|
||||
direccion = 'stable'
|
||||
|
||||
# Calcular volatilidad (desviación estándar relativa)
|
||||
media = sum(ventas_diarias) / len(ventas_diarias)
|
||||
if media > 0:
|
||||
varianza = sum((x - media) ** 2 for x in ventas_diarias) / len(ventas_diarias)
|
||||
desviacion = varianza ** 0.5
|
||||
volatilidad = (desviacion / media) * 100
|
||||
else:
|
||||
volatilidad = 0
|
||||
|
||||
return {
|
||||
'direccion': direccion,
|
||||
'cambio_porcentual': round(cambio_pct, 2),
|
||||
'volatilidad': round(volatilidad, 2),
|
||||
'promedio_anterior': round(promedio_primera, 2),
|
||||
'promedio_actual': round(promedio_segunda, 2)
|
||||
}
|
||||
|
||||
|
||||
def generar_prediccion_extendida(
|
||||
ventas_diarias: List[float],
|
||||
dias_historico: int = 30,
|
||||
dias_futuro: int = 14
|
||||
) -> Dict:
|
||||
"""
|
||||
Genera predicción extendida con más detalles.
|
||||
|
||||
Args:
|
||||
ventas_diarias: Datos históricos
|
||||
dias_historico: Días de historia a considerar
|
||||
dias_futuro: Días a predecir
|
||||
|
||||
Returns:
|
||||
dict con predicción completa
|
||||
"""
|
||||
# Limitar datos al histórico solicitado
|
||||
datos = ventas_diarias[-dias_historico:] if len(ventas_diarias) > dias_historico else ventas_diarias
|
||||
|
||||
# Obtener predicción básica
|
||||
prediccion = prediccion_basica(datos, dias_futuro)
|
||||
|
||||
# Agregar análisis de tendencia
|
||||
tendencia_info = calcular_tendencia(datos)
|
||||
|
||||
# Calcular estadísticas adicionales
|
||||
if datos:
|
||||
maximo = max(datos)
|
||||
minimo = min(datos)
|
||||
promedio = sum(datos) / len(datos)
|
||||
mejor_dia_idx = datos.index(maximo)
|
||||
else:
|
||||
maximo = minimo = promedio = 0
|
||||
mejor_dia_idx = 0
|
||||
|
||||
return {
|
||||
**prediccion,
|
||||
'tendencia_detalle': tendencia_info,
|
||||
'estadisticas': {
|
||||
'maximo': round(maximo, 2),
|
||||
'minimo': round(minimo, 2),
|
||||
'promedio': round(promedio, 2),
|
||||
'mejor_dia_indice': mejor_dia_idx,
|
||||
'dias_analizados': len(datos)
|
||||
}
|
||||
}
|
||||
282
sales-bot/analytics/trends.py
Normal file
282
sales-bot/analytics/trends.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Trend analysis for Sales Bot
|
||||
Aggregates and analyzes sales data over time
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mexico timezone
|
||||
TZ_MEXICO = timezone(timedelta(hours=-6))
|
||||
|
||||
|
||||
class TrendAnalyzer:
|
||||
"""
|
||||
Analyzes sales trends over different time periods.
|
||||
"""
|
||||
|
||||
def __init__(self, nocodb_client):
|
||||
self.nocodb = nocodb_client
|
||||
|
||||
def get_daily_trends(self, dias: int = 30, vendedor: str = None) -> Dict:
|
||||
"""
|
||||
Obtiene tendencias diarias de ventas.
|
||||
|
||||
Args:
|
||||
dias: Número de días a analizar
|
||||
vendedor: Username del vendedor (opcional, None para todos)
|
||||
|
||||
Returns:
|
||||
dict con labels, ventas, tubos, y predicciones
|
||||
"""
|
||||
try:
|
||||
# Obtener ventas del período
|
||||
ventas = self._obtener_ventas_periodo(dias, vendedor)
|
||||
|
||||
# Agrupar por día
|
||||
ventas_por_dia = self._agrupar_por_dia(ventas, dias)
|
||||
|
||||
# Generar labels (fechas)
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
labels = []
|
||||
valores_ventas = []
|
||||
valores_tubos = []
|
||||
|
||||
for i in range(dias - 1, -1, -1):
|
||||
fecha = hoy - timedelta(days=i)
|
||||
fecha_str = fecha.strftime('%Y-%m-%d')
|
||||
labels.append(fecha.strftime('%d/%m'))
|
||||
|
||||
dia_data = ventas_por_dia.get(fecha_str, {'monto': 0, 'tubos': 0})
|
||||
valores_ventas.append(dia_data['monto'])
|
||||
valores_tubos.append(dia_data['tubos'])
|
||||
|
||||
# Generar predicciones si hay datos
|
||||
prediccion = []
|
||||
if valores_ventas:
|
||||
from .predictions import prediccion_basica
|
||||
pred_result = prediccion_basica(valores_ventas, 7)
|
||||
if pred_result.get('predicciones'):
|
||||
# Añadir None para los días históricos y luego las predicciones
|
||||
prediccion = [None] * len(valores_ventas)
|
||||
prediccion.extend(pred_result['predicciones'])
|
||||
|
||||
return {
|
||||
'labels': labels,
|
||||
'ventas': valores_ventas,
|
||||
'tubos': valores_tubos,
|
||||
'prediccion': prediccion,
|
||||
'periodo': {
|
||||
'inicio': (hoy - timedelta(days=dias-1)).strftime('%Y-%m-%d'),
|
||||
'fin': hoy.strftime('%Y-%m-%d'),
|
||||
'dias': dias
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo tendencias diarias: {str(e)}")
|
||||
return {
|
||||
'labels': [],
|
||||
'ventas': [],
|
||||
'tubos': [],
|
||||
'prediccion': [],
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _obtener_ventas_periodo(self, dias: int, vendedor: str = None) -> List[Dict]:
|
||||
"""Obtiene ventas de los últimos N días."""
|
||||
try:
|
||||
# Usar método existente del cliente NocoDB
|
||||
if hasattr(self.nocodb, 'get_ventas_historico'):
|
||||
return self.nocodb.get_ventas_historico(dias, vendedor)
|
||||
|
||||
# Fallback: obtener ventas del mes y filtrar
|
||||
ventas = self.nocodb.get_ventas_mes(vendedor) if vendedor else self.nocodb.get_ventas_mes()
|
||||
|
||||
# Filtrar por días
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
fecha_inicio = hoy - timedelta(days=dias)
|
||||
|
||||
ventas_filtradas = []
|
||||
for venta in ventas:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
try:
|
||||
if 'T' in fecha_str:
|
||||
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||
|
||||
if fecha.tzinfo is None:
|
||||
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||
|
||||
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||
|
||||
if fecha_local >= fecha_inicio:
|
||||
ventas_filtradas.append(venta)
|
||||
except:
|
||||
continue
|
||||
|
||||
return ventas_filtradas
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo ventas del período: {str(e)}")
|
||||
return []
|
||||
|
||||
def _agrupar_por_dia(self, ventas: List[Dict], dias: int) -> Dict[str, Dict]:
|
||||
"""Agrupa ventas por día."""
|
||||
ventas_por_dia = defaultdict(lambda: {'monto': 0, 'tubos': 0, 'count': 0})
|
||||
|
||||
for venta in ventas:
|
||||
try:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
if 'T' in fecha_str:
|
||||
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||
|
||||
if fecha.tzinfo is None:
|
||||
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||
|
||||
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||
fecha_key = fecha_local.strftime('%Y-%m-%d')
|
||||
|
||||
monto = float(venta.get('monto', 0) or 0)
|
||||
ventas_por_dia[fecha_key]['monto'] += monto
|
||||
ventas_por_dia[fecha_key]['count'] += 1
|
||||
|
||||
# Contar tubos si hay información de productos
|
||||
tubos = self._contar_tubos_venta(venta)
|
||||
ventas_por_dia[fecha_key]['tubos'] += tubos
|
||||
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
return dict(ventas_por_dia)
|
||||
|
||||
def _contar_tubos_venta(self, venta: Dict) -> int:
|
||||
"""Cuenta tubos de tinte en una venta."""
|
||||
# Esto depende de la estructura de datos
|
||||
# Por ahora retorna un valor estimado basado en el monto
|
||||
# En producción, debería consultar la tabla de detalles
|
||||
tubos = venta.get('tubos', 0)
|
||||
if tubos:
|
||||
return int(tubos)
|
||||
|
||||
# Estimación: ~$50 por tubo en promedio
|
||||
monto = float(venta.get('monto', 0) or 0)
|
||||
return max(1, int(monto / 50)) if monto > 0 else 0
|
||||
|
||||
def get_weekly_summary(self, semanas: int = 4) -> Dict:
|
||||
"""
|
||||
Obtiene resumen semanal de ventas.
|
||||
|
||||
Args:
|
||||
semanas: Número de semanas a analizar
|
||||
|
||||
Returns:
|
||||
dict con datos semanales
|
||||
"""
|
||||
try:
|
||||
dias_totales = semanas * 7
|
||||
ventas = self._obtener_ventas_periodo(dias_totales)
|
||||
|
||||
# Agrupar por semana
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
semanas_data = []
|
||||
|
||||
for i in range(semanas):
|
||||
fin_semana = hoy - timedelta(days=i * 7)
|
||||
inicio_semana = fin_semana - timedelta(days=6)
|
||||
|
||||
# Filtrar ventas de esta semana
|
||||
ventas_semana = []
|
||||
for venta in ventas:
|
||||
try:
|
||||
fecha_str = venta.get('fecha_venta', '')
|
||||
if 'T' in fecha_str:
|
||||
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
fecha = datetime.strptime(fecha_str[:10], '%Y-%m-%d')
|
||||
|
||||
if fecha.tzinfo is None:
|
||||
fecha = fecha.replace(tzinfo=timezone.utc)
|
||||
|
||||
fecha_local = fecha.astimezone(TZ_MEXICO).date()
|
||||
|
||||
if inicio_semana <= fecha_local <= fin_semana:
|
||||
ventas_semana.append(venta)
|
||||
except:
|
||||
continue
|
||||
|
||||
monto_total = sum(float(v.get('monto', 0) or 0) for v in ventas_semana)
|
||||
|
||||
semanas_data.append({
|
||||
'semana': i + 1,
|
||||
'inicio': inicio_semana.strftime('%Y-%m-%d'),
|
||||
'fin': fin_semana.strftime('%Y-%m-%d'),
|
||||
'label': f"{inicio_semana.strftime('%d/%m')} - {fin_semana.strftime('%d/%m')}",
|
||||
'monto': round(monto_total, 2),
|
||||
'ventas': len(ventas_semana)
|
||||
})
|
||||
|
||||
# Invertir para orden cronológico
|
||||
semanas_data.reverse()
|
||||
|
||||
return {
|
||||
'semanas': semanas_data,
|
||||
'total_semanas': semanas
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo resumen semanal: {str(e)}")
|
||||
return {'semanas': [], 'error': str(e)}
|
||||
|
||||
def get_monthly_summary(self, meses: int = 6) -> Dict:
|
||||
"""
|
||||
Obtiene resumen mensual de ventas.
|
||||
|
||||
Args:
|
||||
meses: Número de meses a analizar
|
||||
|
||||
Returns:
|
||||
dict con datos mensuales
|
||||
"""
|
||||
try:
|
||||
# Esto requeriría acceso a más datos históricos
|
||||
# Por ahora retornamos estructura básica
|
||||
hoy = datetime.now(TZ_MEXICO).date()
|
||||
meses_data = []
|
||||
|
||||
for i in range(meses):
|
||||
# Calcular mes
|
||||
mes_actual = hoy.month - i
|
||||
anio_actual = hoy.year
|
||||
|
||||
while mes_actual < 1:
|
||||
mes_actual += 12
|
||||
anio_actual -= 1
|
||||
|
||||
mes_str = f"{anio_actual}-{mes_actual:02d}"
|
||||
mes_nombre = datetime(anio_actual, mes_actual, 1).strftime('%B %Y')
|
||||
|
||||
meses_data.append({
|
||||
'mes': mes_str,
|
||||
'nombre': mes_nombre,
|
||||
'monto': 0, # Se llenaría con datos reales
|
||||
'ventas': 0
|
||||
})
|
||||
|
||||
meses_data.reverse()
|
||||
|
||||
return {
|
||||
'meses': meses_data,
|
||||
'total_meses': meses
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error obteniendo resumen mensual: {str(e)}")
|
||||
return {'meses': [], 'error': str(e)}
|
||||
Reference in New Issue
Block a user