@@ -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 = `
+
${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 = '
';
+ 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 = '
';
+ }
+ }
+
+ _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 = '
';
+ 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.
+
+
+
+
+ Marca
+
+
+ Modelo
+
+
+ Año
+
+
+ Motor
+
+
+ Ver Todos
+
+
+
+
+
+
+
+
+
+
Diagramas: 0
+
Frontal: 0
+
Direccion: 0
+
Trasera: 0
+
+
+
+
+
+
+
+
+
+
+ Todos
+ Delantera
+ Dirección
+ Trasera
+
+
+
+
+
+
+
+
+
Selecciona un vehículo arriba para ver sus diagramas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
Saltar al contenido
-
-
+
+
+
+
@@ -1924,6 +2101,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+
+
+
+
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 = ''
+ + '';
+
+ 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