Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

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