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

@@ -53,6 +53,9 @@
connectedDiv.style.display = 'block';
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
if (!cfg.has_refresh_token) {
statusDiv.innerHTML += '<div style="margin-top:8px;padding:10px;border-radius:6px;background:rgba(234,179,8,.12);color:#eab308;font-size:var(--text-caption);">⚠️ Esta conexión <strong>no tiene token de larga duración</strong> (refresh token) y expirará en ~6 horas. Habilite el scope <strong>offline_access</strong> en su aplicación de MercadoLibre Developers y vuelva a conectar para corregirlo.</div>';
}
} else {
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
formDiv.style.display = 'block';
@@ -133,6 +136,7 @@
if (!res.ok) throw new Error('Failed to load listings');
var data = await res.json();
listingsData = data.items || [];
listingsData.forEach(function(l) { l.external_status = normStatus(l.external_status); });
renderListings();
} catch (e) {
container.innerHTML = renderEmptyState({
@@ -244,6 +248,7 @@
if (!res.ok) throw new Error('Failed to load orders');
var data = await res.json();
ordersData = data.items || [];
ordersData.forEach(function(o) { o.status = normStatus(o.status); });
renderOrders();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6">' + renderEmptyState({
@@ -304,6 +309,7 @@
var res = await fetch(API + '/orders/' + id, { headers: headers() });
var o = await res.json();
if (!res.ok) throw new Error(o.error || 'Error');
o.status = normStatus(o.status);
var itemsHtml = (o.items || []).map(function(it) {
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
@@ -368,6 +374,11 @@
// ─── Utils ─────────────────────────────────────────────────────────────
// ML devuelve el estado de las preguntas en MAYÚSCULAS (UNANSWERED/ANSWERED/CLOSED)
// mientras que nuestros filtros y literales usan minúsculas -> comparar
// siempre en minúsculas para que las no respondidas aparezcan y se puedan responder.
function normStatus(s) { return String(s == null ? '' : s).toLowerCase(); }
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
@@ -394,7 +405,11 @@
});
var data = await res.json();
if (res.ok) {
alert('¡Conectado exitosamente con MercadoLibre!');
if (data.warning === 'no_refresh_token') {
alert('Conectado con MercadoLibre, pero con una advertencia:\n\nMercadoLibre NO devolvió un token de larga duración (refresh token), así que la conexión expirará en ~6 horas.\n\nPara corregirlo: entre a developers.mercadolibre.com → su aplicación → Editar → habilite el scope "offline_access" y vuelva a conectar.');
} else {
alert('¡Conectado exitosamente con MercadoLibre!');
}
window.history.replaceState({}, document.title, '/pos/marketplace-external');
loadConfig();
} else {
@@ -415,9 +430,9 @@
if (!res.ok) throw new Error('Failed');
var data = await res.json();
var items = data.items || [];
var active = items.filter(function(l) { return l.external_status === 'active'; }).length;
var paused = items.filter(function(l) { return l.external_status === 'paused'; }).length;
var closed = items.filter(function(l) { return l.external_status === 'closed'; }).length;
var active = items.filter(function(l) { return normStatus(l.external_status) === 'active'; }).length;
var paused = items.filter(function(l) { return normStatus(l.external_status) === 'paused'; }).length;
var closed = items.filter(function(l) { return normStatus(l.external_status) === 'closed'; }).length;
var total = items.length;
var html = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
@@ -489,6 +504,7 @@
if (!res.ok) throw new Error('Failed to load questions');
var data = await res.json();
questionsData = data.items || [];
questionsData.forEach(function(q) { q.status = normStatus(q.status); });
renderQuestions();
} catch (e) {
container.innerHTML = renderEmptyState({
@@ -506,14 +522,14 @@
var search = document.getElementById('questionSearch').value.toLowerCase();
var filtered = questionsData.filter(function(q) {
if (statusFilter && q.status !== statusFilter) return false;
if (statusFilter && normStatus(q.status) !== normStatus(statusFilter)) return false;
if (search && !((q.question_text || '').toLowerCase().includes(search)) && !((q.listing_title || '').toLowerCase().includes(search))) return false;
return true;
});
// Stats bar
var unanswered = questionsData.filter(function(q) { return q.status === 'unanswered'; }).length;
var answered = questionsData.filter(function(q) { return q.status === 'answered'; }).length;
var unanswered = questionsData.filter(function(q) { return normStatus(q.status) === 'unanswered'; }).length;
var answered = questionsData.filter(function(q) { return normStatus(q.status) === 'answered'; }).length;
var total = questionsData.length;
var statsHtml = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-primary);">' + total + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Total preguntas</div></div>' +
@@ -533,10 +549,10 @@
}
container.innerHTML = filtered.map(function(q) {
var statusClass = 'meli-status--' + (q.status || 'pending');
var statusLabel = q.status === 'unanswered' ? 'Sin responder' : (q.status === 'answered' ? 'Respondida' : (q.status || '—'));
var statusClass = 'meli-status--' + (normStatus(q.status) || 'pending');
var statusLabel = normStatus(q.status) === 'unanswered' ? 'Sin responder' : (normStatus(q.status) === 'answered' ? 'Respondida' : (q.status || '—'));
var answerHtml = '';
if (q.status === 'unanswered') {
if (normStatus(q.status) === 'unanswered') {
answerHtml = '<div style="margin-top:var(--space-2);">' +
'<textarea class="meli-title-input" id="qAnswer-' + q.id + '" rows="2" placeholder="Escribe tu respuesta..."></textarea>' +
'<button class="btn btn--primary btn--xs" style="margin-top:6px;" onclick="submitAnswer(' + q.id + ')">Enviar respuesta</button>' +