Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -7,7 +7,16 @@ Provides:
|
||||
- Customer analytics (LTV, frequency, churn risk)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _aware(dt):
|
||||
"""Normalize a possibly-naive datetime (psycopg2 may return either) to UTC."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
# ─── Customer Activities ─────────────────────────────
|
||||
@@ -302,11 +311,15 @@ def get_customer_analytics(conn, customer_id):
|
||||
WHERE customer_id = %s AND status = 'completed'
|
||||
""", (customer_id,))
|
||||
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
|
||||
first_purchase = _aware(first_purchase)
|
||||
last_purchase = _aware(last_purchase)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Days since last purchase
|
||||
days_since_last = None
|
||||
if last_purchase:
|
||||
days_since_last = (datetime.utcnow() - last_purchase).days
|
||||
days_since_last = (now - last_purchase).days
|
||||
|
||||
# Purchase frequency (orders per month)
|
||||
frequency = 0.0
|
||||
@@ -314,13 +327,69 @@ def get_customer_analytics(conn, customer_id):
|
||||
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
||||
frequency = round(total_orders / months, 2)
|
||||
|
||||
# Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
|
||||
churn_risk = 'low'
|
||||
if days_since_last is not None:
|
||||
if days_since_last > 90:
|
||||
# Real inter-purchase interval per customer (A12). Uses the gaps between
|
||||
# consecutive completed sales instead of fixed 60/90-day thresholds.
|
||||
cur.execute("""
|
||||
SELECT created_at FROM sales
|
||||
WHERE customer_id = %s AND status = 'completed'
|
||||
ORDER BY created_at
|
||||
""", (customer_id,))
|
||||
purchase_dates = [_aware(r[0]) for r in cur.fetchall() if r[0] is not None]
|
||||
intervals = [
|
||||
(purchase_dates[i] - purchase_dates[i - 1]).days
|
||||
for i in range(1, len(purchase_dates))
|
||||
]
|
||||
intervals = [d for d in intervals if d >= 0]
|
||||
avg_interval = round(sum(intervals) / len(intervals), 1) if intervals else None
|
||||
|
||||
# Next-purchase prediction + churn probability.
|
||||
predicted_next_purchase_date = None
|
||||
days_until_next_purchase = None
|
||||
churn_probability = 0.0
|
||||
churn_reason = ''
|
||||
if avg_interval and last_purchase and avg_interval > 0:
|
||||
predicted = last_purchase + timedelta(days=round(avg_interval))
|
||||
predicted_next_purchase_date = predicted
|
||||
days_until_next_purchase = (predicted - now).days
|
||||
# How many of its own typical cycles the customer has been silent.
|
||||
ratio = (days_since_last or 0) / max(float(avg_interval), 1.0)
|
||||
if ratio > 2.0:
|
||||
churn_risk = 'high'
|
||||
churn_probability = 0.9
|
||||
churn_reason = (f"Lleva {days_since_last} días sin comprar, más de 2× "
|
||||
f"su intervalo habitual ({avg_interval:.0f} días).")
|
||||
elif ratio > 1.5:
|
||||
churn_risk = 'high'
|
||||
churn_probability = 0.7
|
||||
churn_reason = (f"Rebasó 1.5× su intervalo habitual de compra "
|
||||
f"({avg_interval:.0f} días).")
|
||||
elif ratio > 1.0:
|
||||
churn_risk = 'medium'
|
||||
churn_probability = 0.4
|
||||
churn_reason = f"Ya superó su intervalo habitual de compra ({avg_interval:.0f} días)."
|
||||
else:
|
||||
churn_risk = 'low'
|
||||
churn_probability = 0.15
|
||||
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)."
|
||||
else:
|
||||
# Fewer than two purchases: not enough history for a cadence. Fall back
|
||||
# to the legacy fixed thresholds but report that we lacked data.
|
||||
if days_since_last is None:
|
||||
churn_risk = 'low'
|
||||
churn_probability = 0.0
|
||||
churn_reason = 'Sin compras registradas.'
|
||||
elif days_since_last > 90:
|
||||
churn_risk = 'high'
|
||||
churn_probability = 0.75
|
||||
churn_reason = f"Sin compras desde hace {days_since_last} días."
|
||||
elif days_since_last > 60:
|
||||
churn_risk = 'medium'
|
||||
churn_probability = 0.45
|
||||
churn_reason = f"Sin compras desde hace {days_since_last} días."
|
||||
else:
|
||||
churn_risk = 'low'
|
||||
churn_probability = 0.15
|
||||
churn_reason = f"Compra reciente (hace {days_since_last} días)."
|
||||
|
||||
# Favorite categories (from inventory via sale_items)
|
||||
cur.execute("""
|
||||
@@ -362,6 +431,15 @@ def get_customer_analytics(conn, customer_id):
|
||||
'days_since_last_purchase': days_since_last,
|
||||
'purchase_frequency_monthly': frequency,
|
||||
'churn_risk': churn_risk,
|
||||
# A12 additions: real inter-purchase cadence + prediction.
|
||||
'avg_days_between_purchases': avg_interval,
|
||||
'purchase_intervals_count': len(intervals),
|
||||
'predicted_next_purchase_date': (
|
||||
predicted_next_purchase_date.isoformat() if predicted_next_purchase_date else None
|
||||
),
|
||||
'days_until_next_purchase': days_until_next_purchase,
|
||||
'churn_probability': churn_probability,
|
||||
'churn_reason': churn_reason,
|
||||
'favorite_categories': categories,
|
||||
'loyalty': {
|
||||
'points_balance': loyalty_balance,
|
||||
|
||||
Reference in New Issue
Block a user