feat: robust ML publish with pre-flight, preview, validation, async

- Add /inventory-check endpoint for local pre-flight validation
- Add /listings/validate endpoint using ML /items/validate API
- Add /categories/<id>/attributes endpoint for required attrs
- Add /listings/async + polling for background publishing via Celery
- Editable preview: title (0/60 counter), price, stock per item
- Pre-flight checks: image, stock, price, duplicate detection
- Image upload directly from publish modal (uses existing /items/<id>/image)
- Dynamic required attributes form based on selected ML category
- Frontend: validate button, async polling with progress, detailed error display
- Backend: build_item_payload supports custom_title, extra_attributes
This commit is contained in:
2026-05-26 04:37:05 +00:00
parent 4866823ba9
commit b314a781a1
8 changed files with 706 additions and 108 deletions

View File

@@ -1333,6 +1333,73 @@
/* History table inside modal */
.inv-modal .data-table { width: 100%; }
/* ─── MercadoLibre Publish Modal Enhancements ────────────────────────── */
.meli-preview-card {
display: grid;
grid-template-columns: 56px 1fr auto auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
background: var(--color-surface-1);
}
.meli-preview-card img {
width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius-sm);
background: var(--color-surface-2);
}
.meli-preview-card .meli-title-input {
width: 100%;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
padding: 4px 8px;
font-size: var(--text-caption);
}
.meli-preview-card .meli-num-input {
width: 80px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
padding: 4px 8px;
font-size: var(--text-caption);
text-align: right;
}
.meli-check { font-size: var(--text-caption); display: flex; align-items: center; gap: 4px; }
.meli-check.ok { color: var(--color-success); }
.meli-check.fail { color: var(--color-error); }
.meli-checks-row {
display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-1);
}
.meli-attrs-section {
margin-top: var(--space-3);
padding: var(--space-3);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-1);
}
.meli-attrs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
}
.meli-img-upload {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
text-align: center;
color: var(--color-text-muted);
font-size: var(--text-caption);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.meli-img-upload:hover { border-color: var(--color-primary); }
.meli-img-upload input { display: none; }
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
.meli-cat-dropdown {
position: absolute;

View File

@@ -784,6 +784,9 @@
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
var meliPreviewData = {};
var meliCategoryAttrs = [];
window.openMeliPublishModal = function() {
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
document.getElementById('meliPublishModal').classList.add('is-open');
@@ -791,6 +794,9 @@
document.getElementById('meliCategoryId').value = '';
document.getElementById('meliCategorySearch').value = '';
document.getElementById('meliCategoryResults').innerHTML = '';
document.getElementById('meliAttrsSection').style.display = 'none';
document.getElementById('meliAttrsGrid').innerHTML = '';
meliCategoryAttrs = [];
refreshMeliPublishPreview();
};
@@ -802,17 +808,80 @@
var container = document.getElementById('meliPublishItemsPreview');
var countEl = document.getElementById('meliPublishSelectedCount');
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
if (!inventoryVS || !inventoryVS.data) { container.innerHTML = '<p style="color:var(--color-text-muted);">Sin datos</p>'; return; }
var items = inventoryVS.data.filter(function(it) { return selectedItems.has(it.id); });
if (!items.length) { container.innerHTML = '<p style="color:var(--color-text-muted);">Ninguno</p>'; return; }
var html = '<table class="data-table" style="font-size:var(--text-caption);"><thead><tr><th>ID</th><th>No. Parte</th><th>Nombre</th><th>Stock</th><th style="text-align:right">Precio</th></tr></thead><tbody>';
items.forEach(function(it) {
html += '<tr><td>' + it.id + '</td><td class="td--mono">' + esc(it.part_number) + '</td><td>' + esc(it.name) + '</td><td>' + it.stock + '</td><td style="text-align:right">$' + fmt(it.price_1) + '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando verificaciones...</p>';
var ids = Array.from(selectedItems);
fetch('/pos/api/marketplace-ext/inventory-check', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ inventory_ids: ids })
}).then(function(r){ return r.json(); })
.then(function(data) {
if (!data.items) { container.innerHTML = '<p style="color:var(--color-error);">Error cargando preview</p>'; return; }
var html = '';
data.items.forEach(function(it) {
if (!it.exists) {
html += '<div class="meli-preview-card" style="opacity:0.6;"><div style="color:var(--color-error);">Item #' + it.inventory_id + ' no encontrado</div></div>';
return;
}
meliPreviewData[it.inventory_id] = it;
var checks = '';
checks += '<span class="meli-check ' + (it.has_image ? 'ok' : 'fail') + '">' + (it.has_image ? '✅' : '❌') + ' Imagen</span>';
checks += '<span class="meli-check ' + (it.has_stock ? 'ok' : 'fail') + '">' + (it.has_stock ? '✅' : '❌') + ' Stock</span>';
checks += '<span class="meli-check ' + (it.has_price ? 'ok' : 'fail') + '">' + (it.has_price ? '✅' : '❌') + ' Precio</span>';
if (it.already_published) {
checks += '<span class="meli-check ok">✅ <a href="' + esc(it.existing_listing.permalink || '#') + '" target="_blank" style="color:var(--color-success);text-decoration:underline;">Ya publicado (' + esc(it.existing_listing.status) + ')</a></span>';
}
var imgSrc = it.image_url || '';
var imgHtml = imgSrc ? '<img src="' + esc(imgSrc) + '" alt="" onerror="this.style.display=\'none\'">' : '<div style="width:56px;height:56px;background:var(--color-surface-2);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-muted);">Sin img</div>';
if (!it.has_image) {
imgHtml = '<div class="meli-img-upload" onclick="document.getElementById(\'meliImgUpload-' + it.inventory_id + '\').click()">' +
'<div style="font-size:10px;">+ Subir</div>' +
'<input type="file" id="meliImgUpload-' + it.inventory_id + '" accept="image/*" onchange="handleMeliImageUpload(' + it.inventory_id + ', this)">' +
'</div>';
}
html += '<div class="meli-preview-card" id="meliCard-' + it.inventory_id + '">' +
imgHtml +
'<div>' +
'<input type="text" class="meli-title-input" id="meliTitle-' + it.inventory_id + '" value="' + esc(it.title) + '" maxlength="60" oninput="updateMeliTitleCount(' + it.inventory_id + ')">' +
'<div style="font-size:10px;color:var(--color-text-muted);text-align:right;" id="meliTitleCount-' + it.inventory_id + '">' + it.title.length + '/60</div>' +
'<div class="meli-checks-row">' + checks + '</div>' +
'</div>' +
'<div><label style="font-size:10px;color:var(--color-text-muted);">Precio</label><input type="number" class="meli-num-input" id="meliPrice-' + it.inventory_id + '" value="' + it.price + '"></div>' +
'<div><label style="font-size:10px;color:var(--color-text-muted);">Stock</label><input type="number" class="meli-num-input" id="meliStock-' + it.inventory_id + '" value="' + it.stock + '"></div>' +
'</div>';
});
container.innerHTML = html;
}).catch(function() { container.innerHTML = '<p style="color:var(--color-error);">Error de red</p>'; });
}
window.updateMeliTitleCount = function(id) {
var el = document.getElementById('meliTitle-' + id);
var countEl = document.getElementById('meliTitleCount-' + id);
if (el && countEl) countEl.textContent = el.value.length + '/60';
};
window.handleMeliImageUpload = function(itemId, input) {
if (!input.files || !input.files[0]) return;
var file = input.files[0];
var formData = new FormData();
formData.append('image', file);
fetch('/pos/api/inventory/items/' + itemId + '/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
}).then(function(r){ return r.json(); })
.then(function(data) {
if (data.image_url) {
showToast('Imagen subida', 'success');
refreshMeliPublishPreview();
if (inventoryVS) inventoryVS.refresh();
} else {
showToast(data.error || 'Error subiendo imagen', 'error');
}
}).catch(function(){ showToast('Error de red', 'error'); });
};
var meliCategorySearchTimeout;
var meliCatItems = [];
var meliCatActiveIndex = -1;
@@ -863,6 +932,34 @@
document.getElementById('meliCategoryResults').innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
loadCategoryAttributes(id);
};
window.loadCategoryAttributes = function(categoryId) {
var grid = document.getElementById('meliAttrsGrid');
var section = document.getElementById('meliAttrsSection');
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
section.style.display = 'block';
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); })
.then(function(data) {
meliCategoryAttrs = data.attributes || [];
if (!meliCategoryAttrs.length) { grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay atributos obligatorios adicionales.</p>'; return; }
var html = '';
meliCategoryAttrs.forEach(function(attr) {
var attrId = esc(attr.id);
var attrName = esc(attr.name || attr.id);
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
if (attr.values && attr.values.length) {
inputHtml = '<select class="meli-title-input" id="meliAttr-' + attrId + '">' +
'<option value="">Selecciona ' + attrName + '</option>' +
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
'</select>';
}
html += '<div class="inv-field"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
});
grid.innerHTML = html;
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
};
window.handleMeliCatKeydown = function(e) {
@@ -896,6 +993,114 @@
}
});
function _collectMeliCustomData() {
var ids = Array.from(selectedItems);
var customData = { titles: {}, prices: {}, stocks: {}, attributes: {} };
ids.forEach(function(id) {
var titleEl = document.getElementById('meliTitle-' + id);
var priceEl = document.getElementById('meliPrice-' + id);
var stockEl = document.getElementById('meliStock-' + id);
if (titleEl) customData.titles[id] = titleEl.value;
if (priceEl) customData.prices[id] = parseFloat(priceEl.value);
if (stockEl) customData.stocks[id] = parseInt(stockEl.value);
var attrs = [];
meliCategoryAttrs.forEach(function(attr) {
var el = document.getElementById('meliAttr-' + attr.id);
if (el && el.value) {
attrs.push({ id: attr.id, value_name: el.value });
}
});
if (attrs.length) customData.attributes[id] = attrs;
});
return customData;
}
window.validateMeliPublish = function() {
var categoryId = document.getElementById('meliCategoryId').value.trim();
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
var listingType = document.getElementById('meliListingType').value;
var shippingMode = document.getElementById('meliShippingMode').value;
var ids = Array.from(selectedItems);
var resultEl = document.getElementById('meliPublishResult');
var btn = document.getElementById('meliValidateBtn');
btn.disabled = true;
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
fetch('/pos/api/marketplace-ext/listings/validate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
listing_type: listingType,
shipping_mode: shippingMode,
custom_data: _collectMeliCustomData()
})
}).then(function(r){ return r.json(); })
.then(function(data) {
btn.disabled = false;
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
var valid = (data.valid || []).length;
var invalid = (data.invalid || []);
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + valid + ' válido(s)</span> · <span style="color:var(--color-error);">❌ ' + invalid.length + ' inválido(s)</span></div>';
if (invalid.length) {
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
invalid.forEach(function(f) {
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
});
html += '</ul>';
}
resultEl.innerHTML = html;
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
};
function _renderPublishResult(data, resultEl) {
var success = (data.success || []).length;
var failedList = data.failed || [];
var failed = failedList.length;
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
if (failedList.length) {
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
failedList.forEach(function(f) {
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
});
html += '</ul>';
}
resultEl.innerHTML = html;
if (success > 0) {
selectedItems.clear();
updateSelectionUI();
if (inventoryVS) inventoryVS.refresh();
}
}
function _pollMeliAsync(taskId, resultEl, btn) {
var attempts = 0;
var maxAttempts = 60; // 2 min
var interval = setInterval(function() {
attempts++;
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.status === 'done') {
clearInterval(interval);
btn.disabled = false;
_renderPublishResult(data.result || {}, resultEl);
setTimeout(function() { closeMeliPublishModal(); }, 3000);
} else if (attempts >= maxAttempts) {
clearInterval(interval);
btn.disabled = false;
resultEl.innerHTML = '<span style="color:var(--color-error);">Timeout esperando resultado. Revisa la pestaña Publicaciones más tarde.</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando en segundo plano... (' + attempts + 's)</span>';
}
}).catch(function() {
clearInterval(interval);
btn.disabled = false;
resultEl.innerHTML = '<span style="color:var(--color-error);">Error consultando progreso</span>';
});
}, 2000);
}
window.executeMeliPublish = function() {
var categoryId = document.getElementById('meliCategoryId').value.trim();
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
@@ -905,37 +1110,30 @@
var resultEl = document.getElementById('meliPublishResult');
var btn = document.getElementById('meliPublishBtn');
btn.disabled = true;
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando ' + ids.length + ' producto(s)...</span>';
fetch('/pos/api/marketplace-ext/listings', {
var useAsync = ids.length > 3;
var endpoint = useAsync ? '/pos/api/marketplace-ext/listings/async' : '/pos/api/marketplace-ext/listings';
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
listing_type: listingType,
shipping_mode: shippingMode
shipping_mode: shippingMode,
custom_data: _collectMeliCustomData()
})
}).then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
var success = (data.success || []).length;
var failedList = data.failed || [];
var failed = failedList.length;
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
if (failedList.length) {
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
failedList.forEach(function(f) {
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
});
html += '</ul>';
}
resultEl.innerHTML = html;
if (success > 0) {
selectedItems.clear();
updateSelectionUI();
if (inventoryVS) inventoryVS.refresh();
setTimeout(function() { closeMeliPublishModal(); }, 2500);
if (data.error) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
if (useAsync && data.task_id) {
_pollMeliAsync(data.task_id, resultEl, btn);
} else {
btn.disabled = false;
_renderPublishResult(data, resultEl);
if ((data.success || []).length > 0) {
setTimeout(function() { closeMeliPublishModal(); }, 2500);
}
}
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
};