"""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)", }