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

@@ -47,6 +47,79 @@
return d.innerHTML;
}
// --- Dashboard summary badges ---
function loadSummary() {
apiFetch(API + '/summary').then(function(data) {
if (!data) return;
var totalSkusEl = document.getElementById('inv-total-skus');
var totalValueEl = document.getElementById('inv-total-value');
var lowStockEl = document.getElementById('inv-low-stock');
var noMovementEl = document.getElementById('inv-no-movement');
if (totalSkusEl) totalSkusEl.textContent = (data.total_skus || 0).toLocaleString('es-MX');
if (totalValueEl) totalValueEl.textContent = '$' + (data.total_value || 0).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (lowStockEl) lowStockEl.textContent = (data.low_stock || 0).toLocaleString('es-MX');
if (noMovementEl) noMovementEl.textContent = (data.no_movement || 0).toLocaleString('es-MX');
}).catch(function(err) {
console.error('Inventory summary load failed:', err);
});
}
loadSummary();
// --- Global tier discounts ---
var globalDiscounts = { 2: 15, 3: 25 };
function loadTierDiscounts() {
apiFetch(API + '/tier-discounts').then(function(data) {
if (data && data.data) {
data.data.forEach(function(d) {
globalDiscounts[d.tier_id] = d.discount_pct;
});
}
var discEl = document.getElementById('tierDiscountBadge');
if (discEl) {
discEl.textContent = 'Taller -' + globalDiscounts[2] + '% · Mayoreo -' + globalDiscounts[3] + '%';
}
});
}
loadTierDiscounts();
function showTierDiscountModal() {
document.getElementById('tierDisc2').value = globalDiscounts[2];
document.getElementById('tierDisc3').value = globalDiscounts[3];
document.getElementById('tierDiscountModal').classList.add('is-open');
}
function closeTierDiscountModal() {
document.getElementById('tierDiscountModal').classList.remove('is-open');
}
function saveTierDiscounts() {
var d2 = parseFloat(document.getElementById('tierDisc2').value) || 0;
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
fetch(API + '/tier-discounts', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
}).then(function(r) { return r.json(); })
.then(function(res) {
showToast(res.message || 'Guardado', 'ok');
globalDiscounts[2] = d2;
globalDiscounts[3] = d3;
var discEl = document.getElementById('tierDiscountBadge');
if (discEl) {
discEl.textContent = 'Taller -' + d2 + '% · Mayoreo -' + d3 + '%';
}
closeTierDiscountModal();
}).catch(function() {
showToast('Error al guardar descuentos', 'error');
});
}
// Handle hash-based tab switching (e.g. /pos/inventory#alertas)
(function handleHashTab() {
var hash = window.location.hash.replace('#', '');
if (hash && ['stock', 'entradas', 'salidas', 'traspasos', 'ajustes', 'conteos', 'alertas'].indexOf(hash) !== -1) {
setTimeout(function() { switchTab(hash); }, 100);
}
})();
// =====================================================================
// TAB SWITCHING — uses design-system switchTab() already in the HTML.
// We hook into it to trigger data loads when tabs are activated.
@@ -63,8 +136,12 @@
// STOCK / PRODUCTS (panel-stock)
// =====================================================================
var selectedItems = new Set();
function renderInventoryRow(it) {
var isChecked = selectedItems.has(it.id) ? 'checked' : '';
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
'<td onclick="event.stopPropagation();"><input type="checkbox" class="item-checkbox" data-id="' + it.id + '" ' + isChecked + ' onclick="event.stopPropagation();toggleItemSelection(' + it.id + ')"></td>' +
'<td class="td--mono" style="font-size:var(--text-caption);color:var(--color-text-muted);">' + it.id + '</td>' +
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
@@ -79,10 +156,50 @@
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button>' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
'</td></tr>';
}
window.toggleItemSelection = function(id) {
if (selectedItems.has(id)) {
selectedItems.delete(id);
} else {
selectedItems.add(id);
}
updateSelectionUI();
};
window.toggleSelectAllItems = function() {
var cb = document.getElementById('selectAllItems');
var allChecked = cb.checked;
// We need to get all visible items from inventoryVS
if (inventoryVS && inventoryVS.data) {
inventoryVS.data.forEach(function(it) {
if (allChecked) selectedItems.add(it.id);
else selectedItems.delete(it.id);
});
inventoryVS.refresh();
}
updateSelectionUI();
};
function updateSelectionUI() {
var count = selectedItems.size;
var btn = document.getElementById('btnPublishML');
var badge = document.getElementById('meliSelectedCountBadge');
if (btn) btn.style.display = count > 0 ? 'inline-flex' : 'none';
if (badge) badge.textContent = count;
// Update select-all checkbox state
var selectAll = document.getElementById('selectAllItems');
if (selectAll && inventoryVS && inventoryVS.data) {
var visibleIds = inventoryVS.data.map(function(it) { return it.id; });
var allSelected = visibleIds.length > 0 && visibleIds.every(function(id) { return selectedItems.has(id); });
selectAll.checked = allSelected;
}
}
function loadItems(page, search) {
currentPage = page || 1;
currentSearch = search !== undefined ? search : currentSearch;
@@ -220,7 +337,7 @@
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newPrice2','newPrice3','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
@@ -634,6 +751,195 @@
document.getElementById('historyModal').classList.remove('is-open');
}
// =====================================================================
// DELETE ITEM
// =====================================================================
function deleteItem(itemId) {
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
var token = localStorage.getItem('pos_token') || '';
fetch(API + '/items/' + itemId, {
method: 'DELETE',
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { alert('Error: ' + data.error); return; }
showToast('Artículo eliminado');
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function() { alert('Error al eliminar artículo'); });
}
// =====================================================================
// MERCADOLIBRE PUBLISH
// =====================================================================
function publishToMeli(itemId) {
selectedItems.clear();
selectedItems.add(itemId);
updateSelectionUI();
openMeliPublishModal();
}
window.publishToMeli = publishToMeli;
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
window.openMeliPublishModal = function() {
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
document.getElementById('meliPublishModal').classList.add('is-open');
document.getElementById('meliPublishResult').innerHTML = '';
document.getElementById('meliCategoryId').value = '';
document.getElementById('meliCategorySearch').value = '';
document.getElementById('meliCategoryResults').innerHTML = '';
refreshMeliPublishPreview();
};
window.closeMeliPublishModal = function() {
document.getElementById('meliPublishModal').classList.remove('is-open');
};
function refreshMeliPublishPreview() {
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;
}
var meliCategorySearchTimeout;
var meliCatItems = [];
var meliCatActiveIndex = -1;
window.searchMeliCategories = function() {
var q = document.getElementById('meliCategorySearch').value.trim();
var resultsDiv = document.getElementById('meliCategoryResults');
if (q.length < 2) { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; return; }
clearTimeout(meliCategorySearchTimeout);
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
meliCategorySearchTimeout = setTimeout(function() {
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.categories || [];
meliCatItems = cats.slice(0, 10);
meliCatActiveIndex = -1;
if (!meliCatItems.length) { resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-empty">Sin resultados</div></div>'; return; }
var html = '<div class="meli-cat-dropdown">';
meliCatItems.forEach(function(c, idx) {
html += '<div class="meli-cat-item" data-idx="' + idx + '" onmouseenter="highlightMeliCat(' + idx + ')" onmousedown="selectMeliCategoryIdx(' + idx + ')">' +
'<span>' + esc(c.category_name || c.category_id) + '</span>' +
'<span class="cat-id">' + esc(c.category_id) + '</span>' +
'</div>';
});
html += '</div>';
resultsDiv.innerHTML = html;
})
.catch(function() { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; });
}, 300);
};
window.highlightMeliCat = function(idx) {
meliCatActiveIndex = idx;
var items = document.querySelectorAll('.meli-cat-item');
items.forEach(function(el, i) { el.classList.toggle('is-active', i === idx); });
};
window.selectMeliCategoryIdx = function(idx) {
var c = meliCatItems[idx];
if (!c) return;
selectMeliCategory(c.category_id, c.category_name || c.category_id);
};
window.selectMeliCategory = function(id, name) {
document.getElementById('meliCategoryId').value = id;
document.getElementById('meliCategorySearch').value = name;
document.getElementById('meliCategoryResults').innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
};
window.handleMeliCatKeydown = function(e) {
if (!meliCatItems.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
meliCatActiveIndex = Math.min(meliCatActiveIndex + 1, meliCatItems.length - 1);
highlightMeliCat(meliCatActiveIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
meliCatActiveIndex = Math.max(meliCatActiveIndex - 1, -1);
highlightMeliCat(meliCatActiveIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (meliCatActiveIndex >= 0) selectMeliCategoryIdx(meliCatActiveIndex);
} else if (e.key === 'Escape') {
document.getElementById('meliCategoryResults').innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
}
};
/* Cerrar dropdown al hacer click fuera */
document.addEventListener('click', function(e) {
var field = document.getElementById('meliCategorySearch');
var results = document.getElementById('meliCategoryResults');
if (field && results && !field.contains(e.target) && !results.contains(e.target)) {
results.innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
}
});
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; }
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('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', {
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
})
}).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);
}
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
};
// =====================================================================
// BARCODE LABEL PRINT
// =====================================================================
@@ -747,9 +1053,9 @@
// Prices
html += '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Costo</span><span class="td--amount">$' + fmt(data.cost) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 1</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 2</span><span class="td--amount">$' + fmt(data.price_2) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 3</span><span class="td--amount">$' + fmt(data.price_3) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mostrador</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Taller</span><span class="td--amount">$' + fmt(data.price_2) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[2] + '%)</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mayoreo</span><span class="td--amount">$' + fmt(data.price_3) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[3] + '%)</span></div>';
html += '</div>';
// Cross-references section
@@ -950,6 +1256,7 @@
window.cancelDraft = cancelDraft;
window.loadAlerts = loadAlerts;
window.printBarcode = printBarcode;
window.deleteItem = deleteItem;
window.autoMatchCompat = autoMatchCompat;
window.removeCompat = removeCompat;