feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes

- Cleaned 137+ fake engine-displacement models from supplier imports
  (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.)
- Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs,
  empty names, trailing-year variants)
- Migrated supplier tables to master DB (supplier_catalog,
  supplier_catalog_compat, supplier_catalog_interchange)
- Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from
  master DB so supplier-only vehicles appear for all tenants
- Added fuzzy model matcher with parenthesis stripping, noise suffix removal,
  compact matching, prefix/substring fallback, model aliases, and ±3 year
  proximity
- Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500,
  LUK +477, RAYBESTOS +1,743
- Added KNADIAN catalog importer with year-range expansion and future-year
  filtering
- Added VAZLO catalog importer with position parsing and SKU-in-model cleanup
- Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers
- Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*,
  nexus:brand_mye_counts:*)

Final match rates:
- KEEP GREEN: 90.3%
- VAZLO: 93.6%
- YOKOMITSU: 100.0%
- KNADIAN: 57.4%
- LUK: 51.0%
- RAYBESTOS: 55.9%
This commit is contained in:
2026-06-09 07:47:42 +00:00
parent 5ea667b80e
commit ea29cc31c0
53 changed files with 7727 additions and 548 deletions

View File

@@ -68,6 +68,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>
Catalogo
</a>
<a class="nav-item" href="/pos/supplier-catalog" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M2 3h6l2 3h12v14H2V3z"/></svg>
Cat. Proveedores
</a>
<div class="nav-section-label" style="margin-top: var(--space-2);">Gestion</div>
<a class="nav-item" href="/pos/customers" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
@@ -291,7 +295,7 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=2" defer></script>
<script src="/pos/static/js/catalog.js?v=5" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css">
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<body>
@@ -368,7 +368,7 @@
Ventas por Hora
</div>
</div>
<canvas id="hourlySalesChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="hourlySalesChart"></canvas></div>
</div>
<div class="rank-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
@@ -376,7 +376,7 @@
Top Productos (Hoy)
</div>
</div>
<canvas id="topProductsChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="topProductsChart"></canvas></div>
</div>
</div>
</section>
@@ -494,8 +494,8 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js" defer></script>
<script src="/pos/static/js/dashboard.js?v=2" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=3" 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>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -183,6 +183,10 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV
</button>
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('inventario')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar CSV
@@ -708,8 +712,20 @@
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
</select>
</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>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></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 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>
@@ -973,6 +989,47 @@
</div>
</div>
<!-- ══════════ Bulk Import Modal ══════════ -->
<div class="inv-modal-overlay" id="bulkImportModal">
<div class="inv-modal" style="max-width:520px;">
<div class="inv-modal__header">
<h3>Importar Productos Masivamente</h3>
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Modo de importación</label>
<select id="bulkImportMode" class="select-filter" style="width:100%;">
<option value="strict">Estricto — abortar al primer error</option>
<option value="lenient" selected>Permisivo — saltar filas con error</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Compatibilidad de vehículo faltante</label>
<select id="bulkImportStrategy" class="select-filter" style="width:100%;">
<option value="qwen" selected>Auto-generar con IA (QWEN)</option>
<option value="skip">Omitir compatibilidad</option>
<option value="reject">Rechazar filas sin compatibilidad</option>
</select>
</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">Cancelar</button>
<button class="btn btn--primary" onclick="submitBulkImport()">Importar</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>

View File

@@ -184,6 +184,9 @@
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
Órdenes
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-questions" onclick="switchTab('questions')">
Preguntas
</button>
</div>
<!-- Tab Panels -->
@@ -262,6 +265,27 @@
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
</div>
<!-- ══════════ TAB: Preguntas ══════════ -->
<div class="tab-panel" id="panel-questions" role="tabpanel">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="questionSearch" placeholder="Buscar pregunta..." oninput="filterQuestions()" />
</div>
<select class="select-filter" id="questionStatusFilter" onchange="filterQuestions()">
<option value="">Todas</option>
<option value="unanswered">Sin responder</option>
<option value="answered">Respondidas</option>
<option value="closed">Cerradas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm" onclick="syncQuestions()">🔄 Sincronizar con ML</button>
<button class="btn btn--primary" onclick="loadQuestions()">🔄 Actualizar</button>
</div>
<div id="questionsStatsBar" style="margin-bottom:var(--space-4);"></div>
<div id="questionsContainer" class="meli-grid"></div>
</div>
<!-- ══════════ TAB: Órdenes ══════════ -->
<div class="tab-panel" id="panel-orders" role="tabpanel">
<div class="toolbar">

