Files
Autoparts-DB/manager/services/business_metrics.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
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
2026-10-12 00:57:31 +00:00

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