Add admin panel, enhanced search, Gonher import and expand API
- Add admin interface (admin.html, admin.js) for managing catalog data - Add enhanced search module with advanced filtering capabilities - Expand server.py with new API endpoints and admin functionality - Add Gonher catalog import scripts (import_gonher_catalog.py, import_gonher_complete.py) - Add demo data population script and sample CSV data - Update customer landing page and dashboard with UI improvements - Update database with enriched vehicle and parts data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1599
dashboard/admin.html
Normal file
1599
dashboard/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
1690
dashboard/admin.js
Normal file
1690
dashboard/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-links a.admin-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.admin-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1061,6 +1072,7 @@
|
|||||||
<a href="#brands-section">Marcas</a>
|
<a href="#brands-section">Marcas</a>
|
||||||
<a href="#featured-section">Productos</a>
|
<a href="#featured-section">Productos</a>
|
||||||
<a href="#cta-section">Contacto</a>
|
<a href="#cta-section">Contacto</a>
|
||||||
|
<a href="admin.html" class="admin-link">⚡ Admin</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="search-btn" onclick="openSearchModal()">🔍</button>
|
<button class="search-btn" onclick="openSearchModal()">🔍</button>
|
||||||
|
|||||||
@@ -503,16 +503,16 @@ class VehicleDashboard {
|
|||||||
const myeRecords = await myeRes.json();
|
const myeRecords = await myeRes.json();
|
||||||
|
|
||||||
// Merge mye_id into vehicles based on matching fields
|
// Merge mye_id into vehicles based on matching fields
|
||||||
|
// Only keep vehicles that have a matching mye_id (i.e., have parts)
|
||||||
this.allVehicles = vehicles.map(v => {
|
this.allVehicles = vehicles.map(v => {
|
||||||
const mye = myeRecords.find(m =>
|
const mye = myeRecords.find(m =>
|
||||||
m.brand === v.brand &&
|
m.brand === v.brand &&
|
||||||
m.model === v.model &&
|
m.model === v.model &&
|
||||||
m.year === v.year &&
|
m.year === v.year &&
|
||||||
m.engine === v.engine &&
|
m.engine === v.engine
|
||||||
(m.trim_level === v.trim_level || (!m.trim_level && !v.trim_level) || (m.trim_level === 'unknown' && v.trim_level === 'unknown'))
|
|
||||||
);
|
);
|
||||||
return { ...v, mye_id: mye ? mye.id : null };
|
return { ...v, mye_id: mye ? mye.id : null };
|
||||||
});
|
}).filter(v => v.mye_id !== null); // Only show vehicles with parts
|
||||||
this.filteredVehicles = [...this.allVehicles];
|
this.filteredVehicles = [...this.allVehicles];
|
||||||
|
|
||||||
// Poblar filtros
|
// Poblar filtros
|
||||||
@@ -714,7 +714,79 @@ class VehicleDashboard {
|
|||||||
document.getElementById('filtersBar').classList.remove('visible');
|
document.getElementById('filtersBar').classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate to vehicle from search results
|
||||||
|
async navigateToVehicle(myeId, brand, model, year) {
|
||||||
|
// Set the state for breadcrumb navigation
|
||||||
|
this.selectedBrand = brand;
|
||||||
|
this.selectedModel = model;
|
||||||
|
this.selectedYear = year;
|
||||||
|
|
||||||
|
// Add vehicle to allVehicles if not already there (for breadcrumb)
|
||||||
|
if (!this.allVehicles.find(v => v.mye_id === myeId)) {
|
||||||
|
this.allVehicles.push({
|
||||||
|
mye_id: myeId,
|
||||||
|
brand: brand,
|
||||||
|
model: model,
|
||||||
|
year: year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to categories
|
||||||
|
await this.goToCategories(myeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to vehicle and directly to a specific category
|
||||||
|
async navigateToVehicleCategory(myeId, brand, model, year, categoryId) {
|
||||||
|
// Set the state for breadcrumb navigation
|
||||||
|
this.selectedBrand = brand;
|
||||||
|
this.selectedModel = model;
|
||||||
|
this.selectedYear = year;
|
||||||
|
|
||||||
|
// Add vehicle to allVehicles if not already there (for breadcrumb)
|
||||||
|
if (!this.allVehicles.find(v => v.mye_id === myeId)) {
|
||||||
|
this.allVehicles.push({
|
||||||
|
mye_id: myeId,
|
||||||
|
brand: brand,
|
||||||
|
model: model,
|
||||||
|
year: year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedVehicleId = myeId;
|
||||||
|
|
||||||
|
// Load categories if not available (needed for breadcrumb)
|
||||||
|
if (this.allCategories.length === 0) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories');
|
||||||
|
if (response.ok) {
|
||||||
|
this.allCategories = await response.json();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading categories:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate directly to the category's groups
|
||||||
|
await this.goToGroups(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
async goToCategories(myeId) {
|
async goToCategories(myeId) {
|
||||||
|
// Validate myeId before proceeding
|
||||||
|
if (!myeId || myeId === 'null' || myeId === 'undefined') {
|
||||||
|
const container = document.getElementById('mainContent');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<h4>Vehículo sin partes disponibles</h4>
|
||||||
|
<p>Este vehículo no tiene partes registradas en el catálogo.</p>
|
||||||
|
<button class="btn btn-back mt-3" onclick="dashboard.goToModels('${this.selectedBrand}')">
|
||||||
|
<i class="fas fa-arrow-left"></i> Volver a modelos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.currentView = 'categories';
|
this.currentView = 'categories';
|
||||||
this.selectedVehicleId = myeId;
|
this.selectedVehicleId = myeId;
|
||||||
this.selectedCategory = null;
|
this.selectedCategory = null;
|
||||||
@@ -737,8 +809,8 @@ class VehicleDashboard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all categories (since we don't have vehicle-specific parts yet, show all categories)
|
// Get vehicle-specific categories (only categories with parts for this vehicle)
|
||||||
const response = await fetch('/api/categories');
|
const response = await fetch(`/api/vehicles/${myeId}/categories`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al cargar categorías');
|
throw new Error('Error al cargar categorías');
|
||||||
@@ -824,7 +896,15 @@ class VehicleDashboard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/categories/${categoryId}/groups`);
|
// Use vehicle-specific endpoint when a vehicle is selected
|
||||||
|
let url;
|
||||||
|
if (this.selectedVehicleId) {
|
||||||
|
url = `/api/vehicles/${this.selectedVehicleId}/groups?category_id=${categoryId}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/categories/${categoryId}/groups`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al cargar grupos');
|
throw new Error('Error al cargar grupos');
|
||||||
@@ -921,15 +1001,23 @@ class VehicleDashboard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/parts?group_id=${groupId}`);
|
// Use vehicle-specific endpoint when a vehicle is selected
|
||||||
|
let url;
|
||||||
|
if (this.selectedVehicleId) {
|
||||||
|
url = `/api/vehicles/${this.selectedVehicleId}/parts?group_id=${groupId}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/parts?group_id=${groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error al cargar partes');
|
throw new Error('Error al cargar partes');
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsData = await response.json();
|
const partsData = await response.json();
|
||||||
// Handle paginated response
|
// Handle both array response (vehicle parts) and paginated response
|
||||||
this.allParts = partsData.data || partsData;
|
this.allParts = Array.isArray(partsData) ? partsData : (partsData.data || partsData);
|
||||||
this.displayParts();
|
this.displayParts();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
677
dashboard/enhanced-search.js
Normal file
677
dashboard/enhanced-search.js
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Search Component
|
||||||
|
* Features: Autocomplete, filters, recent searches, keyboard navigation, highlighting
|
||||||
|
*/
|
||||||
|
|
||||||
|
const enhancedSearch = {
|
||||||
|
// Configuration
|
||||||
|
config: {
|
||||||
|
minChars: 2,
|
||||||
|
debounceMs: 300,
|
||||||
|
maxResults: 8,
|
||||||
|
maxRecent: 5,
|
||||||
|
storageKey: 'autopartes_recent_searches'
|
||||||
|
},
|
||||||
|
|
||||||
|
// State
|
||||||
|
state: {
|
||||||
|
query: '',
|
||||||
|
results: { parts: [], vehicles: [] },
|
||||||
|
highlightedIndex: -1,
|
||||||
|
isOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
filtersVisible: false,
|
||||||
|
debounceTimer: null
|
||||||
|
},
|
||||||
|
|
||||||
|
// DOM elements cache
|
||||||
|
elements: {},
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
init() {
|
||||||
|
this.cacheElements();
|
||||||
|
this.loadCategories();
|
||||||
|
this.renderRecentSearches();
|
||||||
|
this.setupClickOutside();
|
||||||
|
},
|
||||||
|
|
||||||
|
cacheElements() {
|
||||||
|
this.elements = {
|
||||||
|
input: document.getElementById('searchInput'),
|
||||||
|
dropdown: document.getElementById('searchDropdown'),
|
||||||
|
loading: document.getElementById('searchLoading'),
|
||||||
|
filters: document.getElementById('searchFilters'),
|
||||||
|
recent: document.getElementById('searchRecent'),
|
||||||
|
recentItems: document.getElementById('searchRecentItems'),
|
||||||
|
resultsContainer: document.getElementById('searchResultsContainer'),
|
||||||
|
partsResults: document.getElementById('partsResults'),
|
||||||
|
partsResultsList: document.getElementById('partsResultsList'),
|
||||||
|
vehiclesResults: document.getElementById('vehiclesResults'),
|
||||||
|
vehiclesResultsList: document.getElementById('vehiclesResultsList'),
|
||||||
|
noResults: document.getElementById('searchNoResults'),
|
||||||
|
footer: document.getElementById('searchFooter'),
|
||||||
|
categoryFilter: document.getElementById('searchCategoryFilter'),
|
||||||
|
typeFilter: document.getElementById('searchTypeFilter')
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load categories for filter
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories');
|
||||||
|
const categories = await response.json();
|
||||||
|
|
||||||
|
if (this.elements.categoryFilter) {
|
||||||
|
this.elements.categoryFilter.innerHTML = '<option value="">Todas las categorías</option>' +
|
||||||
|
this.flattenCategories(categories).map(cat =>
|
||||||
|
`<option value="${cat.id}">${cat.name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading categories:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
flattenCategories(categories, result = []) {
|
||||||
|
categories.forEach(cat => {
|
||||||
|
result.push(cat);
|
||||||
|
if (cat.children && cat.children.length) {
|
||||||
|
this.flattenCategories(cat.children, result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
onInput(value) {
|
||||||
|
this.state.query = value.trim();
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (this.state.debounceTimer) {
|
||||||
|
clearTimeout(this.state.debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.query.length < this.config.minChars) {
|
||||||
|
this.showRecent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
this.state.debounceTimer = setTimeout(() => {
|
||||||
|
this.performSearch();
|
||||||
|
}, this.config.debounceMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeydown(event) {
|
||||||
|
const allItems = this.getAllResultItems();
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightNext(allItems);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightPrevious(allItems);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.state.highlightedIndex >= 0 && allItems[this.state.highlightedIndex]) {
|
||||||
|
allItems[this.state.highlightedIndex].click();
|
||||||
|
} else if (this.state.query.length >= this.config.minChars) {
|
||||||
|
this.viewAllResults();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
this.close();
|
||||||
|
this.elements.input.blur();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Tab':
|
||||||
|
// Autocomplete with highlighted item
|
||||||
|
if (this.state.isOpen && this.state.highlightedIndex >= 0 && allItems[this.state.highlightedIndex]) {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = allItems[this.state.highlightedIndex];
|
||||||
|
const autocompleteText = item.dataset.autocomplete;
|
||||||
|
if (autocompleteText && this.elements.input) {
|
||||||
|
this.elements.input.value = autocompleteText;
|
||||||
|
this.state.query = autocompleteText;
|
||||||
|
this.performSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onFocus() {
|
||||||
|
if (this.state.query.length >= this.config.minChars) {
|
||||||
|
this.open();
|
||||||
|
} else {
|
||||||
|
this.showRecent();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation helpers
|
||||||
|
getAllResultItems() {
|
||||||
|
return Array.from(this.elements.dropdown.querySelectorAll('.search-result-item'));
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightNext(items) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
this.state.highlightedIndex++;
|
||||||
|
if (this.state.highlightedIndex >= items.length) {
|
||||||
|
this.state.highlightedIndex = 0;
|
||||||
|
}
|
||||||
|
this.updateHighlight(items);
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPrevious(items) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
this.state.highlightedIndex--;
|
||||||
|
if (this.state.highlightedIndex < 0) {
|
||||||
|
this.state.highlightedIndex = items.length - 1;
|
||||||
|
}
|
||||||
|
this.updateHighlight(items);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHighlight(items) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
item.classList.toggle('highlighted', index === this.state.highlightedIndex);
|
||||||
|
if (index === this.state.highlightedIndex) {
|
||||||
|
item.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search
|
||||||
|
async performSearch() {
|
||||||
|
const query = this.state.query;
|
||||||
|
const categoryId = this.elements.categoryFilter?.value;
|
||||||
|
const searchType = this.elements.typeFilter?.value || 'all';
|
||||||
|
|
||||||
|
this.showLoading(true);
|
||||||
|
this.open();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/search?q=${encodeURIComponent(query)}&limit=${this.config.maxResults}`;
|
||||||
|
if (categoryId) url += `&category_id=${categoryId}`;
|
||||||
|
if (searchType !== 'all') url += `&type=${searchType}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.state.results = {
|
||||||
|
parts: data.parts || [],
|
||||||
|
vehicles: data.vehicles || [],
|
||||||
|
vehicleParts: data.vehicle_parts || [],
|
||||||
|
matchedVehicle: data.matched_vehicle || null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderResults();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Search error:', e);
|
||||||
|
this.showNoResults();
|
||||||
|
} finally {
|
||||||
|
this.showLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
if (this.state.query.length >= this.config.minChars) {
|
||||||
|
this.performSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rendering
|
||||||
|
renderResults() {
|
||||||
|
const { parts, vehicles, vehicleParts, matchedVehicle } = this.state.results;
|
||||||
|
const hasVehicleParts = vehicleParts && vehicleParts.length > 0;
|
||||||
|
const hasResults = parts.length > 0 || vehicles.length > 0 || hasVehicleParts;
|
||||||
|
|
||||||
|
// Hide recent searches when showing results
|
||||||
|
if (this.elements.recent) this.elements.recent.style.display = 'none';
|
||||||
|
|
||||||
|
// If we have matched vehicle + parts (combined search result)
|
||||||
|
if (hasVehicleParts && matchedVehicle && this.elements.partsResults && this.elements.partsResultsList) {
|
||||||
|
this.elements.partsResults.style.display = 'block';
|
||||||
|
|
||||||
|
// Show vehicle header
|
||||||
|
const vehicleHeader = `
|
||||||
|
<div class="vehicle-parts-header">
|
||||||
|
<i class="fas fa-car"></i>
|
||||||
|
<span>${matchedVehicle.brand} ${matchedVehicle.model} ${matchedVehicle.year}</span>
|
||||||
|
<small>${matchedVehicle.engine}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.elements.partsResultsList.innerHTML = vehicleHeader + vehicleParts.map((part, index) =>
|
||||||
|
this.renderVehiclePartItem(part, matchedVehicle, index)
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Hide regular vehicles section
|
||||||
|
if (this.elements.vehiclesResults) {
|
||||||
|
this.elements.vehiclesResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular parts
|
||||||
|
if (parts.length > 0 && this.elements.partsResults && this.elements.partsResultsList) {
|
||||||
|
this.elements.partsResults.style.display = 'block';
|
||||||
|
this.elements.partsResultsList.innerHTML = parts.map((part, index) =>
|
||||||
|
this.renderPartItem(part, index)
|
||||||
|
).join('');
|
||||||
|
} else if (this.elements.partsResults) {
|
||||||
|
this.elements.partsResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
if (vehicles.length > 0 && this.elements.vehiclesResults && this.elements.vehiclesResultsList) {
|
||||||
|
this.elements.vehiclesResults.style.display = 'block';
|
||||||
|
this.elements.vehiclesResultsList.innerHTML = vehicles.map((vehicle, index) =>
|
||||||
|
this.renderVehicleItem(vehicle, parts.length + index)
|
||||||
|
).join('');
|
||||||
|
} else if (this.elements.vehiclesResults) {
|
||||||
|
this.elements.vehiclesResults.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No results
|
||||||
|
if (this.elements.noResults) {
|
||||||
|
this.elements.noResults.style.display = hasResults ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
if (this.elements.footer) {
|
||||||
|
this.elements.footer.style.display = hasResults ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-highlight first result for quick Enter selection
|
||||||
|
if (hasResults) {
|
||||||
|
this.state.highlightedIndex = 0;
|
||||||
|
const firstItem = this.elements.dropdown.querySelector('.search-result-item');
|
||||||
|
if (firstItem) {
|
||||||
|
firstItem.classList.add('highlighted');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.state.highlightedIndex = -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPartItem(part, index) {
|
||||||
|
const title = this.highlightText(part.name, this.state.query);
|
||||||
|
const partNumber = part.matched_number || part.oem_part_number;
|
||||||
|
const subtitle = this.highlightText(partNumber, this.state.query);
|
||||||
|
const categoryBadge = part.category_name ? `<span class="search-category-badge">${part.category_name}</span>` : '';
|
||||||
|
|
||||||
|
// Match type badge with better labels
|
||||||
|
const matchLabels = {
|
||||||
|
'aftermarket': 'Aftermarket',
|
||||||
|
'cross_reference': 'Cross-Ref'
|
||||||
|
};
|
||||||
|
const matchBadge = part.match_type && part.match_type !== 'oem'
|
||||||
|
? `<span class="search-result-badge ${part.match_type}">${matchLabels[part.match_type] || part.match_type}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Text for Tab autocomplete
|
||||||
|
const autocompleteText = part.oem_part_number || part.name;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="search-result-item" data-index="${index}" data-autocomplete="${this.escapeHtml(autocompleteText)}" onclick="enhancedSearch.selectPart(${part.id}, '${this.escapeHtml(part.name)}')">
|
||||||
|
<div class="search-result-icon">
|
||||||
|
${part.image_url ? `<img src="${part.image_url}" alt="">` : '<i class="fas fa-cog"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="search-result-info">
|
||||||
|
<div class="search-result-title">${title}</div>
|
||||||
|
<div class="search-result-subtitle">
|
||||||
|
<span class="part-number">${subtitle}</span>
|
||||||
|
${categoryBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${matchBadge}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Render a part that's linked to a specific vehicle (combined search)
|
||||||
|
renderVehiclePartItem(part, vehicle, index) {
|
||||||
|
const title = this.highlightText(part.name_es || part.name, this.state.query);
|
||||||
|
const subtitle = this.highlightText(part.oem_part_number, this.state.query);
|
||||||
|
const groupBadge = part.group_name ? `<span class="search-category-badge">${part.group_name}</span>` : '';
|
||||||
|
|
||||||
|
// Escape data for onclick
|
||||||
|
const vehicleData = JSON.stringify({
|
||||||
|
id: vehicle.id,
|
||||||
|
brand: vehicle.brand,
|
||||||
|
model: vehicle.model,
|
||||||
|
year: vehicle.year
|
||||||
|
}).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
|
||||||
|
const autocompleteText = `${vehicle.brand} ${vehicle.model} ${vehicle.year} ${part.name}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="search-result-item vehicle-part-item" data-index="${index}" data-autocomplete="${this.escapeHtml(autocompleteText)}"
|
||||||
|
onclick="enhancedSearch.selectVehiclePart('${vehicleData}', ${part.id}, ${part.category_id}, ${part.group_id})">
|
||||||
|
<div class="search-result-icon">
|
||||||
|
${part.image_url ? `<img src="${part.image_url}" alt="">` : '<i class="fas fa-cog"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="search-result-info">
|
||||||
|
<div class="search-result-title">${title}</div>
|
||||||
|
<div class="search-result-subtitle">
|
||||||
|
<span class="part-number">${subtitle}</span>
|
||||||
|
${groupBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="search-result-badge vehicle-part-badge">
|
||||||
|
<i class="fas fa-check-circle"></i> Compatible
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderVehicleItem(vehicle, index) {
|
||||||
|
const title = `${vehicle.brand} ${vehicle.model} ${vehicle.year}`;
|
||||||
|
const subtitle = vehicle.engine || '';
|
||||||
|
|
||||||
|
// Escape vehicle data for onclick
|
||||||
|
const vehicleData = JSON.stringify({
|
||||||
|
id: vehicle.id,
|
||||||
|
brand: vehicle.brand,
|
||||||
|
model: vehicle.model,
|
||||||
|
year: vehicle.year
|
||||||
|
}).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
|
||||||
|
// Quick category buttons (IDs match database)
|
||||||
|
const categories = [
|
||||||
|
{ id: 6, icon: 'fa-cog', name: 'Motor' },
|
||||||
|
{ id: 2, icon: 'fa-compact-disc', name: 'Frenos' },
|
||||||
|
{ id: 5, icon: 'fa-bolt', name: 'Eléctrico' },
|
||||||
|
{ id: 11, icon: 'fa-truck-monster', name: 'Suspensión' },
|
||||||
|
{ id: 8, icon: 'fa-gas-pump', name: 'Combustible' },
|
||||||
|
{ id: 12, icon: 'fa-gears', name: 'Transmisión' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryButtons = categories.map(cat =>
|
||||||
|
`<button class="vehicle-category-btn" onclick="event.stopPropagation(); enhancedSearch.selectVehicleCategory('${vehicleData}', ${cat.id})" title="${cat.name}">
|
||||||
|
<i class="fas ${cat.icon}"></i>
|
||||||
|
</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Text for Tab autocomplete (vehicle name for adding part search)
|
||||||
|
const autocompleteText = `${vehicle.brand} ${vehicle.model} ${vehicle.year}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="search-result-item vehicle-item" data-index="${index}" data-autocomplete="${this.escapeHtml(autocompleteText)} ">
|
||||||
|
<div class="search-result-icon"><i class="fas fa-car"></i></div>
|
||||||
|
<div class="search-result-info">
|
||||||
|
<div class="search-result-title">${this.highlightText(title, this.state.query)}</div>
|
||||||
|
<div class="search-result-subtitle">${this.highlightText(subtitle, this.state.query)}</div>
|
||||||
|
<div class="vehicle-categories-row">
|
||||||
|
${categoryButtons}
|
||||||
|
<button class="vehicle-category-btn vehicle-all-btn" onclick="event.stopPropagation(); enhancedSearch.selectVehicle('${vehicleData}')" title="Ver todas las categorías">
|
||||||
|
<i class="fas fa-th"></i> Todo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightText(text, query) {
|
||||||
|
if (!query || !text) return text;
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
|
||||||
|
return text.replace(regex, '<span class="highlight">$1</span>');
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
return str.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Selection handlers
|
||||||
|
selectPart(partId, name) {
|
||||||
|
this.saveRecentSearch(name);
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Open part detail modal via dashboard
|
||||||
|
if (typeof dashboard !== 'undefined' && typeof dashboard.showPartDetail === 'function') {
|
||||||
|
dashboard.showPartDetail(partId);
|
||||||
|
} else {
|
||||||
|
// Fallback: search in dashboard
|
||||||
|
window.location.href = `/?search=${encodeURIComponent(name)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select a part that's specific to a vehicle (from combined search)
|
||||||
|
selectVehiclePart(vehicleDataStr, partId, categoryId, groupId) {
|
||||||
|
try {
|
||||||
|
const vehicle = JSON.parse(vehicleDataStr.replace(/"/g, '"'));
|
||||||
|
const displayName = `${vehicle.brand} ${vehicle.model} ${vehicle.year}`;
|
||||||
|
|
||||||
|
this.saveRecentSearch(this.state.query);
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Navigate to vehicle's category/group and show part detail
|
||||||
|
if (typeof dashboard !== 'undefined') {
|
||||||
|
// Set up vehicle context first
|
||||||
|
if (typeof dashboard.navigateToVehicleCategory === 'function') {
|
||||||
|
dashboard.navigateToVehicleCategory(vehicle.id, vehicle.brand, vehicle.model, vehicle.year, categoryId);
|
||||||
|
// Then show part detail after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof dashboard.showPartDetail === 'function') {
|
||||||
|
dashboard.showPartDetail(partId);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error selecting vehicle part:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectVehicle(vehicleDataStr) {
|
||||||
|
try {
|
||||||
|
// Parse vehicle data from JSON string
|
||||||
|
const vehicle = JSON.parse(vehicleDataStr.replace(/"/g, '"'));
|
||||||
|
const displayName = `${vehicle.brand} ${vehicle.model} ${vehicle.year}`;
|
||||||
|
|
||||||
|
this.saveRecentSearch(displayName);
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Navigate directly to the vehicle's categories
|
||||||
|
if (typeof dashboard !== 'undefined' && typeof dashboard.navigateToVehicle === 'function') {
|
||||||
|
dashboard.navigateToVehicle(vehicle.id, vehicle.brand, vehicle.model, vehicle.year);
|
||||||
|
} else {
|
||||||
|
console.log('Navigating to vehicle:', vehicle);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing vehicle data:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectVehicleCategory(vehicleDataStr, categoryId) {
|
||||||
|
try {
|
||||||
|
const vehicle = JSON.parse(vehicleDataStr.replace(/"/g, '"'));
|
||||||
|
const displayName = `${vehicle.brand} ${vehicle.model} ${vehicle.year}`;
|
||||||
|
|
||||||
|
this.saveRecentSearch(displayName);
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Navigate to vehicle and then to specific category
|
||||||
|
if (typeof dashboard !== 'undefined' && typeof dashboard.navigateToVehicleCategory === 'function') {
|
||||||
|
dashboard.navigateToVehicleCategory(vehicle.id, vehicle.brand, vehicle.model, vehicle.year, categoryId);
|
||||||
|
} else {
|
||||||
|
console.log('Navigating to vehicle category:', vehicle, categoryId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing vehicle data:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
viewAllResults() {
|
||||||
|
if (this.state.query) {
|
||||||
|
this.saveRecentSearch(this.state.query);
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Open search modal with full results
|
||||||
|
if (typeof dashboard !== 'undefined' && typeof dashboard.searchPartNumber === 'function') {
|
||||||
|
dashboard.searchPartNumber();
|
||||||
|
} else {
|
||||||
|
// Fallback: reload page with search parameter
|
||||||
|
console.log('Dashboard not available, search query:', this.state.query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recent searches
|
||||||
|
getRecentSearches() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(this.config.storageKey)) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveRecentSearch(query) {
|
||||||
|
if (!query || query.length < 2) return;
|
||||||
|
|
||||||
|
let recent = this.getRecentSearches();
|
||||||
|
|
||||||
|
// Remove if exists
|
||||||
|
recent = recent.filter(r => r.toLowerCase() !== query.toLowerCase());
|
||||||
|
|
||||||
|
// Add to front
|
||||||
|
recent.unshift(query);
|
||||||
|
|
||||||
|
// Limit
|
||||||
|
recent = recent.slice(0, this.config.maxRecent);
|
||||||
|
|
||||||
|
localStorage.setItem(this.config.storageKey, JSON.stringify(recent));
|
||||||
|
this.renderRecentSearches();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearRecent() {
|
||||||
|
localStorage.removeItem(this.config.storageKey);
|
||||||
|
this.renderRecentSearches();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderRecentSearches() {
|
||||||
|
const recent = this.getRecentSearches();
|
||||||
|
|
||||||
|
if (recent.length === 0 || !this.elements.recent) {
|
||||||
|
if (this.elements.recent) this.elements.recent.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.elements.recentItems) {
|
||||||
|
this.elements.recentItems.innerHTML = recent.map(term =>
|
||||||
|
`<span class="search-recent-item" onclick="enhancedSearch.searchRecent('${this.escapeHtml(term)}')">${term}</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
searchRecent(term) {
|
||||||
|
if (this.elements.input) {
|
||||||
|
this.elements.input.value = term;
|
||||||
|
}
|
||||||
|
this.state.query = term;
|
||||||
|
this.performSearch();
|
||||||
|
},
|
||||||
|
|
||||||
|
showRecent() {
|
||||||
|
const recent = this.getRecentSearches();
|
||||||
|
|
||||||
|
if (recent.length > 0 && this.elements.recent) {
|
||||||
|
this.elements.recent.style.display = 'block';
|
||||||
|
if (this.elements.partsResults) this.elements.partsResults.style.display = 'none';
|
||||||
|
if (this.elements.vehiclesResults) this.elements.vehiclesResults.style.display = 'none';
|
||||||
|
if (this.elements.noResults) this.elements.noResults.style.display = 'none';
|
||||||
|
if (this.elements.footer) this.elements.footer.style.display = 'none';
|
||||||
|
this.open();
|
||||||
|
} else {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Filters toggle
|
||||||
|
toggleFilters() {
|
||||||
|
this.state.filtersVisible = !this.state.filtersVisible;
|
||||||
|
if (this.elements.filters) {
|
||||||
|
this.elements.filters.style.display = this.state.filtersVisible ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = document.querySelector('.search-filters-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.classList.toggle('active', this.state.filtersVisible);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
open() {
|
||||||
|
this.state.isOpen = true;
|
||||||
|
if (this.elements.dropdown) this.elements.dropdown.classList.add('active');
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.state.isOpen = false;
|
||||||
|
if (this.elements.dropdown) this.elements.dropdown.classList.remove('active');
|
||||||
|
this.state.highlightedIndex = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
showLoading(show) {
|
||||||
|
this.state.isLoading = show;
|
||||||
|
if (this.elements.loading) this.elements.loading.style.display = show ? 'block' : 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
showNoResults() {
|
||||||
|
if (this.elements.partsResults) this.elements.partsResults.style.display = 'none';
|
||||||
|
if (this.elements.vehiclesResults) this.elements.vehiclesResults.style.display = 'none';
|
||||||
|
if (this.elements.noResults) this.elements.noResults.style.display = 'block';
|
||||||
|
if (this.elements.footer) this.elements.footer.style.display = 'none';
|
||||||
|
if (this.elements.recent) this.elements.recent.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
setupClickOutside() {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const searchBox = document.querySelector('.search-box-enhanced');
|
||||||
|
if (searchBox && !searchBox.contains(e.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
enhancedSearch.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global keyboard shortcut for search
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// "/" to focus search (when not in an input)
|
||||||
|
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+K or Cmd+K to focus search
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Catálogo de Autopartes - AutoParts DB</title>
|
<title>Catálogo de Autopartes - AutoParts DB</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔧</text></svg>">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<style>
|
<style>
|
||||||
@@ -135,6 +136,479 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Search Styles */
|
||||||
|
.search-box-enhanced {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 107, 53, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper .search-icon {
|
||||||
|
padding: 0 0.5rem 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper .search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.9rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters-toggle {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters-toggle:hover,
|
||||||
|
.search-filters-toggle.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-loading {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Dropdown */
|
||||||
|
.search-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dropdown.active {
|
||||||
|
display: block;
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Filters */
|
||||||
|
.search-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters .filter-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent Searches */
|
||||||
|
.search-recent {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section-title i {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-recent-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-recent-item {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-recent-item:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Results */
|
||||||
|
.search-results-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-section {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-section .search-section-title {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
background: rgba(255, 107, 53, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item.highlighted {
|
||||||
|
background: rgba(255, 107, 53, 0.2);
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-icon img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-title .highlight {
|
||||||
|
background: rgba(255, 107, 53, 0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge.aftermarket {
|
||||||
|
background: rgba(76, 175, 80, 0.15);
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge.cross_reference {
|
||||||
|
background: rgba(255, 152, 0, 0.15);
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge.vehicle-badge {
|
||||||
|
background: rgba(33, 150, 243, 0.15);
|
||||||
|
color: #42a5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vehicle category buttons in search */
|
||||||
|
.vehicle-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-categories-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-category-btn {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-category-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-category-btn i {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-all-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
border-color: rgba(33, 150, 243, 0.3);
|
||||||
|
color: #42a5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-all-btn:hover {
|
||||||
|
background: #42a5f5;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vehicle parts search results (combined search) */
|
||||||
|
.vehicle-parts-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: linear-gradient(135deg, rgba(33, 150, 243, 0.15), rgba(33, 150, 243, 0.05));
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-left: 3px solid #42a5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-parts-header i {
|
||||||
|
color: #42a5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-parts-header span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-parts-header small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-part-item {
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-part-item:hover {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge.vehicle-part-badge {
|
||||||
|
background: rgba(76, 175, 80, 0.15);
|
||||||
|
color: #66bb6a;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-badge.vehicle-part-badge i {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-subtitle .part-number {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-suggestion-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tag {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tag:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Results */
|
||||||
|
.search-no-results {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-no-results i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-no-results p {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-no-results span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Footer */
|
||||||
|
.search-dropdown-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint kbd {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-view-all {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-view-all:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1221,16 +1695,97 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<div class="search-box">
|
<div class="search-box-enhanced">
|
||||||
<input type="text" class="search-input" id="searchInput"
|
<div class="search-input-wrapper">
|
||||||
placeholder="Buscar por número de parte o nombre... (presiona /)"
|
<i class="fas fa-search search-icon"></i>
|
||||||
aria-label="Buscar partes">
|
<input type="text" class="search-input" id="searchInput"
|
||||||
<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">
|
placeholder="Buscar partes, números OEM, vehículos... (presiona /)"
|
||||||
<i class="fas fa-barcode"></i>
|
aria-label="Buscar partes"
|
||||||
</button>
|
autocomplete="off"
|
||||||
<button class="search-btn" onclick="dashboard.searchPartNumber()">
|
oninput="enhancedSearch.onInput(this.value)"
|
||||||
<i class="fas fa-search"></i>
|
onkeydown="enhancedSearch.onKeydown(event)"
|
||||||
</button>
|
onfocus="enhancedSearch.onFocus()">
|
||||||
|
<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">
|
||||||
|
<i class="fas fa-sliders-h"></i>
|
||||||
|
</div>
|
||||||
|
<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">
|
||||||
|
<i class="fas fa-barcode"></i>
|
||||||
|
</button>
|
||||||
|
<div class="search-loading" id="searchLoading" style="display: none;">
|
||||||
|
<div class="search-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown de resultados -->
|
||||||
|
<div class="search-dropdown" id="searchDropdown">
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="search-filters" id="searchFilters" style="display: none;">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Categoría</label>
|
||||||
|
<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Buscar en</label>
|
||||||
|
<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">
|
||||||
|
<option value="all">Todo</option>
|
||||||
|
<option value="parts">Solo Partes</option>
|
||||||
|
<option value="vehicles">Solo Vehículos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Búsquedas recientes -->
|
||||||
|
<div class="search-recent" id="searchRecent">
|
||||||
|
<div class="search-section-title">
|
||||||
|
<i class="fas fa-history"></i> Búsquedas recientes
|
||||||
|
<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-recent-items" id="searchRecentItems"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultados -->
|
||||||
|
<div class="search-results-container" id="searchResultsContainer">
|
||||||
|
<!-- Parts results -->
|
||||||
|
<div class="search-results-section" id="partsResults" style="display: none;">
|
||||||
|
<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>
|
||||||
|
<div class="search-results-list" id="partsResultsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicles results -->
|
||||||
|
<div class="search-results-section" id="vehiclesResults" style="display: none;">
|
||||||
|
<div class="search-section-title"><i class="fas fa-car"></i> Vehículos</div>
|
||||||
|
<div class="search-results-list" id="vehiclesResultsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No results -->
|
||||||
|
<div class="search-no-results" id="searchNoResults" style="display: none;">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<p>No se encontraron resultados</p>
|
||||||
|
<span>Intenta con otros términos de búsqueda</span>
|
||||||
|
<div class="search-suggestions" style="margin-top: 1rem;">
|
||||||
|
<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">Búsquedas populares:</span>
|
||||||
|
<div class="search-suggestion-tags">
|
||||||
|
<span class="search-tag" onclick="enhancedSearch.searchRecent('brake')">brake</span>
|
||||||
|
<span class="search-tag" onclick="enhancedSearch.searchRecent('filter')">filter</span>
|
||||||
|
<span class="search-tag" onclick="enhancedSearch.searchRecent('spark plug')">spark plug</span>
|
||||||
|
<span class="search-tag" onclick="enhancedSearch.searchRecent('camry')">camry</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer con acciones -->
|
||||||
|
<div class="search-dropdown-footer" id="searchFooter" style="display: none;">
|
||||||
|
<span class="search-hint">
|
||||||
|
<kbd>↑↓</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar
|
||||||
|
</span>
|
||||||
|
<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">
|
||||||
|
Ver todos los resultados <i class="fas fa-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1252,6 +1807,9 @@
|
|||||||
<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio">
|
<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio">
|
||||||
<i class="fas fa-home"></i>
|
<i class="fas fa-home"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="admin.html" class="btn btn-primary btn-icon" title="Panel de administración">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1310,7 +1868,7 @@
|
|||||||
<div class="modal-overlay" id="searchResultsModal">
|
<div class="modal-overlay" id="searchResultsModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title">
|
<h2 class="modal-title" id="searchResultsModalLabel">
|
||||||
<i class="fas fa-search"></i> Resultados de Búsqueda
|
<i class="fas fa-search"></i> Resultados de Búsqueda
|
||||||
</h2>
|
</h2>
|
||||||
<button class="modal-close" onclick="dashboard.closeModal('searchResultsModal')">×</button>
|
<button class="modal-close" onclick="dashboard.closeModal('searchResultsModal')">×</button>
|
||||||
@@ -1367,5 +1925,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="dashboard.js"></script>
|
<script src="dashboard.js"></script>
|
||||||
|
<script src="enhanced-search.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
13
dashboard/sample_data/aftermarket_sample.csv
Normal file
13
dashboard/sample_data/aftermarket_sample.csv
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
oem_part_id,manufacturer_id,part_number,name,name_es,quality_tier,price_usd,warranty_months
|
||||||
|
1,1,P06050,Premium Brake Pad Set,Juego de Pastillas Premium,premium,89.99,36
|
||||||
|
1,5,PGD923,Professional Brake Pads,Pastillas Profesionales,standard,49.99,24
|
||||||
|
1,14,D923,Economy Brake Pads,Pastillas Economicas,economy,29.99,12
|
||||||
|
3,2,72226WS,Premium Oil Filter,Filtro de Aceite Premium,premium,12.99,12
|
||||||
|
3,4,PF63E,Professional Oil Filter,Filtro de Aceite Profesional,standard,8.99,12
|
||||||
|
4,2,5067WS,Premium Air Filter,Filtro de Aire Premium,premium,24.99,12
|
||||||
|
4,3,1A3000,High Flow Air Filter,Filtro de Aire Alto Flujo,premium,29.99,12
|
||||||
|
6,9,ILKAR7B11,Iridium IX Spark Plug,Bujia de Iridio IX,premium,14.99,24
|
||||||
|
6,2,9652,Platinum Spark Plug,Bujia de Platino,standard,9.99,12
|
||||||
|
7,11,T245,Timing Belt,Banda de Tiempo,premium,45.99,36
|
||||||
|
8,11,K060685,Serpentine Belt,Banda Serpentina,premium,32.99,24
|
||||||
|
8,12,4060685,Multi-V Belt,Banda Multi-V,standard,24.99,12
|
||||||
|
11
dashboard/sample_data/fitment_sample.csv
Normal file
11
dashboard/sample_data/fitment_sample.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
model_year_engine_id,part_id,quantity_required,position,fitment_notes
|
||||||
|
1,1,1,front,Fits all trims
|
||||||
|
1,2,1,rear,Fits all trims
|
||||||
|
1,3,1,,Recommended every 5000 miles
|
||||||
|
1,4,1,,Recommended every 15000 miles
|
||||||
|
1,6,4,,Check gap before installation
|
||||||
|
2,1,1,front,Fits all trims
|
||||||
|
2,2,1,rear,Fits all trims
|
||||||
|
2,3,1,,Recommended every 5000 miles
|
||||||
|
3,1,1,front,Sport model requires different pads
|
||||||
|
3,3,1,,Synthetic oil recommended
|
||||||
|
16
dashboard/sample_data/manufacturers_sample.csv
Normal file
16
dashboard/sample_data/manufacturers_sample.csv
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name,type,quality_tier,country,website
|
||||||
|
Brembo,aftermarket,premium,Italy,https://www.brembo.com
|
||||||
|
Bosch,aftermarket,premium,Germany,https://www.bosch.com
|
||||||
|
Denso,aftermarket,premium,Japan,https://www.denso.com
|
||||||
|
ACDelco,aftermarket,standard,USA,https://www.acdelco.com
|
||||||
|
Raybestos,aftermarket,standard,USA,https://www.raybestos.com
|
||||||
|
Wagner,aftermarket,standard,USA,https://www.wagnerbrake.com
|
||||||
|
Monroe,aftermarket,standard,USA,https://www.monroe.com
|
||||||
|
KYB,aftermarket,premium,Japan,https://www.kyb.com
|
||||||
|
NGK,aftermarket,premium,Japan,https://www.ngk.com
|
||||||
|
Aisin,aftermarket,premium,Japan,https://www.aisin.com
|
||||||
|
Gates,aftermarket,premium,USA,https://www.gates.com
|
||||||
|
Continental,aftermarket,premium,Germany,https://www.continental.com
|
||||||
|
Moog,aftermarket,standard,USA,https://www.moogparts.com
|
||||||
|
Dorman,aftermarket,economy,USA,https://www.dormanproducts.com
|
||||||
|
Centric,aftermarket,standard,USA,https://www.centricparts.com
|
||||||
|
11
dashboard/sample_data/parts_sample.csv
Normal file
11
dashboard/sample_data/parts_sample.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
oem_part_number,name,name_es,group_id,description,description_es,weight_kg,material
|
||||||
|
04465-33450,Front Brake Pad Set,Juego de Pastillas de Freno Delanteras,5,Premium ceramic brake pads for front wheels,Pastillas de freno de ceramica premium para ruedas delanteras,1.2,Ceramic
|
||||||
|
04465-33460,Rear Brake Pad Set,Juego de Pastillas de Freno Traseras,5,Premium ceramic brake pads for rear wheels,Pastillas de freno de ceramica premium para ruedas traseras,0.9,Ceramic
|
||||||
|
90915-YZZD4,Oil Filter,Filtro de Aceite,15,High-efficiency oil filter,Filtro de aceite de alta eficiencia,0.3,Steel/Paper
|
||||||
|
16400-0W010,Air Filter,Filtro de Aire,16,Engine air filter element,Elemento de filtro de aire del motor,0.2,Paper
|
||||||
|
23300-79525,Fuel Filter,Filtro de Combustible,17,Inline fuel filter,Filtro de combustible en linea,0.4,Steel
|
||||||
|
90048-51003,Spark Plug,Bujia,20,Iridium spark plug,Bujia de iridio,0.05,Iridium
|
||||||
|
13568-09130,Timing Belt,Banda de Tiempo,25,Engine timing belt,Banda de distribucion del motor,0.3,Rubber
|
||||||
|
SU003-02574,Serpentine Belt,Banda Serpentina,26,Multi-rib drive belt,Banda de transmision multi-canal,0.2,EPDM Rubber
|
||||||
|
17801-0P010,Engine Air Filter,Filtro de Aire del Motor,16,High flow air filter,Filtro de aire de alto flujo,0.25,Cotton/Paper
|
||||||
|
48157-42010,Front Strut Mount,Soporte de Amortiguador Delantero,30,Front suspension strut mount,Soporte del amortiguador de suspension delantera,0.8,Steel/Rubber
|
||||||
|
1559
dashboard/server.py
1559
dashboard/server.py
File diff suppressed because it is too large
Load Diff
342
vehicle_database/scripts/import_gonher_catalog.py
Normal file
342
vehicle_database/scripts/import_gonher_catalog.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import Gonher Filter Catalog PDF into the autoparts database.
|
||||||
|
Extracts filter part numbers and vehicle compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/filtros_catalog.pdf'
|
||||||
|
|
||||||
|
# Filter type mapping
|
||||||
|
FILTER_TYPES = {
|
||||||
|
'ACEITE': {'category': 'Engine', 'group': 'Oil Filters', 'name_prefix': 'Oil Filter'},
|
||||||
|
'SINTÉTICO': {'category': 'Engine', 'group': 'Oil Filters', 'name_prefix': 'Synthetic Oil Filter'},
|
||||||
|
'AIRE': {'category': 'Engine', 'group': 'Air Filters', 'name_prefix': 'Air Filter'},
|
||||||
|
'COMB.': {'category': 'Fuel & Air', 'group': 'Fuel Filters', 'name_prefix': 'Fuel Filter'},
|
||||||
|
'CABINA': {'category': 'Heat & Air Conditioning', 'group': 'Cabin Air Filters', 'name_prefix': 'Cabin Air Filter'}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name='Gonher'):
|
||||||
|
"""Ensure Gonher manufacturer exists"""
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE name = ?", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO manufacturers (name, type, quality_tier, country)
|
||||||
|
VALUES (?, 'aftermarket', 'standard', 'Mexico')
|
||||||
|
""", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def get_or_create_group(cursor, category_name, group_name):
|
||||||
|
"""Get or create a part group"""
|
||||||
|
# Find category
|
||||||
|
cursor.execute("SELECT id FROM part_categories WHERE name = ?", (category_name,))
|
||||||
|
cat_row = cursor.fetchone()
|
||||||
|
if not cat_row:
|
||||||
|
print(f" Warning: Category '{category_name}' not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
category_id = cat_row['id']
|
||||||
|
|
||||||
|
# Find or create group
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM part_groups
|
||||||
|
WHERE category_id = ? AND name = ?
|
||||||
|
""", (category_id, group_name))
|
||||||
|
group_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if group_row:
|
||||||
|
return group_row['id']
|
||||||
|
|
||||||
|
# Create group
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO part_groups (category_id, name, name_es)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (category_id, group_name, group_name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def parse_year_range(year_str):
|
||||||
|
"""Parse year range like '2019 - 2016' or '2020- 2018' into list of years"""
|
||||||
|
year_str = year_str.strip()
|
||||||
|
|
||||||
|
# Handle single year
|
||||||
|
if re.match(r'^\d{4}$', year_str):
|
||||||
|
return [int(year_str)]
|
||||||
|
|
||||||
|
# Handle range with various separators
|
||||||
|
match = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_str)
|
||||||
|
if match:
|
||||||
|
start, end = int(match.group(1)), int(match.group(2))
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
return list(range(end, start + 1))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse_catalog_page(text, current_brand=None):
|
||||||
|
"""Parse a catalog page and extract vehicle-filter mappings"""
|
||||||
|
entries = []
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
brand = current_brand
|
||||||
|
model = None
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip header lines
|
||||||
|
if 'AÑO' in line and 'MOTOR' in line:
|
||||||
|
continue
|
||||||
|
if 'ACEITE' in line and 'AIRE' in line:
|
||||||
|
continue
|
||||||
|
if 'Los filtros Gonher' in line:
|
||||||
|
continue
|
||||||
|
if 'Para vehículos anteriores' in line:
|
||||||
|
continue
|
||||||
|
if '(Continúa)' in line:
|
||||||
|
# Extract brand/model from continuation
|
||||||
|
match = re.match(r'^([A-Z\s]+)\s*\(Continúa\)', line)
|
||||||
|
if match:
|
||||||
|
potential = match.group(1).strip()
|
||||||
|
if len(potential) > 2:
|
||||||
|
if potential in ['ACURA', 'ALFA ROMEO', 'AUDI', 'BMW', 'BUICK', 'CADILLAC',
|
||||||
|
'CHEVROLET', 'CHRYSLER', 'DODGE', 'FIAT', 'FORD', 'GMC',
|
||||||
|
'HONDA', 'HYUNDAI', 'INFINITI', 'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES BENZ', 'MERCURY',
|
||||||
|
'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PONTIAC',
|
||||||
|
'PORSCHE', 'RAM', 'RENAULT', 'SEAT', 'SMART', 'SUBARU',
|
||||||
|
'SUZUKI', 'TOYOTA', 'VOLKSWAGEN', 'VOLVO']:
|
||||||
|
brand = potential
|
||||||
|
else:
|
||||||
|
model = potential
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this is a brand line (all caps, single word or known brand)
|
||||||
|
if re.match(r'^[A-Z][A-Z\s]+$', line) and len(line) > 2:
|
||||||
|
potential_brand = line.strip()
|
||||||
|
if potential_brand in ['ACURA', 'ALFA ROMEO', 'AUDI', 'BMW', 'BUICK', 'CADILLAC',
|
||||||
|
'CHEVROLET', 'CHRYSLER', 'DODGE', 'FIAT', 'FORD', 'GMC',
|
||||||
|
'HONDA', 'HYUNDAI', 'INFINITI', 'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES BENZ', 'MERCURY',
|
||||||
|
'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PONTIAC',
|
||||||
|
'PORSCHE', 'RAM', 'RENAULT', 'SEAT', 'SMART', 'SUBARU',
|
||||||
|
'SUZUKI', 'TOYOTA', 'VOLKSWAGEN', 'VOLVO']:
|
||||||
|
brand = potential_brand
|
||||||
|
model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this is a model line (letters/numbers, no year pattern at start)
|
||||||
|
if brand and re.match(r'^[A-Z][A-Z0-9\s\-]+$', line) and not re.match(r'^\d{4}', line):
|
||||||
|
if not any(c.isdigit() for c in line[:4]): # Model names don't start with 4 digits
|
||||||
|
potential_model = line.strip()
|
||||||
|
if len(potential_model) >= 2 and potential_model not in ['ACEITE', 'AIRE', 'COMB', 'CABINA']:
|
||||||
|
model = potential_model
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to parse data line (year, motor, filters)
|
||||||
|
# Pattern: YEAR[-YEAR] MOTOR FILTER1 FILTER2 ...
|
||||||
|
if brand and model:
|
||||||
|
# Look for year at start
|
||||||
|
match = re.match(r'^(\d{4}(?:\s*[-–]\s*\d{4})?)\s+(.+)$', line)
|
||||||
|
if match:
|
||||||
|
year_str = match.group(1)
|
||||||
|
rest = match.group(2)
|
||||||
|
|
||||||
|
# Extract motor (usually like L4-2.0L or V6-3.5L)
|
||||||
|
motor_match = re.match(r'^([LV]\d+[-][\d.]+L(?:\s+(?:Turbo|TURBOCHARGED|diésel|ELECTRIC))?)\s*(.*)$', rest, re.IGNORECASE)
|
||||||
|
if motor_match:
|
||||||
|
motor = motor_match.group(1).strip()
|
||||||
|
filters_str = motor_match.group(2).strip()
|
||||||
|
|
||||||
|
# Parse filter part numbers
|
||||||
|
# They follow pattern like: GP-149 GPS-149 GA-1113 GAC-92
|
||||||
|
filter_parts = re.findall(r'[A-Z]+[-][\dA-Z]+(?:\(\d+\))?', filters_str)
|
||||||
|
|
||||||
|
years = parse_year_range(year_str)
|
||||||
|
|
||||||
|
if years and filter_parts:
|
||||||
|
for year in years:
|
||||||
|
entry = {
|
||||||
|
'brand': brand,
|
||||||
|
'model': model,
|
||||||
|
'year': year,
|
||||||
|
'motor': motor,
|
||||||
|
'filters': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Categorize filters by prefix
|
||||||
|
for fp in filter_parts:
|
||||||
|
fp_clean = re.sub(r'\(\d+\)', '', fp) # Remove (1) markers
|
||||||
|
if fp_clean.startswith('GP-') or fp_clean.startswith('G-'):
|
||||||
|
entry['filters']['ACEITE'] = fp_clean
|
||||||
|
elif fp_clean.startswith('GPS-'):
|
||||||
|
entry['filters']['SINTÉTICO'] = fp_clean
|
||||||
|
elif fp_clean.startswith('GA-') and not fp_clean.startswith('GAC-') and not fp_clean.startswith('GAVW-'):
|
||||||
|
entry['filters']['AIRE'] = fp_clean
|
||||||
|
elif fp_clean.startswith('GG-'):
|
||||||
|
entry['filters']['COMB.'] = fp_clean
|
||||||
|
elif fp_clean.startswith('GAC-'):
|
||||||
|
entry['filters']['CABINA'] = fp_clean
|
||||||
|
|
||||||
|
if entry['filters']:
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return entries, brand
|
||||||
|
|
||||||
|
def import_catalog():
|
||||||
|
"""Main import function"""
|
||||||
|
print("="*60)
|
||||||
|
print("GONHER FILTER CATALOG IMPORT")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Read PDF
|
||||||
|
print(f"\nReading PDF: {PDF_PATH}")
|
||||||
|
pdf = pypdf.PdfReader(PDF_PATH)
|
||||||
|
total_pages = len(pdf.pages)
|
||||||
|
print(f"Total pages: {total_pages}")
|
||||||
|
|
||||||
|
# Parse all pages
|
||||||
|
all_entries = []
|
||||||
|
current_brand = None
|
||||||
|
|
||||||
|
for i in range(total_pages):
|
||||||
|
text = pdf.pages[i].extract_text()
|
||||||
|
if text:
|
||||||
|
entries, current_brand = parse_catalog_page(text, current_brand)
|
||||||
|
all_entries.extend(entries)
|
||||||
|
|
||||||
|
if (i + 1) % 20 == 0:
|
||||||
|
print(f" Processed {i + 1}/{total_pages} pages, {len(all_entries)} entries so far...")
|
||||||
|
|
||||||
|
print(f"\nTotal entries extracted: {len(all_entries)}")
|
||||||
|
|
||||||
|
# Get unique filters
|
||||||
|
unique_filters = {}
|
||||||
|
for entry in all_entries:
|
||||||
|
for filter_type, part_num in entry['filters'].items():
|
||||||
|
if part_num not in unique_filters:
|
||||||
|
unique_filters[part_num] = filter_type
|
||||||
|
|
||||||
|
print(f"Unique filter part numbers: {len(unique_filters)}")
|
||||||
|
|
||||||
|
# Import to database
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Ensure Gonher manufacturer exists
|
||||||
|
manufacturer_id = ensure_manufacturer(cursor, 'Gonher')
|
||||||
|
print(f"\nManufacturer ID: {manufacturer_id}")
|
||||||
|
|
||||||
|
# Create parts for each unique filter
|
||||||
|
print("\nCreating filter parts...")
|
||||||
|
filter_part_ids = {}
|
||||||
|
|
||||||
|
for part_num, filter_type in unique_filters.items():
|
||||||
|
config = FILTER_TYPES.get(filter_type)
|
||||||
|
if not config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
group_id = get_or_create_group(cursor, config['category'], config['group'])
|
||||||
|
if not group_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if part exists
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_num,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
filter_part_ids[part_num] = existing['id']
|
||||||
|
else:
|
||||||
|
# Create new part
|
||||||
|
name = f"{config['name_prefix']} {part_num}"
|
||||||
|
name_es = f"Filtro {part_num}"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name, name_es, group_id, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (part_num, name, name_es, group_id, f"Gonher {config['name_prefix']}"))
|
||||||
|
|
||||||
|
filter_part_ids[part_num] = cursor.lastrowid
|
||||||
|
|
||||||
|
print(f" Created/found {len(filter_part_ids)} filter parts")
|
||||||
|
|
||||||
|
# Create vehicle fitments
|
||||||
|
print("\nCreating vehicle fitments...")
|
||||||
|
fitments_created = 0
|
||||||
|
fitments_skipped = 0
|
||||||
|
|
||||||
|
for entry in all_entries:
|
||||||
|
# Find matching vehicle (model_year_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']))
|
||||||
|
|
||||||
|
mye_row = cursor.fetchone()
|
||||||
|
if not mye_row:
|
||||||
|
fitments_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
mye_id = mye_row['id']
|
||||||
|
|
||||||
|
# Create fitments for each filter
|
||||||
|
for filter_type, part_num in entry['filters'].items():
|
||||||
|
part_id = filter_part_ids.get(part_num)
|
||||||
|
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():
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
""", (mye_id, part_id, f"Gonher catalog 2022 - {filter_type}"))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Fitments created: {fitments_created}")
|
||||||
|
print(f" Entries skipped (vehicle not found): {fitments_skipped}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("IMPORT COMPLETE")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_entries': len(all_entries),
|
||||||
|
'unique_filters': len(unique_filters),
|
||||||
|
'parts_created': len(filter_part_ids),
|
||||||
|
'fitments_created': fitments_created
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
results = import_catalog()
|
||||||
|
print(f"\nSummary: {results}")
|
||||||
511
vehicle_database/scripts/import_gonher_complete.py
Normal file
511
vehicle_database/scripts/import_gonher_complete.py
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR COMPLETO DEL CATÁLOGO GONHER 2022
|
||||||
|
- Crea vehículos faltantes
|
||||||
|
- Crea partes de filtros Gonher
|
||||||
|
- Crea referencias cruzadas con otras marcas (AC Delco, Fram, etc.)
|
||||||
|
- Crea fitments (vincula partes a vehículos)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/filtros_catalog.pdf'
|
||||||
|
|
||||||
|
# Filter type configuration
|
||||||
|
FILTER_TYPES = {
|
||||||
|
'ACEITE': {'category': 'Engine', 'group': 'Oil Filters', 'prefix': 'Oil Filter', 'prefix_es': 'Filtro de Aceite'},
|
||||||
|
'SINTÉTICO': {'category': 'Engine', 'group': 'Oil Filters', 'prefix': 'Synthetic Oil Filter', 'prefix_es': 'Filtro de Aceite Sintético'},
|
||||||
|
'AIRE': {'category': 'Engine', 'group': 'Air Filters', 'prefix': 'Air Filter', 'prefix_es': 'Filtro de Aire'},
|
||||||
|
'COMB.': {'category': 'Fuel & Air', 'group': 'Fuel Filters', 'prefix': 'Fuel Filter', 'prefix_es': 'Filtro de Combustible'},
|
||||||
|
'CABINA': {'category': 'Heat & Air Conditioning', 'group': 'Cabin Air Filters', 'prefix': 'Cabin Air Filter', 'prefix_es': 'Filtro de Cabina'}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known brands in catalog
|
||||||
|
CATALOG_BRANDS = [
|
||||||
|
'ACURA', 'ALFA ROMEO', 'AUDI', 'BMW', 'BUICK', 'CADILLAC',
|
||||||
|
'CHEVROLET', 'CHRYSLER', 'DODGE', 'FIAT', 'FORD', 'GMC',
|
||||||
|
'HONDA', 'HYUNDAI', 'INFINITI', 'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES BENZ', 'MERCURY',
|
||||||
|
'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PONTIAC',
|
||||||
|
'PORSCHE', 'RAM', 'RENAULT', 'SEAT', 'SMART', 'SUBARU',
|
||||||
|
'SUZUKI', 'TOYOTA', 'VOLKSWAGEN', 'VOLVO'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cross-reference brands
|
||||||
|
XREF_BRANDS = ['AC DELCO', 'FRAM', 'INTERFIL', 'MANN', 'MOTORCRAFT']
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Create manufacturer if not exists"""
|
||||||
|
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):
|
||||||
|
"""Create brand if not exists"""
|
||||||
|
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):
|
||||||
|
"""Create model if not exists"""
|
||||||
|
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):
|
||||||
|
"""Create year if not exists"""
|
||||||
|
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):
|
||||||
|
"""Create engine if not exists"""
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = ?", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
|
||||||
|
# Parse engine details from name
|
||||||
|
displacement = None
|
||||||
|
cylinders = None
|
||||||
|
fuel_type = 'gasoline' # lowercase to match DB constraint
|
||||||
|
|
||||||
|
# Parse displacement and cylinders from patterns like "L4-2.0L" or "V6-3.5L"
|
||||||
|
match = re.match(r'([LV])(\d+)[-]?([\d.]+)L?', name)
|
||||||
|
if match:
|
||||||
|
engine_type = match.group(1) # L or V
|
||||||
|
cylinders = int(match.group(2))
|
||||||
|
displacement = int(float(match.group(3)) * 1000)
|
||||||
|
|
||||||
|
if 'DIESEL' in name.upper() or 'DIÉSEL' in name.upper():
|
||||||
|
fuel_type = 'diesel'
|
||||||
|
elif 'ELECTRIC' in name.upper():
|
||||||
|
fuel_type = 'electric'
|
||||||
|
elif 'HYBRID' in name.upper():
|
||||||
|
fuel_type = 'hybrid'
|
||||||
|
# Note: 'TURBO' is not a fuel type, it's a modifier - default to gasoline
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO engines (name, displacement_cc, cylinders, fuel_type)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (name, displacement, cylinders, fuel_type))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id):
|
||||||
|
"""Create model_year_engine if not exists"""
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM model_year_engine
|
||||||
|
WHERE model_id = ? AND year_id = ? AND engine_id = ?
|
||||||
|
""", (model_id, year_id, engine_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
|
||||||
|
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_group(cursor, category_name, group_name):
|
||||||
|
"""Get or create part group"""
|
||||||
|
cursor.execute("SELECT id FROM part_categories WHERE name = ?", (category_name,))
|
||||||
|
cat_row = cursor.fetchone()
|
||||||
|
if not cat_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
category_id = cat_row['id']
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM part_groups WHERE category_id = ? AND name = ?
|
||||||
|
""", (category_id, group_name))
|
||||||
|
group_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if group_row:
|
||||||
|
return group_row['id']
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO part_groups (category_id, name, name_es)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (category_id, group_name, group_name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def parse_year_range(year_str):
|
||||||
|
"""Parse year range into list of years"""
|
||||||
|
year_str = year_str.strip()
|
||||||
|
|
||||||
|
if re.match(r'^\d{4}$', year_str):
|
||||||
|
return [int(year_str)]
|
||||||
|
|
||||||
|
match = re.match(r'(\d{4})\s*[-–]\s*(\d{4})', year_str)
|
||||||
|
if match:
|
||||||
|
start, end = int(match.group(1)), int(match.group(2))
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
return list(range(end, start + 1))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def classify_filter(part_number):
|
||||||
|
"""Classify filter type by part number prefix"""
|
||||||
|
part_number = part_number.upper()
|
||||||
|
if part_number.startswith('GP-') or part_number.startswith('GPS-'):
|
||||||
|
if part_number.startswith('GPS-'):
|
||||||
|
return 'SINTÉTICO'
|
||||||
|
return 'ACEITE'
|
||||||
|
elif part_number.startswith('GA-') and not part_number.startswith('GAC-') and not part_number.startswith('GAVW-'):
|
||||||
|
return 'AIRE'
|
||||||
|
elif part_number.startswith('GG-'):
|
||||||
|
return 'COMB.'
|
||||||
|
elif part_number.startswith('GAC-'):
|
||||||
|
return 'CABINA'
|
||||||
|
elif part_number.startswith('G-'):
|
||||||
|
return 'ACEITE' # Generic oil filter
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_vehicle_entries(pdf):
|
||||||
|
"""Extract all vehicle entries from catalog"""
|
||||||
|
entries = []
|
||||||
|
current_brand = None
|
||||||
|
current_model = None
|
||||||
|
|
||||||
|
for page in pdf.pages:
|
||||||
|
text = page.extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip header/footer
|
||||||
|
if 'AÑO' in line and 'MOTOR' in line:
|
||||||
|
continue
|
||||||
|
if 'Los filtros Gonher' in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Brand detection
|
||||||
|
if line in CATALOG_BRANDS:
|
||||||
|
current_brand = line
|
||||||
|
current_model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle (Continúa) lines
|
||||||
|
if '(Continúa)' in line:
|
||||||
|
match = re.match(r'^([A-Z][A-Z0-9\s\-]+)\s*\(Continúa\)', line)
|
||||||
|
if match:
|
||||||
|
potential = match.group(1).strip()
|
||||||
|
if potential in CATALOG_BRANDS:
|
||||||
|
current_brand = potential
|
||||||
|
elif current_brand:
|
||||||
|
current_model = potential
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Model detection
|
||||||
|
if current_brand:
|
||||||
|
if re.match(r'^[A-Z][A-Z0-9\s\-/]+$', line) and not re.match(r'^\d{4}', line):
|
||||||
|
if line not in ['ACEITE', 'AIRE', 'COMB', 'CABINA', 'SINTÉTICO', 'AÑO', 'MOTOR']:
|
||||||
|
if not re.match(r'^G[APCS]?[-]?\d', line): # Not a part number
|
||||||
|
current_model = line
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Data line with year
|
||||||
|
if current_brand and current_model:
|
||||||
|
match = re.match(r'^(\d{4}(?:\s*[-–]\s*\d{4})?)\s+(.+)$', line)
|
||||||
|
if match:
|
||||||
|
year_str = match.group(1)
|
||||||
|
rest = match.group(2)
|
||||||
|
|
||||||
|
# Extract motor
|
||||||
|
motor_match = re.match(r'^([LV]\d+[-][\d.]+L(?:\s+(?:Turbo|TURBOCHARGED|diésel|ELECTRIC|HYBRID))?)\s*(.*)$', rest, re.IGNORECASE)
|
||||||
|
if motor_match:
|
||||||
|
motor = motor_match.group(1).strip()
|
||||||
|
filters_str = motor_match.group(2).strip()
|
||||||
|
|
||||||
|
# Parse filter part numbers
|
||||||
|
filter_parts = re.findall(r'G[A-Z]*[-]?[\dA-Z]+(?:\(\d+\))?', filters_str)
|
||||||
|
|
||||||
|
years = parse_year_range(year_str)
|
||||||
|
|
||||||
|
if years:
|
||||||
|
for year in years:
|
||||||
|
entry = {
|
||||||
|
'brand': current_brand,
|
||||||
|
'model': current_model,
|
||||||
|
'year': year,
|
||||||
|
'motor': motor,
|
||||||
|
'filters': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for fp in filter_parts:
|
||||||
|
fp_clean = re.sub(r'\(\d+\)', '', fp)
|
||||||
|
filter_type = classify_filter(fp_clean)
|
||||||
|
if filter_type:
|
||||||
|
entry['filters'][filter_type] = fp_clean
|
||||||
|
|
||||||
|
if entry['filters']:
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def extract_cross_references(pdf):
|
||||||
|
"""Extract cross-reference data from catalog"""
|
||||||
|
xrefs = []
|
||||||
|
current_brand = None
|
||||||
|
|
||||||
|
# Cross-references are typically in pages 117+
|
||||||
|
for i in range(117, len(pdf.pages)):
|
||||||
|
text = pdf.pages[i].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
# Brand header
|
||||||
|
if line in XREF_BRANDS:
|
||||||
|
current_brand = line
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cross-reference line
|
||||||
|
if current_brand:
|
||||||
|
match = re.match(r'^([A-Z0-9\-/]+)\s+(G[A-Z]*[-]?\d+[A-Z]*)$', line)
|
||||||
|
if match:
|
||||||
|
xrefs.append({
|
||||||
|
'brand': current_brand,
|
||||||
|
'part_number': match.group(1),
|
||||||
|
'gonher_part': match.group(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return xrefs
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("IMPORTADOR COMPLETO - CATÁLOGO GONHER 2022")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Read PDF
|
||||||
|
print(f"\n[1/7] Leyendo PDF: {PDF_PATH}")
|
||||||
|
pdf = pypdf.PdfReader(PDF_PATH)
|
||||||
|
print(f" Total páginas: {len(pdf.pages)}")
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
print("\n[2/7] Extrayendo datos del catálogo...")
|
||||||
|
vehicle_entries = extract_vehicle_entries(pdf)
|
||||||
|
cross_refs = extract_cross_references(pdf)
|
||||||
|
print(f" Entradas de vehículos: {len(vehicle_entries)}")
|
||||||
|
print(f" Referencias cruzadas: {len(cross_refs)}")
|
||||||
|
|
||||||
|
# Get unique filters
|
||||||
|
unique_filters = {}
|
||||||
|
for entry in vehicle_entries:
|
||||||
|
for filter_type, part_num in entry['filters'].items():
|
||||||
|
if part_num not in unique_filters:
|
||||||
|
unique_filters[part_num] = filter_type
|
||||||
|
print(f" Filtros únicos: {len(unique_filters)}")
|
||||||
|
|
||||||
|
# Connect to database
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create manufacturers
|
||||||
|
print("\n[3/7] Creando fabricantes...")
|
||||||
|
manufacturers = {
|
||||||
|
'Gonher': ensure_manufacturer(cursor, 'Gonher', 'aftermarket', 'standard', 'Mexico'),
|
||||||
|
'AC Delco': ensure_manufacturer(cursor, 'AC Delco', 'oem', 'oem', 'USA'),
|
||||||
|
'Fram': ensure_manufacturer(cursor, 'Fram', 'aftermarket', 'standard', 'USA'),
|
||||||
|
'Interfil': ensure_manufacturer(cursor, 'Interfil', 'aftermarket', 'economy', 'Mexico'),
|
||||||
|
'Mann': ensure_manufacturer(cursor, 'Mann', 'aftermarket', 'premium', 'Germany'),
|
||||||
|
'Motorcraft': ensure_manufacturer(cursor, 'Motorcraft', 'oem', 'oem', 'USA'),
|
||||||
|
}
|
||||||
|
print(f" Fabricantes: {list(manufacturers.keys())}")
|
||||||
|
|
||||||
|
# Create vehicles
|
||||||
|
print("\n[4/7] Creando vehículos faltantes...")
|
||||||
|
vehicles_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for entry in vehicle_entries:
|
||||||
|
cache_key = (entry['brand'], entry['model'], entry['year'], entry['motor'])
|
||||||
|
if cache_key in mye_cache:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if vehicle exists
|
||||||
|
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
|
||||||
|
JOIN engines e ON mye.engine_id = e.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?)
|
||||||
|
AND y.year = ? AND e.name = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", (entry['brand'], entry['model'], entry['year'], entry['motor']))
|
||||||
|
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
# Create vehicle
|
||||||
|
brand_id = ensure_brand(cursor, entry['brand'])
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
engine_id = ensure_engine(cursor, entry['motor'])
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id, engine_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created}")
|
||||||
|
|
||||||
|
# Create filter parts
|
||||||
|
print("\n[5/7] Creando partes de filtros...")
|
||||||
|
filter_parts = {}
|
||||||
|
parts_created = 0
|
||||||
|
|
||||||
|
for part_num, filter_type in unique_filters.items():
|
||||||
|
config = FILTER_TYPES.get(filter_type)
|
||||||
|
if not config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
group_id = get_or_create_group(cursor, config['category'], config['group'])
|
||||||
|
if not group_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if part exists
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_num,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
filter_parts[part_num] = existing['id']
|
||||||
|
else:
|
||||||
|
name = f"{config['prefix']} {part_num}"
|
||||||
|
name_es = f"{config['prefix_es']} {part_num}"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name, name_es, group_id, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (part_num, name, name_es, group_id, f"Gonher {config['prefix']}"))
|
||||||
|
|
||||||
|
filter_parts[part_num] = cursor.lastrowid
|
||||||
|
parts_created += 1
|
||||||
|
|
||||||
|
print(f" Partes creadas: {parts_created}")
|
||||||
|
|
||||||
|
# Create fitments
|
||||||
|
print("\n[6/7] Creando fitments (vehículo-parte)...")
|
||||||
|
fitments_created = 0
|
||||||
|
|
||||||
|
for entry in vehicle_entries:
|
||||||
|
cache_key = (entry['brand'], entry['model'], entry['year'], entry['motor'])
|
||||||
|
mye_id = mye_cache.get(cache_key)
|
||||||
|
|
||||||
|
if not mye_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for filter_type, part_num in entry['filters'].items():
|
||||||
|
part_id = filter_parts.get(part_num)
|
||||||
|
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():
|
||||||
|
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 Gonher 2022 - {filter_type}"))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Fitments creados: {fitments_created}")
|
||||||
|
|
||||||
|
# Create cross-references
|
||||||
|
print("\n[7/7] Creando referencias cruzadas...")
|
||||||
|
xrefs_created = 0
|
||||||
|
|
||||||
|
for xref in cross_refs:
|
||||||
|
gonher_part_id = filter_parts.get(xref['gonher_part'])
|
||||||
|
if not gonher_part_id:
|
||||||
|
# Part might not exist yet, try to find by OEM number
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (xref['gonher_part'],))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
gonher_part_id = row['id']
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if cross-reference exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM part_cross_references
|
||||||
|
WHERE part_id = ? AND cross_reference_number = ?
|
||||||
|
""", (gonher_part_id, xref['part_number']))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
# Map brand to reference type
|
||||||
|
ref_type = 'interchange'
|
||||||
|
if xref['brand'] in ['AC DELCO', 'MOTORCRAFT']:
|
||||||
|
ref_type = 'oem_alternate'
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (gonher_part_id, xref['part_number'], ref_type))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
print(f" Referencias cruzadas creadas: {xrefs_created}")
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IMPORTACIÓN COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Cross-refs creadas: {xrefs_created:,}
|
||||||
|
- Fabricantes: {len(manufacturers)}
|
||||||
|
""")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
315
vehicle_database/scripts/populate_demo_data.py
Normal file
315
vehicle_database/scripts/populate_demo_data.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script para poblar la base de datos con datos de demo realistas.
|
||||||
|
Incluye partes OEM, fabricantes aftermarket, partes alternativas y fitments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
|
||||||
|
# Demo Parts Data - Partes realistas comunes
|
||||||
|
DEMO_PARTS = [
|
||||||
|
# Brake System (category 2, group varies)
|
||||||
|
{'oem': '04465-33450', 'name': 'Front Brake Pad Set', 'name_es': 'Juego de Pastillas Delanteras', 'group': 'Brake Pads/Shoes', 'desc': 'Ceramic front brake pads', 'weight': 1.2},
|
||||||
|
{'oem': '04466-33180', 'name': 'Rear Brake Pad Set', 'name_es': 'Juego de Pastillas Traseras', 'group': 'Brake Pads/Shoes', 'desc': 'Ceramic rear brake pads', 'weight': 0.9},
|
||||||
|
{'oem': '43512-33130', 'name': 'Front Brake Rotor', 'name_es': 'Disco de Freno Delantero', 'group': 'Brake Rotors', 'desc': 'Vented front brake disc', 'weight': 8.5},
|
||||||
|
{'oem': '42431-33130', 'name': 'Rear Brake Rotor', 'name_es': 'Disco de Freno Trasero', 'group': 'Brake Rotors', 'desc': 'Solid rear brake disc', 'weight': 5.2},
|
||||||
|
{'oem': '47750-33210', 'name': 'Brake Caliper Front Right', 'name_es': 'Caliper Delantero Derecho', 'group': 'Brake Calipers', 'desc': 'Front right brake caliper assembly', 'weight': 4.5},
|
||||||
|
{'oem': '47730-33210', 'name': 'Brake Caliper Front Left', 'name_es': 'Caliper Delantero Izquierdo', 'group': 'Brake Calipers', 'desc': 'Front left brake caliper assembly', 'weight': 4.5},
|
||||||
|
|
||||||
|
# Engine (category 6)
|
||||||
|
{'oem': '90915-YZZD4', 'name': 'Oil Filter', 'name_es': 'Filtro de Aceite', 'group': 'Oil Filters', 'desc': 'Spin-on oil filter element', 'weight': 0.3},
|
||||||
|
{'oem': '17801-0P010', 'name': 'Air Filter', 'name_es': 'Filtro de Aire', 'group': 'Air Filters', 'desc': 'Engine air filter element', 'weight': 0.25},
|
||||||
|
{'oem': '23300-79525', 'name': 'Fuel Filter', 'name_es': 'Filtro de Combustible', 'group': 'Fuel Filters', 'desc': 'In-line fuel filter', 'weight': 0.4},
|
||||||
|
{'oem': '90048-51003', 'name': 'Spark Plug Iridium', 'name_es': 'Bujia de Iridio', 'group': 'Spark Plugs', 'desc': 'Long-life iridium spark plug', 'weight': 0.05},
|
||||||
|
{'oem': '13568-09130', 'name': 'Timing Belt', 'name_es': 'Banda de Tiempo', 'group': 'Timing Components', 'desc': 'Engine timing belt', 'weight': 0.35},
|
||||||
|
{'oem': 'SU003-02574', 'name': 'Serpentine Belt', 'name_es': 'Banda Serpentina', 'group': 'Belts', 'desc': 'Multi-rib accessory drive belt', 'weight': 0.2},
|
||||||
|
{'oem': '16100-09515', 'name': 'Water Pump', 'name_es': 'Bomba de Agua', 'group': 'Water Pumps', 'desc': 'Engine coolant water pump', 'weight': 2.1},
|
||||||
|
{'oem': '16363-0P030', 'name': 'Thermostat', 'name_es': 'Termostato', 'group': 'Thermostats', 'desc': 'Engine thermostat with housing', 'weight': 0.3},
|
||||||
|
|
||||||
|
# Suspension (category 11)
|
||||||
|
{'oem': '48157-42010', 'name': 'Front Strut Mount', 'name_es': 'Soporte de Amortiguador Delantero', 'group': 'Strut Mounts', 'desc': 'Front suspension strut mount', 'weight': 0.8},
|
||||||
|
{'oem': '48609-48020', 'name': 'Front Strut Assembly', 'name_es': 'Amortiguador Delantero Completo', 'group': 'Struts/Shocks', 'desc': 'Complete front strut assembly', 'weight': 6.5},
|
||||||
|
{'oem': '48530-09S00', 'name': 'Rear Shock Absorber', 'name_es': 'Amortiguador Trasero', 'group': 'Struts/Shocks', 'desc': 'Rear shock absorber', 'weight': 3.2},
|
||||||
|
{'oem': '48068-33070', 'name': 'Front Lower Control Arm', 'name_es': 'Brazo de Control Inferior Delantero', 'group': 'Control Arms', 'desc': 'Front lower control arm with bushing', 'weight': 4.8},
|
||||||
|
{'oem': '48725-33050', 'name': 'Rear Trailing Arm', 'name_es': 'Brazo Trasero', 'group': 'Control Arms', 'desc': 'Rear suspension trailing arm', 'weight': 3.5},
|
||||||
|
{'oem': '48815-33070', 'name': 'Sway Bar Link Front', 'name_es': 'Enlace de Barra Estabilizadora', 'group': 'Sway Bar Links', 'desc': 'Front stabilizer bar end link', 'weight': 0.4},
|
||||||
|
|
||||||
|
# Steering (category 10)
|
||||||
|
{'oem': '45046-39335', 'name': 'Outer Tie Rod End', 'name_es': 'Terminal Exterior de Direccion', 'group': 'Tie Rods', 'desc': 'Outer steering tie rod end', 'weight': 0.6},
|
||||||
|
{'oem': '45503-39165', 'name': 'Inner Tie Rod', 'name_es': 'Terminal Interior de Direccion', 'group': 'Tie Rods', 'desc': 'Inner steering tie rod', 'weight': 0.5},
|
||||||
|
{'oem': '44200-33480', 'name': 'Power Steering Pump', 'name_es': 'Bomba de Direccion', 'group': 'Power Steering', 'desc': 'Hydraulic power steering pump', 'weight': 3.8},
|
||||||
|
{'oem': '45510-33310', 'name': 'Steering Rack', 'name_es': 'Cremallera de Direccion', 'group': 'Steering Racks', 'desc': 'Power steering rack and pinion', 'weight': 12.5},
|
||||||
|
|
||||||
|
# Electrical (category 5)
|
||||||
|
{'oem': '28100-21030', 'name': 'Starter Motor', 'name_es': 'Motor de Arranque', 'group': 'Starters', 'desc': 'Engine starter motor', 'weight': 4.2},
|
||||||
|
{'oem': '27060-37030', 'name': 'Alternator', 'name_es': 'Alternador', 'group': 'Alternators', 'desc': '100 amp alternator', 'weight': 5.8},
|
||||||
|
{'oem': '90919-02240', 'name': 'Ignition Coil', 'name_es': 'Bobina de Ignicion', 'group': 'Ignition Coils', 'desc': 'Direct ignition coil', 'weight': 0.35},
|
||||||
|
{'oem': '89467-33040', 'name': 'Oxygen Sensor', 'name_es': 'Sensor de Oxigeno', 'group': 'Sensors', 'desc': 'Upstream O2 sensor', 'weight': 0.15},
|
||||||
|
|
||||||
|
# Cooling System (category 3)
|
||||||
|
{'oem': '16400-0W010', 'name': 'Radiator', 'name_es': 'Radiador', 'group': 'Radiators', 'desc': 'Engine cooling radiator', 'weight': 8.5},
|
||||||
|
{'oem': '16711-31310', 'name': 'Radiator Fan', 'name_es': 'Ventilador de Radiador', 'group': 'Cooling Fans', 'desc': 'Electric radiator cooling fan', 'weight': 3.2},
|
||||||
|
{'oem': '16361-0P030', 'name': 'Radiator Hose Upper', 'name_es': 'Manguera Superior de Radiador', 'group': 'Hoses', 'desc': 'Upper radiator coolant hose', 'weight': 0.4},
|
||||||
|
|
||||||
|
# Exhaust (category 7)
|
||||||
|
{'oem': '17140-31620', 'name': 'Catalytic Converter', 'name_es': 'Convertidor Catalitico', 'group': 'Catalytic Converters', 'desc': 'Three-way catalytic converter', 'weight': 6.5},
|
||||||
|
{'oem': '17430-31410', 'name': 'Muffler', 'name_es': 'Silenciador', 'group': 'Mufflers', 'desc': 'Rear exhaust muffler', 'weight': 8.2},
|
||||||
|
|
||||||
|
# Fuel System (category 8)
|
||||||
|
{'oem': '23220-31100', 'name': 'Fuel Pump', 'name_es': 'Bomba de Combustible', 'group': 'Fuel Pumps', 'desc': 'Electric in-tank fuel pump', 'weight': 0.8},
|
||||||
|
{'oem': '23250-31010', 'name': 'Fuel Injector', 'name_es': 'Inyector de Combustible', 'group': 'Fuel Injectors', 'desc': 'Multi-port fuel injector', 'weight': 0.1},
|
||||||
|
|
||||||
|
# Drivetrain (category 4)
|
||||||
|
{'oem': '31470-52011', 'name': 'Clutch Kit', 'name_es': 'Kit de Embrague', 'group': 'Clutches', 'desc': 'Complete clutch replacement kit', 'weight': 7.5},
|
||||||
|
{'oem': '43502-35210', 'name': 'CV Axle Front', 'name_es': 'Flecha Homocinética Delantera', 'group': 'CV Axles', 'desc': 'Front CV axle shaft assembly', 'weight': 5.2},
|
||||||
|
|
||||||
|
# HVAC (category 9)
|
||||||
|
{'oem': '88310-33250', 'name': 'AC Compressor', 'name_es': 'Compresor de AC', 'group': 'AC Compressors', 'desc': 'Air conditioning compressor', 'weight': 6.8},
|
||||||
|
{'oem': '87103-33110', 'name': 'Blower Motor', 'name_es': 'Motor de Ventilador', 'group': 'Blower Motors', 'desc': 'HVAC blower motor', 'weight': 1.5},
|
||||||
|
{'oem': '87139-YZZ05', 'name': 'Cabin Air Filter', 'name_es': 'Filtro de Cabina', 'group': 'Cabin Filters', 'desc': 'Cabin air filter element', 'weight': 0.15},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Demo Manufacturers
|
||||||
|
DEMO_MANUFACTURERS = [
|
||||||
|
{'name': 'Brembo', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Italy'},
|
||||||
|
{'name': 'Bosch', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Germany'},
|
||||||
|
{'name': 'Denso', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Japan'},
|
||||||
|
{'name': 'ACDelco', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Raybestos', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Wagner', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Monroe', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'KYB', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Japan'},
|
||||||
|
{'name': 'NGK', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Japan'},
|
||||||
|
{'name': 'Aisin', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Japan'},
|
||||||
|
{'name': 'Gates', 'type': 'aftermarket', 'tier': 'premium', 'country': 'USA'},
|
||||||
|
{'name': 'Continental', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Germany'},
|
||||||
|
{'name': 'Moog', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Dorman', 'type': 'aftermarket', 'tier': 'economy', 'country': 'USA'},
|
||||||
|
{'name': 'Centric', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Beck/Arnley', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Cardone', 'type': 'remanufactured', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Standard Motor', 'type': 'aftermarket', 'tier': 'standard', 'country': 'USA'},
|
||||||
|
{'name': 'Spectra Premium', 'type': 'aftermarket', 'tier': 'economy', 'country': 'Canada'},
|
||||||
|
{'name': 'TRW', 'type': 'aftermarket', 'tier': 'premium', 'country': 'Germany'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_id(cursor, group_name):
|
||||||
|
"""Get or create a group by name"""
|
||||||
|
cursor.execute("SELECT id FROM part_groups WHERE name = ?", (group_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def populate_parts(cursor):
|
||||||
|
"""Populate OEM parts"""
|
||||||
|
print("Adding OEM parts...")
|
||||||
|
added = 0
|
||||||
|
for part in DEMO_PARTS:
|
||||||
|
group_id = get_group_id(cursor, part['group'])
|
||||||
|
if not group_id:
|
||||||
|
print(f" Warning: Group '{part['group']}' not found, skipping {part['oem']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if part already exists
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part['oem'],))
|
||||||
|
if cursor.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name, name_es, group_id, description, weight_kg)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (part['oem'], part['name'], part['name_es'], group_id, part['desc'], part['weight']))
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
print(f" Added {added} OEM parts")
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def populate_manufacturers(cursor):
|
||||||
|
"""Populate manufacturers"""
|
||||||
|
print("Adding manufacturers...")
|
||||||
|
added = 0
|
||||||
|
for mfr in DEMO_MANUFACTURERS:
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE name = ?", (mfr['name'],))
|
||||||
|
if cursor.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO manufacturers (name, type, quality_tier, country)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (mfr['name'], mfr['type'], mfr['tier'], mfr['country']))
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
print(f" Added {added} manufacturers")
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def populate_aftermarket(cursor):
|
||||||
|
"""Populate aftermarket parts linked to OEM"""
|
||||||
|
print("Adding aftermarket alternatives...")
|
||||||
|
|
||||||
|
# Get all parts and manufacturers
|
||||||
|
cursor.execute("SELECT id, oem_part_number, name FROM parts")
|
||||||
|
parts = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute("SELECT id, name, quality_tier FROM manufacturers WHERE type != 'oem'")
|
||||||
|
manufacturers = cursor.fetchall()
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
import random
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
part_id, oem_num, part_name = part
|
||||||
|
|
||||||
|
# Add 2-4 aftermarket alternatives per OEM part
|
||||||
|
num_alternatives = random.randint(2, 4)
|
||||||
|
selected_mfrs = random.sample(manufacturers, min(num_alternatives, len(manufacturers)))
|
||||||
|
|
||||||
|
for mfr in selected_mfrs:
|
||||||
|
mfr_id, mfr_name, tier = mfr
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM aftermarket_parts WHERE oem_part_id = ? AND manufacturer_id = ?
|
||||||
|
""", (part_id, mfr_id))
|
||||||
|
if cursor.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate part number
|
||||||
|
prefix = mfr_name[:3].upper()
|
||||||
|
part_num = f"{prefix}-{oem_num.replace('-', '')[:6]}"
|
||||||
|
|
||||||
|
# Set price based on tier
|
||||||
|
base_price = random.uniform(20, 200)
|
||||||
|
if tier == 'premium':
|
||||||
|
price = base_price * 1.5
|
||||||
|
warranty = 36
|
||||||
|
elif tier == 'economy':
|
||||||
|
price = base_price * 0.6
|
||||||
|
warranty = 12
|
||||||
|
else:
|
||||||
|
price = base_price
|
||||||
|
warranty = 24
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name, quality_tier, price_usd, warranty_months)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (part_id, mfr_id, part_num, part_name, tier, round(price, 2), warranty))
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
print(f" Added {added} aftermarket parts")
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def populate_fitments(cursor):
|
||||||
|
"""Create fitments linking parts to vehicles"""
|
||||||
|
print("Adding fitments (linking parts to vehicles)...")
|
||||||
|
|
||||||
|
# Get some vehicle configurations (first 50)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT mye.id, b.name as brand, m.name as model, y.year
|
||||||
|
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 b.name IN ('TOYOTA', 'HONDA', 'FORD', 'CHEVROLET', 'NISSAN')
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT 100
|
||||||
|
""")
|
||||||
|
vehicles = cursor.fetchall()
|
||||||
|
|
||||||
|
# Get all parts
|
||||||
|
cursor.execute("SELECT id, name FROM parts")
|
||||||
|
parts = cursor.fetchall()
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
import random
|
||||||
|
|
||||||
|
for vehicle in vehicles:
|
||||||
|
mye_id = vehicle[0]
|
||||||
|
|
||||||
|
# Assign 10-20 random parts to each vehicle
|
||||||
|
num_parts = random.randint(10, 20)
|
||||||
|
selected_parts = random.sample(parts, min(num_parts, len(parts)))
|
||||||
|
|
||||||
|
for part in selected_parts:
|
||||||
|
part_id = part[0]
|
||||||
|
|
||||||
|
# Check if fitment already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?
|
||||||
|
""", (mye_id, part_id))
|
||||||
|
if cursor.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine position based on part name
|
||||||
|
part_name = part[1].lower()
|
||||||
|
position = None
|
||||||
|
quantity = 1
|
||||||
|
|
||||||
|
if 'front' in part_name:
|
||||||
|
position = 'front'
|
||||||
|
elif 'rear' in part_name:
|
||||||
|
position = 'rear'
|
||||||
|
elif 'left' in part_name:
|
||||||
|
position = 'left'
|
||||||
|
elif 'right' in part_name:
|
||||||
|
position = 'right'
|
||||||
|
|
||||||
|
# Some parts need multiples
|
||||||
|
if 'spark plug' in part_name or 'injector' in part_name:
|
||||||
|
quantity = 4
|
||||||
|
elif 'pad' in part_name or 'rotor' in part_name:
|
||||||
|
quantity = 2 if position else 1
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, position)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (mye_id, part_id, quantity, position))
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
print(f" Added {added} fitments")
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print("Populating Demo Data")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
populate_parts(cursor)
|
||||||
|
populate_manufacturers(cursor)
|
||||||
|
populate_aftermarket(cursor)
|
||||||
|
populate_fitments(cursor)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nDemo data populated successfully!")
|
||||||
|
|
||||||
|
# Show final counts
|
||||||
|
print("\n=== Final Counts ===")
|
||||||
|
for table in ['parts', 'manufacturers', 'aftermarket_parts', 'vehicle_parts']:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
print(f" {table}: {count}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Binary file not shown.
Reference in New Issue
Block a user