feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
@@ -57,10 +57,11 @@
|
||||
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Mi Inventario</h2>
|
||||
<div class="upload-box">
|
||||
<label>Cargar inventario via CSV</label>
|
||||
<textarea id="csvText" placeholder="part_number,stock,price AB-123,5,150.50 CD-456,12,89.00"></textarea>
|
||||
<textarea id="csvText" placeholder="part_number,stock,price,name AB-123,5,150.50,Filtro de aceite CD-456,12,89.00,Balata delantera"></textarea>
|
||||
<div class="hint">
|
||||
Columnas requeridas: <code>part_number, stock, price</code>.
|
||||
Opcionales: <code>min_order, warehouse_location, currency</code>.
|
||||
Opcionales: <code>name, min_order, warehouse_location, currency</code>.
|
||||
<br>Si la parte no existe en el catálogo, se crea automáticamente como <em>seller listing</em>.
|
||||
</div>
|
||||
<button style="margin-top:var(--space-3);padding:var(--space-3) var(--space-6);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-md);font-weight:bold;cursor:pointer;" onclick="uploadCSV()">Subir CSV</button>
|
||||
<div id="uploadResult" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
|
||||
@@ -205,8 +206,18 @@
|
||||
var priceStr = p.min_price === p.max_price
|
||||
? '$' + fmt(p.min_price)
|
||||
: '$' + fmt(p.min_price) + ' – $' + fmt(p.max_price);
|
||||
return '<div class="part-card" onclick="openPartDetail(' + p.id_part + ')">' +
|
||||
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
|
||||
var isOem = p.listing_type === 'oem';
|
||||
var badge = isOem
|
||||
? '<span style="background:#3FB95020;color:#3FB950;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Catálogo</span>'
|
||||
: '<span style="background:#F5A62320;color:#F5A623;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Listing</span>';
|
||||
var onclick = isOem
|
||||
? 'openPartDetail(' + p.id + ')'
|
||||
: 'openListingDetail(' + p.id + ')';
|
||||
return '<div class="part-card" onclick="' + onclick + '">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-1);">' +
|
||||
'<div class="part-card__oem">' + esc(p.part_number) + '</div>' +
|
||||
badge +
|
||||
'</div>' +
|
||||
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||||
'<div class="part-card__meta">' +
|
||||
'<span class="price-range">' + priceStr + '</span>' +
|
||||
@@ -248,7 +259,7 @@
|
||||
'</div>' +
|
||||
'<div style="text-align:right;">' +
|
||||
'<div class="price-range">$' + fmt(b.price) + '</div>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', null, ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
@@ -257,7 +268,38 @@
|
||||
});
|
||||
};
|
||||
|
||||
window.createOrderFor = function (partId, bodegaId, bodegaName, price) {
|
||||
window.openListingDetail = function (wiId) {
|
||||
var modal = document.getElementById('partModal');
|
||||
modal.classList.add('open');
|
||||
document.getElementById('partModalBody').innerHTML = 'Cargando bodegas...';
|
||||
|
||||
apiFetch('/inventory/listing/' + wiId).then(function (resp) {
|
||||
if (!resp || !resp.data) return;
|
||||
var bodegas = resp.data;
|
||||
if (bodegas.length === 0) {
|
||||
document.getElementById('partModalBody').innerHTML = '<div class="empty-state">Ninguna bodega tiene esta parte en stock.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '<p style="color:var(--color-text-muted);margin-bottom:var(--space-3);">Elige una bodega para ordenar:</p>';
|
||||
html += '<div style="display:flex;flex-direction:column;gap:var(--space-2);">';
|
||||
bodegas.forEach(function (b) {
|
||||
html += '<div style="padding:var(--space-3);border:1px solid var(--glass-border);border-radius:var(--radius-md);display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<div>' +
|
||||
'<strong>' + esc(b.name) + '</strong>' +
|
||||
'<div style="color:var(--color-text-muted);font-size:var(--text-caption);">' + esc(b.city || '') + ' · ' + esc(b.stock_hint) + '</div>' +
|
||||
'</div>' +
|
||||
'<div style="text-align:right;">' +
|
||||
'<div class="price-range">$' + fmt(b.price) + '</div>' +
|
||||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(null, ' + wiId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
document.getElementById('partModalBody').innerHTML = html;
|
||||
});
|
||||
};
|
||||
|
||||
window.createOrderFor = function (partId, wiId, bodegaId, bodegaName, price) {
|
||||
var qty = prompt('Cantidad a ordenar para "' + bodegaName + '":', '1');
|
||||
if (!qty) return;
|
||||
qty = parseInt(qty);
|
||||
@@ -265,12 +307,22 @@
|
||||
|
||||
var notes = prompt('Notas para la bodega (opcional):', '') || '';
|
||||
|
||||
var item = { quantity: qty, unit_price: price };
|
||||
if (partId) {
|
||||
item.part_id = partId;
|
||||
} else if (wiId) {
|
||||
item.wi_id = wiId;
|
||||
} else {
|
||||
alert('Error: se requiere part_id o wi_id');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create PO draft
|
||||
apiFetch('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bodega_id: bodegaId,
|
||||
items: [{ part_id: partId, quantity: qty, unit_price: price }],
|
||||
items: [item],
|
||||
delivery_method: 'pickup',
|
||||
buyer_notes: notes,
|
||||
}),
|
||||
@@ -482,6 +534,10 @@
|
||||
r.updated + ' actualizados';
|
||||
if (r.skipped > 0) msg += ', ' + r.skipped + ' omitidos';
|
||||
msg += '</span>';
|
||||
if (r.oem_count !== undefined || r.seller_count !== undefined) {
|
||||
msg += '<div style="margin-top:var(--space-2);font-size:var(--text-caption);color:var(--color-text-muted);">' +
|
||||
(r.oem_count || 0) + ' catálogo OEM · ' + (r.seller_count || 0) + ' seller listings</div>';
|
||||
}
|
||||
if (r.errors && r.errors.length) {
|
||||
msg += '<div style="margin-top:var(--space-2);color:var(--color-text-muted);font-size:var(--text-caption);">';
|
||||
r.errors.slice(0, 5).forEach(function (e) {
|
||||
|
||||
Reference in New Issue
Block a user