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

@@ -30,6 +30,10 @@ const Customers = (() => {
return '$' + n.toFixed(0);
};
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
}
@@ -281,6 +285,7 @@ const Customers = (() => {
set('detailEmail', c.email);
set('detailSince', formatDate(c.created_at));
set('detailLastPurchase', formatDate(c.last_purchase));
if (typeof loadCustomerCRM === 'function') loadCustomerCRM(c.id);
// Credit
const limit = parseFloat(c.credit_limit || 0);
@@ -733,6 +738,137 @@ const Customers = (() => {
if (typeof openSlidePanel === 'function') openSlidePanel();
}
// ─── CRM & Loyalty ───────────────────────
async function loadCustomerCRM(customerId) {
try {
const [loy, acts, analytics, creditScore] = await Promise.all([
api(`/pos/api/customers/${customerId}/loyalty`),
api(`/pos/api/customers/${customerId}/activities`).catch(() => ({ data: [] })),
api(`/pos/api/customers/${customerId}/analytics`).catch(() => null),
api(`/pos/api/customers/${customerId}/credit-score`).catch(() => null),
]);
const balEl = document.getElementById('crmLoyaltyBalance');
if (balEl) balEl.textContent = (loy.balance != null ? loy.balance : 0);
const actsEl = document.getElementById('crmActivities');
if (actsEl) {
const list = acts.data || acts.activities || [];
if (!list.length) {
actsEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin actividad registrada</span>';
} else {
actsEl.innerHTML = list.slice(0, 20).map(a =>
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${esc(a.created_at || '')} · ${esc(a.activity_type || '')}</div>
<div style="font-size:var(--text-body-sm);">${esc(a.description || a.title || '')}</div>
</div>`).join('');
}
}
renderCustomerInsights(analytics, creditScore);
} catch (e) {
console.warn('CRM load failed:', e);
}
}
window.loadCustomerCRM = loadCustomerCRM;
// Renders analytics (LTV, next purchase, churn) + credit risk score into
// the CRM section of the customer detail panel. All values come from the
// backend; missing data is shown as "—" rather than guessed.
function renderCustomerInsights(analytics, creditScore) {
const section = document.getElementById('crmSection');
if (!section) return;
let el = document.getElementById('crmInsights');
if (!el) {
el = document.createElement('div');
el.id = 'crmInsights';
el.style.cssText = 'display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);' +
'margin-bottom:var(--space-3);';
const actsEl = document.getElementById('crmActivities');
section.insertBefore(el, actsEl || null);
}
if (!analytics && !creditScore) { el.innerHTML = ''; return; }
const card = (label, value, sub, color) =>
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-wider);">${esc(label)}</div>
<div style="font-size:var(--text-body-sm);font-weight:700;color:${color || 'var(--color-text-primary)'};">${value}</div>
${sub ? `<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${sub}</div>` : ''}
</div>`;
const cards = [];
const a = analytics || {};
cards.push(card('Valor de vida (LTV)', fmt(a.ltv),
`${(a.total_orders || 0)} compras · ticket ${fmt(a.avg_order_value)}`));
cards.push(card('Intervalo de compra',
(a.avg_days_between_purchases != null && a.avg_days_between_purchases > 0)
? `${a.avg_days_between_purchases} días` : '—',
(a.days_since_last_purchase != null)
? `Última hace ${a.days_since_last_purchase} días` : 'Sin compras'));
let nextSub = '';
if (a.days_until_next_purchase != null) {
nextSub = a.days_until_next_purchase >= 0
? `En ${a.days_until_next_purchase} días`
: `Vencida hace ${Math.abs(a.days_until_next_purchase)} días`;
}
cards.push(card('Próxima compra probable',
a.predicted_next_purchase_date ? formatDate(a.predicted_next_purchase_date) : '—',
nextSub));
const riskColor = a.churn_risk === 'high' ? 'var(--color-error)'
: a.churn_risk === 'medium' ? '#d97706' : 'var(--color-success, #16a34a)';
const riskLabel = a.churn_risk === 'high' ? 'Alto' : a.churn_risk === 'medium' ? 'Medio' : 'Bajo';
const probTxt = (a.churn_probability != null) ? ` (${Math.round(a.churn_probability * 100)}%)` : '';
cards.push(card('Riesgo de abandono (churn)',
`<span style="color:${riskColor};">${riskLabel}${probTxt}</span>`,
esc(a.churn_reason || '')));
if (creditScore && creditScore.score != null) {
const tierMap = { good: 'Bueno', medium: 'Medio', risk: 'Riesgo' };
const tierColor = creditScore.tier === 'good' ? 'var(--color-success, #16a34a)'
: creditScore.tier === 'medium' ? '#d97706' : 'var(--color-error)';
const reasons = (creditScore.reasons || []).slice(0, 2).map(esc).join(' · ');
cards.push(card('Score de crédito',
`<span style="color:${tierColor};">${creditScore.score}/100 · ${esc(tierMap[creditScore.tier] || creditScore.tier)}</span>`,
`Límite sugerido ${fmt(creditScore.recommended_limit)} · ${esc(creditScore.data_points)} señales` +
(reasons ? `<br>${reasons}` : '')));
}
el.innerHTML = cards.join('');
}
async function crmRedeem() {
if (!currentCustomer) return;
const pts = prompt('Puntos a canjear:');
if (!pts) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}/loyalty/redeem`, {
method: 'POST',
body: JSON.stringify({ points: parseInt(pts, 10) }),
});
alert('Canje registrado');
loadCustomerCRM(currentCustomer.id);
} catch (e) {
alert('Error: ' + e.message);
}
}
async function crmAddNote() {
if (!currentCustomer) return;
const note = prompt('Nota sobre el cliente:');
if (!note) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}/activities`, {
method: 'POST',
body: JSON.stringify({ activity_type: 'note', description: note }),
});
loadCustomerCRM(currentCustomer.id);
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Init ────────────────────────────
function init() {
// Auth check
@@ -1011,6 +1147,8 @@ const Customers = (() => {
}
};
publicApi.crmRedeem = crmRedeem;
publicApi.crmAddNote = crmAddNote;
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;