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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user