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:
@@ -8,6 +8,7 @@ Panel de control central para gestionar instancias multi-tenant de Nexus POS.
|
||||
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
||||
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
||||
- **Cartera**: métricas de negocio por tenant (rollup diario), score de salud/riesgo de churn y anomalías entre tenants
|
||||
- **Dashboard** con estadísticas globales y alertas de demos por expirar
|
||||
|
||||
## Estructura
|
||||
@@ -21,12 +22,15 @@ manager/
|
||||
├── services/ # Lógica de negocio
|
||||
│ ├── health_service.py # Health checks de infraestructura
|
||||
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
|
||||
│ ├── portfolio_service.py# Rollup de métricas, lectura de cartera y anomalías
|
||||
│ ├── business_metrics.py # Z-score robusto multi-tenant + score de salud
|
||||
│ └── migration_service.py# Orquestación de migraciones
|
||||
├── blueprints/ # API REST
|
||||
│ ├── auth_bp.py # Login/logout JWT
|
||||
│ ├── tenants_bp.py # Gestión de tenants
|
||||
│ ├── demos_bp.py # Creación de demos
|
||||
│ ├── health_bp.py # Health checks
|
||||
│ ├── portfolio_bp.py # /api/tenants/metrics y /api/portfolio/anomalies
|
||||
│ └── admin_bp.py # Dashboard stats y migraciones
|
||||
├── static/ # Frontend SPA
|
||||
│ ├── css/manager.css
|
||||
@@ -193,6 +197,29 @@ systemctl start nexus-manager
|
||||
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
||||
|
||||
### Cartera (métricas, salud y anomalías)
|
||||
- La sección **Cartera** muestra el score de salud/riesgo de churn por tenant y
|
||||
las anomalías detectadas entre tenants (z-score robusto con mediana + MAD).
|
||||
- Los datos vienen de la tabla maestra `tenant_metrics_daily`, que se llena con
|
||||
el rollup diario:
|
||||
|
||||
```bash
|
||||
python3 scripts/rollup_tenant_metrics.py # hoy, tenants activos
|
||||
python3 scripts/rollup_tenant_metrics.py --days 30 # backfill últimos 30 días
|
||||
python3 scripts/rollup_tenant_metrics.py --all # incluir inactivos
|
||||
```
|
||||
|
||||
El script crea la tabla de forma idempotente (no usa el runner de migraciones
|
||||
de tenants) y es seguro re-ejecutarlo para la misma fecha.
|
||||
|
||||
- Endpoints (requieren `Authorization: Bearer <token>`):
|
||||
- `GET /api/tenants/metrics?days=14` — rollup + `{health_score, band, signals}` por tenant
|
||||
- `GET /api/portfolio/anomalies?date=YYYY-MM-DD&z=3.0&min_tenants=5` — anomalías entre tenants
|
||||
|
||||
Con menos de `min_tenants` tenants en la cohorte el detector devuelve
|
||||
`cold_start: true` y no emite hallazgos. La "actividad" usa `audit_log` como
|
||||
proxy porque no se persisten logins individuales.
|
||||
|
||||
---
|
||||
|
||||
## Notas de seguridad
|
||||
|
||||
@@ -15,6 +15,7 @@ from blueprints.tenants_bp import tenants_bp
|
||||
from blueprints.demos_bp import demos_bp
|
||||
from blueprints.health_bp import health_bp
|
||||
from blueprints.admin_bp import admin_bp
|
||||
from blueprints.portfolio_bp import portfolio_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
@@ -31,6 +32,7 @@ def create_app():
|
||||
app.register_blueprint(demos_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(portfolio_bp)
|
||||
|
||||
# ─── Frontend Routes ───────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
@@ -57,6 +59,10 @@ def create_app():
|
||||
def health_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/portfolio")
|
||||
def portfolio_page():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/migrations")
|
||||
def migrations_page():
|
||||
return render_template("index.html")
|
||||
|
||||
48
manager/blueprints/portfolio_bp.py
Normal file
48
manager/blueprints/portfolio_bp.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Portfolio operations blueprint: business metrics, health and anomalies.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
``GET /api/tenants/metrics`` rollup window + health score per tenant
|
||||
``GET /api/portfolio/anomalies`` cross-tenant anomalies (median + MAD z-score)
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services import portfolio_service
|
||||
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
|
||||
portfolio_bp = Blueprint("portfolio", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@portfolio_bp.route("/tenants/metrics", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def tenant_metrics():
|
||||
days = request.args.get("days", 14, type=int)
|
||||
include_inactive = request.args.get("all", "false").lower() == "true"
|
||||
return jsonify(portfolio_service.get_tenant_metrics(
|
||||
days=days, include_inactive=include_inactive))
|
||||
|
||||
|
||||
@portfolio_bp.route("/portfolio/anomalies", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def portfolio_anomalies():
|
||||
target = request.args.get("date")
|
||||
if target:
|
||||
try:
|
||||
date_cls.fromisoformat(target)
|
||||
except ValueError:
|
||||
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
|
||||
|
||||
kwargs = {
|
||||
"date": target,
|
||||
"include_inactive": request.args.get("all", "false").lower() == "true",
|
||||
}
|
||||
z = request.args.get("z", type=float)
|
||||
if z is not None:
|
||||
kwargs["z_threshold"] = z
|
||||
min_tenants = request.args.get("min_tenants", type=int)
|
||||
if min_tenants is not None:
|
||||
kwargs["min_tenants"] = min_tenants
|
||||
|
||||
return jsonify(portfolio_service.get_portfolio_anomalies(**kwargs))
|
||||
352
manager/services/business_metrics.py
Normal file
352
manager/services/business_metrics.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""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)",
|
||||
}
|
||||
522
manager/services/portfolio_service.py
Normal file
522
manager/services/portfolio_service.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""Portfolio operations service: per-tenant business metrics rollup, health
|
||||
scores and cross-tenant anomaly detection for the manager.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* The daily rollup lives in the **master** database table
|
||||
``tenant_metrics_daily`` (one row per tenant per day). It is created
|
||||
idempotently here and by ``scripts/rollup_tenant_metrics.py`` — the tenant
|
||||
migration runner is intentionally not involved.
|
||||
* Point-in-time counts (inventory, customers, employees, db size, fitment)
|
||||
are snapshots taken at collection time; daily activity metrics (sales,
|
||||
new customers, audit events, API calls) are computed for the requested day.
|
||||
* Data sources are guarded: optional tables (``audit_log``,
|
||||
``api_request_logs``, ``inventory_vehicle_compat``) may be absent in an older
|
||||
tenant and simply yield 0 / NULL instead of failing the rollup.
|
||||
* DSNs are resolved from environment exactly like the manager config
|
||||
(``MASTER_DB_URL`` / ``TENANT_DB_URL_TEMPLATE``) with a local fallback, so the
|
||||
script can run standalone without the manager's required secrets.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from services.business_metrics import (
|
||||
DEFAULT_Z,
|
||||
MIN_COHORT,
|
||||
compute_health,
|
||||
detect_portfolio_anomalies,
|
||||
)
|
||||
|
||||
BUSINESS_TZ = os.environ.get("PORTFOLIO_TZ", "America/Mexico_City")
|
||||
|
||||
_PGUSER = os.environ.get("PGUSER", "postgres")
|
||||
MASTER_DB_URL = (
|
||||
os.environ.get("MASTER_DB_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or f"postgresql://{_PGUSER}@localhost/nexus_autoparts"
|
||||
)
|
||||
TENANT_DB_URL_TEMPLATE = (
|
||||
os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||
or f"postgresql://{_PGUSER}@localhost/{{db_name}}"
|
||||
)
|
||||
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS tenant_metrics_daily (
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
ventas_count INTEGER NOT NULL DEFAULT 0,
|
||||
ventas_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ticket_promedio NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
ventas_canceladas INTEGER NOT NULL DEFAULT 0,
|
||||
clientes_nuevos INTEGER NOT NULL DEFAULT 0,
|
||||
items_activos INTEGER NOT NULL DEFAULT 0,
|
||||
skus INTEGER NOT NULL DEFAULT 0,
|
||||
clientes INTEGER NOT NULL DEFAULT 0,
|
||||
empleados_activos INTEGER NOT NULL DEFAULT 0,
|
||||
fitment_pct NUMERIC(5,2),
|
||||
uso_modulos JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ai_calls INTEGER NOT NULL DEFAULT 0,
|
||||
api_calls INTEGER NOT NULL DEFAULT 0,
|
||||
errores_5xx INTEGER NOT NULL DEFAULT 0,
|
||||
actividad_count INTEGER NOT NULL DEFAULT 0,
|
||||
ultima_venta_at TIMESTAMPTZ,
|
||||
ultima_actividad_at TIMESTAMPTZ,
|
||||
db_size_mb NUMERIC(12,2),
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_metrics_daily_date
|
||||
ON tenant_metrics_daily (date);
|
||||
"""
|
||||
|
||||
UPSERT = """
|
||||
INSERT INTO tenant_metrics_daily (
|
||||
tenant_id, date, ventas_count, ventas_total, ticket_promedio,
|
||||
ventas_canceladas, clientes_nuevos, items_activos, skus, clientes,
|
||||
empleados_activos, fitment_pct, uso_modulos, ai_calls, api_calls,
|
||||
errores_5xx, actividad_count, ultima_venta_at, ultima_actividad_at,
|
||||
db_size_mb, collected_at
|
||||
) VALUES (
|
||||
%(tenant_id)s, %(date)s, %(ventas_count)s, %(ventas_total)s,
|
||||
%(ticket_promedio)s, %(ventas_canceladas)s, %(clientes_nuevos)s,
|
||||
%(items_activos)s, %(skus)s, %(clientes)s, %(empleados_activos)s,
|
||||
%(fitment_pct)s, %(uso_modulos)s, %(ai_calls)s, %(api_calls)s,
|
||||
%(errores_5xx)s, %(actividad_count)s, %(ultima_venta_at)s,
|
||||
%(ultima_actividad_at)s, %(db_size_mb)s, NOW()
|
||||
)
|
||||
ON CONFLICT (tenant_id, date) DO UPDATE SET
|
||||
ventas_count = EXCLUDED.ventas_count,
|
||||
ventas_total = EXCLUDED.ventas_total,
|
||||
ticket_promedio = EXCLUDED.ticket_promedio,
|
||||
ventas_canceladas = EXCLUDED.ventas_canceladas,
|
||||
clientes_nuevos = EXCLUDED.clientes_nuevos,
|
||||
items_activos = EXCLUDED.items_activos,
|
||||
skus = EXCLUDED.skus,
|
||||
clientes = EXCLUDED.clientes,
|
||||
empleados_activos = EXCLUDED.empleados_activos,
|
||||
fitment_pct = EXCLUDED.fitment_pct,
|
||||
uso_modulos = EXCLUDED.uso_modulos,
|
||||
ai_calls = EXCLUDED.ai_calls,
|
||||
api_calls = EXCLUDED.api_calls,
|
||||
errores_5xx = EXCLUDED.errores_5xx,
|
||||
actividad_count = EXCLUDED.actividad_count,
|
||||
ultima_venta_at = EXCLUDED.ultima_venta_at,
|
||||
ultima_actividad_at = EXCLUDED.ultima_actividad_at,
|
||||
db_size_mb = EXCLUDED.db_size_mb,
|
||||
collected_at = NOW()
|
||||
"""
|
||||
|
||||
|
||||
def get_master_conn():
|
||||
return psycopg2.connect(MASTER_DB_URL, connect_timeout=10)
|
||||
|
||||
|
||||
def tenant_dsn(db_name):
|
||||
return TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
|
||||
def ensure_metrics_table(conn=None):
|
||||
"""Create ``tenant_metrics_daily`` if missing. Idempotent."""
|
||||
own = conn is None
|
||||
conn = conn or get_master_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(DDL)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
if own:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_tenants(conn=None, include_inactive=True):
|
||||
own = conn is None
|
||||
conn = conn or get_master_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
where = "" if include_inactive else "WHERE t.is_active = true"
|
||||
cur.execute(f"""
|
||||
SELECT t.id, t.name, t.db_name, t.plan, t.is_active, t.created_at,
|
||||
s.expires_at
|
||||
FROM tenants t
|
||||
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||
{where}
|
||||
ORDER BY t.id
|
||||
""")
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
cur.close()
|
||||
return rows
|
||||
finally:
|
||||
if own:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Collection ────────────────────────────────────────────────────────────
|
||||
|
||||
def _q(cur, sql, params=None, default=None):
|
||||
"""Run a scalar query, returning ``default`` if the object is missing."""
|
||||
try:
|
||||
cur.execute(sql, params or ())
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] is not None else default
|
||||
except Exception:
|
||||
cur.connection.rollback()
|
||||
return default
|
||||
|
||||
|
||||
def _load_modules(cur):
|
||||
cfg = {}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM tenant_config")
|
||||
cfg = dict(cur.fetchall())
|
||||
except Exception:
|
||||
cur.connection.rollback()
|
||||
|
||||
def flag(key):
|
||||
return (cfg.get(key) or "").lower() == "true"
|
||||
|
||||
return {
|
||||
"module_whatsapp": flag("module_whatsapp"),
|
||||
"module_marketplace": flag("module_marketplace"),
|
||||
"module_meli": flag("module_meli"),
|
||||
"module_catalog": flag("module_catalog"),
|
||||
"whatsapp_enabled": flag("whatsapp_enabled"),
|
||||
"vehicle_compat_source": cfg.get("vehicle_compat_source"),
|
||||
"facturapi": bool(cfg.get("cfdi_facturapi_key")),
|
||||
}
|
||||
|
||||
|
||||
def _collect_tenant(tenant, start_dt, end_dt, dates):
|
||||
"""Collect one tenant's rollup rows for ``dates`` inside [start, end)."""
|
||||
conn = psycopg2.connect(tenant_dsn(tenant["db_name"]), connect_timeout=10)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
items_activos = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active", default=0)
|
||||
skus = _q(cur, "SELECT COUNT(DISTINCT part_number) FROM inventory "
|
||||
"WHERE is_active AND part_number IS NOT NULL", default=0)
|
||||
clientes = _q(cur, "SELECT COUNT(*) FROM customers WHERE is_active", default=0)
|
||||
empleados = _q(cur, "SELECT COUNT(*) FROM employees WHERE is_active", default=0)
|
||||
db_size = _q(cur, "SELECT pg_database_size(current_database()) / 1048576.0")
|
||||
ultima_venta = _q(cur, "SELECT MAX(created_at) FROM sales "
|
||||
"WHERE status = 'completed'")
|
||||
ultima_actividad = _q(cur, "SELECT MAX(created_at) FROM audit_log")
|
||||
modules = _load_modules(cur)
|
||||
|
||||
icv = _q(cur, "SELECT to_regclass('public.inventory_vehicle_compat') IS NOT NULL",
|
||||
default=False)
|
||||
if icv:
|
||||
fit = _q(cur, "SELECT COUNT(*) FROM inventory i WHERE i.is_active AND ("
|
||||
"i.vehicle_compatibility IS NOT NULL OR EXISTS ("
|
||||
"SELECT 1 FROM inventory_vehicle_compat c "
|
||||
"WHERE c.inventory_id = i.id))", default=0)
|
||||
else:
|
||||
fit = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active "
|
||||
"AND vehicle_compatibility IS NOT NULL", default=0)
|
||||
fitment_pct = round(100.0 * fit / items_activos, 2) if items_activos else None
|
||||
|
||||
# Daily sales (completed / cancelled).
|
||||
sales = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'),
|
||||
COALESCE(SUM(total) FILTER (WHERE status = 'completed'), 0),
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled')
|
||||
FROM sales
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
for d, vc, vt, vx in cur.fetchall():
|
||||
sales[d] = (vc or 0, float(vt or 0), vx or 0)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
nuevos = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
|
||||
FROM customers
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
nuevos = dict(cur.fetchall())
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
actividad = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
|
||||
FROM audit_log
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
actividad = dict(cur.fetchall())
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
apis = {}
|
||||
has_api = _q(cur, "SELECT to_regclass('public.api_request_logs') IS NOT NULL",
|
||||
default=False)
|
||||
if has_api:
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date,
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status_code >= 500),
|
||||
COUNT(*) FILTER (WHERE path ~*
|
||||
'(/ai/|/chat|vision|semantic|classif|categoriz|/ml/)')
|
||||
FROM api_request_logs
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
for d, calls, err5, ai in cur.fetchall():
|
||||
apis[d] = (calls or 0, err5 or 0, ai or 0)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
rows = []
|
||||
for d in dates:
|
||||
vc, vt, vx = sales.get(d, (0, 0.0, 0))
|
||||
api_calls, err5, ai = apis.get(d, (0, 0, 0))
|
||||
rows.append({
|
||||
"tenant_id": tenant["id"],
|
||||
"date": d,
|
||||
"ventas_count": vc,
|
||||
"ventas_total": round(vt, 2),
|
||||
"ticket_promedio": round(vt / vc, 2) if vc else 0,
|
||||
"ventas_canceladas": vx,
|
||||
"clientes_nuevos": nuevos.get(d, 0),
|
||||
"items_activos": items_activos or 0,
|
||||
"skus": skus or 0,
|
||||
"clientes": clientes or 0,
|
||||
"empleados_activos": empleados or 0,
|
||||
"fitment_pct": fitment_pct,
|
||||
"uso_modulos": Json(modules),
|
||||
"ai_calls": ai,
|
||||
"api_calls": api_calls,
|
||||
"errores_5xx": err5,
|
||||
"actividad_count": actividad.get(d, 0),
|
||||
"ultima_venta_at": ultima_venta,
|
||||
"ultima_actividad_at": ultima_actividad,
|
||||
"db_size_mb": round(db_size, 2) if db_size is not None else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def run_rollup(date=None, days=1, tenant_ids=None, include_inactive=True):
|
||||
"""Collect and upsert rollup rows. Idempotent on (tenant_id, date).
|
||||
|
||||
``date`` is the last day of the range (defaults to today in BUSINESS_TZ);
|
||||
``days`` extends the range backwards (1 == a single day).
|
||||
"""
|
||||
tz = ZoneInfo(BUSINESS_TZ)
|
||||
end_date = date or datetime.now(tz).date()
|
||||
if isinstance(end_date, str):
|
||||
end_date = date_cls.fromisoformat(end_date)
|
||||
dates = [end_date - timedelta(days=i) for i in range(days - 1, -1, -1)]
|
||||
|
||||
start_dt = datetime.combine(dates[0], time.min, tzinfo=tz)
|
||||
end_dt = datetime.combine(dates[-1] + timedelta(days=1), time.min, tzinfo=tz)
|
||||
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
tenants = list_tenants(conn, include_inactive=include_inactive)
|
||||
if tenant_ids:
|
||||
tenants = [t for t in tenants
|
||||
if t["id"] in tenant_ids or t["db_name"] in tenant_ids]
|
||||
|
||||
written = 0
|
||||
errors = []
|
||||
per_tenant = []
|
||||
for t in tenants:
|
||||
try:
|
||||
rows = _collect_tenant(t, start_dt, end_dt, dates)
|
||||
except Exception as e: # noqa: BLE001 — report and continue
|
||||
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
|
||||
"error": str(e)})
|
||||
continue
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for row in rows:
|
||||
cur.execute(UPSERT, row)
|
||||
conn.commit()
|
||||
written += len(rows)
|
||||
per_tenant.append({
|
||||
"tenant_id": t["id"], "tenant_name": t["name"],
|
||||
"db_name": t["db_name"], "rows": len(rows),
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
conn.rollback()
|
||||
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
|
||||
"error": str(e)})
|
||||
finally:
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"date_from": str(dates[0]),
|
||||
"date_to": str(dates[-1]),
|
||||
"days": days,
|
||||
"rows_written": written,
|
||||
"tenants": per_tenant,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ─── Readers ───────────────────────────────────────────────────────────────
|
||||
|
||||
METRIC_FIELDS = [
|
||||
"ventas_count", "ventas_total", "ticket_promedio", "ventas_canceladas",
|
||||
"clientes_nuevos", "items_activos", "skus", "clientes",
|
||||
"empleados_activos", "fitment_pct", "ai_calls", "api_calls",
|
||||
"errores_5xx", "actividad_count", "db_size_mb",
|
||||
]
|
||||
|
||||
|
||||
def _serialize_row(row):
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
if isinstance(v, (datetime, date_cls)):
|
||||
out[k] = v.isoformat()
|
||||
elif hasattr(v, "quantize"): # Decimal
|
||||
out[k] = float(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _latest_date(conn):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT MAX(date) FROM tenant_metrics_daily")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def get_tenant_metrics(days=14, include_inactive=True):
|
||||
"""Rollup window + health score for every tenant (manager API payload)."""
|
||||
days = max(1, min(int(days), 90))
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
latest = _latest_date(conn)
|
||||
tenants = list_tenants(conn, include_inactive=include_inactive)
|
||||
if latest is None:
|
||||
return {
|
||||
"rollup_available": False,
|
||||
"date": None,
|
||||
"window_days": days,
|
||||
"notes": {
|
||||
"hint": "Sin datos; ejecuta scripts/rollup_tenant_metrics.py",
|
||||
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
||||
},
|
||||
"tenants": [
|
||||
{"tenant_id": t["id"], "name": t["name"],
|
||||
"db_name": t["db_name"], "plan": t["plan"],
|
||||
"is_active": t["is_active"],
|
||||
"health": compute_health(t, []), "metrics": None}
|
||||
for t in tenants
|
||||
],
|
||||
}
|
||||
|
||||
start = latest - timedelta(days=days - 1)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT tenant_id, date, ventas_count, ventas_total, ticket_promedio,
|
||||
ventas_canceladas, clientes_nuevos, items_activos, skus,
|
||||
clientes, empleados_activos, fitment_pct, uso_modulos,
|
||||
ai_calls, api_calls, errores_5xx, actividad_count,
|
||||
ultima_venta_at, ultima_actividad_at, db_size_mb
|
||||
FROM tenant_metrics_daily
|
||||
WHERE date >= %s AND date <= %s
|
||||
ORDER BY tenant_id, date
|
||||
""", (start, latest))
|
||||
cols = [d[0] for d in cur.description]
|
||||
by_tenant = {}
|
||||
for r in cur.fetchall():
|
||||
row = dict(zip(cols, r)) # raw: tz-aware datetimes for health
|
||||
by_tenant.setdefault(row["tenant_id"], []).append(row)
|
||||
cur.close()
|
||||
|
||||
out = []
|
||||
for t in tenants:
|
||||
history = by_tenant.get(t["id"], [])
|
||||
health = compute_health(t, history)
|
||||
out.append({
|
||||
"tenant_id": t["id"],
|
||||
"name": t["name"],
|
||||
"db_name": t["db_name"],
|
||||
"plan": t["plan"],
|
||||
"is_active": t["is_active"],
|
||||
"health": health,
|
||||
"metrics": _serialize_row(history[-1]) if history else None,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"rollup_available": True,
|
||||
"date": str(latest),
|
||||
"window_days": days,
|
||||
"notes": {
|
||||
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
||||
"point_in_time": "items/skus/clientes/empleados/fitment son snapshot al momento del rollup",
|
||||
},
|
||||
"tenants": out,
|
||||
}
|
||||
|
||||
|
||||
def get_portfolio_anomalies(date=None, z_threshold=DEFAULT_Z, min_tenants=MIN_COHORT,
|
||||
include_inactive=False):
|
||||
"""Cross-tenant anomaly report built from the daily rollup."""
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
target = date or _latest_date(conn)
|
||||
if target is None:
|
||||
return detect_portfolio_anomalies([], date=None,
|
||||
min_tenants=min_tenants,
|
||||
z_threshold=z_threshold)
|
||||
|
||||
where_active = "" if include_inactive else "AND t.is_active = true"
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT m.tenant_id, t.name, m.date,
|
||||
m.ventas_total, m.ventas_count, m.ticket_promedio,
|
||||
m.clientes_nuevos, m.actividad_count, m.errores_5xx,
|
||||
m.items_activos, m.skus, m.clientes, m.fitment_pct
|
||||
FROM tenant_metrics_daily m
|
||||
JOIN tenants t ON t.id = m.tenant_id
|
||||
WHERE m.date = %s {where_active}
|
||||
ORDER BY m.tenant_id
|
||||
""", (target,))
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
row = _serialize_row(dict(zip(cols, r)))
|
||||
row["tenant_name"] = row.pop("name")
|
||||
rows.append(row)
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
report = detect_portfolio_anomalies(rows, date=target,
|
||||
min_tenants=min_tenants,
|
||||
z_threshold=z_threshold)
|
||||
report["tenants_scanned"] = len(rows)
|
||||
return report
|
||||
@@ -193,40 +193,59 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
tables_to_truncate = [
|
||||
"inventory_operations",
|
||||
"inventory",
|
||||
"sale_items",
|
||||
"sales",
|
||||
"customer_payments",
|
||||
"cash_register_closings",
|
||||
"cash_register_movements",
|
||||
"cash_registers",
|
||||
"invoices",
|
||||
"accounting_entries",
|
||||
"journal_entries",
|
||||
"service_orders",
|
||||
"fleet_vehicles",
|
||||
"crm_activities",
|
||||
"quotations",
|
||||
"quotation_items",
|
||||
"savings_transactions",
|
||||
"savings_accounts",
|
||||
"supplier_orders",
|
||||
"supplier_order_items",
|
||||
"warranty_claims",
|
||||
"notifications",
|
||||
"inventory_uploads",
|
||||
# Whitelist de tablas de NEGOCIO/PII a limpiar. Antes faltaban customers,
|
||||
# whatsapp/wa_*, audit_log, layaways, returns, marketplace_*, consignaciones
|
||||
# → el "reset" dejaba datos personales e historial de conversaciones.
|
||||
candidate_tables = [
|
||||
# Inventario
|
||||
"inventory_operations", "inventory_stock", "inventory_stock_summary",
|
||||
"inventory_images", "inventory_sku_aliases", "inventory_vehicle_compat",
|
||||
"inventory_transfer_lines", "inventory_transfers", "physical_count_lines",
|
||||
"physical_counts", "reorder_alerts", "inventory", "inventory_uploads",
|
||||
# Ventas / caja
|
||||
"sale_payments", "sale_items", "sales", "customer_payments",
|
||||
"cash_movements", "cash_register_movements", "cash_registers",
|
||||
"layaway_payments", "layaway_items", "layaways",
|
||||
# Devoluciones / garantías
|
||||
"return_items", "returns", "warranty_claims", "warranties",
|
||||
# Cotizaciones / compras
|
||||
"quotation_items", "quotations",
|
||||
"purchase_order_items", "purchase_orders",
|
||||
# Taller / flotilla
|
||||
"service_order_items", "service_order_labor", "service_orders",
|
||||
"fleet_maintenance_logs", "fleet_vehicles",
|
||||
# CRM / clientes (PII)
|
||||
"customer_activities", "crm_activities", "loyalty_transactions",
|
||||
"loyalty_points", "customers",
|
||||
# Marketplace / MeLi
|
||||
"marketplace_order_items", "marketplace_orders", "marketplace_listings",
|
||||
"marketplace_questions", "meli_sync_queue",
|
||||
# WhatsApp (PII)
|
||||
"whatsapp_messages", "whatsapp_sessions", "wa_customer_links",
|
||||
"wa_learning_sessions",
|
||||
# Contabilidad / fiscal
|
||||
"cfdi_queue", "invoices", "journal_lines", "journal_entries",
|
||||
# Auditoría / sesiones
|
||||
"audit_log", "employee_sessions", "notifications",
|
||||
# Consignación (Yonke)
|
||||
"consignment_items", "consignments",
|
||||
]
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for table in tables_to_truncate:
|
||||
# Solo se truncan las que existan en este tenant (esquema puede variar).
|
||||
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
existing = {r[0] for r in cur.fetchall()}
|
||||
truncated = 0
|
||||
for table in candidate_tables:
|
||||
if table not in existing:
|
||||
continue
|
||||
try:
|
||||
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||
cur.execute(f'TRUNCATE TABLE public."{table}" RESTART IDENTITY CASCADE')
|
||||
truncated += 1
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
conn.rollback()
|
||||
conn.commit()
|
||||
success = True
|
||||
except Exception as e:
|
||||
@@ -237,7 +256,7 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": truncated}
|
||||
|
||||
|
||||
def delete_tenant(tenant_id):
|
||||
|
||||
@@ -10,6 +10,7 @@ const routes = {
|
||||
"#dashboard": "dashboard",
|
||||
"#demos": "demos",
|
||||
"#tenants": "tenants",
|
||||
"#portfolio": "portfolio",
|
||||
"#health": "health",
|
||||
"#migrations": "migrations"
|
||||
};
|
||||
@@ -29,6 +30,7 @@ function navigate() {
|
||||
dashboard: "Dashboard",
|
||||
demos: "Crear Demos",
|
||||
tenants: "Tenants",
|
||||
portfolio: "Cartera y Salud",
|
||||
health: "Salud del Sistema",
|
||||
migrations: "Migraciones"
|
||||
};
|
||||
@@ -38,6 +40,7 @@ function navigate() {
|
||||
if (page === "dashboard") loadDashboard();
|
||||
if (page === "demos") loadDemos();
|
||||
if (page === "tenants") loadTenants();
|
||||
if (page === "portfolio") loadPortfolio();
|
||||
if (page === "health") loadHealth();
|
||||
if (page === "migrations") loadMigrations();
|
||||
}
|
||||
@@ -271,6 +274,77 @@ document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Portfolio (business metrics + health + anomalies) ─────────────────────
|
||||
const BAND_TAG = {
|
||||
healthy: "success",
|
||||
stable: "info",
|
||||
at_risk: "warning",
|
||||
critical: "danger",
|
||||
unknown: "default"
|
||||
};
|
||||
|
||||
async function loadPortfolio() {
|
||||
const tbody = document.getElementById("portfolio-table");
|
||||
const aBody = document.getElementById("anomalies-table");
|
||||
|
||||
const metricsRes = await api("/api/tenants/metrics?days=14");
|
||||
if (!metricsRes || metricsRes.status !== 200) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Error al cargar métricas</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const m = metricsRes.data;
|
||||
document.getElementById("portfolio-meta").textContent = m.rollup_available
|
||||
? `Último día: ${m.date} · ventana ${m.window_days}d`
|
||||
: "Sin rollup";
|
||||
|
||||
if (!m.rollup_available) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Sin datos de rollup. Ejecuta <code>scripts/rollup_tenant_metrics.py</code></td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = m.tenants.map(t => {
|
||||
const h = t.health || {};
|
||||
const x = t.metrics || {};
|
||||
const score = h.health_score == null ? "—" : h.health_score;
|
||||
const signals = (h.signals || []).map(s => s.code).join(", ") || "—";
|
||||
const fitment = x.fitment_pct == null ? "—" : `${x.fitment_pct}%`;
|
||||
return `<tr>
|
||||
<td><strong>${escapeHtml(t.name)}</strong><br><span class="text-muted" style="font-size:11px">${escapeHtml(t.db_name)}</span></td>
|
||||
<td>${tag(`${score} · ${h.band_label || h.band}`, BAND_TAG[h.band] || "default")}</td>
|
||||
<td>${x.ventas_count ?? 0} / $${(x.ventas_total ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>$${(x.ticket_promedio ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.items_activos ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.clientes ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${fitment}</td>
|
||||
<td style="font-size:11px" class="text-muted">${escapeHtml(signals)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const anomRes = await api("/api/portfolio/anomalies");
|
||||
if (!anomRes || anomRes.status !== 200) {
|
||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Error al cargar anomalías</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const a = anomRes.data;
|
||||
document.getElementById("anomalies-note").textContent =
|
||||
`Día ${a.date || "—"} · cohorte ${a.cohort_size} tenants · z≥${a.thresholds?.z_threshold} (${a.thresholds?.method})` +
|
||||
(a.cold_start ? " · COLD START: cohorte insuficiente" : "");
|
||||
|
||||
if (!a.anomalies || !a.anomalies.length) {
|
||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Sin anomalías entre tenants</td></tr>`;
|
||||
return;
|
||||
}
|
||||
aBody.innerHTML = a.anomalies.map(x => `
|
||||
<tr>
|
||||
<td>${escapeHtml(x.tenant_name)}</td>
|
||||
<td>${escapeHtml(x.label)}</td>
|
||||
<td>${(x.value ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.cohort_median ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${x.z}</td>
|
||||
<td>${tag(x.direction === "peak" ? "Pico inusual" : "Caída inusual", x.direction === "peak" ? "info" : "danger")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────────────────
|
||||
async function loadHealth() {
|
||||
const res = await api("/api/health");
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<span>Tenants</span>
|
||||
<span class="badge" id="tenant-count">0</span>
|
||||
</a>
|
||||
<a href="#/portfolio" class="nav-item" data-page="portfolio">
|
||||
<i class="fas fa-briefcase"></i>
|
||||
<span>Cartera</span>
|
||||
</a>
|
||||
<a href="#/health" class="nav-item" data-page="health">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>Salud</span>
|
||||
@@ -240,6 +244,63 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Portfolio Page -->
|
||||
<section id="page-portfolio" class="page" style="display:none;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-briefcase"></i> Salud de Cartera</h3>
|
||||
<div class="card-actions">
|
||||
<span id="portfolio-meta" class="text-muted" style="font-size:12px;"></span>
|
||||
<button class="btn btn-sm btn-secondary" onclick="loadPortfolio()">
|
||||
<i class="fas fa-sync"></i> Refrescar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Salud</th>
|
||||
<th>Ventas (día)</th>
|
||||
<th>Ticket prom.</th>
|
||||
<th>Items</th>
|
||||
<th>Clientes</th>
|
||||
<th>Fitment</th>
|
||||
<th>Señales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="portfolio-table">
|
||||
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-exclamation-triangle"></i> Anomalías entre Tenants</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="anomalies-note" class="text-muted" style="font-size:12px;margin-bottom:8px;"></div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Métrica</th>
|
||||
<th>Valor</th>
|
||||
<th>Mediana cohorte</th>
|
||||
<th>z</th>
|
||||
<th>Dirección</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="anomalies-table">
|
||||
<tr><td colspan="6" class="text-muted">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Health Page -->
|
||||
<section id="page-health" class="page" style="display:none;">
|
||||
<div class="grid-3">
|
||||
|
||||
Reference in New Issue
Block a user