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

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