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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user