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:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -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&#10;AB-123,5,150.50&#10;CD-456,12,89.00"></textarea>
<textarea id="csvText" placeholder="part_number,stock,price,name&#10;AB-123,5,150.50,Filtro de aceite&#10;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) {