diff --git a/dashboard/admin.html b/dashboard/admin.html index 1054eb3..ea991bc 100644 --- a/dashboard/admin.html +++ b/dashboard/admin.html @@ -5,76 +5,20 @@ Admin Panel - Autopartes DB + - -
- - -
+ +
+
@@ -768,6 +649,14 @@
+ + + + +
+ + +
+

+ Busca un diagrama por código y haz clic en la imagen para agregar hotspots vinculados a partes. +

+
+ + +
+
+ + +
+ + + +
diff --git a/dashboard/admin.js b/dashboard/admin.js index fcde228..0902c9f 100644 --- a/dashboard/admin.js +++ b/dashboard/admin.js @@ -115,6 +115,9 @@ function showSection(sectionId) { case 'fitment': loadFitment(); break; + case 'diagrams': + // Just show section, user uses search + break; } } @@ -1175,11 +1178,12 @@ async function loadVehiclesForSelect(selectId) { if (!select) return; try { - const response = await fetch('/api/model-year-engine'); - const vehicles = await response.json(); + const response = await fetch('/api/model-year-engine?per_page=100'); + const result = await response.json(); + const vehicles = result.data || result; select.innerHTML = '' + - vehicles.slice(0, 100).map(v => + vehicles.map(v => `` ).join(''); } catch (e) { @@ -1558,8 +1562,9 @@ async function loadBulkEngines() { const engines = await response.json(); // Get MYE IDs for each engine - const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}`); - const myeData = await myeResponse.json(); + const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}&per_page=100`); + const myeResult = await myeResponse.json(); + const myeData = myeResult.data || myeResult; engineSelect.innerHTML = '' + myeData.map(mye => ``).join(''); @@ -1707,3 +1712,258 @@ showSection = function(sectionId) { initBulkEditor(); } }; + +// ============================================================================ +// Diagram Hotspot Editor +// ============================================================================ + +let currentEditorDiagramId = null; +let currentEditorHotspots = []; +let partSearchTimeout = null; + +async function searchDiagramsAdmin() { + const q = document.getElementById('diagramSearchInput').value.trim(); + const container = document.getElementById('diagramSearchResults'); + + if (!q) { + container.innerHTML = '

Ingresa un código de diagrama para buscar

'; + return; + } + + container.innerHTML = '

Buscando...

'; + + try { + const res = await fetch(`/api/diagrams/search?q=${encodeURIComponent(q)}`); + const diagrams = await res.json(); + + if (diagrams.length === 0) { + container.innerHTML = '

No se encontraron diagramas

'; + return; + } + + container.innerHTML = diagrams.map(d => { + const imgSrc = d.image_path ? '/' + d.image_path : `/static/diagrams/moog/${d.name}.jpg`; + return ` +
+ ${d.name} +
+
${d.name}
+
${d.name_es || d.source || ''}
+
+
`; + }).join(''); + } catch (e) { + container.innerHTML = '

Error al buscar diagramas

'; + } +} + +async function openHotspotEditor(diagramId) { + currentEditorDiagramId = diagramId; + document.getElementById('hotspotEditorArea').style.display = 'block'; + + try { + const res = await fetch(`/api/diagrams/${diagramId}`); + const diagram = await res.json(); + + document.getElementById('hotspotEditorTitle').textContent = `${diagram.name} - ${diagram.name_es || diagram.group_name || ''}`; + + const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : ''); + document.getElementById('hotspotEditorImg').src = imgSrc; + + currentEditorHotspots = diagram.hotspots || []; + renderEditorHotspots(); + clearHotspotForm(); + + // Auto-set next callout number + const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0); + document.getElementById('hsCallout').value = maxCallout + 1; + + // Scroll to editor + document.getElementById('hotspotEditorArea').scrollIntoView({ behavior: 'smooth' }); + } catch (e) { + showAlert('Error al cargar diagrama', 'error'); + } +} + +function closeHotspotEditor() { + document.getElementById('hotspotEditorArea').style.display = 'none'; + currentEditorDiagramId = null; + currentEditorHotspots = []; +} + +function onHotspotImageClick(event) { + const img = event.target; + const rect = img.getBoundingClientRect(); + const xPct = ((event.clientX - rect.left) / rect.width * 100).toFixed(2); + const yPct = ((event.clientY - rect.top) / rect.height * 100).toFixed(2); + + document.getElementById('hsCoords').value = `${xPct},${yPct}`; + + // Show temporary marker + renderEditorHotspots(); + const container = document.getElementById('hotspotMarkersContainer'); + const tempMarker = document.createElement('div'); + tempMarker.style.cssText = `position:absolute;left:${xPct}%;top:${yPct}%;width:24px;height:24px;border-radius:50%;background:rgba(46,204,113,0.5);border:2px solid #2ecc71;transform:translate(-50%,-50%);pointer-events:none;z-index:10`; + container.appendChild(tempMarker); +} + +function renderEditorHotspots() { + const container = document.getElementById('hotspotMarkersContainer'); + const list = document.getElementById('hotspotsList'); + + // Markers on image + container.innerHTML = currentEditorHotspots.map(h => { + const coords = (h.coords || '').split(','); + if (coords.length < 2) return ''; + return `
${h.callout_number || ''}
`; + }).join(''); + + // List + if (currentEditorHotspots.length === 0) { + list.innerHTML = '

No hay hotspots

'; + return; + } + + list.innerHTML = currentEditorHotspots.map(h => ` +
+ ${h.callout_number || '?'} +
+
${h.part_name || h.label || 'Sin parte'}
+
${h.part_number || ''} | ${h.coords}
+
+ + +
+ `).join(''); +} + +function editHotspot(hotspotId) { + const hs = currentEditorHotspots.find(h => h.id === hotspotId); + if (!hs) return; + + document.getElementById('hsEditId').value = hs.id; + document.getElementById('hsCoords').value = hs.coords || ''; + document.getElementById('hsCallout').value = hs.callout_number || ''; + document.getElementById('hsLabel').value = hs.label || ''; + document.getElementById('hsPartId').value = hs.part_id || ''; + document.getElementById('hsPartSearch').value = hs.part_name ? `${hs.part_number} - ${hs.part_name}` : ''; +} + +function clearHotspotForm() { + document.getElementById('hsEditId').value = ''; + document.getElementById('hsCoords').value = ''; + document.getElementById('hsLabel').value = ''; + document.getElementById('hsPartId').value = ''; + document.getElementById('hsPartSearch').value = ''; + document.getElementById('hsPartSelect').style.display = 'none'; + + // Keep callout at next number + const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0); + document.getElementById('hsCallout').value = maxCallout + 1; +} + +async function searchPartsForHotspot(query) { + clearTimeout(partSearchTimeout); + const select = document.getElementById('hsPartSelect'); + + if (!query || query.length < 2) { + select.style.display = 'none'; + return; + } + + partSearchTimeout = setTimeout(async () => { + try { + const res = await fetch(`/api/parts?search=${encodeURIComponent(query)}&per_page=20`); + const data = await res.json(); + const parts = data.data || data; + + if (parts.length === 0) { + select.innerHTML = ''; + } else { + select.innerHTML = parts.map(p => + `` + ).join(''); + } + select.style.display = 'block'; + + select.onchange = function() { + const opt = select.options[select.selectedIndex]; + document.getElementById('hsPartId').value = opt.value; + document.getElementById('hsPartSearch').value = opt.textContent; + select.style.display = 'none'; + }; + } catch (e) { + select.innerHTML = ''; + select.style.display = 'block'; + } + }, 300); +} + +async function saveHotspot() { + const editId = document.getElementById('hsEditId').value; + const coords = document.getElementById('hsCoords').value.trim(); + const callout = parseInt(document.getElementById('hsCallout').value) || null; + const partId = parseInt(document.getElementById('hsPartId').value) || null; + const label = document.getElementById('hsLabel').value.trim(); + + if (!coords) { + showAlert('Haz clic en la imagen para seleccionar posición', 'error'); + return; + } + + const body = { + diagram_id: currentEditorDiagramId, + coords: coords, + callout_number: callout, + part_id: partId, + label: label, + shape: 'circle', + color: '#e74c3c' + }; + + try { + let res; + if (editId) { + res = await fetch(`/api/admin/hotspots/${editId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } else { + res = await fetch('/api/admin/hotspots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } + + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Error al guardar'); + + showAlert(editId ? 'Hotspot actualizado' : 'Hotspot creado'); + + // Reload diagram to refresh hotspots + await openHotspotEditor(currentEditorDiagramId); + } catch (e) { + showAlert(e.message, 'error'); + } +} + +async function deleteHotspot(hotspotId) { + if (!confirm('Eliminar este hotspot?')) return; + + try { + const res = await fetch(`/api/admin/hotspots/${hotspotId}`, { method: 'DELETE' }); + const result = await res.json(); + if (!res.ok) throw new Error(result.error || 'Error al eliminar'); + + showAlert('Hotspot eliminado'); + await openHotspotEditor(currentEditorDiagramId); + } catch (e) { + showAlert(e.message, 'error'); + } +} diff --git a/dashboard/customer-landing.html b/dashboard/customer-landing.html index 8e8da05..6f6c4db 100644 --- a/dashboard/customer-landing.html +++ b/dashboard/customer-landing.html @@ -5,125 +5,9 @@ AutoParts DB - Tienda de Autopartes + - -
- - -
- - - Dashboard - -
-
+ +
+ +
diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js index ce81f7b..53be5d8 100644 --- a/dashboard/dashboard.js +++ b/dashboard/dashboard.js @@ -50,14 +50,15 @@ class VehicleDashboard { if (brandsRes.ok && vehiclesRes.ok) { const brands = await brandsRes.json(); - const vehicles = await vehiclesRes.json(); + const vehiclesData = await vehiclesRes.json(); + const vehicles = vehiclesData.data || vehiclesData; // Contar modelos únicos const uniqueModels = new Set(vehicles.map(v => `${v.brand}-${v.model}`)); this.stats.brands = brands.length; this.stats.models = uniqueModels.size; - this.stats.vehicles = vehicles.length; + this.stats.vehicles = vehiclesData.pagination ? vehiclesData.pagination.total : vehicles.length; const brandsEl = document.getElementById('totalBrands'); const modelsEl = document.getElementById('totalModels'); @@ -300,29 +301,18 @@ class VehicleDashboard { `; try { - const [brandsRes, vehiclesRes] = await Promise.all([ - fetch('/api/brands'), - fetch('/api/vehicles') - ]); + const brandsRes = await fetch('/api/brands?detailed=true'); - if (!brandsRes.ok || !vehiclesRes.ok) { + if (!brandsRes.ok) { throw new Error('Error al cargar datos'); } const brands = await brandsRes.json(); - const vehicles = await vehiclesRes.json(); - // Contar modelos y vehículos por marca + // Build brandStats from detailed response const brandStats = {}; - brands.forEach(brand => { - brandStats[brand] = { models: new Set(), vehicles: 0 }; - }); - - vehicles.forEach(v => { - if (brandStats[v.brand]) { - brandStats[v.brand].models.add(v.model); - brandStats[v.brand].vehicles++; - } + brands.forEach(b => { + brandStats[b.name] = { models: { size: b.model_count }, vehicles: b.vehicle_count }; }); if (brands.length === 0) { @@ -337,17 +327,17 @@ class VehicleDashboard { } container.innerHTML = `
- ${brands.map(brand => ` -
+ ${brands.map(b => ` +
-
${brand}
+
${b.name}
- ${brandStats[brand].models.size} modelos + ${b.model_count} modelos
- ${brandStats[brand].vehicles} vehículos + ${b.vehicle_count} vehículos
`).join('')} @@ -386,31 +376,13 @@ class VehicleDashboard { `; try { - const [modelsRes, vehiclesRes] = await Promise.all([ - fetch(`/api/models?brand=${encodeURIComponent(brand)}`), - fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}`) - ]); + const modelsRes = await fetch(`/api/models?brand=${encodeURIComponent(brand)}&detailed=true`); - if (!modelsRes.ok || !vehiclesRes.ok) { + if (!modelsRes.ok) { throw new Error('Error al cargar datos'); } const models = await modelsRes.json(); - const vehicles = await vehiclesRes.json(); - - // Contar vehículos y años por modelo - const modelStats = {}; - models.forEach(model => { - modelStats[model] = { years: new Set(), vehicles: 0, engines: new Set() }; - }); - - vehicles.forEach(v => { - if (modelStats[v.model]) { - modelStats[v.model].years.add(v.year); - modelStats[v.model].vehicles++; - modelStats[v.model].engines.add(v.engine); - } - }); if (models.length === 0) { container.innerHTML = ` @@ -427,26 +399,22 @@ class VehicleDashboard { } container.innerHTML = `
- ${models.map(model => { - const stats = modelStats[model]; - const yearsArray = Array.from(stats.years).sort((a, b) => b - a); - const yearRange = yearsArray.length > 0 - ? (yearsArray.length > 1 - ? `${yearsArray[yearsArray.length - 1]} - ${yearsArray[0]}` - : `${yearsArray[0]}`) - : 'N/A'; + ${models.map(m => { + const yearRange = m.year_count > 1 + ? `${m.year_min} - ${m.year_max}` + : `${m.year_min}`; return ` -
-
${model}
+
+
${m.name}
${yearRange}
- ${stats.engines.size} motores + ${m.engine_count} motores
- ${stats.vehicles} variantes + ${m.vehicle_count} variantes
`; @@ -491,16 +459,18 @@ class VehicleDashboard { try { // Fetch both vehicles info and model_year_engine IDs const [vehiclesRes, myeRes] = await Promise.all([ - fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`), - fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`) + fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`), + fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`) ]); if (!vehiclesRes.ok || !myeRes.ok) { throw new Error('Error al cargar vehículos'); } - const vehicles = await vehiclesRes.json(); - const myeRecords = await myeRes.json(); + const vehiclesData = await vehiclesRes.json(); + const myeData = await myeRes.json(); + const vehicles = vehiclesData.data || vehiclesData; + const myeRecords = myeData.data || myeData; // Merge mye_id into vehicles based on matching fields // Only keep vehicles that have a matching mye_id (i.e., have parts) @@ -911,7 +881,24 @@ class VehicleDashboard { } const groups = await response.json(); - this.displayGroups(groups, categoryId); + + // Fetch diagrams for Suspension (11) or Steering (10) when vehicle is selected + let vehicleDiagrams = []; + if (this.selectedVehicleId && (categoryId === 10 || categoryId === 11)) { + try { + const diagRes = await fetch(`/api/vehicles/${this.selectedVehicleId}/diagrams/by-category?category_id=${categoryId}`); + if (diagRes.ok) { + const catGroups = await diagRes.json(); + for (const cg of catGroups) { + vehicleDiagrams.push(...cg.diagrams); + } + } + } catch (e) { + console.error('Error loading diagrams for strip:', e); + } + } + + this.displayGroups(groups, categoryId, vehicleDiagrams); } catch (error) { console.error('Error:', error); @@ -928,10 +915,10 @@ class VehicleDashboard { } } - displayGroups(groups, categoryId) { + displayGroups(groups, categoryId, vehicleDiagrams = []) { const container = document.getElementById('mainContent'); - if (groups.length === 0) { + if (groups.length === 0 && vehicleDiagrams.length === 0) { container.innerHTML = `
@@ -944,8 +931,42 @@ class VehicleDashboard { return; } + // Build diagram strip HTML if diagrams are available + let diagramStripHtml = ''; + if (vehicleDiagrams.length > 0) { + // Store diagram list for the viewer + this._currentDiagramList = vehicleDiagrams; + + diagramStripHtml = ` +
+
+
Diagramas MOOG para tu vehículo
+ ${vehicleDiagrams.length} diagrama${vehicleDiagrams.length !== 1 ? 's' : ''} +
+
+ ${vehicleDiagrams.map((d, idx) => { + const type = (d.name || '')[0]; + const typeLabel = type === 'F' ? 'Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Trasera' : ''; + const imgSrc = d.image_url || '/static/diagrams/moog/' + d.name + '.jpg'; + return ` +
+ ${d.name} +
+
${d.name}
+
${typeLabel}
+
+
`; + }).join('')} +
+
`; + } + container.innerHTML = `

${this.selectedCategory.name_es || this.selectedCategory.name}

+ ${diagramStripHtml}
${groups.map(group => `
@@ -1602,6 +1623,305 @@ class VehicleDashboard { wrapper.style.transform = `scale(${this.currentDiagramZoom})`; } + // ================================================================ + // FASE 6: Full-screen Diagram Viewer (split layout) + // ================================================================ + + openDiagramViewer(diagramId, indexInList) { + this._dvCurrentIndex = typeof indexInList === 'number' ? indexInList : -1; + this._dvDiagramList = this._currentDiagramList || []; + this._dvZoom = 1; + this._dvDragging = false; + + const overlay = document.getElementById('diagramViewerOverlay'); + overlay.classList.add('active'); + document.body.style.overflow = 'hidden'; + + this._loadDiagramInViewer(diagramId); + this._bindDiagramViewerEvents(); + } + + closeDiagramViewer() { + const overlay = document.getElementById('diagramViewerOverlay'); + overlay.classList.remove('active'); + document.body.style.overflow = ''; + this._unbindDiagramViewerEvents(); + } + + async _loadDiagramInViewer(diagramId) { + const titleEl = document.getElementById('dvTitle'); + const subtitleEl = document.getElementById('dvSubtitle'); + const imgWrapper = document.getElementById('dvImgWrapper'); + const img = document.getElementById('dvImg'); + const partsList = document.getElementById('dvPartsList'); + const partsCount = document.getElementById('dvPartsCount'); + + // Show loading in parts + partsList.innerHTML = '

Cargando...

'; + partsCount.textContent = '...'; + + try { + // Fetch diagram detail + parts in parallel + const [diagRes, partsRes] = await Promise.all([ + fetch(`/api/diagrams/${diagramId}`), + fetch(`/api/diagrams/${diagramId}/parts${this.selectedVehicleId ? '?mye_id=' + this.selectedVehicleId : ''}`) + ]); + + const diagram = await diagRes.json(); + const parts = await partsRes.json(); + + // Update title + const type = (diagram.name || '')[0]; + const typeLabel = type === 'F' ? 'Suspensión Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Suspensión Trasera' : diagram.group_name || ''; + titleEl.textContent = diagram.name || 'Diagrama'; + subtitleEl.textContent = diagram.name_es || typeLabel; + + // Update image + const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : ''); + img.src = imgSrc; + img.alt = diagram.name_es || diagram.name; + this._dvZoom = 1; + imgWrapper.style.transform = ''; + imgWrapper.classList.remove('zoomed'); + document.getElementById('dvZoomLevel').textContent = '100%'; + + // Render hotspots on image + this._renderViewerHotspots(diagram.hotspots || [], imgWrapper); + + // Render parts list + this._renderViewerParts(parts, diagram.hotspots || []); + + // Update nav button states + const prevBtn = document.getElementById('dvPrevBtn'); + const nextBtn = document.getElementById('dvNextBtn'); + prevBtn.disabled = this._dvCurrentIndex <= 0; + nextBtn.disabled = this._dvCurrentIndex < 0 || this._dvCurrentIndex >= this._dvDiagramList.length - 1; + prevBtn.style.opacity = prevBtn.disabled ? '0.3' : '1'; + nextBtn.style.opacity = nextBtn.disabled ? '0.3' : '1'; + + } catch (e) { + console.error('Error loading diagram in viewer:', e); + partsList.innerHTML = '

Error cargando diagrama

'; + } + } + + _renderViewerHotspots(hotspots, wrapper) { + // Remove existing hotspot markers + wrapper.querySelectorAll('.hotspot-marker').forEach(el => el.remove()); + + if (!hotspots || hotspots.length === 0) return; + + hotspots.forEach((hs, idx) => { + // coords stored as "x%,y%" (percentage-based) + const coords = (hs.coords || '').split(','); + if (coords.length < 2) return; + + const xPct = parseFloat(coords[0]); + const yPct = parseFloat(coords[1]); + if (isNaN(xPct) || isNaN(yPct)) return; + + const marker = document.createElement('div'); + marker.className = 'hotspot-marker pulse'; + marker.style.left = xPct + '%'; + marker.style.top = yPct + '%'; + marker.dataset.partId = hs.part_id || ''; + marker.dataset.callout = hs.callout_number || (idx + 1); + marker.title = hs.part_name || hs.label || 'Parte ' + (idx + 1); + marker.innerHTML = `${hs.callout_number || (idx + 1)}`; + + marker.addEventListener('click', () => { + this._highlightPartInList(hs.part_id); + // Highlight this marker + wrapper.querySelectorAll('.hotspot-marker').forEach(m => m.classList.remove('active')); + marker.classList.add('active'); + }); + + wrapper.appendChild(marker); + }); + } + + _renderViewerParts(parts, hotspots) { + const listEl = document.getElementById('dvPartsList'); + const countEl = document.getElementById('dvPartsCount'); + + countEl.textContent = parts.length; + + if (!parts || parts.length === 0) { + listEl.innerHTML = '

No hay partes vinculadas

'; + return; + } + + // Build a hotspot lookup by part_id + const hotspotMap = {}; + (hotspots || []).forEach((hs, idx) => { + if (hs.part_id) hotspotMap[hs.part_id] = hs.callout_number || (idx + 1); + }); + + // Group by group_name + const grouped = {}; + parts.forEach(p => { + const g = p.group_name_es || p.group_name || 'Otros'; + if (!grouped[g]) grouped[g] = []; + grouped[g].push(p); + }); + + let html = ''; + for (const [group, groupParts] of Object.entries(grouped)) { + html += `
${group}
`; + for (const p of groupParts) { + const callout = hotspotMap[p.id]; + let xrefHtml = ''; + if (p.cross_references && p.cross_references.length > 0) { + xrefHtml = `
${p.cross_references.map(x => `${x.number}`).join('')}
`; + } + html += ` +
+
+ ${callout ? `${callout}` : ''} +
${p.part_number || p.oem_part_number}
+
+
${p.name_es || p.name || ''}
+ ${xrefHtml} +
`; + } + } + + listEl.innerHTML = html; + } + + _highlightPartInList(partId) { + if (!partId) return; + const listEl = document.getElementById('dvPartsList'); + listEl.querySelectorAll('.dv-part-item').forEach(el => el.classList.remove('highlighted')); + const target = listEl.querySelector(`.dv-part-item[data-part-id="${partId}"]`); + if (target) { + target.classList.add('highlighted'); + target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + + _onViewerPartClick(partId) { + // Highlight in list + this._highlightPartInList(partId); + + // Highlight matching hotspot on image + const wrapper = document.getElementById('dvImgWrapper'); + wrapper.querySelectorAll('.hotspot-marker').forEach(m => { + m.classList.remove('active'); + if (m.dataset.partId == partId) { + m.classList.add('active'); + } + }); + } + + _dvNavigate(delta) { + const newIdx = this._dvCurrentIndex + delta; + if (newIdx < 0 || newIdx >= this._dvDiagramList.length) return; + this._dvCurrentIndex = newIdx; + const d = this._dvDiagramList[newIdx]; + if (d) this._loadDiagramInViewer(d.id); + } + + _dvSetZoom(level) { + this._dvZoom = Math.max(0.25, Math.min(4, level)); + const wrapper = document.getElementById('dvImgWrapper'); + if (this._dvZoom !== 1) { + wrapper.classList.add('zoomed'); + wrapper.style.transform = `scale(${this._dvZoom})`; + } else { + wrapper.classList.remove('zoomed'); + wrapper.style.transform = ''; + } + document.getElementById('dvZoomLevel').textContent = `${Math.round(this._dvZoom * 100)}%`; + } + + _bindDiagramViewerEvents() { + // Avoid duplicate bindings + if (this._dvBound) return; + this._dvBound = true; + + this._dvHandlers = { + close: () => this.closeDiagramViewer(), + prev: () => this._dvNavigate(-1), + next: () => this._dvNavigate(1), + zoomIn: () => this._dvSetZoom(this._dvZoom + 0.25), + zoomOut: () => this._dvSetZoom(this._dvZoom - 0.25), + zoomFit: () => this._dvSetZoom(1), + keydown: (e) => { + const overlay = document.getElementById('diagramViewerOverlay'); + if (!overlay.classList.contains('active')) return; + if (e.key === 'Escape') this.closeDiagramViewer(); + if (e.key === 'ArrowLeft') this._dvNavigate(-1); + if (e.key === 'ArrowRight') this._dvNavigate(1); + if (e.key === '+' || e.key === '=') this._dvSetZoom(this._dvZoom + 0.25); + if (e.key === '-') this._dvSetZoom(this._dvZoom - 0.25); + }, + wheel: (e) => { + const overlay = document.getElementById('diagramViewerOverlay'); + if (!overlay.classList.contains('active')) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.15 : 0.15; + this._dvSetZoom(this._dvZoom + delta); + }, + partsFilter: (e) => { + const q = e.target.value.toLowerCase(); + document.querySelectorAll('#dvPartsList .dv-part-item').forEach(el => { + el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none'; + }); + }, + mousedown: (e) => { + if (this._dvZoom <= 1) return; + this._dvDragging = true; + this._dvDragStart = { x: e.clientX, y: e.clientY }; + const container = document.getElementById('dvImgContainer'); + this._dvScrollStart = { x: container.scrollLeft, y: container.scrollTop }; + container.style.cursor = 'grabbing'; + }, + mousemove: (e) => { + if (!this._dvDragging) return; + const container = document.getElementById('dvImgContainer'); + container.scrollLeft = this._dvScrollStart.x - (e.clientX - this._dvDragStart.x); + container.scrollTop = this._dvScrollStart.y - (e.clientY - this._dvDragStart.y); + }, + mouseup: () => { + this._dvDragging = false; + const container = document.getElementById('dvImgContainer'); + if (container) container.style.cursor = ''; + } + }; + + document.getElementById('dvCloseBtn').addEventListener('click', this._dvHandlers.close); + document.getElementById('dvPrevBtn').addEventListener('click', this._dvHandlers.prev); + document.getElementById('dvNextBtn').addEventListener('click', this._dvHandlers.next); + document.getElementById('dvZoomIn').addEventListener('click', this._dvHandlers.zoomIn); + document.getElementById('dvZoomOut').addEventListener('click', this._dvHandlers.zoomOut); + document.getElementById('dvZoomFit').addEventListener('click', this._dvHandlers.zoomFit); + document.getElementById('dvPartsFilter').addEventListener('input', this._dvHandlers.partsFilter); + document.addEventListener('keydown', this._dvHandlers.keydown); + document.getElementById('dvImgContainer').addEventListener('wheel', this._dvHandlers.wheel, { passive: false }); + document.getElementById('dvImgContainer').addEventListener('mousedown', this._dvHandlers.mousedown); + window.addEventListener('mousemove', this._dvHandlers.mousemove); + window.addEventListener('mouseup', this._dvHandlers.mouseup); + } + + _unbindDiagramViewerEvents() { + if (!this._dvBound) return; + this._dvBound = false; + + document.getElementById('dvCloseBtn')?.removeEventListener('click', this._dvHandlers.close); + document.getElementById('dvPrevBtn')?.removeEventListener('click', this._dvHandlers.prev); + document.getElementById('dvNextBtn')?.removeEventListener('click', this._dvHandlers.next); + document.getElementById('dvZoomIn')?.removeEventListener('click', this._dvHandlers.zoomIn); + document.getElementById('dvZoomOut')?.removeEventListener('click', this._dvHandlers.zoomOut); + document.getElementById('dvZoomFit')?.removeEventListener('click', this._dvHandlers.zoomFit); + document.getElementById('dvPartsFilter')?.removeEventListener('input', this._dvHandlers.partsFilter); + document.removeEventListener('keydown', this._dvHandlers.keydown); + document.getElementById('dvImgContainer')?.removeEventListener('wheel', this._dvHandlers.wheel); + document.getElementById('dvImgContainer')?.removeEventListener('mousedown', this._dvHandlers.mousedown); + window.removeEventListener('mousemove', this._dvHandlers.mousemove); + window.removeEventListener('mouseup', this._dvHandlers.mouseup); + } + // FASE 4: Open VIN decoder modal openVinDecoder() { // Clear previous results diff --git a/dashboard/diagrams.html b/dashboard/diagrams.html new file mode 100644 index 0000000..91f3329 --- /dev/null +++ b/dashboard/diagrams.html @@ -0,0 +1,1089 @@ + + + + + + Diagramas de Suspensión - AutoParts DB + + + + + + + + + +
+ + + +
+ + +
+

+ Diagramas de Suspensión y Dirección +

+

+ Selecciona tu vehículo para ver los diagramas MOOG disponibles, o usa "Ver Todos" para navegar la galería completa. +

+ +
+ + + + + +
+
+ + + + + + + + + + + +
+
+ +

Selecciona un vehículo arriba para ver sus diagramas

+
+
+ + + +
+ + +
+ + + + +
+ +
+
+
+
F200
+
Suspension Delantera
+
+
+
+ Diagram +
+
+ + 100% + + +
+
+ + +
+
+ +

Partes del Diagrama

+ 0 +
+ +
+
+ +

Selecciona un diagrama

+
+
+
+
+
+ + + + diff --git a/dashboard/index.html b/dashboard/index.html index b32c091..b5517c4 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -7,88 +7,9 @@ + - -
-
- - -
-
-
- - -
- -
- - -
- - -
- - - - -
-
- Búsquedas recientes - Limpiar -
-
-
- - -
- - - - - - - - -
- - - -
-
-
- -
-
-
-
0
-
Marcas
-
-
-
0
-
Modelos
-
-
-
0
-
Partes
-
-
- - - - - - -
-
-
+ +
+ +
@@ -1924,6 +2101,54 @@
+ +
+
+ +
+
+
+
F200
+
Suspension Delantera
+
+ + + +
+
+
+ Diagram + +
+
+ + 100% + + +
+
+
+ + +
+
+ +

Partes del Diagrama

+ 0 +
+ +
+
+ +

Cargando partes...

+
+
+
+
+
+ diff --git a/dashboard/nav.js b/dashboard/nav.js new file mode 100644 index 0000000..cff3845 --- /dev/null +++ b/dashboard/nav.js @@ -0,0 +1,109 @@ +/** + * nav.js -- Shared navigation component for AutoParts DB + * + * Injects a consistent header/nav bar into
. + * Auto-highlights the current page link based on window.location.pathname. + * + * The injected header includes a
slot + * that pages can populate with additional header content (search bars, stats, etc.) + * after this script runs. + */ +(function () { + 'use strict'; + + var path = window.location.pathname; + + function isActive(href) { + var h = href.replace(/\/+$/, '') || '/'; + var p = path.replace(/\/+$/, '') || '/'; + if (h === p) return true; + if ((h === '/' || h === '/index.html') && (p === '/' || p === '/index.html')) return true; + if ((h === '/admin.html' || h === '/admin') && (p === '/admin.html' || p === '/admin')) return true; + if ((h === '/diagramas' || h === '/diagrams.html') && (p === '/diagramas' || p === '/diagrams.html')) return true; + if ((h === '/customer-landing.html') && (p === '/customer-landing.html')) return true; + return false; + } + + var navLinks = [ + { label: 'Cat\u00e1logo', href: '/' }, + { label: 'Diagramas', href: '/diagramas' }, + { label: 'Admin', href: '/admin' } + ]; + + var linksHTML = navLinks.map(function (link) { + var baseStyle = 'text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: color 0.2s;'; + if (isActive(link.href)) { + baseStyle += ' color: var(--accent);'; + } else { + baseStyle += ' color: var(--text-secondary);'; + } + return '' + link.label + ''; + }).join(''); + + var html = '' + + '
' + + '
' + // Logo + + '' + + '
\u2699\uFE0F
' + + 'AUTOPARTS DB' + + '
' + // Slot for extra page-specific content (search bars, stats, etc.) + + '
' + // Nav links + + '' + + '
' + + '
'; + + var target = document.getElementById('shared-nav'); + if (target) { + target.innerHTML = html; + } +})(); diff --git a/dashboard/server.py b/dashboard/server.py index a902656..e0ab121 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -13,19 +13,38 @@ def get_db_connection(): conn.row_factory = sqlite3.Row # This enables column access by name return conn -def get_all_brands(): +def get_all_brands(detailed=False): """Get all unique brands that have vehicles with parts""" conn = get_db_connection() cursor = conn.cursor() - cursor.execute(""" - SELECT DISTINCT b.name - FROM brands b - JOIN models m ON m.brand_id = b.id - JOIN model_year_engine mye ON mye.model_id = m.id - JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id - ORDER BY b.name - """) - brands = [row['name'] for row in cursor.fetchall()] + if detailed: + cursor.execute(""" + SELECT b.name, + COUNT(DISTINCT m.name) AS model_count, + COUNT(DISTINCT mye.id) AS vehicle_count + FROM brands b + JOIN models m ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) vp + ON mye.id = vp.model_year_engine_id + GROUP BY b.name + ORDER BY b.name + LIMIT 500 + """) + brands = [{'name': row['name'], 'model_count': row['model_count'], + 'vehicle_count': row['vehicle_count']} for row in cursor.fetchall()] + else: + cursor.execute(""" + SELECT DISTINCT b.name + FROM brands b + JOIN models m ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) vp + ON mye.id = vp.model_year_engine_id + ORDER BY b.name + LIMIT 500 + """) + brands = [row['name'] for row in cursor.fetchall()] conn.close() return brands @@ -33,7 +52,7 @@ def get_all_years(): """Get all unique years from the database""" conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT DISTINCT year FROM years ORDER BY year DESC") + cursor.execute("SELECT DISTINCT year FROM years ORDER BY year DESC LIMIT 200") years = [row['year'] for row in cursor.fetchall()] conn.close() return years @@ -42,7 +61,7 @@ def get_all_engines(): """Get all unique engines from the database""" conn = get_db_connection() cursor = conn.cursor() - cursor.execute("SELECT DISTINCT name FROM engines ORDER BY name") + cursor.execute("SELECT DISTINCT name FROM engines ORDER BY name LIMIT 5000") engines = [row['name'] for row in cursor.fetchall()] conn.close() return engines @@ -61,6 +80,7 @@ def get_models_by_brand(brand_name=None): JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id WHERE UPPER(b.name) = UPPER(?) ORDER BY m.name + LIMIT 1000 """, (brand_name,)) else: cursor.execute(""" @@ -69,17 +89,55 @@ def get_models_by_brand(brand_name=None): JOIN model_year_engine mye ON mye.model_id = m.id JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id ORDER BY m.name + LIMIT 1000 """) models = [row['name'] for row in cursor.fetchall()] conn.close() return models -def search_vehicles(brand=None, model=None, year=None, engine=None, with_parts=True): +def search_vehicles(brand=None, model=None, year=None, engine=None, with_parts=True, page=1, per_page=50): """Search for vehicles based on filters. By default only returns vehicles with parts.""" conn = get_db_connection() cursor = conn.cursor() + per_page = min(per_page, 100) + offset = (page - 1) * per_page + + base_from = """ + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + JOIN engines e ON mye.engine_id = e.id + """ + + # Only show vehicles that have parts — use JOIN instead of EXISTS for performance + if with_parts: + base_from += " JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) AS has_parts ON mye.id = has_parts.model_year_engine_id" + where = " WHERE 1=1" + else: + where = " WHERE 1=1" + + params = [] + if brand: + where += " AND UPPER(b.name) = UPPER(?)" + params.append(brand) + if model: + where += " AND UPPER(m.name) = UPPER(?)" + params.append(model) + if year: + where += " AND y.year = ?" + params.append(int(year)) + if engine: + where += " AND e.name = ?" + params.append(engine) + + # Get total count + cursor.execute("SELECT COUNT(*) as total " + base_from + where, params) + total_count = cursor.fetchone()['total'] + + # Get paginated data query = """ SELECT b.name AS brand, @@ -94,40 +152,12 @@ def search_vehicles(brand=None, model=None, year=None, engine=None, with_parts=T mye.trim_level, mye.drivetrain, mye.transmission - FROM model_year_engine mye - JOIN models m ON mye.model_id = m.id - JOIN brands b ON m.brand_id = b.id - JOIN years y ON mye.year_id = y.id - JOIN engines e ON mye.engine_id = e.id - """ + """ + base_from + where + " ORDER BY b.name, m.name, y.year LIMIT ? OFFSET ?" - # Only show vehicles that have parts - if with_parts: - query += " WHERE EXISTS (SELECT 1 FROM vehicle_parts vp WHERE vp.model_year_engine_id = mye.id)" - else: - query += " WHERE 1=1" - - params = [] - if brand: - query += " AND UPPER(b.name) = UPPER(?)" - params.append(brand) - if model: - query += " AND UPPER(m.name) = UPPER(?)" - params.append(model) - if year: - query += " AND y.year = ?" - params.append(int(year)) - if engine: - query += " AND e.name = ?" - params.append(engine) - - query += " ORDER BY b.name, m.name, y.year" - - cursor.execute(query, params) + cursor.execute(query, params + [per_page, offset]) results = cursor.fetchall() conn.close() - - # Convert to list of dictionaries + vehicles = [] for row in results: vehicle = { @@ -145,8 +175,17 @@ def search_vehicles(brand=None, model=None, year=None, engine=None, with_parts=T 'transmission': row['transmission'] or 'unknown' } vehicles.append(vehicle) - - return vehicles + + total_pages = (total_count + per_page - 1) // per_page + return { + 'data': vehicles, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total_count, + 'total_pages': total_pages + } + } @app.route('/') def index(): @@ -163,15 +202,66 @@ def landing_page(): """Serve the customer landing page""" return send_from_directory('.', 'customer-landing.html') -@app.route('/') +@app.route('/diagramas') +def diagrams_page(): + """Serve the diagrams viewer page""" + return send_from_directory('.', 'diagrams.html') + +@app.route('/index.html') +def index_html(): + """Redirect index.html to canonical route""" + return send_from_directory('.', 'index.html') + +@app.route('/admin.html') +def admin_html(): + """Redirect admin.html to canonical route""" + return send_from_directory('.', 'admin.html') + +@app.route('/customer-landing.html') +def customer_landing_html(): + """Serve customer-landing.html by direct path""" + return send_from_directory('.', 'customer-landing.html') + +@app.route('/diagrams.html') +def diagrams_html(): + """Serve diagrams.html by direct path""" + return send_from_directory('.', 'diagrams.html') + +@app.route('/static/') def static_files(path): - """Serve static files""" - return send_from_directory('.', path) + """Serve static files from the static/ subdirectory only""" + return send_from_directory('static', path) + +@app.route('/shared.css') +def shared_css(): + """Serve shared CSS""" + return send_from_directory('.', 'shared.css') + +@app.route('/nav.js') +def nav_js(): + """Serve shared navigation JS""" + return send_from_directory('.', 'nav.js') + +@app.route('/dashboard.js') +def dashboard_js(): + """Serve dashboard JS""" + return send_from_directory('.', 'dashboard.js') + +@app.route('/admin.js') +def admin_js(): + """Serve admin JS""" + return send_from_directory('.', 'admin.js') + +@app.route('/enhanced-search.js') +def enhanced_search_js(): + """Serve enhanced search JS""" + return send_from_directory('.', 'enhanced-search.js') @app.route('/api/brands') def api_brands(): - """API endpoint to get all brands""" - brands = get_all_brands() + """API endpoint to get all brands, optionally with model/vehicle counts""" + detailed = request.args.get('detailed', 'false').lower() == 'true' + brands = get_all_brands(detailed=detailed) return jsonify(brands) @app.route('/api/years') @@ -251,21 +341,53 @@ def api_engines(): @app.route('/api/models') def api_models(): - """API endpoint to get models, optionally filtered by brand""" + """API endpoint to get models, optionally filtered by brand. Use detailed=true for stats.""" brand = request.args.get('brand') + detailed = request.args.get('detailed', 'false').lower() == 'true' + + if detailed and brand: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT m.name, + MIN(y.year) AS year_min, + MAX(y.year) AS year_max, + COUNT(DISTINCT y.year) AS year_count, + COUNT(DISTINCT mye.id) AS vehicle_count, + COUNT(DISTINCT e.name) AS engine_count + FROM models m + JOIN brands b ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN years y ON mye.year_id = y.id + JOIN engines e ON mye.engine_id = e.id + JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) vp + ON mye.id = vp.model_year_engine_id + WHERE UPPER(b.name) = UPPER(?) + GROUP BY m.name + ORDER BY m.name + LIMIT 1000 + """, (brand,)) + models = [{'name': r['name'], 'year_min': r['year_min'], 'year_max': r['year_max'], + 'year_count': r['year_count'], 'vehicle_count': r['vehicle_count'], + 'engine_count': r['engine_count']} for r in cursor.fetchall()] + conn.close() + return jsonify(models) + models = get_models_by_brand(brand) return jsonify(models) @app.route('/api/vehicles') def api_vehicles(): - """API endpoint to search for vehicles""" + """API endpoint to search for vehicles with pagination""" brand = request.args.get('brand') model = request.args.get('model') year = request.args.get('year') engine = request.args.get('engine') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) - vehicles = search_vehicles(brand, model, year, engine) - return jsonify(vehicles) + result = search_vehicles(brand, model, year, engine, page=page, per_page=per_page) + return jsonify(result) # ============================================================================ # Parts Catalog API Endpoints @@ -283,6 +405,7 @@ def api_categories(): SELECT id, name, name_es, slug, icon_name, display_order, parent_id FROM part_categories ORDER BY display_order, name + LIMIT 50 """) all_categories = cursor.fetchall() conn.close() @@ -328,6 +451,7 @@ def api_category_groups(category_id): FROM part_groups WHERE category_id = ? ORDER BY display_order, name + LIMIT 200 """, (category_id,)) groups = [] @@ -509,6 +633,7 @@ def api_vehicle_categories(mye_id): JOIN vehicle_parts vp ON vp.part_id = p.id WHERE vp.model_year_engine_id = ? ORDER BY pc.display_order, pc.name + LIMIT 50 """, (mye_id,)) categories = [] @@ -555,7 +680,7 @@ def api_vehicle_groups(mye_id): query += " AND pg.category_id = ?" params.append(category_id) - query += " GROUP BY pg.id ORDER BY pg.display_order, pg.name" + query += " GROUP BY pg.id ORDER BY pg.display_order, pg.name LIMIT 200" cursor.execute(query, params) @@ -577,14 +702,40 @@ def api_vehicle_groups(mye_id): @app.route('/api/vehicles//parts') def api_vehicle_parts(mye_id): - """API endpoint to get parts for a specific vehicle""" + """API endpoint to get parts for a specific vehicle with pagination""" try: category_id = request.args.get('category_id', type=int) group_id = request.args.get('group_id', type=int) + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + per_page = min(per_page, 100) + offset = (page - 1) * per_page conn = get_db_connection() cursor = conn.cursor() + base_query = """ + FROM vehicle_parts vp + JOIN parts p ON vp.part_id = p.id + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE vp.model_year_engine_id = ? + """ + params = [mye_id] + + if category_id: + base_query += " AND pc.id = ?" + params.append(category_id) + + if group_id: + base_query += " AND pg.id = ?" + params.append(group_id) + + # Get total count + cursor.execute("SELECT COUNT(*) as total " + base_query, params) + total_count = cursor.fetchone()['total'] + + # Get paginated data query = """ SELECT p.id, @@ -595,25 +746,9 @@ def api_vehicle_parts(mye_id): vp.position, pc.name AS category_name, pg.name AS group_name - FROM vehicle_parts vp - JOIN parts p ON vp.part_id = p.id - JOIN part_groups pg ON p.group_id = pg.id - JOIN part_categories pc ON pg.category_id = pc.id - WHERE vp.model_year_engine_id = ? - """ - params = [mye_id] + """ + base_query + " ORDER BY pc.display_order, pg.display_order, p.name LIMIT ? OFFSET ?" - if category_id: - query += " AND pc.id = ?" - params.append(category_id) - - if group_id: - query += " AND pg.id = ?" - params.append(group_id) - - query += " ORDER BY pc.display_order, pg.display_order, p.name" - - cursor.execute(query, params) + cursor.execute(query, params + [per_page, offset]) parts = [] for row in cursor.fetchall(): @@ -629,22 +764,68 @@ def api_vehicle_parts(mye_id): }) conn.close() - return jsonify(parts) + + total_pages = (total_count + per_page - 1) // per_page + return jsonify({ + 'data': parts, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total_count, + 'total_pages': total_pages + } + }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/model-year-engine') def api_model_year_engine(): - """API endpoint to get model_year_engine records with filters""" + """API endpoint to get model_year_engine records with filters and pagination""" try: brand = request.args.get('brand') model = request.args.get('model') year = request.args.get('year', type=int) with_parts = request.args.get('with_parts', 'true').lower() == 'true' + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + per_page = min(per_page, 100) + offset = (page - 1) * per_page conn = get_db_connection() cursor = conn.cursor() + base_from = """ + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + JOIN engines e ON mye.engine_id = e.id + """ + + # Only show vehicles that have parts — use JOIN for performance + if with_parts: + base_from += " JOIN (SELECT DISTINCT model_year_engine_id FROM vehicle_parts) AS has_parts ON mye.id = has_parts.model_year_engine_id" + + where = " WHERE 1=1" + params = [] + + if brand: + where += " AND UPPER(b.name) = UPPER(?)" + params.append(brand) + + if model: + where += " AND UPPER(m.name) = UPPER(?)" + params.append(model) + + if year: + where += " AND y.year = ?" + params.append(year) + + # Get total count + cursor.execute("SELECT COUNT(*) as total " + base_from + where, params) + total_count = cursor.fetchone()['total'] + + # Get paginated data query = """ SELECT mye.id, @@ -655,36 +836,9 @@ def api_model_year_engine(): mye.trim_level, mye.drivetrain, mye.transmission - FROM model_year_engine mye - JOIN models m ON mye.model_id = m.id - JOIN brands b ON m.brand_id = b.id - JOIN years y ON mye.year_id = y.id - JOIN engines e ON mye.engine_id = e.id - """ + """ + base_from + where + " ORDER BY b.name, m.name, y.year, e.name LIMIT ? OFFSET ?" - # Only show vehicles that have parts - if with_parts: - query += " WHERE EXISTS (SELECT 1 FROM vehicle_parts vp WHERE vp.model_year_engine_id = mye.id)" - else: - query += " WHERE 1=1" - - params = [] - - if brand: - query += " AND UPPER(b.name) = UPPER(?)" - params.append(brand) - - if model: - query += " AND UPPER(m.name) = UPPER(?)" - params.append(model) - - if year: - query += " AND y.year = ?" - params.append(year) - - query += " ORDER BY b.name, m.name, y.year, e.name" - - cursor.execute(query, params) + cursor.execute(query, params + [per_page, offset]) records = [] for row in cursor.fetchall(): @@ -700,7 +854,17 @@ def api_model_year_engine(): }) conn.close() - return jsonify(records) + + total_pages = (total_count + per_page - 1) // per_page + return jsonify({ + 'data': records, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total_count, + 'total_pages': total_pages + } + }) except Exception as e: return jsonify({'error': str(e)}), 500 @@ -733,7 +897,7 @@ def api_manufacturers(): query += " AND quality_tier = ?" params.append(quality_tier) - query += " ORDER BY name" + query += " ORDER BY name LIMIT 200" cursor.execute(query, params) @@ -791,7 +955,7 @@ def api_part_alternatives(part_id): query += " AND ap.manufacturer_id = ?" params.append(manufacturer_id) - query += " ORDER BY ap.quality_tier DESC, ap.price_usd ASC" + query += " ORDER BY ap.quality_tier DESC, ap.price_usd ASC LIMIT 50" cursor.execute(query, params) @@ -828,6 +992,7 @@ def api_part_cross_references(part_id): FROM part_cross_references WHERE part_id = ? ORDER BY reference_type, cross_reference_number + LIMIT 100 """, (part_id,)) cross_references = [] @@ -1035,7 +1200,7 @@ def api_diagrams(): query += " AND d.group_id = ?" params.append(group_id) - query += " ORDER BY d.display_order, d.name" + query += " ORDER BY d.display_order, d.name LIMIT 200" cursor.execute(query, params) @@ -1087,13 +1252,17 @@ def api_diagram_detail(diagram_id): conn.close() return jsonify({'error': 'Diagram not found'}), 404 + image_path = row['image_path'] or '' + image_url = '/' + image_path if image_path and not image_path.startswith('/') else image_path + diagram = { 'id': row['id'], 'name': row['name'], 'name_es': row['name_es'], 'group_id': row['group_id'], 'group_name': row['group_name'], - 'image_path': row['image_path'], + 'image_path': image_path, + 'image_url': image_url, 'svg_content': row['svg_content'], 'width': row['width'], 'height': row['height'], @@ -1159,6 +1328,7 @@ def api_diagram_hotspots(diagram_id): LEFT JOIN parts p ON h.part_id = p.id WHERE h.diagram_id = ? ORDER BY h.callout_number + LIMIT 500 """, (diagram_id,)) hotspots = [] @@ -1198,6 +1368,7 @@ def api_group_diagrams(group_id): FROM diagrams WHERE group_id = ? ORDER BY display_order, name + LIMIT 100 """, (group_id,)) diagrams = [] @@ -1229,7 +1400,9 @@ def api_vehicle_diagrams(mye_id): d.name, d.name_es, d.group_id, + d.image_path, pg.name AS group_name, + pc.id AS category_id, pc.name AS category_name, d.thumbnail_path, vd.notes @@ -1239,19 +1412,162 @@ def api_vehicle_diagrams(mye_id): JOIN part_categories pc ON pg.category_id = pc.id WHERE vd.model_year_engine_id = ? ORDER BY pc.display_order, pg.display_order, d.display_order + LIMIT 200 """, (mye_id,)) + diagrams = [] + for row in cursor.fetchall(): + image_path = row['image_path'] or '' + image_url = '/' + image_path if image_path and not image_path.startswith('/') else image_path + diagrams.append({ + 'id': row['id'], + 'name': row['name'], + 'name_es': row['name_es'], + 'group_id': row['group_id'], + 'group_name': row['group_name'], + 'category_id': row['category_id'], + 'category_name': row['category_name'], + 'image_url': image_url, + 'thumbnail_path': row['thumbnail_path'], + 'notes': row['notes'] + }) + + conn.close() + return jsonify(diagrams) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/diagrams//parts') +def api_diagram_parts(diagram_id): + """Get all parts associated with a diagram via vehicle fitment. + Optional query param: mye_id - filter to a specific vehicle configuration.""" + try: + mye_id = request.args.get('mye_id', type=int) + + conn = get_db_connection() + cursor = conn.cursor() + + # Get parts linked to vehicles that use this diagram + # Filter to suspension (cat 10, 11) related parts only + query = """ + SELECT DISTINCT + p.id, + p.oem_part_number, + p.name, + p.name_es, + p.description, + pg.id AS group_id, + pg.name AS group_name, + pg.name_es AS group_name_es + FROM vehicle_diagrams vd + JOIN vehicle_parts vp ON vp.model_year_engine_id = vd.model_year_engine_id + JOIN parts p ON vp.part_id = p.id + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE vd.diagram_id = ? + AND pc.id IN (10, 11) + """ + params = [diagram_id] + + if mye_id: + query += " AND vd.model_year_engine_id = ?" + params.append(mye_id) + + query += " ORDER BY pg.name, p.oem_part_number LIMIT 200" + + cursor.execute(query, params) + rows = cursor.fetchall() + + # Batch-fetch cross-references for all parts (fixes N+1 query) + xrefs_map = {} + if rows: + part_ids = list(set(row['id'] for row in rows)) + placeholders = ','.join('?' * len(part_ids)) + cursor.execute(f""" + SELECT part_id, cross_reference_number, source + FROM part_cross_references + WHERE part_id IN ({placeholders}) + """, part_ids) + for xrow in cursor.fetchall(): + xrefs_map.setdefault(xrow['part_id'], []).append( + {'number': xrow['cross_reference_number'], 'source': xrow['source']} + ) + + parts = [] + for row in rows: + parts.append({ + 'id': row['id'], + 'part_number': row['oem_part_number'], + 'name': row['name'], + 'name_es': row['name_es'], + 'description': row['description'], + 'group_name': row['group_name'], + 'group_name_es': row['group_name_es'], + 'cross_references': xrefs_map.get(row['id'], []), + }) + + conn.close() + return jsonify(parts) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/diagrams/search') +def api_diagrams_search(): + """Search diagrams by figure code or vehicle brand/model.""" + try: + q = request.args.get('q', '').strip() + brand = request.args.get('brand', '').strip() + model = request.args.get('model', '').strip() + + conn = get_db_connection() + cursor = conn.cursor() + + if q: + cursor.execute(""" + SELECT DISTINCT d.id, d.name, d.name_es, d.image_path, d.source + FROM diagrams d + WHERE d.name LIKE ? OR d.name_es LIKE ? + ORDER BY d.name + LIMIT 50 + """, (f'%{q}%', f'%{q}%')) + elif brand or model: + params = [] + query = """ + SELECT DISTINCT d.id, d.name, d.name_es, d.image_path, d.source + FROM diagrams d + JOIN vehicle_diagrams vd ON vd.diagram_id = d.id + JOIN model_year_engine mye ON vd.model_year_engine_id = mye.id + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + WHERE 1=1 + """ + if brand: + query += " AND UPPER(b.name) = UPPER(?)" + params.append(brand) + if model: + query += " AND UPPER(m.name) = UPPER(?)" + params.append(model) + query += " ORDER BY d.name LIMIT 50" + cursor.execute(query, params) + else: + cursor.execute(""" + SELECT d.id, d.name, d.name_es, d.image_path, d.source + FROM diagrams d + WHERE d.source = 'MOOG Catalog' + ORDER BY d.name + LIMIT 50 + """) + diagrams = [] for row in cursor.fetchall(): diagrams.append({ 'id': row['id'], 'name': row['name'], 'name_es': row['name_es'], - 'group_id': row['group_id'], - 'group_name': row['group_name'], - 'category_name': row['category_name'], - 'thumbnail_path': row['thumbnail_path'], - 'notes': row['notes'] + 'image_path': row['image_path'], + 'source': row['source'], }) conn.close() @@ -3194,50 +3510,52 @@ def api_admin_import_csv(import_type): @app.route('/api/admin/export/') def api_admin_export_csv(export_type): - """Export data as JSON (to be converted to CSV on frontend)""" + """Export data as JSON with pagination (to be converted to CSV on frontend)""" try: + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 1000, type=int) + per_page = min(per_page, 10000) + offset = (page - 1) * per_page + conn = get_db_connection() cursor = conn.cursor() - data = [] + export_queries = { + 'categories': ("SELECT id, name, name_es, slug, icon_name, display_order FROM part_categories ORDER BY display_order, name", "part_categories"), + 'groups': ("SELECT id, category_id, name, name_es, display_order FROM part_groups ORDER BY category_id, display_order, name", "part_groups"), + 'parts': ("SELECT id, oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material FROM parts ORDER BY id", "parts"), + 'manufacturers': ("SELECT id, name, type, quality_tier, country, website FROM manufacturers ORDER BY name", "manufacturers"), + 'aftermarket': ("SELECT id, oem_part_id, manufacturer_id, part_number, name, name_es, quality_tier, price_usd, warranty_months FROM aftermarket_parts ORDER BY id", "aftermarket_parts"), + 'crossref': ("SELECT id, part_id, cross_reference_number, reference_type, source, notes FROM part_cross_references ORDER BY id", "part_cross_references"), + 'fitment': ("SELECT id, model_year_engine_id, part_id, quantity_required, position, fitment_notes FROM vehicle_parts ORDER BY id", "vehicle_parts"), + } - if export_type == 'categories': - cursor.execute("SELECT id, name, name_es, slug, icon_name, display_order FROM part_categories ORDER BY display_order, name") - for row in cursor.fetchall(): - data.append(dict(row)) + if export_type not in export_queries: + conn.close() + return jsonify({'error': f'Unknown export type: {export_type}'}), 400 - elif export_type == 'groups': - cursor.execute("SELECT id, category_id, name, name_es, display_order FROM part_groups ORDER BY category_id, display_order, name") - for row in cursor.fetchall(): - data.append(dict(row)) + base_query, table_name = export_queries[export_type] - elif export_type == 'parts': - cursor.execute("SELECT id, oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material FROM parts ORDER BY id") - for row in cursor.fetchall(): - data.append(dict(row)) + # Get total count + cursor.execute(f"SELECT COUNT(*) as total FROM {table_name}") + total_count = cursor.fetchone()['total'] - elif export_type == 'manufacturers': - cursor.execute("SELECT id, name, type, quality_tier, country, website FROM manufacturers ORDER BY name") - for row in cursor.fetchall(): - data.append(dict(row)) - - elif export_type == 'aftermarket': - cursor.execute("SELECT id, oem_part_id, manufacturer_id, part_number, name, name_es, quality_tier, price_usd, warranty_months FROM aftermarket_parts ORDER BY id") - for row in cursor.fetchall(): - data.append(dict(row)) - - elif export_type == 'crossref': - cursor.execute("SELECT id, part_id, cross_reference_number, reference_type, source, notes FROM part_cross_references ORDER BY id") - for row in cursor.fetchall(): - data.append(dict(row)) - - elif export_type == 'fitment': - cursor.execute("SELECT id, model_year_engine_id, part_id, quantity_required, position, fitment_notes FROM vehicle_parts ORDER BY id") - for row in cursor.fetchall(): - data.append(dict(row)) + # Get paginated data + cursor.execute(base_query + " LIMIT ? OFFSET ?", (per_page, offset)) + data = [dict(row) for row in cursor.fetchall()] conn.close() - return jsonify({'data': data}) + + total_pages = (total_count + per_page - 1) // per_page + return jsonify({ + 'data': data, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': total_count, + 'total_pages': total_pages + } + }) except Exception as e: return jsonify({'error': str(e)}), 500 @@ -3300,6 +3618,175 @@ def api_admin_upload_image(): return jsonify({'error': str(e)}), 500 +# ============================================================================ +# FASE 6: Diagrams Integration - Additional Endpoints +# ============================================================================ + +@app.route('/api/vehicles//diagrams/by-category') +def api_vehicle_diagrams_by_category(mye_id): + """Get diagrams for a vehicle grouped by category, optionally filtered by category_id""" + try: + category_id = request.args.get('category_id', type=int) + + conn = get_db_connection() + cursor = conn.cursor() + + query = """ + SELECT DISTINCT + d.id, + d.name, + d.name_es, + d.group_id, + d.image_path, + d.thumbnail_path, + pg.name AS group_name, + pg.name_es AS group_name_es, + pc.id AS category_id, + pc.name AS category_name, + pc.name_es AS category_name_es, + vd.notes + FROM vehicle_diagrams vd + JOIN diagrams d ON vd.diagram_id = d.id + JOIN part_groups pg ON d.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE vd.model_year_engine_id = ? + """ + params = [mye_id] + + if category_id: + query += " AND pc.id = ?" + params.append(category_id) + + query += " ORDER BY pc.display_order, pg.display_order, d.display_order, d.name" + + cursor.execute(query, params) + + # Group by category + categories = {} + for row in cursor.fetchall(): + cat_id = row['category_id'] + if cat_id not in categories: + categories[cat_id] = { + 'category_id': cat_id, + 'category_name': row['category_name'], + 'category_name_es': row['category_name_es'], + 'diagrams': [] + } + image_path = row['image_path'] or '' + image_url = '/' + image_path if image_path and not image_path.startswith('/') else image_path + categories[cat_id]['diagrams'].append({ + 'id': row['id'], + 'name': row['name'], + 'name_es': row['name_es'], + 'group_id': row['group_id'], + 'group_name': row['group_name'], + 'group_name_es': row['group_name_es'], + 'image_url': image_url, + 'thumbnail_path': row['thumbnail_path'], + 'notes': row['notes'] + }) + + conn.close() + return jsonify(list(categories.values())) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/admin/hotspots', methods=['POST']) +def api_admin_create_hotspot(): + """Create a new hotspot on a diagram""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + diagram_id = data.get('diagram_id') + part_id = data.get('part_id') + callout_number = data.get('callout_number') + label = data.get('label', '') + shape = data.get('shape', 'circle') + coords = data.get('coords', '') + color = data.get('color', '#e74c3c') + + if not diagram_id or not coords: + return jsonify({'error': 'diagram_id and coords are required'}), 400 + + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO diagram_hotspots (diagram_id, part_id, callout_number, label, shape, coords, color) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (diagram_id, part_id, callout_number, label, shape, coords, color)) + + hotspot_id = cursor.lastrowid + conn.commit() + conn.close() + + return jsonify({'id': hotspot_id, 'message': 'Hotspot created'}), 201 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/admin/hotspots/', methods=['PUT']) +def api_admin_update_hotspot(hotspot_id): + """Update an existing hotspot""" + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + conn = get_db_connection() + cursor = conn.cursor() + + # Check it exists + cursor.execute("SELECT id FROM diagram_hotspots WHERE id = ?", (hotspot_id,)) + if not cursor.fetchone(): + conn.close() + return jsonify({'error': 'Hotspot not found'}), 404 + + fields = [] + params = [] + for field in ['part_id', 'callout_number', 'label', 'shape', 'coords', 'color']: + if field in data: + fields.append(f"{field} = ?") + params.append(data[field]) + + if not fields: + conn.close() + return jsonify({'error': 'No fields to update'}), 400 + + params.append(hotspot_id) + cursor.execute(f"UPDATE diagram_hotspots SET {', '.join(fields)} WHERE id = ?", params) + conn.commit() + conn.close() + + return jsonify({'message': 'Hotspot updated'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/admin/hotspots/', methods=['DELETE']) +def api_admin_delete_hotspot(hotspot_id): + """Delete a hotspot""" + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute("SELECT id FROM diagram_hotspots WHERE id = ?", (hotspot_id,)) + if not cursor.fetchone(): + conn.close() + return jsonify({'error': 'Hotspot not found'}), 404 + + cursor.execute("DELETE FROM diagram_hotspots WHERE id = ?", (hotspot_id,)) + conn.commit() + conn.close() + + return jsonify({'message': 'Hotspot deleted'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + if __name__ == '__main__': # Check if database exists if not os.path.exists(DATABASE_PATH): @@ -3310,4 +3797,4 @@ if __name__ == '__main__': print("Starting Vehicle Dashboard Server...") print("Visit http://localhost:5000 to access the dashboard locally") print("Visit http://192.168.10.198:5000 to access the dashboard from other computers on the network") - app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file + app.run(debug=False, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/dashboard/shared.css b/dashboard/shared.css new file mode 100644 index 0000000..04f1c6f --- /dev/null +++ b/dashboard/shared.css @@ -0,0 +1,262 @@ +/* ============================================================ + shared.css -- Common styles for all AutoParts DB pages + ============================================================ */ + +/* --- Reset --- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* --- CSS Variables (union of all pages) --- */ +:root { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --bg-hover: #252532; + --bg-tertiary: #1a1a25; + --accent: #ff6b35; + --accent-hover: #ff8555; + --accent-glow: rgba(255, 107, 53, 0.3); + --text-primary: #ffffff; + --text-secondary: #a0a0b0; + --border: #2a2a3a; + --success: #22c55e; + --warning: #f59e0b; + --info: #3b82f6; + --danger: #ff4444; +} + +/* --- Base body --- */ +body { + font-family: 'Inter', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; +} + +/* --- Shared Button Styles --- */ +.btn { + padding: 0.7rem 1.5rem; + border-radius: 10px; + border: none; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + font-size: 0.9rem; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%); + color: white; + box-shadow: 0 4px 15px var(--accent-glow); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 25px var(--accent-glow); +} + +.btn-secondary { + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-primary); +} + +.btn-secondary:hover { + border-color: var(--accent); + color: var(--accent); +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.2rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text-primary); + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + margin-bottom: 1.5rem; +} + +.btn-back:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* --- Shared Animations --- */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* --- Loading & Empty States --- */ +.state-container { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); +} + +.state-container i { + font-size: 4rem; + margin-bottom: 1rem; + color: var(--text-secondary); +} + +.state-container h4 { + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +/* --- Scrollbar Styling --- */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* --- Skip Link (accessibility) --- */ +.skip-link { + position: absolute; + top: -50px; + left: 0; + background: var(--accent); + color: white; + padding: 0.75rem 1.5rem; + z-index: 3000; + text-decoration: none; + font-weight: 600; + border-radius: 0 0 8px 0; +} + +.skip-link:focus { + top: 0; +} + +/* --- Screen Reader Only --- */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* --- Alert / Toast Styles --- */ +.alert { + padding: 1rem 1.5rem; + border-radius: 8px; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.alert-success { + background: rgba(0, 214, 143, 0.1); + border: 1px solid var(--success); + color: var(--success); +} + +.alert-error { + background: rgba(255, 68, 68, 0.1); + border: 1px solid var(--danger); + color: var(--danger); +} + +/* --- Modal Base Styles --- */ +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 2000; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.modal-overlay.active { + display: flex; +} + +/* --- Form Styles --- */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.form-input { + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.95rem; + transition: border-color 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--accent); +} + +.form-input::placeholder { + color: var(--text-secondary); +} + +/* --- Quality Badges --- */ +.quality-badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.quality-economy { background: var(--warning); color: #000; } +.quality-standard { background: var(--info); color: white; } +.quality-premium { background: var(--success); color: white; } +.quality-oem { background: #9b59b6; color: white; } diff --git a/vehicle_database/scripts/create_cross_references.py b/vehicle_database/scripts/create_cross_references.py new file mode 100644 index 0000000..c48e9ad --- /dev/null +++ b/vehicle_database/scripts/create_cross_references.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +GENERADOR DE REFERENCIAS CRUZADAS ENTRE MARCAS +Encuentra partes de diferentes fabricantes que cubren los mismos vehículos +y crea referencias cruzadas bidireccionales entre ellas. +""" + +import sqlite3 +from pathlib import Path +from collections import defaultdict + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def main(): + print("=" * 70) + print("GENERADOR DE REFERENCIAS CRUZADAS ENTRE MARCAS") + print("=" * 70) + + conn = get_db() + cursor = conn.cursor() + + # Get existing cross-ref count + cursor.execute("SELECT COUNT(*) FROM part_cross_references") + existing_xrefs = cursor.fetchone()[0] + print(f"\nCross-refs existentes: {existing_xrefs:,}") + + # Step 1: For each part_group, find parts from different brands + # that fit the same vehicle (model_year_engine) + print("\n[1/3] Buscando partes que cubren los mismos vehículos...") + + # Build a map: (group_id, mye_id) -> list of (part_id, part_number) + cursor.execute(""" + SELECT vp.model_year_engine_id, vp.part_id, p.oem_part_number, p.group_id + FROM vehicle_parts vp + JOIN parts p ON vp.part_id = p.id + WHERE p.group_id IS NOT NULL + ORDER BY p.group_id, vp.model_year_engine_id + """) + + group_mye_parts = defaultdict(set) + for row in cursor.fetchall(): + key = (row['group_id'], row['model_year_engine_id']) + group_mye_parts[key].add((row['part_id'], row['oem_part_number'])) + + print(f" Combinaciones grupo+vehículo: {len(group_mye_parts):,}") + + # Step 2: For each (group, vehicle) with multiple parts from different brands, + # create cross-references + print("\n[2/3] Generando pares de cross-reference...") + + # Build existing cross-ref set for fast lookup + cursor.execute("SELECT part_id, cross_reference_number FROM part_cross_references") + existing = set() + for row in cursor.fetchall(): + existing.add((row['part_id'], row['cross_reference_number'])) + + print(f" Cross-refs existentes en set: {len(existing):,}") + + # Collect new cross-reference pairs + new_xrefs = [] + for key, parts_set in group_mye_parts.items(): + if len(parts_set) < 2: + continue + + parts_list = list(parts_set) + for i in range(len(parts_list)): + pid_a, pn_a = parts_list[i] + for j in range(i + 1, len(parts_list)): + pid_b, pn_b = parts_list[j] + + # Skip if same part number prefix (same brand) + if pn_a[:3] == pn_b[:3]: + continue + + # Add A->B + if (pid_a, pn_b) not in existing: + new_xrefs.append((pid_a, pn_b)) + existing.add((pid_a, pn_b)) + + # Add B->A + if (pid_b, pn_a) not in existing: + new_xrefs.append((pid_b, pn_a)) + existing.add((pid_b, pn_a)) + + print(f" Nuevas cross-refs a crear: {len(new_xrefs):,}") + + # Step 3: Insert + print("\n[3/3] Insertando cross-references...") + inserted = 0 + for i, (part_id, xref_number) in enumerate(new_xrefs): + if i % 5000 == 0 and i > 0: + print(f" Insertando {i}/{len(new_xrefs)}...") + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Vehicle Fitment Match')", + (part_id, xref_number)) + inserted += 1 + + conn.commit() + + # Final stats + cursor.execute("SELECT COUNT(*) FROM part_cross_references") + total_xrefs = cursor.fetchone()[0] + + conn.close() + + print("\n" + "=" * 70) + print("CROSS-REFERENCES COMPLETADAS") + print("=" * 70) + print(f""" +RESUMEN: + - Cross-refs antes: {existing_xrefs:,} + - Nuevas cross-refs: {inserted:,} + - Total cross-refs: {total_xrefs:,} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/extract_moog_diagrams.py b/vehicle_database/scripts/extract_moog_diagrams.py new file mode 100644 index 0000000..54bd7c6 --- /dev/null +++ b/vehicle_database/scripts/extract_moog_diagrams.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +EXTRACTOR DE IMÁGENES DE DIAGRAMAS MOOG +Extrae las ilustraciones de suspensión/dirección de los PDFs MOOG +y las guarda como archivos de imagen mapeados a sus figure codes. +""" + +import re +import sys +import io +import hashlib +from pathlib import Path + +import pypdf + +OUTPUT_DIR = Path(__file__).parent.parent.parent / 'dashboard' / 'static' / 'diagrams' / 'moog' + +VOLUMES = { + '1': { + 'path': '/tmp/catalogs/suspension/moog_vol1_1989back.pdf', + 'start_page': 3, + 'end_page': 1037, + 'label': 'Vol 1 (≤1989)', + }, + '2': { + 'path': '/tmp/catalogs/suspension/moog_vol2_1990_2005.pdf', + 'start_page': 6, + 'end_page': 1641, + 'label': 'Vol 2 (1990-2005)', + }, + '3': { + 'path': '/tmp/catalogs/suspension/moog_vol3_2006up.pdf', + 'start_page': 7, + 'end_page': 1089, + 'label': 'Vol 3 (2006+)', + }, +} + +FIGURE_RE = re.compile(r'\b([FSR]\d{3})\b') + + +def extract_figure_codes(text): + """Extract ordered unique figure codes from page text.""" + codes = [] + seen = set() + for m in FIGURE_RE.finditer(text): + code = m.group(1) + if code not in seen: + codes.append(code) + seen.add(code) + return codes + + +def extract_volume(vol_key, already_extracted): + """Extract diagram images from one MOOG volume.""" + vol = VOLUMES[vol_key] + print(f"\n--- Procesando {vol['label']} ---") + print(f" PDF: {vol['path']}") + + pdf = pypdf.PdfReader(vol['path']) + total_pages = len(pdf.pages) + end_page = min(vol['end_page'], total_pages - 1) + + extracted = 0 + skipped = 0 + errors = 0 + + for page_idx in range(vol['start_page'], end_page + 1): + if page_idx % 100 == 0: + print(f" Página {page_idx}/{end_page}... (extraídas: {extracted})") + + try: + page = pdf.pages[page_idx] + text = page.extract_text() or '' + + # Get figure codes from this page + fig_codes = extract_figure_codes(text) + if not fig_codes: + continue + + # Filter out already-extracted codes + needed_codes = [c for c in fig_codes if c not in already_extracted] + if not needed_codes: + skipped += len(fig_codes) + continue + + # Extract images from page + images = [] + try: + for img_key in page.images: + img_data = img_key.data + # Filter by size - diagram images are >10KB typically + if len(img_data) > 5000: + images.append(img_data) + except Exception: + # Fallback: try to extract from xobjects directly + try: + if '/XObject' in page['/Resources']: + xobjects = page['/Resources']['/XObject'].get_object() + for obj_name in sorted(xobjects.keys()): + xobj = xobjects[obj_name].get_object() + if xobj.get('/Subtype') == '/Image': + w = int(xobj.get('/Width', 0)) + h = int(xobj.get('/Height', 0)) + if w > 200 and h > 100: + try: + img_data = xobj.get_data() + if len(img_data) > 5000: + images.append(img_data) + except Exception: + pass + except Exception: + pass + + if not images: + continue + + # Match figure codes to images + # Strategy: if same number of large images and figure codes, match 1:1 in order + # If fewer images than codes, some codes share images (use first available) + # If more images than codes, filter further by size + for i, code in enumerate(needed_codes): + if i < len(images): + img_data = images[i] + # Determine file extension from magic bytes + ext = 'jpg' + if img_data[:4] == b'\x89PNG': + ext = 'png' + elif img_data[:4] == b'\x00\x00\x00\x0c': + ext = 'jp2' + + out_path = OUTPUT_DIR / f"{code}.{ext}" + out_path.write_bytes(img_data) + already_extracted.add(code) + extracted += 1 + + except Exception as e: + errors += 1 + if errors <= 5: + print(f" Error en página {page_idx}: {e}") + + print(f" Resultado: {extracted} extraídas, {skipped} ya existentes, {errors} errores") + return extracted + + +def main(): + volumes = sys.argv[1:] if len(sys.argv) > 1 else ['3', '2', '1'] + + print("=" * 70) + print("EXTRACTOR DE DIAGRAMAS MOOG") + print("=" * 70) + + # Create output directory + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + print(f"Directorio de salida: {OUTPUT_DIR}") + + # Check what's already extracted + already_extracted = set() + for f in OUTPUT_DIR.iterdir(): + if f.suffix in ('.jpg', '.png', '.jp2'): + already_extracted.add(f.stem) + print(f"Ya extraídas: {len(already_extracted)}") + + total = 0 + for vol_key in volumes: + if vol_key not in VOLUMES: + print(f"Volumen {vol_key} no reconocido, saltando...") + continue + count = extract_volume(vol_key, already_extracted) + total += count + + print(f"\n{'=' * 70}") + print(f"EXTRACCIÓN COMPLETADA: {total} nuevas imágenes") + print(f"Total en directorio: {len(list(OUTPUT_DIR.iterdir()))}") + print(f"{'=' * 70}") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/import_cartek_catalog.py b/vehicle_database/scripts/import_cartek_catalog.py new file mode 100644 index 0000000..673eaeb --- /dev/null +++ b/vehicle_database/scripts/import_cartek_catalog.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +IMPORTADOR DEL CATÁLOGO CARTEK - FILTROS DE ACEITE +Formato: Brand → Model | YearFrom | YearTo | CTK#### | Observations +Solo aceite. PDF: /tmp/catalogs/cartek_aceite.pdf +""" + +import sqlite3 +import re +import pypdf +from pathlib import Path + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' +PDF_PATH = '/tmp/catalogs/cartek_aceite.pdf' + +# Known brand headers in the Cartek catalog +BRAND_HEADERS = { + 'ACURA', 'ALFA ROMEO', 'AM GENERAL', 'AMERICAN MOTORS', 'ASTON MARTIN', + 'ASUNA', 'AUDI', 'AUSTIN', 'AUSTIN HEALEY', 'AVANTI', 'BAIC', 'BENTLEY', + 'BERTONE', 'BMW', 'BRICKLIN', 'BUICK', 'CADILLAC', 'CHECKER', 'CHEVROLET', + 'CHRYSLER', 'DAEWOO', 'DAIHATSU', 'DATSUN', 'DELOREAN', 'DESOTO', + 'DETOMASO', 'DODGE', 'EAGLE', 'EDSEL', 'EXCALIBUR', 'FAW', 'FIAT', 'FORD', + 'FREIGHTLINER', 'GEO', 'GMC', 'HILLMAN', 'HONDA', 'HUMMER', 'HYUNDAI', + 'IC CORPORATION', 'INFINITI', 'INTERNATIONAL', 'ISUZU', 'JAC', 'JAGUAR', + 'JEEP', 'JENSEN', 'KARMA', 'KIA', 'KUBOTA', 'LAFORZA', 'LAND ROVER', + 'LEXUS', 'LINCOLN', 'LOTUS', 'MACK', 'MAZDA', 'MERCEDES-BENZ', 'MERCURY', + 'MERKUR', 'MINI', 'MITSUBISHI', 'MORGAN', 'NISSAN', 'NSU', 'OLDSMOBILE', + 'OPEL', 'OSHKOSH MOTOR TRUCK CO.', 'PETERBILT', 'PEUGEOT', 'PLYMOUTH', + 'POLARIS', 'PONTIAC', 'PORSCHE', 'QVALE', 'RAM', 'RENAULT', 'ROLLS ROYCE', + 'SAAB', 'SATURN', 'SCION', 'SEAT', 'SHELBY', 'SMART', 'SRT', + 'STERLING TRUCK', 'STUDEBAKER', 'SUBARU', 'SUNBEAM', 'SUZUKI', 'TOYOTA', + 'TRIUMPH', 'VAM', 'VOLKSWAGEN', 'VOLVO', 'VPG', 'WORKHORSE', + 'WORKHORSE CUSTOM CHASSIS', 'YAMAHA', 'YUGO', +} + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None): + cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute( + "INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)", + (name, type_, quality, country)) + return cursor.lastrowid + + +def ensure_brand(cursor, name): + cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,)) + return cursor.lastrowid + + +def ensure_model(cursor, brand_id, name): + cursor.execute( + "SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)", + (brand_id, name)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name)) + return cursor.lastrowid + + +def ensure_year(cursor, year): + cursor.execute("SELECT id FROM years WHERE year = ?", (year,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO years (year) VALUES (?)", (year,)) + return cursor.lastrowid + + +def get_generic_engine(cursor): + """Get or create a generic engine for catalogs without engine data.""" + cursor.execute("SELECT id FROM engines WHERE name = 'Generic'") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')") + return cursor.lastrowid + + +def ensure_mye(cursor, model_id, year_id, engine_id=None): + if engine_id: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?", + (model_id, year_id, engine_id)) + else: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?", + (model_id, year_id)) + row = cursor.fetchone() + if row: + return row['id'] + if not engine_id: + engine_id = get_generic_engine(cursor) + cursor.execute( + "INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)", + (model_id, year_id, engine_id)) + return cursor.lastrowid + + +def get_or_create_part(cursor, part_number, group_id, name, name_es, description): + cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,)) + row = cursor.fetchone() + if row: + return row['id'], False + cursor.execute( + "INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)", + (part_number, name, name_es, group_id, description)) + return cursor.lastrowid, True + + +def get_oil_filter_group(cursor): + cursor.execute( + "SELECT id FROM part_groups WHERE name = 'Oil Filters' LIMIT 1") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("SELECT id FROM part_categories WHERE name = 'Engine' LIMIT 1") + cat = cursor.fetchone() + if not cat: + return None + cursor.execute( + "INSERT INTO part_groups (category_id, name, name_es) VALUES (?, 'Oil Filters', 'Filtros de Aceite')", + (cat['id'],)) + return cursor.lastrowid + + +def parse_cartek_pdf(pdf_path): + """Parse the Cartek oil filter catalog PDF.""" + pdf = pypdf.PdfReader(pdf_path) + entries = [] + current_brand = None + + for page_num in range(4, len(pdf.pages)): # Skip cover/index pages + text = pdf.pages[page_num].extract_text() + if not text: + continue + + lines = text.split('\n') + pending_model = None + + for line in lines: + line = line.strip() + if not line: + continue + + # Skip header/footer lines + if 'Marca/Modelo' in line or 'Observaciones' in line: + continue + # Skip page numbers + if re.match(r'^\d{1,3}$', line): + continue + + # Check for brand header + if line in BRAND_HEADERS: + current_brand = line + pending_model = None + continue + + if not current_brand: + continue + + # Try to parse data line: Model YearFrom YearTo CTK#### Observations + match = re.match( + r'^(.+?)\s+(\d{4})\s+(\d{4})\s+(CTK\w+)\s+(.*)$', line) + if match: + model = match.group(1).strip() + if pending_model: + model = f"{pending_model} {model}" + pending_model = None + + year_from = int(match.group(2)) + year_to = int(match.group(3)) + part_number = match.group(4).strip() + observations = match.group(5).strip() + + for year in range(year_from, year_to + 1): + entries.append({ + 'brand': current_brand, + 'model': model, + 'year': year, + 'part_number': part_number, + 'observations': observations, + }) + else: + # Check if this is a continuation model name (e.g., "Avalanche") + # followed by a sub-model on the next line + if not re.match(r'^\d', line) and not line.startswith('CTK'): + # Could be a model name prefix (like "Avalanche" before "1500") + # or a sub-brand header we don't recognize + pending_model = line + else: + pending_model = None + + return entries + + +def main(): + print("=" * 70) + print("IMPORTADOR - CATÁLOGO CARTEK FILTROS DE ACEITE") + print("=" * 70) + + print(f"\n[1/5] Leyendo PDF: {PDF_PATH}") + entries = parse_cartek_pdf(PDF_PATH) + print(f" Entradas parseadas: {len(entries)}") + + # Get unique parts and brands + unique_parts = set(e['part_number'] for e in entries) + unique_brands = set(e['brand'] for e in entries) + print(f" Partes únicas: {len(unique_parts)}") + print(f" Marcas de vehículos: {len(unique_brands)}") + + conn = get_db() + cursor = conn.cursor() + + # Create Cartek manufacturer + print("\n[2/5] Creando fabricante Cartek...") + cartek_mfr_id = ensure_manufacturer(cursor, 'Cartek', 'aftermarket', 'standard', 'Mexico') + print(f" Cartek manufacturer_id: {cartek_mfr_id}") + + # Get oil filter group + oil_group_id = get_oil_filter_group(cursor) + print(f" Oil Filters group_id: {oil_group_id}") + + # Create parts + print("\n[3/5] Creando partes de filtros...") + part_ids = {} + parts_created = 0 + for pn in sorted(unique_parts): + name = f"Oil Filter {pn}" + name_es = f"Filtro de Aceite {pn}" + part_id, created = get_or_create_part( + cursor, pn, oil_group_id, name, name_es, "Cartek Oil Filter") + part_ids[pn] = part_id + if created: + parts_created += 1 + print(f" Partes creadas: {parts_created}") + print(f" Partes existentes: {len(unique_parts) - parts_created}") + + # Create vehicles and fitments + print("\n[4/5] Creando vehículos y fitments...") + vehicles_created = 0 + fitments_created = 0 + mye_cache = {} + + for entry in entries: + cache_key = (entry['brand'], entry['model'], entry['year']) + if cache_key not in mye_cache: + brand_id = ensure_brand(cursor, entry['brand']) + model_id = ensure_model(cursor, brand_id, entry['model']) + year_id = ensure_year(cursor, entry['year']) + + # Try to find existing MYE (any engine) + cursor.execute( + """SELECT mye.id FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? + LIMIT 1""", + (entry['brand'], entry['model'], entry['year'])) + existing = cursor.fetchone() + + if existing: + mye_cache[cache_key] = existing['id'] + else: + mye_id = ensure_mye(cursor, model_id, year_id) + mye_cache[cache_key] = mye_id + vehicles_created += 1 + + mye_id = mye_cache[cache_key] + part_id = part_ids.get(entry['part_number']) + if not part_id: + continue + + # Check if fitment exists + cursor.execute( + "SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?", + (mye_id, part_id)) + if not cursor.fetchone(): + notes = f"Catálogo Cartek - ACEITE" + if entry['observations'] and entry['observations'] != '-': + notes += f" ({entry['observations']})" + cursor.execute( + "INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)", + (mye_id, part_id, notes)) + fitments_created += 1 + + print(f" Vehículos creados: {vehicles_created}") + print(f" Fitments creados: {fitments_created}") + + # Create cross-references by matching Cartek parts to existing parts (Gonher, etc.) + # that fit the same vehicle + print("\n[5/5] Creando referencias cruzadas...") + xrefs_created = 0 + + for pn, part_id in part_ids.items(): + # Find other parts in the same group that fit the same vehicles + cursor.execute(""" + SELECT DISTINCT p2.id, p2.oem_part_number + FROM vehicle_parts vp1 + JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id + JOIN parts p2 ON vp2.part_id = p2.id + WHERE vp1.part_id = ? + AND p2.id != ? + AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?) + AND p2.oem_part_number NOT LIKE 'CTK%' + LIMIT 20 + """, (part_id, part_id, part_id)) + + for row in cursor.fetchall(): + # Add cross-ref from Cartek to other brand + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (part_id, row['oem_part_number'])) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Cartek Catalog')", + (part_id, row['oem_part_number'])) + xrefs_created += 1 + + # Add reverse cross-ref + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (row['id'], pn)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Cartek Catalog')", + (row['id'], pn)) + xrefs_created += 1 + + print(f" Cross-refs creadas: {xrefs_created}") + + conn.commit() + conn.close() + + print("\n" + "=" * 70) + print("IMPORTACIÓN CARTEK COMPLETADA") + print("=" * 70) + print(f""" +RESUMEN: + - Partes creadas: {parts_created:,} + - Vehículos creados: {vehicles_created:,} + - Fitments creados: {fitments_created:,} + - Cross-refs creadas: {xrefs_created:,} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/import_dar_catalog.py b/vehicle_database/scripts/import_dar_catalog.py new file mode 100644 index 0000000..5013da3 --- /dev/null +++ b/vehicle_database/scripts/import_dar_catalog.py @@ -0,0 +1,680 @@ +#!/usr/bin/env python3 +""" +IMPORTADOR DEL CATÁLOGO DAR "LÍNEA AZUL" 2020 +Formato: Brand → Model → AÑO DESCRIPCIÓN SKU #PÁG +Pages 27-571 contain vehicle application data. +PDF: /tmp/catalogs/suspension/catalogo_azul_2020.pdf +""" + +import sqlite3 +import re +import pypdf +from pathlib import Path +from collections import defaultdict + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' +PDF_PATH = '/tmp/catalogs/suspension/catalogo_azul_2020.pdf' + +# Page range (0-indexed) for vehicle application data +START_PAGE = 27 +END_PAGE = 571 + +# Known brand headers in the DAR catalog +DAR_BRANDS = { + 'ACURA', 'ALFA ROMEO', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', + 'CHEVROLET, GMC', 'CHRYSLER', 'DATSUN', 'DODGE', 'EAGLE', + 'FIAT', 'FORD, MERCURY', 'GEO', 'HONDA', 'HUMMER', 'HYUNDAI', + 'INFINITI', 'ISUZU', 'JAGUAR', 'JEEP', 'KIA', + 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES-BENZ', + 'MERKUR', 'MINI', 'MITSUBISHI', 'NISSAN', 'OLDSMOBILE', + 'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC', 'PORSCHE', + 'RAM', 'RENAULT', 'SAAB', 'SATURN', 'SCION', 'SEAT', 'SMART', + 'SUBARU', 'SUZUKI', 'TOYOTA', 'TRIUMPH', 'VOLKSWAGEN', + 'VOLVO', 'VOLVO/MASA', +} + +# Year range regex: 2-digit or 4-digit years, or TODOS +YEAR_RE = re.compile(r'^(\d{2,4})\s*-\s*(\d{2,4})\b') +YEAR_SINGLE_RE = re.compile(r'^(\d{2,4})\b') +TODOS_RE = re.compile(r'^TODOS\b', re.IGNORECASE) + +# Line ending with SKU + page ref: ...SKU_TOKEN 3-4_DIGIT_PAGEREF +ENTRY_END_RE = re.compile(r'^(.+?)\s+(\S+)\s+(\d{3,4})\s*$') + +# Skip patterns +SKIP_PATTERNS = [ + 'Línea Azul', + 'CATALOGO AZUL', + 'AÑO DESCRIPCIÓN SKU #PÁG', + 'AÑO DESCRIPCIÓN SKU', + '.indb', +] + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None): + cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute( + "INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)", + (name, type_, quality, country)) + return cursor.lastrowid + + +def ensure_brand(cursor, name): + cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,)) + return cursor.lastrowid + + +def ensure_model(cursor, brand_id, name): + cursor.execute( + "SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)", + (brand_id, name)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name)) + return cursor.lastrowid + + +def ensure_year(cursor, year): + cursor.execute("SELECT id FROM years WHERE year = ?", (year,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO years (year) VALUES (?)", (year,)) + return cursor.lastrowid + + +def get_generic_engine(cursor): + cursor.execute("SELECT id FROM engines WHERE name = 'Generic'") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')") + return cursor.lastrowid + + +def ensure_mye(cursor, model_id, year_id, engine_id=None): + if engine_id: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?", + (model_id, year_id, engine_id)) + else: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?", + (model_id, year_id)) + row = cursor.fetchone() + if row: + return row['id'] + if not engine_id: + engine_id = get_generic_engine(cursor) + cursor.execute( + "INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)", + (model_id, year_id, engine_id)) + return cursor.lastrowid + + +def get_or_create_part(cursor, part_number, group_id, name, name_es, description): + cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,)) + row = cursor.fetchone() + if row: + return row['id'], False + cursor.execute( + "INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)", + (part_number, name, name_es, group_id, description)) + return cursor.lastrowid, True + + +# --- Group ID lookup cache --- +_group_cache = {} + + +def get_group_id(cursor, name_en): + if name_en not in _group_cache: + cursor.execute("SELECT id FROM part_groups WHERE name = ?", (name_en,)) + row = cursor.fetchone() + _group_cache[name_en] = row['id'] if row else None + return _group_cache[name_en] + + +def classify_description(cursor, desc): + """Map DAR description text to a DB group_id.""" + d = desc.upper() + + # Amortiguadores (Shocks) + if 'AMORTIGUADOR' in d and 'BASE' not in d: + if 'CAJUELA' in d or 'COFRE' in d or 'VIDRIO' in d: + return get_group_id(cursor, 'Struts') # trunk/hood/glass struts + if 'DIRECCIÓN' in d or 'DIRECCION' in d: + return get_group_id(cursor, 'Steering Dampers') + return get_group_id(cursor, 'Shocks') + + # Base amortiguador (Strut Mounts) + if 'BASE AMORTIGUADOR' in d: + return get_group_id(cursor, 'Strut Mounts') + + # Balero (Bearings) + if 'BALERO' in d: + return get_group_id(cursor, 'Wheel Bearings') + + # Maza (Wheel Hubs) + if 'MAZA' in d: + return get_group_id(cursor, 'Wheel Hubs') + + # Soporte de Motor / Transmisión (Mounts) + if 'SOPORTE DE MOTOR' in d or 'SOPORTE MOTOR' in d: + return get_group_id(cursor, 'Engine Mounts') + if 'SOPORTE DE TRANSMIS' in d or 'SOPORTE TRANSMIS' in d: + return get_group_id(cursor, 'Transmission Mounts') + if 'SOPORTE' in d and 'AMORTIGUADOR' in d: + return get_group_id(cursor, 'Strut Mounts') + if 'SOPORTE BRAZO' in d: + return get_group_id(cursor, 'Idler Arms') + + # Rotula (Ball Joint) + if 'RÓTULA' in d or 'ROTULA' in d: + return get_group_id(cursor, 'Ball Joints') + + # Terminal exterior / dirección (Tie Rod Ends) + if 'TERMINAL EXTERIOR' in d or 'TERMINAL DIREC' in d: + return get_group_id(cursor, 'Tie Rod Ends') + + # Terminal interior (Inner Tie Rods) + if 'TERMINAL INTERIOR' in d: + return get_group_id(cursor, 'Inner Tie Rods') + + # Horquilla (Control Arms) + if 'HORQUILLA' in d: + return get_group_id(cursor, 'Control Arms') + + # Buje de varilla estabilizadora + if 'GOMA' in d and 'ESTABILIZADORA' in d: + return get_group_id(cursor, 'Sway Bar Bushings') + if 'BUJE' in d and 'ESTABILIZADORA' in d: + return get_group_id(cursor, 'Sway Bar Bushings') + + # Tornillo estabilizador (Sway Bar Links) + if 'TORNILLO ESTABILIZADOR' in d: + return get_group_id(cursor, 'Sway Bar Links') + + # Buje (Bushings) + if 'BUJE' in d: + return get_group_id(cursor, 'Bushings') + + # Resorte (Springs) + if 'RESORTE' in d: + return get_group_id(cursor, 'Coil Springs') + + # Brazo auxiliar (Idler Arm) + if 'BRAZO AUXILIAR' in d: + return get_group_id(cursor, 'Idler Arms') + + # Brazo Pitman + if 'BRAZO PITMAN' in d or 'PITMAN' in d: + return get_group_id(cursor, 'Pitman Arms') + + # Varilla / Barra central (Center Links) + if 'BARRA CENTRAL' in d or 'VARILLA CENTRAL' in d: + return get_group_id(cursor, 'Center Links') + + # Varilla lateral / Barra de arrastre (Drag Links) + if 'VARILLA' in d: + return get_group_id(cursor, 'Drag Links') + + # Cremallera (Steering Rack) + if 'CREMALLERA' in d: + return get_group_id(cursor, 'Steering Racks') + + # Bomba dirección (Power Steering Pump) + if 'BOMBA DIREC' in d: + return get_group_id(cursor, 'Power Steering Pumps') + + # Cople dirección (Steering Gearbox / Coupling) + if 'COPLE DIREC' in d: + return get_group_id(cursor, 'Steering Gearboxes') + + # Flector dirección + if 'FLECTOR' in d: + return get_group_id(cursor, 'Steering Gearboxes') + + # Nudo dirección (Steering Knuckle) + if 'NUDO DIREC' in d: + return get_group_id(cursor, 'Steering Knuckles') + + # Excéntrico (Camber/Caster) + if 'EXCÉNTRICO' in d or 'EXCENTRICO' in d or 'CAMBER' in d: + return get_group_id(cursor, 'Camber/Caster Kits') + + # Junta CV + if 'JUNTA' in d and ('RUEDA' in d or 'CAJA' in d): + return get_group_id(cursor, 'CV Joints') + + # Macheta / Flecha + if 'MACHETA' in d or 'FLECHA' in d: + return get_group_id(cursor, 'CV Axles') + + # Tirante (Trailing Arm) + if 'TIRANTE' in d: + return get_group_id(cursor, 'Trailing Arms') + + # Barra horquilla / Barra torsión + if 'BARRA' in d and 'TORSIÓN' in d: + return get_group_id(cursor, 'Torsion Bars') + if 'BARRA' in d and 'HORQUILLA' in d: + return get_group_id(cursor, 'Control Arms') + + # Default: Ball Joints + return get_group_id(cursor, 'Ball Joints') + + +# --- Part type name from description --- +def part_names_from_desc(desc, sku): + """Generate English and Spanish names from DAR description.""" + name_es = f"{desc} {sku}" + # Simplified English name + name_en = desc + for es, en in [ + ('AMORTIGUADOR DELANTERO', 'Front Shock'), + ('AMORTIGUADOR TRASERO', 'Rear Shock'), + ('AMORTIGUADOR', 'Shock Absorber'), + ('BASE AMORTIGUADOR', 'Strut Mount'), + ('BALERO DOBLE', 'Double Bearing'), + ('BALERO CONICO', 'Tapered Bearing'), + ('BALERO', 'Wheel Bearing'), + ('BOMBA DIREC', 'Power Steering Pump'), + ('BRAZO AUXILIAR', 'Idler Arm'), + ('BRAZO PITMAN', 'Pitman Arm'), + ('BUJE', 'Bushing'), + ('CREMALLERA', 'Steering Rack'), + ('COPLE DIREC', 'Steering Coupler'), + ('FLECTOR', 'Steering Flex Disc'), + ('GOMA VARILLA ESTABILIZADORA', 'Sway Bar Bushing'), + ('HORQUILLA INFERIOR', 'Lower Control Arm'), + ('HORQUILLA SUPERIOR', 'Upper Control Arm'), + ('HORQUILLA', 'Control Arm'), + ('MAZA DELANTERA', 'Front Wheel Hub'), + ('MAZA TRASERA', 'Rear Wheel Hub'), + ('MAZA', 'Wheel Hub'), + ('RESORTE DELANTERO', 'Front Coil Spring'), + ('RESORTE TRASERO', 'Rear Coil Spring'), + ('RESORTE', 'Coil Spring'), + ('RÓTULA INFERIOR', 'Lower Ball Joint'), + ('RÓTULA SUPERIOR', 'Upper Ball Joint'), + ('ROTULA INFERIOR', 'Lower Ball Joint'), + ('ROTULA SUPERIOR', 'Upper Ball Joint'), + ('RÓTULA', 'Ball Joint'), + ('ROTULA', 'Ball Joint'), + ('SOPORTE DE MOTOR', 'Engine Mount'), + ('SOPORTE DE TRANSMIS', 'Transmission Mount'), + ('TERMINAL EXTERIOR', 'Outer Tie Rod End'), + ('TERMINAL INTERIOR', 'Inner Tie Rod'), + ('TERMINAL DIREC', 'Tie Rod End'), + ('TIRANTE', 'Trailing Arm'), + ('TORNILLO ESTABILIZADOR', 'Sway Bar Link'), + ('VARILLA', 'Drag Link'), + ('EXCÉNTRICO', 'Camber Kit'), + ]: + if es in desc.upper(): + name_en = f"{en} {sku}" + break + else: + name_en = f"{desc} {sku}" + return name_en, name_es + + +def convert_year(yy): + """Convert 2-digit year to 4-digit. 00-30 → 2000-2030, 31-99 → 1931-1999.""" + y = int(yy) + if y >= 100: + return y # already 4-digit + if y <= 30: + return 2000 + y + return 1900 + y + + +def is_skip_line(line): + for pat in SKIP_PATTERNS: + if pat in line: + return True + # Pure page numbers + if re.match(r'^\d{1,3}$', line.strip()): + return True + return False + + +def is_brand_line(line): + """Check if line is a brand header.""" + stripped = line.strip() + if stripped in DAR_BRANDS: + return True + # Some brands have extra whitespace or minor variations + for b in DAR_BRANDS: + if stripped.upper() == b: + return True + return False + + +def parse_dar_pdf(pdf_path): + """Parse the DAR Catalogo Azul vehicle application pages.""" + pdf = pypdf.PdfReader(pdf_path) + entries = [] + current_brands = [] # List because some pages have "CHEVROLET, GMC" + current_model = None + + # Accumulator for multi-line entries + entry_year_from = None + entry_year_to = None + entry_lines = [] + + def flush_entry(): + nonlocal entry_year_from, entry_year_to, entry_lines + if not entry_lines or entry_year_from is None: + entry_lines = [] + entry_year_from = None + entry_year_to = None + return + + # Join accumulated lines + full_text = ' '.join(entry_lines) + + # Try to extract SKU and page ref from the end + m = ENTRY_END_RE.match(full_text) + if m: + desc_text = m.group(1).strip() + sku = m.group(2).strip() + # page_ref = m.group(3) # not used for import + + if sku and desc_text and current_model: + for brand_name in current_brands: + for year in range(entry_year_from, entry_year_to + 1): + entries.append({ + 'brand': brand_name, + 'model': current_model, + 'year': year, + 'description': desc_text, + 'sku': sku, + }) + + entry_lines = [] + entry_year_from = None + entry_year_to = None + + for page_num in range(START_PAGE, min(END_PAGE + 1, len(pdf.pages))): + text = pdf.pages[page_num].extract_text() + if not text: + continue + + lines = text.split('\n') + for line in lines: + line = line.strip() + if not line: + continue + if is_skip_line(line): + continue + + # Check for brand header + if is_brand_line(line): + flush_entry() + # Split combined brands like "CHEVROLET, GMC" + current_brands = [b.strip() for b in line.split(',')] + current_model = None + continue + + # Check for model line + # A model line is: not starting with a digit, not a data entry, + # not a brand, and we already have a brand + if not current_brands: + continue + + # Check if this line starts with a year range + m_year = YEAR_RE.match(line) + m_single = YEAR_SINGLE_RE.match(line) if not m_year else None + m_todos = TODOS_RE.match(line) + + if m_year or m_todos: + # Flush previous entry + flush_entry() + + if m_todos: + # "TODOS" = all years, use a reasonable range + entry_year_from = 1960 + entry_year_to = 2020 + rest = line[m_todos.end():].strip() + else: + y1 = convert_year(m_year.group(1)) + y2 = convert_year(m_year.group(2)) + entry_year_from = min(y1, y2) + entry_year_to = max(y1, y2) + rest = line[m_year.end():].strip() + + if rest: + entry_lines.append(rest) + continue + + # If we're accumulating an entry, add continuation line + if entry_year_from is not None: + entry_lines.append(line) + continue + + # Check if it's a single year + data (rare) + if m_single and len(line) > 4: + y_val = int(m_single.group(1)) + # Only treat as year if it's a plausible 2-digit year (not a 4+ digit number) + if y_val < 100 and len(m_single.group(1)) == 2: + flush_entry() + entry_year_from = convert_year(m_single.group(1)) + entry_year_to = entry_year_from + rest = line[m_single.end():].strip() + if rest: + entry_lines.append(rest) + continue + + # If we get here, it's likely a model name + # Strip "(cont)" suffix + model_name = re.sub(r'\s*\(cont\)\s*$', '', line, flags=re.IGNORECASE).strip() + if model_name and not model_name.startswith('AÑO') and len(model_name) > 1: + flush_entry() + current_model = model_name + + # Flush last entry + flush_entry() + return entries + + +def main(): + print("=" * 70) + print("IMPORTADOR - CATÁLOGO DAR 'LÍNEA AZUL' 2020") + print("=" * 70) + + print(f"\n[1/5] Leyendo PDF: {PDF_PATH}") + entries = parse_dar_pdf(PDF_PATH) + print(f" Entradas parseadas: {len(entries):,}") + + unique_skus = set(e['sku'] for e in entries) + unique_brands = set(e['brand'] for e in entries) + unique_models = set((e['brand'], e['model']) for e in entries) + print(f" SKUs únicos: {len(unique_skus):,}") + print(f" Marcas de vehículos: {len(unique_brands):,}") + print(f" Modelos únicos: {len(unique_models):,}") + + # Show sample entries + print("\n Primeras 5 entradas:") + for e in entries[:5]: + print(f" {e['brand']} {e['model']} {e['year']} | {e['description']} | {e['sku']}") + + conn = get_db() + cursor = conn.cursor() + + # Create DAR manufacturer + print("\n[2/5] Creando fabricante DAR...") + dar_mfr_id = ensure_manufacturer(cursor, 'DAR', 'aftermarket', 'standard', 'Mexico') + print(f" DAR manufacturer_id: {dar_mfr_id}") + + # Create parts + print("\n[3/5] Creando partes...") + part_ids = {} + parts_created = 0 + for sku in sorted(unique_skus): + # Find one entry with this SKU to get description + sample = next(e for e in entries if e['sku'] == sku) + group_id = classify_description(cursor, sample['description']) + name_en, name_es = part_names_from_desc(sample['description'], sku) + part_id, created = get_or_create_part( + cursor, sku, group_id, name_en, name_es, 'DAR Línea Azul') + part_ids[sku] = part_id + if created: + parts_created += 1 + + print(f" Partes creadas: {parts_created:,}") + print(f" Partes existentes: {len(unique_skus) - parts_created:,}") + + # Create aftermarket entries for DAR-specific parts + print(" Creando aftermarket entries...") + am_created = 0 + for sku in sorted(unique_skus): + part_id = part_ids.get(sku) + if not part_id: + continue + cursor.execute( + "SELECT id FROM aftermarket_parts WHERE manufacturer_id = ? AND part_number = ?", + (dar_mfr_id, sku)) + if not cursor.fetchone(): + sample = next(e for e in entries if e['sku'] == sku) + name_en, name_es = part_names_from_desc(sample['description'], sku) + cursor.execute( + "INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name, name_es) VALUES (?, ?, ?, ?, ?)", + (part_id, dar_mfr_id, sku, name_en, name_es)) + am_created += 1 + print(f" Aftermarket entries creadas: {am_created:,}") + + # Create vehicles and fitments + print("\n[4/5] Creando vehículos y fitments...") + vehicles_created = 0 + fitments_created = 0 + mye_cache = {} + + for i, entry in enumerate(entries): + if i % 10000 == 0 and i > 0: + print(f" Procesando {i:,}/{len(entries):,}...") + + cache_key = (entry['brand'], entry['model'], entry['year']) + if cache_key not in mye_cache: + brand_id = ensure_brand(cursor, entry['brand']) + model_id = ensure_model(cursor, brand_id, entry['model']) + year_id = ensure_year(cursor, entry['year']) + + # Try to find existing MYE + cursor.execute( + """SELECT mye.id FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? + LIMIT 1""", + (entry['brand'], entry['model'], entry['year'])) + existing = cursor.fetchone() + + if existing: + mye_cache[cache_key] = existing['id'] + else: + mye_id = ensure_mye(cursor, model_id, year_id) + mye_cache[cache_key] = mye_id + vehicles_created += 1 + + mye_id = mye_cache[cache_key] + part_id = part_ids.get(entry['sku']) + if not part_id: + continue + + # Check if fitment exists + cursor.execute( + "SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?", + (mye_id, part_id)) + if not cursor.fetchone(): + notes = f"Catálogo DAR Línea Azul 2020" + if entry.get('description'): + notes += f" - {entry['description']}" + cursor.execute( + "INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)", + (mye_id, part_id, notes)) + fitments_created += 1 + + print(f" Vehículos creados: {vehicles_created:,}") + print(f" Fitments creados: {fitments_created:,}") + + # Cross-references: match DAR parts to MOOG parts on same vehicles + print("\n[5/5] Creando referencias cruzadas...") + xrefs_created = 0 + + for sku, part_id in part_ids.items(): + # Find other parts (different brand) in same group fitting same vehicles + cursor.execute(""" + SELECT DISTINCT p2.id, p2.oem_part_number + FROM vehicle_parts vp1 + JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id + JOIN parts p2 ON vp2.part_id = p2.id + WHERE vp1.part_id = ? + AND p2.id != ? + AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?) + AND p2.oem_part_number != ? + LIMIT 30 + """, (part_id, part_id, part_id, sku)) + + for row in cursor.fetchall(): + other_pn = row['oem_part_number'] + # Skip if same part number prefix pattern (same brand) + if other_pn[:3] == sku[:3]: + continue + + # A -> B + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (part_id, other_pn)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'DAR Catalog')", + (part_id, other_pn)) + xrefs_created += 1 + + # B -> A + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (row['id'], sku)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'DAR Catalog')", + (row['id'], sku)) + xrefs_created += 1 + + print(f" Cross-refs creadas: {xrefs_created:,}") + + conn.commit() + conn.close() + + print("\n" + "=" * 70) + print("IMPORTACIÓN DAR COMPLETADA") + print("=" * 70) + print(f""" +RESUMEN: + - Partes creadas: {parts_created:,} + - Aftermarket entries: {am_created:,} + - Vehículos creados: {vehicles_created:,} + - Fitments creados: {fitments_created:,} + - Cross-refs creadas: {xrefs_created:,} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/import_fram_catalog.py b/vehicle_database/scripts/import_fram_catalog.py new file mode 100644 index 0000000..a0ecac6 --- /dev/null +++ b/vehicle_database/scripts/import_fram_catalog.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +""" +IMPORTADOR DEL CATÁLOGO FRAM 2017 +- Sección de vehículos livianos (páginas 3-87): Brand → Model + Motor + Dates + Filters +- Sección de equivalencias (páginas 149-199): Competitor → FRAM mappings +- Filtros: PH/CH = Aceite, CA/PA = Aire, G/P/PS = Combustible, CF/CFA = Cabina +""" + +import sqlite3 +import re +import pypdf +from pathlib import Path +from collections import defaultdict + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' +PDF_PATH = '/tmp/catalogs/fram_2017.pdf' + +# Filter type classification by part number prefix +FILTER_PREFIXES = { + 'PH': ('Oil Filters', 'Oil Filter', 'Filtro de Aceite'), + 'CH': ('Oil Filters', 'Oil Filter Cartridge', 'Filtro de Aceite Cartucho'), + 'CA': ('Air Filters', 'Air Filter', 'Filtro de Aire'), + 'PA': ('Air Filters', 'Air Filter', 'Filtro de Aire'), + 'G': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'), + 'P': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'), + 'PS': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'), + 'CF': ('Cabin Air Filters', 'Cabin Air Filter', 'Filtro de Cabina'), + 'CFA': ('Cabin Air Filters', 'Cabin Air Filter', 'Filtro de Cabina'), +} + +# FRAM part number pattern +FRAM_PART_RE = re.compile(r'\b(CFA?\d[\w-]*|PH\d[\w-]*|CH\d[\w-]*|CA\d[\w-]*|PA\d[\w-]*|PS\d[\w-]*|G\d[\w-]*|P\d[\w-]*)\b') + +# Known brands that appear as headers in the FRAM catalog +KNOWN_BRANDS = { + 'ACURA', 'ALEKO', 'ALFA ROMEO', 'ASIA MOTORS', 'ASTON MARTIN', 'AUDI', + 'BEDFORD', 'BENTLEY', 'BMW', 'BUICK', 'CADILLAC', 'CHANA', 'CHERY', + 'CHEVROLET', 'CHRYSLER', 'CITROEN', 'DAEWOO', 'DACIA', 'DAIHATSU', + 'DODGE', 'EAGLE', 'FAW', 'FIAT', 'FORD', 'GALLOPER', 'GEO', 'GEELY', + 'GREAT WALL', 'HONDA', 'HUMMER', 'HYUNDAI', 'INFINITI', 'ISUZU', + 'IVECO', 'JAC', 'JAGUAR', 'JEEP', 'KIA', 'LADA', 'LANCIA', 'LAND ROVER', + 'LEXUS', 'LIFAN', 'LINCOLN', 'LOTUS', 'MAHINDRA', 'MASERATI', 'MAZDA', + 'MERCEDES BENZ', 'MERCURY', 'MG', 'MINI', 'MITSUBISHI', 'NISSAN', + 'OLDSMOBILE', 'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC', 'PORSCHE', + 'RAM', 'RENAULT', 'ROVER', 'SAAB', 'SAMSUNG', 'SATURN', 'SCION', + 'SEAT', 'SKODA', 'SMART', 'SSANGYONG', 'SUBARU', 'SUZUKI', 'TATA', + 'TOYOTA', 'TRIUMPH', 'VAUXHALL', 'VOLKSWAGEN', 'VOLVO', +} + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None): + cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute( + "INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)", + (name, type_, quality, country)) + return cursor.lastrowid + + +def ensure_brand(cursor, name): + cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,)) + return cursor.lastrowid + + +def ensure_model(cursor, brand_id, name): + cursor.execute( + "SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)", + (brand_id, name)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name)) + return cursor.lastrowid + + +def ensure_year(cursor, year): + cursor.execute("SELECT id FROM years WHERE year = ?", (year,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO years (year) VALUES (?)", (year,)) + return cursor.lastrowid + + +def ensure_engine(cursor, name): + cursor.execute("SELECT id FROM engines WHERE name = ?", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + displacement = None + cylinders = None + fuel_type = 'gasoline' + m = re.search(r'(\d+)cc', name) + if m: + displacement = int(m.group(1)) + if 'diesel' in name.lower() or 'td' in name.lower() or 'tdi' in name.lower() or 'jtd' in name.lower(): + fuel_type = 'diesel' + cursor.execute( + "INSERT INTO engines (name, displacement_cc, cylinders, fuel_type) VALUES (?, ?, ?, ?)", + (name, displacement, cylinders, fuel_type)) + return cursor.lastrowid + + +def get_generic_engine(cursor): + cursor.execute("SELECT id FROM engines WHERE name = 'Generic'") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')") + return cursor.lastrowid + + +def ensure_mye(cursor, model_id, year_id, engine_id=None): + if engine_id: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?", + (model_id, year_id, engine_id)) + else: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?", + (model_id, year_id)) + row = cursor.fetchone() + if row: + return row['id'] + if not engine_id: + engine_id = get_generic_engine(cursor) + cursor.execute( + "INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)", + (model_id, year_id, engine_id)) + return cursor.lastrowid + + +def classify_filter(part_number): + """Classify FRAM filter by part number prefix and return (group_name, name_en, name_es).""" + pn_upper = part_number.upper() + # Check longer prefixes first + for prefix in ['CFA', 'CF', 'PS', 'PH', 'CH', 'CA', 'PA']: + if pn_upper.startswith(prefix): + return FILTER_PREFIXES[prefix] + # Single letter prefixes + if pn_upper.startswith('G') and re.match(r'^G\d', pn_upper): + return FILTER_PREFIXES['G'] + if pn_upper.startswith('P') and re.match(r'^P\d', pn_upper): + return FILTER_PREFIXES['P'] + return None + + +def get_or_create_group(cursor, group_name): + """Get group ID by name.""" + cursor.execute("SELECT id FROM part_groups WHERE name = ?", (group_name,)) + row = cursor.fetchone() + if row: + return row['id'] + # Find category + cat_map = { + 'Oil Filters': 'Engine', 'Air Filters': 'Engine', + 'Fuel Filters': 'Fuel & Air', 'Cabin Air Filters': 'Heat & Air Conditioning', + } + cat_name = cat_map.get(group_name, 'Engine') + cursor.execute("SELECT id FROM part_categories WHERE name = ?", (cat_name,)) + cat = cursor.fetchone() + if not cat: + return None + cursor.execute( + "INSERT INTO part_groups (category_id, name) VALUES (?, ?)", + (cat['id'], group_name)) + return cursor.lastrowid + + +def get_or_create_part(cursor, part_number, group_id, name, name_es, description): + cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,)) + row = cursor.fetchone() + if row: + return row['id'], False + cursor.execute( + "INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)", + (part_number, name, name_es, group_id, description)) + return cursor.lastrowid, True + + +def parse_date_range(date_str): + """Parse FRAM date range like (03/88 - 09/97) into year range.""" + m = re.match(r'\(?\s*(\d{2})/(\d{2,4})\s*-\s*(\d{2})/(\d{2,4})\s*\)?', date_str) + if m: + y1 = int(m.group(2)) + y2 = int(m.group(4)) + if y1 < 100: + y1 += 2000 if y1 < 50 else 1900 + if y2 < 100: + y2 += 2000 if y2 < 50 else 1900 + return list(range(y1, y2 + 1)) + # Try single year + m = re.match(r'\(?\s*(\d{2})/(\d{2,4})\s*-?\s*\)?', date_str) + if m: + y = int(m.group(2)) + if y < 100: + y += 2000 if y < 50 else 1900 + return [y] + return [] + + +def extract_fram_parts(text): + """Extract FRAM part numbers from a text string.""" + return FRAM_PART_RE.findall(text) + + +def parse_vehicle_entries(pdf): + """Parse vehicle entries from FRAM catalog (light vehicles section).""" + entries = [] + current_brand = None + current_model_group = None + + for page_num in range(2, 87): # Pages 3-87 (0-indexed) + text = pdf.pages[page_num].extract_text() + if not text: + continue + + lines = text.split('\n') + prev_line = "" + + for line in lines: + line = line.strip() + if not line: + continue + + # Skip headers/footers + if line.startswith('LIVIANOS') or line.startswith('PESADOS'): + continue + if re.match(r'^\d{1,3}$', line): + continue + if 'MARCA/CATEGORÍA' in line: + continue + # Skip dimension notes + if re.match(r'^H1=', line) or line.startswith('Parcial') or line.startswith('Panel') or line.startswith('Redondo'): + continue + if line.startswith('C/C.') or line.startswith('Unidad Sellada'): + continue + + # Brand detection + if line in KNOWN_BRANDS: + current_brand = line + current_model_group = None + continue + + # Check if line is a brand listed with other brands (e.g., "Acura - Aleko - Alfa Romeo") + if ' - ' in line and all(b.strip() in KNOWN_BRANDS for b in line.split(' - ') if b.strip()): + continue + + if not current_brand: + continue + + # Try to extract data from line + # Format: [MODEL_GROUP] description - Mot.CODE-DISPcc-Powerkw/hp (date_from - date_to) FILTER_CODES + + # Check if this is a continuation of previous line + if prev_line and not re.match(r'^[A-Z]', line) and not FRAM_PART_RE.search(line): + prev_line = "" + continue + + # Extract date range and parts + date_match = re.search(r'\((\d{2}/\d{2,4}\s*-\s*(?:\d{2}/\d{2,4}\s*)?)\)', line) + parts = extract_fram_parts(line) + + if parts: + years = [] + if date_match: + years = parse_date_range(date_match.group(1)) + + # Extract model name + model_name = None + # Check if line starts with an uppercase model group + model_match = re.match(r'^([A-Z][A-Z0-9\s/\-]+?)\s+\S', line) + if model_match: + potential_model = model_match.group(1).strip() + # If it looks like a model group (all caps, short) + if potential_model.isupper() and len(potential_model) < 30: + current_model_group = potential_model + model_name = current_model_group + else: + model_name = current_model_group or "Unknown" + else: + model_name = current_model_group or "Unknown" + + if not years: + years = [2017] # Default to catalog year + + for year in years: + for part in parts: + info = classify_filter(part) + if info: + entries.append({ + 'brand': current_brand, + 'model': model_name, + 'year': year, + 'part_number': part, + 'filter_type': info[0], + }) + + prev_line = line + + return entries + + +def parse_cross_references(pdf): + """Parse the equivalencias/cross-reference section.""" + xrefs = [] + + for page_num in range(148, min(200, len(pdf.pages))): + text = pdf.pages[page_num].extract_text() + if not text: + continue + if 'EQUIVALENCIAS' not in text and 'Código' not in text: + continue + + lines = text.split('\n') + for line in lines: + line = line.strip() + if not line or 'EQUIVALENCIAS' in line or 'Código' in line: + continue + if re.match(r'^\d{1,3}$', line): + continue + # Skip brand header lines + if re.match(r'^[A-Z][a-z]', line) and ' - ' in line: + continue + if line.istitle() or (line[0].isupper() and line[1:2].islower() and len(line.split()) <= 3): + continue + + # Parse: CompetitorNumber FRAMNumber + # FRAM numbers start with PH, CH, CA, PA, G, P, PS, CF, CFA + match = re.match(r'^(\S+)\s+((?:PH|CH|CA|PA|PS|CF|CFA|G|P)\w+)', line) + if match: + competitor_pn = match.group(1).strip() + fram_pn = match.group(2).strip() + # Skip if competitor number looks like a FRAM number + if re.match(r'^(PH|CH|CA|PA|PS|CF|CFA)', competitor_pn): + continue + xrefs.append({ + 'competitor': competitor_pn, + 'fram': fram_pn, + }) + + return xrefs + + +def main(): + print("=" * 70) + print("IMPORTADOR - CATÁLOGO FRAM 2017") + print("=" * 70) + + print(f"\n[1/6] Leyendo PDF: {PDF_PATH}") + pdf = pypdf.PdfReader(PDF_PATH) + print(f" Total páginas: {len(pdf.pages)}") + + print("\n[2/6] Extrayendo datos del catálogo...") + vehicle_entries = parse_vehicle_entries(pdf) + cross_refs = parse_cross_references(pdf) + print(f" Entradas de vehículos: {len(vehicle_entries)}") + print(f" Equivalencias (cross-refs): {len(cross_refs)}") + + # Get unique parts + unique_parts = {} + for e in vehicle_entries: + if e['part_number'] not in unique_parts: + info = classify_filter(e['part_number']) + if info: + unique_parts[e['part_number']] = info + print(f" Partes únicas: {len(unique_parts)}") + + # Also get parts from cross-refs + for xref in cross_refs: + if xref['fram'] not in unique_parts: + info = classify_filter(xref['fram']) + if info: + unique_parts[xref['fram']] = info + + print(f" Partes únicas (incl. cross-refs): {len(unique_parts)}") + + conn = get_db() + cursor = conn.cursor() + + # Create FRAM manufacturer + print("\n[3/6] Creando fabricante FRAM...") + # Check if Fram already exists (from Gonher import) + fram_mfr_id = ensure_manufacturer(cursor, 'FRAM', 'aftermarket', 'standard', 'USA') + print(f" FRAM manufacturer_id: {fram_mfr_id}") + + # Create parts + print("\n[4/6] Creando partes de filtros...") + part_ids = {} + parts_created = 0 + group_cache = {} + + for pn, (group_name, name_en, name_es) in unique_parts.items(): + if group_name not in group_cache: + group_cache[group_name] = get_or_create_group(cursor, group_name) + group_id = group_cache[group_name] + if not group_id: + continue + + full_name = f"{name_en} {pn}" + full_name_es = f"{name_es} {pn}" + part_id, created = get_or_create_part( + cursor, pn, group_id, full_name, full_name_es, "FRAM Filter") + part_ids[pn] = part_id + if created: + parts_created += 1 + + print(f" Partes creadas: {parts_created}") + + # Create vehicles and fitments + print("\n[5/6] Creando vehículos y fitments...") + vehicles_created = 0 + fitments_created = 0 + mye_cache = {} + + for entry in vehicle_entries: + part_id = part_ids.get(entry['part_number']) + if not part_id: + continue + + cache_key = (entry['brand'], entry['model'], entry['year']) + if cache_key not in mye_cache: + brand_id = ensure_brand(cursor, entry['brand']) + model_id = ensure_model(cursor, brand_id, entry['model']) + year_id = ensure_year(cursor, entry['year']) + + cursor.execute( + """SELECT mye.id FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? + LIMIT 1""", + (entry['brand'], entry['model'], entry['year'])) + existing = cursor.fetchone() + + if existing: + mye_cache[cache_key] = existing['id'] + else: + mye_id = ensure_mye(cursor, model_id, year_id) + mye_cache[cache_key] = mye_id + vehicles_created += 1 + + mye_id = mye_cache[cache_key] + + cursor.execute( + "SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?", + (mye_id, part_id)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)", + (mye_id, part_id, f"Catálogo FRAM 2017 - {entry['filter_type']}")) + fitments_created += 1 + + print(f" Vehículos creados: {vehicles_created}") + print(f" Fitments creados: {fitments_created}") + + # Create cross-references + print("\n[6/6] Creando referencias cruzadas...") + xrefs_created = 0 + + # A) From equivalencias section + for xref in cross_refs: + fram_part_id = part_ids.get(xref['fram']) + if not fram_part_id: + continue + + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (fram_part_id, xref['competitor'])) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Equivalencias 2017')", + (fram_part_id, xref['competitor'])) + xrefs_created += 1 + + # B) Match FRAM parts to other brands' parts by vehicle fitment + for pn, part_id in part_ids.items(): + cursor.execute(""" + SELECT DISTINCT p2.id, p2.oem_part_number + FROM vehicle_parts vp1 + JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id + JOIN parts p2 ON vp2.part_id = p2.id + WHERE vp1.part_id = ? + AND p2.id != ? + AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?) + AND p2.oem_part_number NOT LIKE 'PH%' + AND p2.oem_part_number NOT LIKE 'CH%' + AND p2.oem_part_number NOT LIKE 'CA%' + AND p2.oem_part_number NOT LIKE 'PA%' + AND p2.oem_part_number NOT LIKE 'CF%' + AND p2.oem_part_number NOT LIKE 'CFA%' + LIMIT 20 + """, (part_id, part_id, part_id)) + + for row in cursor.fetchall(): + # Cross-ref FRAM → other + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (part_id, row['oem_part_number'])) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Catalog 2017')", + (part_id, row['oem_part_number'])) + xrefs_created += 1 + + # Reverse cross-ref + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (row['id'], pn)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Catalog 2017')", + (row['id'], pn)) + xrefs_created += 1 + + print(f" Cross-refs creadas: {xrefs_created}") + + conn.commit() + conn.close() + + print("\n" + "=" * 70) + print("IMPORTACIÓN FRAM COMPLETADA") + print("=" * 70) + print(f""" +RESUMEN: + - Partes creadas: {parts_created:,} + - Vehículos creados: {vehicles_created:,} + - Fitments creados: {fitments_created:,} + - Cross-refs creadas: {xrefs_created:,} + - Equivalencias leídas: {len(cross_refs):,} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/import_moog_catalog.py b/vehicle_database/scripts/import_moog_catalog.py new file mode 100644 index 0000000..ddee06b --- /dev/null +++ b/vehicle_database/scripts/import_moog_catalog.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +""" +IMPORTADOR DEL CATÁLOGO MOOG - SUSPENSIÓN Y DIRECCIÓN +Funciona para los 3 volúmenes: + Vol 1: ≤1989 /tmp/catalogs/suspension/moog_vol1_1989back.pdf pages 4-1037 + Vol 2: 1990-2005 /tmp/catalogs/suspension/moog_vol2_1990_2005.pdf pages 7-1641 + Vol 3: 2006+ /tmp/catalogs/suspension/moog_vol3_2006up.pdf pages 8-1089 +""" + +import sqlite3 +import re +import sys +import pypdf +from pathlib import Path +from collections import defaultdict + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' + +VOLUMES = { + '1': { + 'path': '/tmp/catalogs/suspension/moog_vol1_1989back.pdf', + 'start_page': 3, # 0-indexed + 'end_page': 1037, + 'label': 'Vol 1 (≤1989)', + }, + '2': { + 'path': '/tmp/catalogs/suspension/moog_vol2_1990_2005.pdf', + 'start_page': 6, + 'end_page': 1641, + 'label': 'Vol 2 (1990-2005)', + }, + '3': { + 'path': '/tmp/catalogs/suspension/moog_vol3_2006up.pdf', + 'start_page': 7, + 'end_page': 1089, + 'label': 'Vol 3 (2006+)', + }, +} + +MOOG_BRANDS = { + 'ACURA', 'ALFA ROMEO', 'AMERICAN MOTORS', 'AMERICAN MOTORS CORP.', + 'ASTON MARTIN', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', + 'CHEVROLET', 'CHEVROLET TRUCK', 'CHRYSLER', + 'DATSUN', 'DODGE', 'DODGE TRUCK', + 'EAGLE', 'FIAT', 'FORD', 'FORD TRUCK', 'FREIGHTLINER', + 'GEO', 'GEO TRUCK', 'GENERAL MOTORS TRUCK', + 'HONDA', 'HUMMER', 'HYUNDAI', + 'INFINITI', 'INTERNATIONAL', 'ISUZU', 'ISUZU TRUCK', + 'JAGUAR', 'JEEP', 'KIA', + 'LAFORZA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'LOTUS', + 'MAZDA', 'MAZDA TRUCK', 'MERCEDES BENZ', 'MERCEDES-BENZ', + 'MERCURY', 'MERKUR', 'MINI', 'MITSUBISHI', 'MITSUBISHI TRUCK', + 'NISSAN', 'NISSAN TRUCK', + 'OLDSMOBILE', 'OPEL', + 'PEUGEOT', 'PLYMOUTH', 'PLYMOUTH TRUCK', 'PONTIAC', 'PORSCHE', + 'RAM TRUCK', 'RENAULT', 'ROLLS ROYCE', + 'SAAB', 'SATURN', 'SCION', 'SEAT', 'SHELBY', 'SMART', 'STERLING', + 'SUBARU', 'SUBARU TRUCK', 'SUZUKI', 'SUZUKI TRUCK', + 'TOYOTA', 'TOYOTA TRUCK', 'TRIUMPH', + 'VOLKSWAGEN', 'VOLKSWAGEN TRUCK', 'VOLVO', 'VOLVO TRUCK', + 'WILLYS MOTORS INC.', +} + +# MOOG part number regex +MOOG_PART_RE = re.compile( + r'\b(K\d{3,7}T?|ES\d{3,7}[A-Z]{0,3}T?|EV\d{3,7}[A-Z]?|DS\d{3,7}' + r'|CC\d{3,6}|CK\d{3,7}|SSD\d{2,4}|BK\d{3,4}[A-Z]?' + r'|SB\d{3,4}|NIBJ\d+|VO[A-Z]{2}\d+|HY[A-Z]{2}\d+|AU[A-Z]{2}\d+|BM[A-Z]{2}\d+)\b' +) + +# Numeric-only springs (only used within spring category context) +SPRING_NUM_RE = re.compile(r'\b(\d{4,6})\b') + +# Figure code +FIGURE_RE = re.compile(r'\b([FSR]\d{3})\b') + +# Year range at start of line +YEAR_RE = re.compile(r'^(\d{4})(?:\s*-\s*(\d{4}))?') + +# System sections +SYSTEM_PATTERNS = { + 'SUSPENSION DELANTERA': 'front_suspension', + 'SUSPENSIÓN DELANTERA': 'front_suspension', + 'DIRECCIÓN': 'steering', + 'DIRECCION': 'steering', + 'SUSPENSION TRASERA': 'rear_suspension', + 'SUSPENSIÓN TRASERA': 'rear_suspension', +} + +# Header/footer markers to skip +SKIP_MARKERS = [ + 'www.moogproblemsolver.com', + 'CATÁLOGO MASTER', + 'CATALOGO MASTER', + 'Solucionador de problemas', + 'búsqueda de piezas electrónicas', + 'FMe-cat.mx', + 'Año Observaciones', + 'Total Solución', + 'P/C\nCTD', + 'Imagenes de piezas', +] + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_manufacturer(cursor, name, type_='aftermarket', quality='premium', country=None): + cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute( + "INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)", + (name, type_, quality, country)) + return cursor.lastrowid + + +def ensure_brand(cursor, name): + cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,)) + return cursor.lastrowid + + +def ensure_model(cursor, brand_id, name): + cursor.execute( + "SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)", + (brand_id, name)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name)) + return cursor.lastrowid + + +def ensure_year(cursor, year): + cursor.execute("SELECT id FROM years WHERE year = ?", (year,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO years (year) VALUES (?)", (year,)) + return cursor.lastrowid + + +def get_generic_engine(cursor): + cursor.execute("SELECT id FROM engines WHERE name = 'Generic'") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')") + return cursor.lastrowid + + +def ensure_mye(cursor, model_id, year_id, engine_id=None): + if engine_id: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?", + (model_id, year_id, engine_id)) + else: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?", + (model_id, year_id)) + row = cursor.fetchone() + if row: + return row['id'] + if not engine_id: + engine_id = get_generic_engine(cursor) + cursor.execute( + "INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)", + (model_id, year_id, engine_id)) + return cursor.lastrowid + + +def get_or_create_part(cursor, part_number, group_id, name, name_es, description): + cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,)) + row = cursor.fetchone() + if row: + return row['id'], False + cursor.execute( + "INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)", + (part_number, name, name_es, group_id, description)) + return cursor.lastrowid, True + + +# --- Group ID lookup cache --- +_group_cache = {} + + +def get_group_id(cursor, name_en): + """Get group ID by English name.""" + if name_en not in _group_cache: + cursor.execute("SELECT id FROM part_groups WHERE name = ?", (name_en,)) + row = cursor.fetchone() + _group_cache[name_en] = row['id'] if row else None + return _group_cache[name_en] + + +def classify_part(cursor, category_text, part_number): + """Map MOOG category text + part number to a DB group_id.""" + cat = category_text.lower() if category_text else '' + + # By category text (Spanish) + if 'rótula' in cat and 'suspensión' in cat: + return get_group_id(cursor, 'Ball Joints') + if 'rótula' in cat and 'prensad' in cat: + return get_group_id(cursor, 'Ball Joints') + if 'brazo de control' in cat and 'rótula' in cat: + return get_group_id(cursor, 'Control Arms') + if 'ensamble de brazo' in cat: + return get_group_id(cursor, 'Control Arms') + if 'brazo de control' in cat: + return get_group_id(cursor, 'Control Arms') + if 'horquilla' in cat: + return get_group_id(cursor, 'Control Arms') + if 'buje' in cat and 'estabilizadora' in cat: + return get_group_id(cursor, 'Sway Bar Bushings') + if 'buje' in cat and 'brazo' in cat: + return get_group_id(cursor, 'Bushings') + if 'buje' in cat and 'amortiguador' in cat: + return get_group_id(cursor, 'Bushings') + if 'buje' in cat and 'tracción' in cat: + return get_group_id(cursor, 'Bushings') + if 'buje' in cat and 'camber' in cat: + return get_group_id(cursor, 'Camber/Caster Kits') + if 'buje' in cat: + return get_group_id(cursor, 'Bushings') + if 'cople' in cat and 'estabilizadora' in cat: + return get_group_id(cursor, 'Sway Bar Links') + if 'soporte' in cat and ('strut' in cat.lower() or 'amortiguador' in cat): + return get_group_id(cursor, 'Strut Mounts') + if 'montaje' in cat and 'amortiguador' in cat: + return get_group_id(cursor, 'Strut Mounts') + if 'fuelle' in cat or 'cubrepolvo' in cat: + return get_group_id(cursor, 'Struts') + if 'asiento' in cat and 'resorte' in cat: + return get_group_id(cursor, 'Spring Seats') + if 'ensamble de terminal' in cat: + return get_group_id(cursor, 'Tie Rod Ends') + if 'terminal' in cat and 'dirección' in cat: + if part_number and part_number.startswith('EV'): + return get_group_id(cursor, 'Inner Tie Rods') + return get_group_id(cursor, 'Tie Rod Ends') + if 'barra central' in cat: + return get_group_id(cursor, 'Center Links') + if 'barra de arrastre' in cat or 'barra de acoplamiento' in cat: + return get_group_id(cursor, 'Drag Links') + if 'varilla de dirección' in cat: + return get_group_id(cursor, 'Drag Links') + if 'resorte' in cat and 'suspensión' in cat: + return get_group_id(cursor, 'Coil Springs') + if 'camber' in cat or 'caster' in cat: + return get_group_id(cursor, 'Camber/Caster Kits') + if 'brazo auxiliar' in cat or 'brazo loco' in cat: + return get_group_id(cursor, 'Idler Arms') + if 'brazo pitman' in cat: + return get_group_id(cursor, 'Pitman Arms') + if 'amortiguador de dirección' in cat: + return get_group_id(cursor, 'Steering Dampers') + if 'pasador' in cat and 'dirección' in cat: + return get_group_id(cursor, 'King Pin Sets') + if 'muelle' in cat: + return get_group_id(cursor, 'Leaf Springs') + if 'barra de torsión' in cat: + return get_group_id(cursor, 'Torsion Bars') + + # Fallback by part prefix + if part_number: + if part_number.startswith('ES'): + return get_group_id(cursor, 'Tie Rod Ends') + if part_number.startswith('EV'): + return get_group_id(cursor, 'Inner Tie Rods') + if part_number.startswith('DS'): + return get_group_id(cursor, 'Center Links') + if part_number.startswith('CC') or (part_number.isdigit() and len(part_number) >= 4): + return get_group_id(cursor, 'Coil Springs') + if part_number.startswith('SSD'): + return get_group_id(cursor, 'Steering Dampers') + if part_number.startswith('CK'): + return get_group_id(cursor, 'Control Arms') + if part_number.startswith('BK'): + return get_group_id(cursor, 'King Pin Sets') + if part_number.startswith('SB'): + return get_group_id(cursor, 'Bushings') + + return get_group_id(cursor, 'Ball Joints') # Default + + +# --- Part type names for DB --- + +PART_TYPE_NAMES = { + 'Ball Joints': ('Ball Joint', 'Rótula de Suspensión'), + 'Bushings': ('Bushing', 'Buje'), + 'Sway Bar Bushings': ('Sway Bar Bushing', 'Buje de Barra Estabilizadora'), + 'Control Arms': ('Control Arm', 'Brazo de Control'), + 'Sway Bar Links': ('Sway Bar Link', 'Cople de Barra Estabilizadora'), + 'Strut Mounts': ('Strut Mount', 'Soporte de Strut'), + 'Struts': ('Strut Boot', 'Fuelle de Strut'), + 'Spring Seats': ('Spring Seat', 'Asiento de Resorte'), + 'Tie Rod Ends': ('Tie Rod End', 'Terminal de Dirección'), + 'Inner Tie Rods': ('Inner Tie Rod', 'Terminal Interior de Dirección'), + 'Center Links': ('Center Link', 'Barra Central'), + 'Drag Links': ('Drag Link', 'Barra de Arrastre'), + 'Coil Springs': ('Coil Spring', 'Resorte Helicoidal'), + 'Camber/Caster Kits': ('Camber/Caster Kit', 'Kit de Camber/Caster'), + 'Idler Arms': ('Idler Arm', 'Brazo Auxiliar'), + 'Pitman Arms': ('Pitman Arm', 'Brazo Pitman'), + 'Steering Dampers': ('Steering Damper', 'Amortiguador de Dirección'), + 'King Pin Sets': ('King Pin Set', 'Juego de Pivote'), + 'Leaf Springs': ('Leaf Spring', 'Muelle'), + 'Torsion Bars': ('Torsion Bar', 'Barra de Torsión'), +} + + +# --- Parsing --- + +def is_skip_line(line): + """Check if line is header/footer to skip.""" + return any(m in line for m in SKIP_MARKERS) + + +def parse_brand_model(line): + """Try to parse a brand-model line. Returns (brand, model) or (None, None).""" + for dash in ['−', '–', '—', '-']: + if dash not in line: + continue + parts = line.split(dash, 1) + if len(parts) != 2: + continue + left = re.sub(r'\s*\(Cont\.?\)\.?\s*', '', parts[0]).strip() + right = re.sub(r'\s*\(Cont\.?\)\.?\s*', '', parts[1]).strip() + if not left or not right: + continue + + left_up = left.upper() + right_up = right.upper() + + # Check which side matches a known brand + for brand in MOOG_BRANDS: + if left_up == brand or left_up.startswith(brand + ' '): + return left, right + if right_up == brand or right_up.startswith(brand + ' '): + return right, left + + # Heuristic: if left is all uppercase words and right has mixed case + if left.isupper() and len(left) > 2: + return left, right + if right.isupper() and len(right) > 2: + return right, left + + return None, None + + +def detect_system(line): + """Check if line is a system section header.""" + clean = line.strip().upper() + for pattern, system in SYSTEM_PATTERNS.items(): + if clean.startswith(pattern.upper()): + return system + return None + + +CATEGORY_KEYWORDS = [ + 'Rótula', 'Rotula', 'Buje', 'Brazo de control', 'Brazo auxiliar', + 'Brazo pitman', 'Brazo loco', 'Cople', 'Soporte', 'Fuelle', + 'Asiento del resorte', 'Terminal de dirección', 'Terminal de direccion', + 'Ensamble de terminal', 'Ensamble de brazo', 'Barra central', + 'Barra de arrastre', 'Barra de dirección', 'Varilla', + 'Juego de resortes', 'Resorte de suspensión', 'Juego para ajuste', + 'Placa para ajuste', 'Seguro guia', 'Amortiguador de dirección', + 'Pasador de dirección', 'Horquilla', 'Muelle', + 'Juego de coples', 'Juego de soporte', 'Juego de montaje', + 'Montaje del amortiguador', +] + + +def is_category_line(line): + """Check if line is a part category header.""" + for kw in CATEGORY_KEYWORDS: + if kw.lower() in line.lower(): + # Make sure it doesn't also contain a part number (data line) + if not MOOG_PART_RE.search(line): + return True + return False + + +def parse_moog_pdf(pdf_path, start_page, end_page): + """Parse a MOOG catalog PDF and return entries.""" + pdf = pypdf.PdfReader(pdf_path) + entries = [] + + current_brand = None + current_model = None + current_submodel = None + current_system = None + current_figure = None + current_category = None + current_year_from = None + current_year_to = None + + total = min(len(pdf.pages), end_page) + + for page_num in range(start_page, total): + if (page_num - start_page) % 100 == 0: + print(f" Página {page_num + 1}/{total}...") + + text = pdf.pages[page_num].extract_text() + if not text: + continue + + lines = text.split('\n') + + for line in lines: + line = line.strip() + if not line: + continue + if is_skip_line(line): + continue + + # Skip standalone page numbers + if re.match(r'^\d{1,4}$', line) and not current_category: + continue + + # Brand-model line + brand, model = parse_brand_model(line) + if brand and model: + current_brand = brand + current_model = model + current_submodel = None + current_system = None + current_figure = None + current_category = None + continue + + # System section + system = detect_system(line) + if system: + current_system = system + current_category = None + current_submodel = None + # Check for figure code on same line or next + fig = FIGURE_RE.search(line) + if fig: + current_figure = fig.group(1) + continue + + # Standalone figure code line + fig_match = re.match(r'^([FSR]\d{3})$', line.strip()) + if fig_match: + current_figure = fig_match.group(1) + continue + + # Figure code with comma (e.g., "F530,\nF531") + fig_multi = re.match(r'^([FSR]\d{3}),?$', line.strip()) + if fig_multi and not YEAR_RE.match(line): + current_figure = fig_multi.group(1) + continue + + if not current_brand or not current_model: + continue + + # Part category header + if is_category_line(line): + current_category = line.strip() + continue + + # Data line with year + year_match = YEAR_RE.match(line) + if year_match: + y1 = int(year_match.group(1)) + y2 = int(year_match.group(2)) if year_match.group(2) else y1 + if 1930 <= y1 <= 2025 and 1930 <= y2 <= 2025: + current_year_from = min(y1, y2) + current_year_to = max(y1, y2) + + # Extract MOOG part numbers from line + parts_found = MOOG_PART_RE.findall(line) + + # Also check for numeric springs in spring context + if current_category and 'resorte' in current_category.lower(): + for m in SPRING_NUM_RE.finditer(line): + num = m.group(1) + if len(num) >= 4 and not any(num == p for p in parts_found): + # Avoid matching years + n = int(num) + if not (1930 <= n <= 2025): + parts_found.append(num) + + if not parts_found or not current_year_from: + continue + + # Build entries for each part found + model_name = current_model + if current_submodel: + model_name = f"{current_model} {current_submodel}" + + for pn in parts_found: + # Clean part number (remove trailing T for Problem Solver) + clean_pn = pn.rstrip('T') if pn.endswith('T') and len(pn) > 4 else pn + + for year in range(current_year_from, current_year_to + 1): + entries.append({ + 'brand': current_brand, + 'model': model_name, + 'year': year, + 'system': current_system or 'front_suspension', + 'figure': current_figure, + 'category': current_category or '', + 'part_number': clean_pn, + 'notes': line.strip(), + }) + + return entries + + +def normalize_brand(brand): + """Normalize MOOG brand names to standard form.""" + mappings = { + 'CHEVROLET TRUCK': 'CHEVROLET', + 'DODGE TRUCK': 'DODGE', + 'FORD TRUCK': 'FORD', + 'GENERAL MOTORS TRUCK': 'GMC', + 'GEO TRUCK': 'GEO', + 'ISUZU TRUCK': 'ISUZU', + 'MAZDA TRUCK': 'MAZDA', + 'MITSUBISHI TRUCK': 'MITSUBISHI', + 'NISSAN TRUCK': 'NISSAN', + 'PLYMOUTH TRUCK': 'PLYMOUTH', + 'SUBARU TRUCK': 'SUBARU', + 'SUZUKI TRUCK': 'SUZUKI', + 'TOYOTA TRUCK': 'TOYOTA', + 'VOLKSWAGEN TRUCK': 'VOLKSWAGEN', + 'VOLVO TRUCK': 'VOLVO', + 'AMERICAN MOTORS CORP.': 'AMERICAN MOTORS', + 'AMERICAN MOTORS': 'AMERICAN MOTORS', + 'MERCEDES BENZ': 'MERCEDES-BENZ', + 'WILLYS MOTORS INC.': 'WILLYS', + 'RAM TRUCK': 'RAM', + } + up = brand.upper().strip() + return mappings.get(up, brand.strip()) + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in VOLUMES: + print("Uso: python3 import_moog_catalog.py <1|2|3>") + print(" 1 = Vol 1 (≤1989)") + print(" 2 = Vol 2 (1990-2005)") + print(" 3 = Vol 3 (2006+)") + sys.exit(1) + + vol = sys.argv[1] + config = VOLUMES[vol] + + print("=" * 70) + print(f"IMPORTADOR - CATÁLOGO MOOG {config['label']}") + print("=" * 70) + + print(f"\n[1/5] Leyendo PDF: {config['path']}") + entries = parse_moog_pdf(config['path'], config['start_page'], config['end_page']) + print(f" Entradas parseadas: {len(entries):,}") + + unique_parts = {} + for e in entries: + if e['part_number'] not in unique_parts: + unique_parts[e['part_number']] = e['category'] + + unique_brands = set(normalize_brand(e['brand']) for e in entries) + print(f" Partes únicas: {len(unique_parts):,}") + print(f" Marcas de vehículos: {len(unique_brands)}") + + conn = get_db() + cursor = conn.cursor() + + print("\n[2/5] Creando fabricante MOOG...") + moog_mfr_id = ensure_manufacturer(cursor, 'MOOG', 'aftermarket', 'premium', 'USA') + print(f" MOOG manufacturer_id: {moog_mfr_id}") + + print("\n[3/5] Creando partes...") + part_ids = {} + parts_created = 0 + + for pn, cat_text in sorted(unique_parts.items()): + group_id = classify_part(cursor, cat_text, pn) + if not group_id: + group_id = get_group_id(cursor, 'Ball Joints') + + # Get group name for part description + cursor.execute("SELECT name FROM part_groups WHERE id = ?", (group_id,)) + group_row = cursor.fetchone() + group_name = group_row['name'] if group_row else 'Suspension Part' + + names = PART_TYPE_NAMES.get(group_name, (group_name, group_name)) + name_en = f"{names[0]} {pn}" + name_es = f"{names[1]} {pn}" + + part_id, created = get_or_create_part( + cursor, pn, group_id, name_en, name_es, f"MOOG {names[0]}") + part_ids[pn] = part_id + if created: + parts_created += 1 + + print(f" Partes creadas: {parts_created:,}") + print(f" Partes existentes: {len(unique_parts) - parts_created:,}") + + print("\n[4/5] Creando vehículos y fitments...") + vehicles_created = 0 + fitments_created = 0 + mye_cache = {} + + for i, entry in enumerate(entries): + if i % 10000 == 0 and i > 0: + print(f" Procesando {i:,}/{len(entries):,}...") + + brand_name = normalize_brand(entry['brand']) + cache_key = (brand_name.upper(), entry['model'].upper(), entry['year']) + + if cache_key not in mye_cache: + brand_id = ensure_brand(cursor, brand_name) + model_id = ensure_model(cursor, brand_id, entry['model']) + year_id = ensure_year(cursor, entry['year']) + + cursor.execute(""" + SELECT mye.id FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? + LIMIT 1 + """, (brand_name, entry['model'], entry['year'])) + existing = cursor.fetchone() + + if existing: + mye_cache[cache_key] = existing['id'] + else: + mye_id = ensure_mye(cursor, model_id, year_id) + mye_cache[cache_key] = mye_id + vehicles_created += 1 + + mye_id = mye_cache[cache_key] + part_id = part_ids.get(entry['part_number']) + if not part_id: + continue + + cursor.execute( + "SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?", + (mye_id, part_id)) + if not cursor.fetchone(): + notes = f"MOOG Catalog {config['label']}" + if entry['figure']: + notes += f" - Fig {entry['figure']}" + if entry['system']: + notes += f" - {entry['system']}" + cursor.execute( + "INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)", + (mye_id, part_id, notes)) + fitments_created += 1 + + print(f" Vehículos creados: {vehicles_created:,}") + print(f" Fitments creados: {fitments_created:,}") + + # Store diagram references + print("\n[5/5] Guardando referencias de diagramas...") + figures_seen = set() + # Get a default group_id for diagrams + susp_group = get_group_id(cursor, 'Ball Joints') or 164 + for entry in entries: + if entry['figure'] and entry['figure'] not in figures_seen: + figures_seen.add(entry['figure']) + cursor.execute("SELECT id FROM diagrams WHERE name = ?", (entry['figure'],)) + if not cursor.fetchone(): + sys_label = { + 'front_suspension': 'Suspensión Delantera', + 'steering': 'Dirección', + 'rear_suspension': 'Suspensión Trasera', + }.get(entry.get('system'), 'Suspensión') + cursor.execute( + "INSERT INTO diagrams (name, name_es, group_id, image_path, source) VALUES (?, ?, ?, ?, ?)", + (entry['figure'], f"MOOG {sys_label} - {entry['figure']}", + susp_group, f"moog/{entry['figure']}.png", 'MOOG Catalog')) + + print(f" Diagramas registrados: {len(figures_seen)}") + + conn.commit() + conn.close() + + print("\n" + "=" * 70) + print(f"IMPORTACIÓN MOOG {config['label']} COMPLETADA") + print("=" * 70) + print(f""" +RESUMEN: + - Partes creadas: {parts_created:,} + - Vehículos creados: {vehicles_created:,} + - Fitments creados: {fitments_created:,} + - Diagramas: {len(figures_seen)} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/scripts/import_wix_catalog.py b/vehicle_database/scripts/import_wix_catalog.py new file mode 100644 index 0000000..ad08c7f --- /dev/null +++ b/vehicle_database/scripts/import_wix_catalog.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +""" +IMPORTADOR DEL CATÁLOGO WIX 2021 - FILTROS +Formato: Brand → Year → Model → Engine + filter columns +Páginas 77-687: Autos de pasajeros / camionetas ligeras +PDF: /tmp/catalogs/wix_2021.pdf +""" + +import sqlite3 +import re +import pypdf +from pathlib import Path + +DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db' +PDF_PATH = '/tmp/catalogs/wix_2021.pdf' + +BRAND_HEADERS = { + 'ACURA', 'ALFA ROMEO', 'AM GENERAL', 'AMERICAN MOTORS', 'ASTON MARTIN', + 'ASUNA', 'AUDI', 'AUSTIN', 'AUSTIN HEALEY', 'AVANTI', 'BENTLEY', 'BMW', + 'BUICK', 'CADILLAC', 'CHECKER', 'CHEVROLET', 'CHRYSLER', 'DAEWOO', + 'DAIHATSU', 'DATSUN', 'DELOREAN', 'DODGE', 'EAGLE', 'FIAT', 'FORD', + 'FREIGHTLINER', 'GEO', 'GMC', 'HILLMAN', 'HONDA', 'HUMMER', 'HYUNDAI', + 'INFINITI', 'INTERNATIONAL', 'ISUZU', 'JAGUAR', 'JEEP', 'KIA', + 'LAFORZA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'LOTUS', 'MACK', 'MAZDA', + 'MERCEDES-BENZ', 'MERCURY', 'MERKUR', 'MINI', 'MITSUBISHI', 'MORGAN', + 'NISSAN', 'OLDSMOBILE', 'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC', + 'PORSCHE', 'RAM', 'RENAULT', 'ROLLS ROYCE', 'SAAB', 'SATURN', 'SCION', + 'SEAT', 'SHELBY', 'SMART', 'SRT', 'STUDEBAKER', 'SUBARU', 'SUNBEAM', + 'SUZUKI', 'TOYOTA', 'TRIUMPH', 'VOLKSWAGEN', 'VOLVO', 'WORKHORSE', + 'WORKHORSE CUSTOM CHASSIS', +} + +ENGINE_RE = re.compile(r'^[VLH]\s*\d+\s+\d+\.\d+L', re.IGNORECASE) + +FOOTER_MARKERS = [ + 'Pass Car/Light Truck', + 'Year/Año/Année', + 'Model/Modelo/Modèle', + 'N/A = Not Available', + 'N/A = Non disponible', + 'N/A = No disponible', + 'Italicized Part Numbers', + 'Las piezas con números', + 'Les numéros de pièc', + 'Engine/Motor/Moteur', + 'Eng. Code', + 'Código de', + 'Code moteur', + 'Oil XP', + 'Aceite XP', + 'Cabina Aire', + 'Cabin Air XP', + 'Combustible', + 'Transmisión', + 'Carburant', +] + +FILTER_GROUPS = { + 'oil': ('Oil Filters', 'Filtros de Aceite', 'Engine'), + 'air': ('Air Filters', 'Filtros de Aire', 'Engine'), + 'cabin_air': ('Cabin Air Filters', 'Filtros de Aire de Cabina', 'HVAC'), + 'fuel': ('Fuel Filters', 'Filtros de Combustible', 'Fuel System'), + 'transmission': ('Transmission Filters', 'Filtros de Transmisión', 'Transmission'), +} + +TYPE_NAMES = { + 'oil': ('Oil Filter', 'Filtro de Aceite'), + 'oil_xp': ('Oil Filter XP', 'Filtro de Aceite XP'), + 'air': ('Air Filter', 'Filtro de Aire'), + 'air_xp': ('Air Filter XP', 'Filtro de Aire XP'), + 'cabin_air': ('Cabin Air Filter', 'Filtro de Aire de Cabina'), + 'cabin_air_xp': ('Cabin Air Filter XP', 'Filtro de Aire de Cabina XP'), + 'fuel': ('Fuel Filter', 'Filtro de Combustible'), + 'fuel_xp': ('Fuel Filter XP', 'Filtro de Combustible XP'), + 'transmission': ('Transmission Filter', 'Filtro de Transmisión'), + 'transmission_xp': ('Transmission Filter XP', 'Filtro de Transmisión XP'), +} + +SKIP_VALUES = {'N/A', 'N/R', 'N/S', 'MT72', '-'} + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None): + cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute( + "INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)", + (name, type_, quality, country)) + return cursor.lastrowid + + +def ensure_brand(cursor, name): + cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,)) + return cursor.lastrowid + + +def ensure_model(cursor, brand_id, name): + cursor.execute( + "SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)", + (brand_id, name)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name)) + return cursor.lastrowid + + +def ensure_year(cursor, year): + cursor.execute("SELECT id FROM years WHERE year = ?", (year,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO years (year) VALUES (?)", (year,)) + return cursor.lastrowid + + +def get_generic_engine(cursor): + cursor.execute("SELECT id FROM engines WHERE name = 'Generic'") + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')") + return cursor.lastrowid + + +def ensure_mye(cursor, model_id, year_id, engine_id=None): + if engine_id: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?", + (model_id, year_id, engine_id)) + else: + cursor.execute( + "SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?", + (model_id, year_id)) + row = cursor.fetchone() + if row: + return row['id'] + if not engine_id: + engine_id = get_generic_engine(cursor) + cursor.execute( + "INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)", + (model_id, year_id, engine_id)) + return cursor.lastrowid + + +def get_or_create_part(cursor, part_number, group_id, name, name_es, description): + cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,)) + row = cursor.fetchone() + if row: + return row['id'], False + cursor.execute( + "INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)", + (part_number, name, name_es, group_id, description)) + return cursor.lastrowid, True + + +def get_filter_group(cursor, filter_type): + name_en, name_es, category_name = FILTER_GROUPS[filter_type] + cursor.execute("SELECT id FROM part_groups WHERE name = ? LIMIT 1", (name_en,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute("SELECT id FROM part_categories WHERE name = ? LIMIT 1", (category_name,)) + cat = cursor.fetchone() + if not cat: + cursor.execute( + "INSERT INTO part_categories (name, name_es) VALUES (?, ?)", + (category_name, category_name)) + cat_id = cursor.lastrowid + else: + cat_id = cat['id'] + cursor.execute( + "INSERT INTO part_groups (category_id, name, name_es) VALUES (?, ?, ?)", + (cat_id, name_en, name_es)) + return cursor.lastrowid + + +# --- Part number extraction --- + +def extract_wix_part(token): + """Extract WIX part number from token, stripping footnote suffixes.""" + token = token.strip().rstrip('.') + if not token or token in SKIP_VALUES: + return None + + # XP variants: 5digits+XP + xp_match = re.match(r'^(\d{5}XP)', token) + if xp_match: + return xp_match.group(1) + + # Alpha-prefixed parts + wl = re.match(r'^(WL\d{4,6})', token) + if wl: + return wl.group(1) + wa = re.match(r'^(WA\d{4,5})', token) + if wa: + return wa.group(1) + wp = re.match(r'^(WP\d{4,5})', token) + if wp: + return wp.group(1) + wf = re.match(r'^(WF\d{4})', token) + if wf: + return wf.group(1) + + # Numeric 5-digit WIX parts + num = re.match(r'^(\d{5})', token) + if num: + pn = num.group(1) + p2 = pn[:2] + if p2 in ('51', '57', '42', '43', '44', '45', '46', '47', '48', '49', + '24', '33', '58'): + return pn + + return None + + +def classify_filter(pn): + """Classify a WIX part number by filter type.""" + if not pn: + return None + if pn.endswith('XP'): + base_type = classify_filter(pn[:-2]) + return f"{base_type}_xp" if base_type else None + if pn.startswith('WL'): + return 'oil' + if pn.startswith('WA'): + return 'air' + if pn.startswith('WP'): + return 'cabin_air' + if pn.startswith('WF'): + return 'fuel' + if re.match(r'^5[17]\d{3}$', pn): + return 'oil' + if re.match(r'^4[2-9]\d{3}$', pn): + return 'air' + if re.match(r'^24\d{3}$', pn): + return 'cabin_air' + if re.match(r'^33\d{3}$', pn): + return 'fuel' + if re.match(r'^58\d{3}$', pn): + return 'transmission' + return None + + +def extract_parts_from_tokens(tokens): + """Extract all unique WIX part numbers from tokens.""" + parts = [] + seen = set() + for token in tokens: + pn = extract_wix_part(token) + if pn and pn not in seen: + ftype = classify_filter(pn) + if ftype: + parts.append((pn, ftype)) + seen.add(pn) + return parts + + +# --- Line classification --- + +def is_footer_line(line): + return any(m in line for m in FOOTER_MARKERS) + + +def is_continuation(line): + """Check if line continues engine data (not a new model/brand/year).""" + tokens = line.split() + if not tokens: + return False + first = tokens[0] + if first in ('Electric/Gas', 'Turbo', 'Diesel', 'Hybrid', 'O'): + return True + if first.startswith('N/'): + return True + if first.startswith('MT'): + return True + if re.match(r'^(WL|WA|WP|WF)\d', first): + return True + if re.match(r'^\d{5}', first): + return True + if first == '-': + return True + # Single/double digit + more tokens with part numbers + if re.match(r'^\d{1,2}$', first) and len(tokens) > 1: + for t in tokens[1:4]: + if extract_wix_part(t): + return True + return False + + +# --- PDF parsing --- + +def parse_wix_pdf(pdf_path): + """Parse WIX 2021 catalog pages 77-687.""" + pdf = pypdf.PdfReader(pdf_path) + entries = [] + + current_brand = None + current_year = None + current_model = None + current_tokens = [] + + def flush_engine(): + nonlocal current_tokens + if current_brand and current_year and current_model and current_tokens: + parts = extract_parts_from_tokens(current_tokens) + if parts: + entries.append({ + 'brand': current_brand, + 'model': current_model, + 'year': current_year, + 'parts': parts, + }) + current_tokens = [] + + total_pages = min(len(pdf.pages), 687) + for page_num in range(76, total_pages): + if (page_num - 76) % 50 == 0: + print(f" Procesando página {page_num + 1}/{total_pages}...") + + text = pdf.pages[page_num].extract_text() + if not text: + continue + + for line in text.split('\n'): + line = line.strip() + if not line: + continue + + # Skip footer lines + if is_footer_line(line): + continue + + # Clean continuation markers + clean = re.sub(r"\s*\(Cont'd/Suite\)\s*", '', line).strip() + if not clean: + continue + + # Brand header + upper_clean = clean.upper() + if upper_clean in BRAND_HEADERS: + flush_engine() + current_brand = clean + current_year = None + current_model = None + continue + + # Year + year_match = re.match(r'^(\d{4})$', clean) + if year_match: + y = int(year_match.group(1)) + if 1940 <= y <= 2025: + flush_engine() + current_year = y + current_model = None + continue + + if not current_brand or not current_year: + continue + + # Engine line + if ENGINE_RE.match(clean): + flush_engine() + current_tokens = clean.split() + continue + + # Continuation of engine data + if current_tokens and is_continuation(clean): + current_tokens.extend(clean.split()) + continue + + # Model name (must contain alpha characters) + if re.search(r'[A-Za-z]', clean): + flush_engine() + current_model = clean + continue + + flush_engine() + return entries + + +def main(): + print("=" * 70) + print("IMPORTADOR - CATÁLOGO WIX 2021") + print("=" * 70) + + print(f"\n[1/6] Leyendo PDF: {PDF_PATH}") + entries = parse_wix_pdf(PDF_PATH) + print(f" Entradas parseadas: {len(entries)}") + + unique_parts = {} + for entry in entries: + for pn, ftype in entry['parts']: + if pn not in unique_parts: + unique_parts[pn] = ftype + + unique_brands = set(e['brand'] for e in entries) + print(f" Partes únicas: {len(unique_parts)}") + print(f" Marcas de vehículos: {len(unique_brands)}") + + conn = get_db() + cursor = conn.cursor() + + print("\n[2/6] Creando fabricante WIX...") + wix_mfr_id = ensure_manufacturer(cursor, 'WIX', 'aftermarket', 'premium', 'USA') + print(f" WIX manufacturer_id: {wix_mfr_id}") + + print("\n[3/6] Creando partes de filtros...") + group_ids = {} + for ftype in FILTER_GROUPS: + group_ids[ftype] = get_filter_group(cursor, ftype) + group_ids[f"{ftype}_xp"] = group_ids[ftype] + + part_ids = {} + parts_created = 0 + for pn, ftype in sorted(unique_parts.items()): + gid = group_ids.get(ftype) + if not gid: + continue + name_en, name_es = TYPE_NAMES.get(ftype, ('Filter', 'Filtro')) + part_id, created = get_or_create_part( + cursor, pn, gid, + f"{name_en} {pn}", f"{name_es} {pn}", + f"WIX {name_en}") + part_ids[pn] = part_id + if created: + parts_created += 1 + + print(f" Partes creadas: {parts_created}") + print(f" Partes existentes: {len(unique_parts) - parts_created}") + + print("\n[4/6] Creando vehículos y fitments...") + vehicles_created = 0 + fitments_created = 0 + mye_cache = {} + + for i, entry in enumerate(entries): + if i % 5000 == 0 and i > 0: + print(f" Procesando entrada {i}/{len(entries)}...") + + cache_key = (entry['brand'].upper(), entry['model'].upper(), entry['year']) + if cache_key not in mye_cache: + brand_id = ensure_brand(cursor, entry['brand']) + model_id = ensure_model(cursor, brand_id, entry['model']) + year_id = ensure_year(cursor, entry['year']) + + cursor.execute(""" + SELECT mye.id FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? + LIMIT 1 + """, (entry['brand'], entry['model'], entry['year'])) + existing = cursor.fetchone() + + if existing: + mye_cache[cache_key] = existing['id'] + else: + mye_id = ensure_mye(cursor, model_id, year_id) + mye_cache[cache_key] = mye_id + vehicles_created += 1 + + mye_id = mye_cache[cache_key] + + for pn, ftype in entry['parts']: + part_id = part_ids.get(pn) + if not part_id: + continue + + cursor.execute( + "SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?", + (mye_id, part_id)) + if not cursor.fetchone(): + notes = f"Catálogo WIX 2021 - {ftype.replace('_', ' ').upper()}" + cursor.execute( + "INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)", + (mye_id, part_id, notes)) + fitments_created += 1 + + print(f" Vehículos creados: {vehicles_created}") + print(f" Fitments creados: {fitments_created}") + + print("\n[5/6] Creando referencias cruzadas...") + xrefs_created = 0 + wix_part_id_set = set(part_ids.values()) + + for i, (pn, part_id) in enumerate(part_ids.items()): + if i % 200 == 0 and i > 0: + print(f" Procesando cross-ref {i}/{len(part_ids)}...") + + cursor.execute(""" + SELECT DISTINCT p2.id, p2.oem_part_number + FROM vehicle_parts vp1 + JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id + JOIN parts p2 ON vp2.part_id = p2.id + WHERE vp1.part_id = ? + AND p2.id != ? + AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?) + LIMIT 50 + """, (part_id, part_id, part_id)) + + for row in cursor.fetchall(): + if row['id'] in wix_part_id_set: + continue + + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (part_id, row['oem_part_number'])) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'WIX 2021 Catalog')", + (part_id, row['oem_part_number'])) + xrefs_created += 1 + + cursor.execute( + "SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?", + (row['id'], pn)) + if not cursor.fetchone(): + cursor.execute( + "INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'WIX 2021 Catalog')", + (row['id'], pn)) + xrefs_created += 1 + + print(f" Cross-refs creadas: {xrefs_created}") + + conn.commit() + conn.close() + + print("\n" + "=" * 70) + print("IMPORTACIÓN WIX COMPLETADA") + print("=" * 70) + print(f""" +RESUMEN: + - Partes creadas: {parts_created:,} + - Vehículos creados: {vehicles_created:,} + - Fitments creados: {fitments_created:,} + - Cross-refs creadas: {xrefs_created:,} +""") + + +if __name__ == '__main__': + main() diff --git a/vehicle_database/vehicle_database.db b/vehicle_database/vehicle_database.db index c9d0cb2..8ff8b15 100644 Binary files a/vehicle_database/vehicle_database.db and b/vehicle_database/vehicle_database.db differ