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

@@ -13,7 +13,7 @@
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="stylesheet" href="/pos/static/css/inventory.css">
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=4">
</head>
<body>
@@ -306,10 +306,18 @@
]);
}
</script>
<button class="btn btn--ghost btn--sm" onclick="showTierDiscountModal()">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
<button class="btn btn--sm btn--meli" id="btnPublishML" style="display:none;" onclick="openMeliPublishModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Publicar en ML <span id="meliSelectedCountBadge" style="background:#2D3277;color:#FFE600;border-radius:10px;padding:0 6px;font-size:11px;margin-left:4px;">0</span>
</button>
</div>
<div class="table-wrapper">
@@ -317,6 +325,7 @@
<table class="data-table" id="stockTable">
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="selectAllItems" onclick="toggleSelectAllItems()" title="Seleccionar todos" /></th>
<th style="font-size:var(--text-caption);color:var(--color-text-muted);">ID</th>
<th>Barcode</th>
<th>No. Parte</th>
@@ -324,9 +333,9 @@
<th>Marca</th>
<th style="text-align:right">Stock</th>
<th style="text-align:right">Costo</th>
<th style="text-align:right">Precio 1</th>
<th style="text-align:right">Precio 2</th>
<th style="text-align:right">Precio 3</th>
<th style="text-align:right">Mostrador</th>
<th style="text-align:right">Taller</th>
<th style="text-align:right">Mayoreo</th>
<th>Ubicación</th>
<th>Acciones</th>
</tr>
@@ -689,9 +698,7 @@
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
@@ -809,6 +816,73 @@
</div>
</div>
<!-- Tier Discounts Modal -->
<div class="inv-modal-overlay" id="tierDiscountModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Descuentos por Tipo de Cliente</h3>
<button class="inv-modal__close" onclick="closeTierDiscountModal()">&times;</button>
</div>
<div class="inv-modal__body">
<p style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-4);">Estos descuentos se aplican automáticamente a todos los productos al calcular precios de Taller y Mayoreo.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="inv-field"><label>Descuento Taller (%)</label><input type="number" id="tierDisc2" step="0.1" min="0" max="100" placeholder="15" /></div>
<div class="inv-field"><label>Descuento Mayoreo (%)</label><input type="number" id="tierDisc3" step="0.1" min="0" max="100" placeholder="25" /></div>
</div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeTierDiscountModal()">Cancelar</button>
<button class="btn btn--primary" onclick="saveTierDiscounts()">Guardar</button>
</div>
</div>
</div>
<!-- ══════════ Publicar en MercadoLibre Modal ══════════ -->
<div class="inv-modal-overlay" id="meliPublishModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Publicar en MercadoLibre</h3>
<button class="inv-modal__close" onclick="closeMeliPublishModal()">&times;</button>
</div>
<div class="inv-modal__body">
<div id="meliPublishSelectedCount" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-3);">0 productos seleccionados</div>
<div id="meliPublishItemsPreview" style="max-height:200px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-4);">
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Selecciona productos del inventario para ver el preview.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:var(--space-4);">
<div class="inv-field">
<label>Categoría ML *</label>
<div style="position:relative;">
<input type="text" id="meliCategorySearch" placeholder="Buscar categoría..." oninput="searchMeliCategories()" onkeydown="handleMeliCatKeydown(event)" autocomplete="off" />
<div id="meliCategoryResults"></div>
</div>
<input type="hidden" id="meliCategoryId" />
</div>
<div class="inv-field">
<label>Tipo de Publicación</label>
<select id="meliListingType">
<option value="gold_special">Gold Special</option>
<option value="gold_pro">Gold Pro</option>
<option value="bronze">Bronce (gratis)</option>
</select>
</div>
<div class="inv-field">
<label>Modo de Envío</label>
<select id="meliShippingMode">
<option value="me2" selected>MercadoEnvíos (me2)</option>
</select>
<small style="color:var(--color-text-muted);font-size:var(--text-caption);">Tu cuenta requiere ME2 obligatoriamente.</small>
</div>
</div>
<div id="meliPublishResult" style="min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeMeliPublishModal()">Cancelar</button>
<button class="btn btn--meli" id="meliPublishBtn" onclick="executeMeliPublish()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Publicar</button>
</div>
</div>
</div>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span>
@@ -821,7 +895,7 @@
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/inventory.js?v=5" defer></script>
<script src="/pos/static/js/inventory.js?v=10" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>