- 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
353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""Portfolio-level business metrics: cross-tenant anomaly detection and a
|
|
tenant health / churn-risk heuristic.
|
|
|
|
This is deliberately *not* conversational AI. At a portfolio of ~8 tenants the
|
|
honest tool is a robust statistic and a transparent heuristic, so every number
|
|
here is explainable:
|
|
|
|
* ``detect_portfolio_anomalies`` compares each tenant against the cohort of the
|
|
other tenants on the same day using a median + MAD robust z-score. MAD is
|
|
used instead of the mean/standard deviation because with a handful of points
|
|
a single runaway tenant would otherwise inflate the scale and hide its own
|
|
anomaly.
|
|
* ``compute_health`` is a weighted penalty/bonus heuristic over usage signals
|
|
(sales recency, activity, module usage, error counts and trend).
|
|
|
|
Both functions are pure (no DB access) so they can be unit-tested and reused
|
|
from the manager API and from scripts. Cold start is explicit: with fewer than
|
|
``min_tenants`` observations in the cohort the detector reports
|
|
``cold_start=True`` and raises no flags rather than inventing signal.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
# Below this many tenants in the cohort we do not trust cross-tenant statistics.
|
|
MIN_COHORT = 5
|
|
|
|
# Robust z threshold. 3.0 robust sigmas with 8 tenants is already conservative
|
|
# (MAD is small, so ~3 is a genuinely unusual point, not a normal spread).
|
|
DEFAULT_Z = 3.0
|
|
|
|
# Metrics compared across tenants. Everything is derived from the daily rollup.
|
|
COHORT_METRICS = [
|
|
("ventas_total", "Ventas (MXN)"),
|
|
("ventas_count", "Tickets"),
|
|
("ticket_promedio", "Ticket promedio"),
|
|
("clientes_nuevos", "Clientes nuevos"),
|
|
("actividad_count", "Eventos de actividad"),
|
|
("errores_5xx", "Errores 5xx"),
|
|
("items_activos", "Items activos"),
|
|
("skus", "SKUs"),
|
|
("clientes", "Clientes"),
|
|
("fitment_pct", "Cobertura fitment (%)"),
|
|
]
|
|
|
|
# Health bands, high to low.
|
|
BANDS = [
|
|
(80, "healthy", "Saludable"),
|
|
(60, "stable", "Estable"),
|
|
(40, "at_risk", "En riesgo"),
|
|
(0, "critical", "Crítico"),
|
|
]
|
|
|
|
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
|
|
|
|
def _median(values):
|
|
"""Median of a list of floats (None for empty)."""
|
|
vals = sorted(float(v) for v in values if v is not None)
|
|
n = len(vals)
|
|
if n == 0:
|
|
return None
|
|
mid = n // 2
|
|
if n % 2:
|
|
return vals[mid]
|
|
return (vals[mid - 1] + vals[mid]) / 2.0
|
|
|
|
|
|
def robust_stats(values):
|
|
"""Return ``(median, scale)`` for a sample.
|
|
|
|
``scale`` is the MAD when non-zero. When most values are identical
|
|
(MAD == 0, common for sparse metrics such as daily sales) it falls back to
|
|
a scaled mean absolute deviation so a real outlier is still measurable;
|
|
when the sample is completely constant it returns 0.0 and the caller skips
|
|
the metric instead of dividing by zero.
|
|
"""
|
|
vals = [float(v) for v in values if v is not None]
|
|
if not vals:
|
|
return None, None
|
|
med = _median(vals)
|
|
mad = _median([abs(v - med) for v in vals])
|
|
if mad > 1e-9:
|
|
return med, mad
|
|
meanad = sum(abs(v - med) for v in vals) / len(vals)
|
|
if meanad > 1e-9:
|
|
return med, meanad * 1.2533
|
|
return med, 0.0
|
|
|
|
|
|
def robust_z(value, med, scale):
|
|
"""Robust z-score ``0.6745 * (x - med) / scale`` or None if undefinable."""
|
|
if value is None or med is None or scale is None or scale <= 1e-9:
|
|
return None
|
|
return 0.6745 * (float(value) - med) / scale
|
|
|
|
|
|
def detect_portfolio_anomalies(rows, date=None, metrics=None,
|
|
min_tenants=MIN_COHORT, z_threshold=DEFAULT_Z):
|
|
"""Flag tenants whose metric is unusually high/low versus the cohort.
|
|
|
|
``rows`` is a list of dicts, one per tenant for a single day, each carrying
|
|
the metric keys from :data:`COHORT_METRICS` plus ``tenant_id`` and
|
|
``tenant_name``. Returns a report dict with ``anomalies`` sorted by
|
|
strength and a ``cold_start`` flag.
|
|
"""
|
|
metrics = metrics or [m for m, _ in COHORT_METRICS]
|
|
labels = dict(COHORT_METRICS)
|
|
cohort_size = len(rows)
|
|
|
|
anomalies = []
|
|
evaluated = []
|
|
skipped = []
|
|
|
|
if cohort_size < min_tenants:
|
|
for key in metrics:
|
|
skipped.append({"metric": key, "reason": "cohorta insuficiente",
|
|
"cohort_size": cohort_size})
|
|
|
|
for key in metrics:
|
|
values = [r.get(key) for r in rows if r.get(key) is not None]
|
|
if cohort_size < min_tenants:
|
|
continue
|
|
if len(values) < min_tenants:
|
|
skipped.append({"metric": key, "reason": "cohorta insuficiente",
|
|
"cohort_size": len(values)})
|
|
continue
|
|
med, scale = robust_stats(values)
|
|
if not scale:
|
|
skipped.append({"metric": key, "reason": "sin varianza en la cohorte",
|
|
"cohort_size": len(values)})
|
|
continue
|
|
evaluated.append({
|
|
"metric": key,
|
|
"label": labels.get(key, key),
|
|
"cohort_median": round(med, 2),
|
|
"cohort_mad": round(scale, 2),
|
|
"cohort_size": len(values),
|
|
})
|
|
for row in rows:
|
|
value = row.get(key)
|
|
z = robust_z(value, med, scale)
|
|
if z is None or abs(z) <= z_threshold:
|
|
continue
|
|
abs_z = abs(z)
|
|
severity = "high" if abs_z >= z_threshold * 1.5 else "medium"
|
|
anomalies.append({
|
|
"tenant_id": row.get("tenant_id"),
|
|
"tenant_name": row.get("tenant_name"),
|
|
"metric": key,
|
|
"label": labels.get(key, key),
|
|
"value": round(float(value), 2),
|
|
"cohort_median": round(med, 2),
|
|
"cohort_mad": round(scale, 2),
|
|
"z": round(z, 2),
|
|
"direction": "peak" if z > 0 else "drop",
|
|
"severity": severity,
|
|
})
|
|
|
|
anomalies.sort(key=lambda a: abs(a["z"]), reverse=True)
|
|
|
|
by_tenant = {}
|
|
for a in anomalies:
|
|
entry = by_tenant.setdefault(a["tenant_id"], {
|
|
"tenant_id": a["tenant_id"],
|
|
"tenant_name": a["tenant_name"],
|
|
"findings": [],
|
|
})
|
|
entry["findings"].append({
|
|
"metric": a["metric"], "label": a["label"], "value": a["value"],
|
|
"z": a["z"], "direction": a["direction"], "severity": a["severity"],
|
|
})
|
|
|
|
return {
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"date": str(date) if date else None,
|
|
"cold_start": cohort_size < min_tenants,
|
|
"cohort_size": cohort_size,
|
|
"thresholds": {
|
|
"method": "robust z-score (median + MAD)",
|
|
"z_threshold": z_threshold,
|
|
"min_tenants": min_tenants,
|
|
},
|
|
"metrics_evaluated": evaluated,
|
|
"metrics_skipped": skipped,
|
|
"anomalies": anomalies,
|
|
"by_tenant": by_tenant,
|
|
"summary": {
|
|
"flagged_tenants": len(by_tenant),
|
|
"findings": len(anomalies),
|
|
},
|
|
}
|
|
|
|
|
|
def _band_for(score):
|
|
for threshold, key, label in BANDS:
|
|
if score >= threshold:
|
|
return key, label
|
|
return "critical", "Crítico"
|
|
|
|
|
|
def _days_since(ts, now):
|
|
if ts is None:
|
|
return None
|
|
if isinstance(ts, str):
|
|
try:
|
|
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
if ts.tzinfo is None:
|
|
ts = ts.replace(tzinfo=UTC)
|
|
return max(0, (now - ts).days)
|
|
|
|
|
|
def compute_health(tenant, history, now=None):
|
|
"""Heuristic health / churn-risk score for one tenant.
|
|
|
|
``tenant`` is the master row (``id``, ``name``, ``plan``, ``is_active``,
|
|
optional ``expires_at``). ``history`` is the tenant's rollup rows, oldest
|
|
first. Returns ``{"health_score", "band", "band_label", "signals",
|
|
"cold_start", "components"}``.
|
|
|
|
The score starts at 100 and applies transparent penalties. Activity is
|
|
measured from ``audit_log`` events because the platform does not persist
|
|
per-login rows; the API response notes this proxy explicitly.
|
|
"""
|
|
now = now or datetime.now(UTC)
|
|
signals = []
|
|
components = {}
|
|
score = 100.0
|
|
|
|
def add_penalty(points, code, severity, detail):
|
|
nonlocal score
|
|
score -= points
|
|
signals.append({"code": code, "severity": severity, "detail": detail})
|
|
|
|
if not history:
|
|
return {
|
|
"health_score": None,
|
|
"band": "unknown",
|
|
"band_label": "Sin datos",
|
|
"cold_start": True,
|
|
"signals": [{"code": "no_metrics", "severity": "medium",
|
|
"detail": "Sin rollup de métricas; ejecuta "
|
|
"scripts/rollup_tenant_metrics.py"}],
|
|
"components": {},
|
|
}
|
|
|
|
latest = history[-1]
|
|
recent7 = history[-7:]
|
|
prev7 = history[-14:-7]
|
|
|
|
# ── 1. Tenant lifecycle ────────────────────────────────────────────────
|
|
if tenant and not tenant.get("is_active", True):
|
|
add_penalty(25, "tenant_inactive", "high", "Tenant desactivado")
|
|
expires_at = (tenant or {}).get("expires_at")
|
|
if expires_at is not None:
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=UTC)
|
|
if expires_at < now:
|
|
add_penalty(15, "subscription_expired", "high",
|
|
"Suscripción vencida")
|
|
|
|
# ── 2. Sales recency ───────────────────────────────────────────────────
|
|
days_sale = _days_since(latest.get("ultima_venta_at"), now)
|
|
components["dias_desde_ultima_venta"] = days_sale
|
|
if days_sale is None:
|
|
add_penalty(20, "no_sales_ever", "high",
|
|
"Sin ventas registradas en el histórico")
|
|
elif days_sale > 30:
|
|
add_penalty(30, "stale_sales", "high",
|
|
f"Sin ventas en {days_sale} días")
|
|
elif days_sale > 14:
|
|
add_penalty(15, "slow_sales", "medium",
|
|
f"Última venta hace {days_sale} días")
|
|
elif days_sale > 7:
|
|
add_penalty(6, "quiet_sales", "low",
|
|
f"Última venta hace {days_sale} días")
|
|
|
|
# ── 3. Activity recency (proxy for logins) ─────────────────────────────
|
|
days_act = _days_since(latest.get("ultima_actividad_at"), now)
|
|
components["dias_desde_ultima_actividad"] = days_act
|
|
act7 = sum((r.get("actividad_count") or 0) for r in recent7)
|
|
components["actividad_7d"] = act7
|
|
if days_act is None:
|
|
add_penalty(15, "no_activity", "medium",
|
|
"Sin actividad operativa registrada")
|
|
elif days_act > 14:
|
|
add_penalty(12, "stale_activity", "medium",
|
|
f"Sin actividad en {days_act} días")
|
|
elif days_act > 7:
|
|
add_penalty(6, "slow_activity", "low",
|
|
f"Última actividad hace {days_act} días")
|
|
elif act7 == 0:
|
|
add_penalty(8, "idle_week", "low", "Sin eventos de actividad en 7 días")
|
|
|
|
# ── 4. Trend (last 7 days vs previous 7) ───────────────────────────────
|
|
cur7 = sum(float(r.get("ventas_total") or 0) for r in recent7)
|
|
prv7 = sum(float(r.get("ventas_total") or 0) for r in prev7)
|
|
components["ventas_7d"] = round(cur7, 2)
|
|
components["ventas_7d_previas"] = round(prv7, 2)
|
|
if prv7 > 0:
|
|
change = (cur7 - prv7) / prv7
|
|
components["tendencia_ventas_pct"] = round(change * 100, 1)
|
|
if change <= -0.5:
|
|
add_penalty(18, "sales_drop", "high",
|
|
f"Ventas 7d cayeron {abs(change) * 100:.0f}% vs semana previa")
|
|
elif change <= -0.2:
|
|
add_penalty(8, "sales_slowdown", "medium",
|
|
f"Ventas 7d bajaron {abs(change) * 100:.0f}% vs semana previa")
|
|
elif change >= 0.2:
|
|
score += 5
|
|
signals.append({"code": "sales_growth", "severity": "low",
|
|
"detail": f"Ventas 7d crecieron {change * 100:.0f}%"})
|
|
elif cur7 > 0:
|
|
signals.append({"code": "sales_new", "severity": "low",
|
|
"detail": "Ventas en los últimos 7 días sin semana previa comparable"})
|
|
|
|
# ── 5. Errors ──────────────────────────────────────────────────────────
|
|
err7 = sum((r.get("errores_5xx") or 0) for r in recent7)
|
|
components["errores_5xx_7d"] = err7
|
|
if err7 > 0:
|
|
add_penalty(min(20, 4 + err7), "api_errors", "medium",
|
|
f"{err7} errores 5xx de la API en 7 días")
|
|
|
|
# ── 6. Module usage / engagement ───────────────────────────────────────
|
|
modules = latest.get("uso_modulos") or {}
|
|
enabled = [k for k, v in modules.items()
|
|
if k.startswith("module_") and v]
|
|
components["modulos_activos"] = len(enabled)
|
|
if not enabled:
|
|
add_penalty(6, "no_modules", "low",
|
|
"Ningún módulo opcional activado")
|
|
|
|
# ── 7. Catalog ─────────────────────────────────────────────────────────
|
|
items = latest.get("items_activos") or 0
|
|
components["items_activos"] = items
|
|
if items == 0:
|
|
add_penalty(15, "empty_catalog", "high", "Inventario activo vacío")
|
|
|
|
score = max(0.0, min(100.0, score))
|
|
band, band_label = _band_for(score)
|
|
signals.sort(key=lambda s: SEVERITY_ORDER.get(s["severity"], 9))
|
|
|
|
return {
|
|
"health_score": round(score, 1),
|
|
"band": band,
|
|
"band_label": band_label,
|
|
"cold_start": False,
|
|
"signals": signals,
|
|
"components": components,
|
|
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
|
}
|