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

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

View File

@@ -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")

View 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))

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

View 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

View File

@@ -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):

View File

@@ -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");

View File

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