View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<style>
.supplier-catalog { padding: var(--space-5); max-width: 1400px; margin: 0 auto; }
.sc-header { display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); margin-bottom:var(--space-5); flex-wrap:wrap; }
.sc-search { display:flex; gap:var(--space-3); flex:1; min-width:280px; }
.sc-search input { flex:1; padding:var(--space-3) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); font-size:var(--text-body); }
.sc-filters { display:flex; gap:var(--space-3); flex-wrap:wrap; margin-bottom:var(--space-5); }
.sc-filters select { padding:var(--space-2) var(--space-3); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); min-width:140px; }
.sc-categories { display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:var(--space-3); margin-bottom:var(--space-5); }
.sc-cat-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; text-align:center; }
.sc-cat-card:hover, .sc-cat-card.active { border-color:var(--color-primary); box-shadow:var(--shadow-sm); }
.sc-cat-card .count { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:2px; }
.sc-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:var(--space-4); }
.sc-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:var(--space-2); }
.sc-card:hover { border-color:var(--color-primary); transform:translateY(-2px); box-shadow:var(--shadow-sm); }
.sc-card__sku { font-family:var(--font-mono); font-size:var(--text-caption); color:var(--color-primary); font-weight:var(--font-weight-bold); }
.sc-card__name { font-weight:var(--font-weight-semibold); color:var(--color-text-primary); line-height:1.3; }
.sc-card__meta { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:auto; }
.sc-card__badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-full); background:var(--color-primary-muted); color:var(--color-primary); font-size:10px; font-weight:var(--font-weight-bold); text-transform:uppercase; }
.sc-empty { text-align:center; padding:var(--space-8); color:var(--color-text-muted); }
.sc-pagination { display:flex; justify-content:center; align-items:center; gap:var(--space-3); margin-top:var(--space-6); }
.sc-pagination button { padding:var(--space-2) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); cursor:pointer; }
.sc-pagination button:disabled { opacity:.4; cursor:not-allowed; }
.sc-pagination span { font-size:var(--text-caption); color:var(--color-text-muted); }
/* Modal */
.sc-modal-overlay { position:fixed; inset:0; background:var(--overlay-backdrop); z-index:var(--z-modal); display:none; align-items:center; justify-content:center; padding:var(--space-4); }
.sc-modal-overlay.open { display:flex; }
.sc-modal { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-xl); width:100%; max-width:720px; max-height:90vh; overflow-y:auto; display:flex; flex-direction:column; }
.sc-modal__header { display:flex; align-items:center; justify-content:space-between; padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--color-border); }
.sc-modal__body { padding:var(--space-5); display:flex; flex-direction:column; gap:var(--space-4); }
.sc-modal__section h4 { font-size:var(--text-body-sm); font-weight:var(--font-weight-bold); color:var(--color-text-secondary); margin-bottom:var(--space-2); text-transform:uppercase; letter-spacing:var(--tracking-wider); }
.sc-compat-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:var(--space-2); }
.sc-compat-item { background:var(--color-surface-1); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-2) var(--space-3); font-size:var(--text-caption); }
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
.sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); }
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
</style>
</head>
<body>
<!-- Theme bar -->
<div class="theme-bar">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn" id="btn-industrial" onclick="setTheme('industrial')">Industrial</button>
<button class="theme-btn" id="btn-modern" onclick="setTheme('modern')">Moderno</button>
</div>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<div class="app-shell">
<nav class="sidebar themed-scrollbar" id="sidebar">
<div class="sidebar__logo">
<div class="sidebar__logo-text">Nexus</div>
<div class="sidebar__logo-sub">Autoparts POS</div>
</div>
<div class="sidebar__nav">
<div class="sidebar__section-label">Principal</div>
<a href="/pos/dashboard" class="nav-link"><span class="nav-link__icon">📊</span> Dashboard</a>
<a href="/pos/sale" class="nav-link"><span class="nav-link__icon">🛒</span> POS</a>
<a href="/pos/catalog" class="nav-link"><span class="nav-link__icon">📁</span> Catalogo</a>
<a href="/pos/supplier-catalog" class="nav-link active"><span class="nav-link__icon">🏭</span> Cat. Proveedores</a>
<a href="/pos/inventory" class="nav-link"><span class="nav-link__icon">📦</span> Inventario</a>
<a href="/pos/config" class="nav-link"><span class="nav-link__icon">⚙️</span> Configuracion</a>
</div>
</nav>
<div class="main">
<header class="header">
<div class="header__left">
<button class="hamburger-btn" onclick="toggleSidebar()"></button>
<div class="header__greeting">
<div class="header__title">Catalogo de Proveedores</div>
<div class="header__subtitle">Busca por vehiculo, SKU o nombre de parte</div>
</div>
</div>
</header>
<div class="supplier-catalog themed-scrollbar">
<div class="sc-header">
<div class="sc-search">
<input type="text" id="searchInput" placeholder="Buscar SKU, nombre o intercambio..." onkeydown="if(event.key==='Enter') doSearch()" />
<button class="btn btn--primary" onclick="doSearch()">Buscar</button>
<button class="btn btn--secondary" onclick="clearFilters()">Limpiar</button>
</div>
</div>
<div class="sc-filters">
<select id="filterMake" onchange="onMakeChange()"><option value="">Marca vehiculo</option></select>
<select id="filterModel" onchange="onModelChange()" disabled><option value="">Modelo</option></select>
<select id="filterYear" onchange="onYearChange()" disabled><option value="">Año</option></select>
<select id="filterEngine" onchange="doSearch()" disabled><option value="">Motorizacion</option></select>
</div>
<div class="sc-categories" id="categoriesGrid"></div>
<div id="resultsArea">
<div class="sc-grid" id="partsGrid"></div>
<div class="sc-pagination" id="pagination"></div>
</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="sc-modal-overlay" id="detailModal" onclick="closeModal(event)">
<div class="sc-modal" onclick="event.stopPropagation()">
<div class="sc-modal__header">
<h3 id="modalTitle">Detalle</h3>
<button class="sc-close" onclick="closeModal()">&times;</button>
</div>
<div class="sc-modal__body" id="modalBody"></div>
</div>
</div>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>