diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js
index 787ec35..9bf9f60 100644
--- a/dashboard/dashboard.js
+++ b/dashboard/dashboard.js
@@ -1,12 +1,21 @@
-// Vehicle Dashboard JavaScript - Navegación por tarjetas
+// Vehicle Dashboard JavaScript - Navegacion por tarjetas
class VehicleDashboard {
constructor() {
- this.currentView = 'brands'; // brands, models, vehicles
+ this.currentView = 'brands'; // brands, models, vehicles, categories, groups, parts, diagrams
this.selectedBrand = null;
this.selectedModel = null;
+ this.selectedYear = null; // FASE 5: Track selected year for breadcrumb
+ this.selectedVehicleId = null;
+ this.selectedCategory = null;
+ this.selectedGroupId = null;
+ this.selectedGroup = null; // FASE 5: Track selected group for breadcrumb
this.allVehicles = [];
this.filteredVehicles = [];
- this.stats = { brands: 0, models: 0, vehicles: 0 };
+ this.allCategories = [];
+ this.allParts = [];
+ this.stats = { brands: 0, models: 0, vehicles: 0, parts: 0 };
+ this.currentDiagramZoom = 1; // FASE 3: Zoom level for diagram viewer
+ this.lastFocusedElement = null; // FASE 5: Track focus for modal management
this.init();
}
@@ -14,13 +23,17 @@ class VehicleDashboard {
await this.loadStats();
await this.showBrands();
this.bindFilterEvents();
+ this.bindKeyboardShortcuts(); // FASE 5: Keyboard shortcuts
+ this.initDarkMode(); // FASE 5: Dark mode
}
async loadStats() {
try {
- const [brandsRes, vehiclesRes] = await Promise.all([
+ const [brandsRes, vehiclesRes, partsRes, categoriesRes] = await Promise.all([
fetch('/api/brands'),
- fetch('/api/vehicles')
+ fetch('/api/vehicles'),
+ fetch('/api/parts'),
+ fetch('/api/categories')
]);
if (brandsRes.ok && vehiclesRes.ok) {
@@ -38,6 +51,16 @@ class VehicleDashboard {
document.getElementById('totalModels').textContent = this.stats.models;
document.getElementById('totalVehicles').textContent = this.stats.vehicles;
}
+
+ if (partsRes.ok) {
+ const parts = await partsRes.json();
+ this.stats.parts = parts.length || 0;
+ document.getElementById('totalParts').textContent = this.stats.parts;
+ }
+
+ if (categoriesRes.ok) {
+ this.allCategories = await categoriesRes.json();
+ }
} catch (error) {
console.error('Error loading stats:', error);
}
@@ -47,6 +70,9 @@ class VehicleDashboard {
const breadcrumb = document.getElementById('breadcrumb');
let html = '';
+ // FASE 5: Enhanced breadcrumb with full path: Brand -> Model -> Year -> Category -> Group
+ const yearDisplay = this.selectedYear ? `${this.selectedYear}` : '';
+
if (this.currentView === 'brands') {
html = `
Marcas`;
} else if (this.currentView === 'models') {
@@ -72,15 +98,272 @@ class VehicleDashboard {
${this.selectedModel}
`;
+ } else if (this.currentView === 'categories') {
+ html = `
+
+
+ Marcas
+
+
+
+
+ ${this.selectedBrand}
+
+
+
+
+ ${this.selectedModel}
+
+
+ ${this.selectedYear ? `${yearDisplay}` : ''}
+ Categorias
+ `;
+ } else if (this.currentView === 'groups') {
+ html = `
+
+
+ Marcas
+
+
+
+
+ ${this.selectedBrand}
+
+
+
+
+ ${this.selectedModel}
+
+
+ ${this.selectedYear ? `${yearDisplay}` : ''}
+
+
+ Categorias
+
+
+ ${this.selectedCategory ? (this.selectedCategory.name_es || this.selectedCategory.name) : 'Grupos'}
+ `;
+ } else if (this.currentView === 'parts') {
+ const groupName = this.selectedGroup ? (this.selectedGroup.name_es || this.selectedGroup.name) : 'Grupo';
+ html = `
+
+
+ Marcas
+
+
+
+
+ ${this.selectedBrand}
+
+
+
+
+ ${this.selectedModel}
+
+
+ ${this.selectedYear ? `${yearDisplay}` : ''}
+
+
+ Categorias
+
+
+
+
+ ${this.selectedCategory ? (this.selectedCategory.name_es || this.selectedCategory.name) : 'Categoria'}
+
+
+ ${groupName}
+ `;
}
breadcrumb.innerHTML = html;
}
+ // FASE 5: Keyboard shortcuts
+ bindKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Check if user is typing in an input/textarea
+ const isTyping = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
+
+ // Close modals with Escape (always works)
+ if (e.key === 'Escape') {
+ this.closeAllModals();
+ return;
+ }
+
+ // Skip other shortcuts if typing
+ if (isTyping) {
+ return;
+ }
+
+ // Focus search input with "/" or Ctrl+K
+ if (e.key === '/' || (e.ctrlKey && e.key === 'k')) {
+ e.preventDefault();
+ const searchInput = document.getElementById('partNumberSearch');
+ if (searchInput) {
+ searchInput.focus();
+ }
+ return;
+ }
+
+ // Toggle dark mode with Ctrl+D
+ if (e.ctrlKey && e.key === 'd') {
+ e.preventDefault();
+ this.toggleDarkMode();
+ return;
+ }
+
+ // Go back one level with Backspace
+ if (e.key === 'Backspace') {
+ e.preventDefault();
+ this.goBack();
+ return;
+ }
+ });
+ }
+
+ // FASE 5: Close all open modals
+ closeAllModals() {
+ const modals = ['partDetailModal', 'searchResultsModal', 'diagramModal', 'vinDecoderModal'];
+ modals.forEach(modalId => {
+ const modalEl = document.getElementById(modalId);
+ if (modalEl) {
+ const modal = bootstrap.Modal.getInstance(modalEl);
+ if (modal) {
+ modal.hide();
+ }
+ }
+ });
+ }
+
+ // FASE 5: Go back one level in navigation
+ goBack() {
+ switch (this.currentView) {
+ case 'models':
+ this.goToBrands();
+ break;
+ case 'vehicles':
+ this.goToModels(this.selectedBrand);
+ break;
+ case 'categories':
+ this.goToVehicles(this.selectedBrand, this.selectedModel);
+ break;
+ case 'groups':
+ this.goToCategories(this.selectedVehicleId);
+ break;
+ case 'parts':
+ if (this.selectedCategory) {
+ this.goToGroups(this.selectedCategory.id);
+ } else {
+ this.goToCategories(this.selectedVehicleId);
+ }
+ break;
+ default:
+ // Already at top level (brands)
+ break;
+ }
+ }
+
+ // FASE 5: Initialize dark mode from localStorage
+ initDarkMode() {
+ const savedTheme = localStorage.getItem('autopartes-theme');
+ if (savedTheme === 'dark') {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ this.updateDarkModeIcon(true);
+ }
+
+ // Bind toggle button
+ const toggleBtn = document.getElementById('darkModeToggle');
+ if (toggleBtn) {
+ toggleBtn.addEventListener('click', () => this.toggleDarkMode());
+ }
+ }
+
+ // FASE 5: Toggle dark mode
+ toggleDarkMode() {
+ const currentTheme = document.documentElement.getAttribute('data-theme');
+ const isDark = currentTheme === 'dark';
+
+ if (isDark) {
+ document.documentElement.removeAttribute('data-theme');
+ localStorage.setItem('autopartes-theme', 'light');
+ } else {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ localStorage.setItem('autopartes-theme', 'dark');
+ }
+
+ this.updateDarkModeIcon(!isDark);
+ }
+
+ // FASE 5: Update dark mode toggle icon
+ updateDarkModeIcon(isDark) {
+ const toggleBtn = document.getElementById('darkModeToggle');
+ if (toggleBtn) {
+ const icon = toggleBtn.querySelector('i');
+ if (icon) {
+ icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
+ }
+ }
+ }
+
+ // FASE 5: Make cards keyboard accessible
+ makeCardsAccessible(containerSelector, cardSelector) {
+ const container = document.querySelector(containerSelector);
+ if (!container) return;
+
+ const cards = container.querySelectorAll(cardSelector);
+ cards.forEach((card, index) => {
+ // Add accessibility attributes
+ card.setAttribute('tabindex', '0');
+ card.setAttribute('role', 'button');
+
+ // Get the card text for aria-label
+ const cardText = card.textContent.trim().replace(/\s+/g, ' ');
+ card.setAttribute('aria-label', cardText.substring(0, 100));
+
+ // Add keyboard event handler
+ card.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ card.click();
+ }
+ });
+ });
+ }
+
+ // FASE 5: Open modal with focus management
+ openModalWithFocus(modalId) {
+ this.lastFocusedElement = document.activeElement;
+ const modalEl = document.getElementById(modalId);
+ if (!modalEl) return null;
+
+ const modal = new bootstrap.Modal(modalEl);
+
+ // Focus first focusable element when modal is shown
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const focusable = modalEl.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
+ if (focusable) {
+ focusable.focus();
+ }
+ }, { once: true });
+
+ // Return focus when modal is hidden
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ if (this.lastFocusedElement && document.body.contains(this.lastFocusedElement)) {
+ this.lastFocusedElement.focus();
+ }
+ }, { once: true });
+
+ modal.show();
+ return modal;
+ }
+
async showBrands() {
this.currentView = 'brands';
this.selectedBrand = null;
this.selectedModel = null;
+ this.selectedYear = null; // FASE 5: Reset year
+ this.selectedGroup = null; // FASE 5: Reset group
this.updateBreadcrumb();
this.hideFilters();
@@ -146,6 +429,9 @@ class VehicleDashboard {
`).join('')}
`;
+ // FASE 5: Make brand cards keyboard accessible
+ this.makeCardsAccessible('#mainContent', '.brand-card');
+
} catch (error) {
console.error('Error:', error);
container.innerHTML = `
@@ -162,6 +448,8 @@ class VehicleDashboard {
this.currentView = 'models';
this.selectedBrand = brand;
this.selectedModel = null;
+ this.selectedYear = null; // FASE 5: Reset year
+ this.selectedGroup = null; // FASE 5: Reset group
this.updateBreadcrumb();
this.hideFilters();
@@ -241,6 +529,9 @@ class VehicleDashboard {
}).join('')}
`;
+ // FASE 5: Make model cards keyboard accessible
+ this.makeCardsAccessible('#mainContent', '.model-card');
+
} catch (error) {
console.error('Error:', error);
container.innerHTML = `
@@ -260,6 +551,8 @@ class VehicleDashboard {
this.currentView = 'vehicles';
this.selectedBrand = brand;
this.selectedModel = model;
+ this.selectedYear = null; // FASE 5: Reset year (will be set when selecting vehicle)
+ this.selectedGroup = null; // FASE 5: Reset group
this.updateBreadcrumb();
this.showFilters();
@@ -272,15 +565,30 @@ class VehicleDashboard {
`;
try {
- const response = await fetch(
- `/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`
- );
+ // Fetch both vehicles info and model_year_engine IDs
+ const [vehiclesRes, myeRes] = await Promise.all([
+ fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`),
+ fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`)
+ ]);
- if (!response.ok) {
+ if (!vehiclesRes.ok || !myeRes.ok) {
throw new Error('Error al cargar vehículos');
}
- this.allVehicles = await response.json();
+ const vehicles = await vehiclesRes.json();
+ const myeRecords = await myeRes.json();
+
+ // Merge mye_id into vehicles based on matching fields
+ this.allVehicles = vehicles.map(v => {
+ const mye = myeRecords.find(m =>
+ m.brand === v.brand &&
+ m.model === v.model &&
+ m.year === v.year &&
+ 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 };
+ });
this.filteredVehicles = [...this.allVehicles];
// Poblar filtros
@@ -411,6 +719,9 @@ class VehicleDashboard {
${v.trim_level}
` : ''}
+
`).join('')}
@@ -431,6 +742,1157 @@ class VehicleDashboard {
hideFilters() {
document.getElementById('filtersBar').style.display = 'none';
}
+
+ async goToCategories(myeId) {
+ this.currentView = 'categories';
+ this.selectedVehicleId = myeId;
+ this.selectedCategory = null;
+
+ // FASE 5: Find the vehicle to get year for breadcrumb
+ const vehicle = this.allVehicles.find(v => v.mye_id === myeId);
+ if (vehicle) {
+ this.selectedYear = vehicle.year;
+ }
+
+ this.updateBreadcrumb();
+ this.hideFilters();
+
+ const container = document.getElementById('mainContent');
+ container.innerHTML = `
+
+
+
Cargando categorías...
+
+ `;
+
+ try {
+ // Get all categories (since we don't have vehicle-specific parts yet, show all categories)
+ const response = await fetch('/api/categories');
+
+ if (!response.ok) {
+ throw new Error('Error al cargar categorías');
+ }
+
+ this.allCategories = await response.json();
+ this.displayCategories();
+
+ } catch (error) {
+ console.error('Error:', error);
+ container.innerHTML = `
+
+
+
Error al cargar categorías
+
${error.message}
+
+
+ `;
+ }
+ }
+
+ displayCategories() {
+ const container = document.getElementById('mainContent');
+
+ if (this.allCategories.length === 0) {
+ container.innerHTML = `
+
+
+
No hay categorías disponibles
+
Este vehículo no tiene partes registradas
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+ ${this.allCategories.map(cat => {
+ // Use icon_name directly from database (e.g., "fa-cog", "fa-bolt")
+ const iconClass = cat.icon_name || 'fa-cog';
+ const displayName = cat.name_es || cat.name;
+ return `
+
+
+
+
+
${displayName}
+
+ ${cat.children ? cat.children.length + ' subcategorías' : ''}
+
+
+ `;
+ }).join('')}
+
+
+
+
`;
+
+ // FASE 5: Make category cards keyboard accessible
+ this.makeCardsAccessible('#mainContent', '.category-card');
+ }
+
+ async goToGroups(categoryId) {
+ this.currentView = 'groups';
+ const category = this.allCategories.find(c => c.id === categoryId);
+ this.selectedCategory = category || { id: categoryId, name: 'Categoria' };
+ this.selectedGroup = null; // FASE 5: Reset group
+ this.updateBreadcrumb();
+ this.hideFilters();
+
+ const container = document.getElementById('mainContent');
+ container.innerHTML = `
+
+
+
Cargando grupos...
+
+ `;
+
+ try {
+ const response = await fetch(`/api/categories/${categoryId}/groups`);
+
+ if (!response.ok) {
+ throw new Error('Error al cargar grupos');
+ }
+
+ const groups = await response.json();
+ this.displayGroups(groups, categoryId);
+
+ } catch (error) {
+ console.error('Error:', error);
+ container.innerHTML = `
+
+
+
Error al cargar grupos
+
${error.message}
+
+
+ `;
+ }
+ }
+
+ displayGroups(groups, categoryId) {
+ const container = document.getElementById('mainContent');
+
+ if (groups.length === 0) {
+ container.innerHTML = `
+
+
+
No hay grupos en esta categoría
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+ ${this.selectedCategory.name_es || this.selectedCategory.name}
+
+ ${groups.map(group => `
+
+
+
+
+
${group.name_es || group.name}
+
+
+
+
+
+ `).join('')}
+
+
+
+
`;
+
+ // FASE 5: Make group cards keyboard accessible
+ this.makeCardsAccessible('#mainContent', '.category-card');
+ }
+
+ async goToParts(groupId) {
+ this.currentView = 'parts';
+ this.selectedGroupId = groupId;
+
+ // FASE 5: Fetch group details for breadcrumb
+ try {
+ const response = await fetch(`/api/groups/${groupId}`);
+ if (response.ok) {
+ this.selectedGroup = await response.json();
+ }
+ } catch (error) {
+ console.error('Error fetching group details:', error);
+ this.selectedGroup = { id: groupId, name: 'Grupo' };
+ }
+
+ this.updateBreadcrumb();
+ this.hideFilters();
+
+ const container = document.getElementById('mainContent');
+ container.innerHTML = `
+
+
+
Cargando partes...
+
+ `;
+
+ try {
+ const response = await fetch(`/api/parts?group_id=${groupId}`);
+
+ if (!response.ok) {
+ throw new Error('Error al cargar partes');
+ }
+
+ this.allParts = await response.json();
+ this.displayParts();
+
+ } catch (error) {
+ console.error('Error:', error);
+ container.innerHTML = `
+
+
+
Error al cargar partes
+
${error.message}
+
+
+ `;
+ }
+ }
+
+ displayParts() {
+ const container = document.getElementById('mainContent');
+
+ if (this.allParts.length === 0) {
+ container.innerHTML = `
+
+
+
No hay partes disponibles
+
Este grupo no tiene partes registradas aún
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
+
+
+ | OEM # |
+ Nombre |
+ Grupo |
+ Acción |
+
+
+
+ ${this.allParts.map(part => `
+
+ | ${part.oem_part_number || 'N/A'} |
+ ${part.name_es || part.name || 'Sin nombre'} |
+ ${part.group_name || 'N/A'} |
+
+
+ |
+
+ `).join('')}
+
+
+
+
+
+
+ `;
+ }
+
+ async showPartDetail(partId) {
+ // FASE 5: Use focus management for modal
+ const contentContainer = document.getElementById('partDetailContent');
+
+ contentContainer.innerHTML = `
+
+
+
Cargando detalles...
+
+ `;
+
+ const modal = this.openModalWithFocus('partDetailModal');
+
+ try {
+ // Fetch part details, alternatives, and cross-references in parallel
+ const [partRes, alternativesRes, crossRefsRes] = await Promise.all([
+ fetch(`/api/parts/${partId}`),
+ fetch(`/api/parts/${partId}/alternatives`),
+ fetch(`/api/parts/${partId}/cross-references`)
+ ]);
+
+ if (!partRes.ok) {
+ throw new Error('Error al cargar detalle de la parte');
+ }
+
+ const part = await partRes.json();
+ const alternatives = alternativesRes.ok ? await alternativesRes.json() : [];
+ const crossRefs = crossRefsRes.ok ? await crossRefsRes.json() : [];
+
+ contentContainer.innerHTML = `
+
+
+
${part.name_es || part.name || 'Sin nombre'}
+
+
+
+ Número OEM
+ ${part.oem_part_number || 'N/A'}
+
+
+ Categoría
+ ${part.category_name_es || part.category_name || 'N/A'}
+
+
+ Grupo
+ ${part.group_name_es || part.group_name || 'N/A'}
+
+ ${part.description || part.description_es ? `
+
+ Descripción
+ ${part.description_es || part.description}
+
+ ` : ''}
+
+
+ ${this.renderCrossReferences(crossRefs)}
+
+
+ ${this.renderAlternatives(alternatives)}
+ `;
+
+ } catch (error) {
+ console.error('Error:', error);
+ contentContainer.innerHTML = `
+
+ `;
+ }
+ }
+
+ // FASE 2: Render cross-references section
+ renderCrossReferences(crossRefs) {
+ if (!crossRefs || crossRefs.length === 0) {
+ return '';
+ }
+
+ const badges = crossRefs.map(ref => {
+ const refNumber = ref.cross_reference_number || ref.part_number || ref;
+ const brand = ref.brand ? ` (${ref.brand})` : '';
+ return `${refNumber}${brand}`;
+ }).join('');
+
+ return `
+
+
Cross-Referencias
+
+ ${badges}
+
+
+ `;
+ }
+
+ // FASE 2: Render alternatives section
+ renderAlternatives(alternatives) {
+ if (!alternatives || alternatives.length === 0) {
+ return '';
+ }
+
+ const rows = alternatives.map(alt => `
+
+ | ${alt.brand || 'N/A'} |
+ ${alt.part_number || 'N/A'} |
+ ${alt.name_es || alt.name || 'N/A'} |
+ ${this.getQualityBadge(alt.quality_tier)} |
+ ${this.formatPrice(alt.price)} |
+
+ `).join('');
+
+ return `
+
+
Alternativas Aftermarket
+
+
+
+ | Marca |
+ Número de Parte |
+ Nombre |
+ Calidad |
+ Precio |
+
+
+
+ ${rows}
+
+
+
+ `;
+ }
+
+ // FASE 2: Get quality tier badge HTML
+ getQualityBadge(tier) {
+ const tiers = {
+ 'economy': { class: 'quality-economy', label: 'Económico' },
+ 'standard': { class: 'quality-standard', label: 'Estándar' },
+ 'premium': { class: 'quality-premium', label: 'Premium' },
+ 'oem': { class: 'quality-oem', label: 'OEM' }
+ };
+
+ const tierInfo = tiers[tier?.toLowerCase()] || tiers['standard'];
+ return `${tierInfo.label}`;
+ }
+
+ // FASE 2: Format price as currency
+ formatPrice(price) {
+ if (price === null || price === undefined) {
+ return 'N/A';
+ }
+ return new Intl.NumberFormat('es-MX', {
+ style: 'currency',
+ currency: 'MXN'
+ }).format(price);
+ }
+
+ // FASE 2/4: Search by part number or general search
+ async searchPartNumber() {
+ const searchInput = document.getElementById('partNumberSearch');
+ const searchTerm = searchInput.value.trim();
+
+ if (!searchTerm) {
+ return;
+ }
+
+ // FASE 4: Check if input looks like a VIN (17 characters alphanumeric)
+ if (searchTerm.length === 17 && /^[A-HJ-NPR-Z0-9]{17}$/i.test(searchTerm)) {
+ // Offer to decode VIN
+ if (confirm('El texto parece ser un VIN. ¿Deseas decodificarlo?')) {
+ document.getElementById('vinInput').value = searchTerm.toUpperCase();
+ this.openVinDecoder();
+ return;
+ }
+ }
+
+ // FASE 5: Use focus management for modal
+ const contentContainer = document.getElementById('searchResultsContent');
+
+ contentContainer.innerHTML = `
+
+
+
Buscando "${searchTerm}"...
+
+ `;
+
+ const modal = this.openModalWithFocus('searchResultsModal');
+
+ try {
+ // FASE 4: Use full-text search endpoint for general search
+ let response;
+ // Try part number search first
+ response = await fetch(`/api/search/part-number/${encodeURIComponent(searchTerm)}`);
+
+ let results = [];
+ if (response.ok) {
+ results = await response.json();
+ }
+
+ // If no results from part number, try general search
+ if (results.length === 0) {
+ const searchResponse = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`);
+ if (searchResponse.ok) {
+ const searchData = await searchResponse.json();
+ results = searchData.parts || searchData || [];
+ }
+ }
+
+ this.showSearchResults(results, searchTerm);
+
+ } catch (error) {
+ console.error('Error:', error);
+ contentContainer.innerHTML = `
+
+
+
Error al buscar: ${error.message}
+
+ `;
+ }
+ }
+
+ // FASE 2: Display search results
+ showSearchResults(results, searchTerm) {
+ const contentContainer = document.getElementById('searchResultsContent');
+ const modalTitle = document.getElementById('searchResultsModalLabel');
+
+ modalTitle.innerHTML = ` Resultados para "${searchTerm}"`;
+
+ if (!results || results.length === 0) {
+ contentContainer.innerHTML = `
+
+
+
No se encontraron resultados para "${searchTerm}"
+
+ `;
+ return;
+ }
+
+ const resultItems = results.map(part => `
+
+
+
+
+ ${part.oem_part_number || part.part_number || 'N/A'}
+
+
${part.name_es || part.name || 'Sin nombre'}
+
+
+ ${part.quality_tier ? this.getQualityBadge(part.quality_tier) : ''}
+ ${part.brand ? `
${part.brand}
` : ''}
+
+
+
+ `).join('');
+
+ contentContainer.innerHTML = `
+ ${results.length} resultado${results.length !== 1 ? 's' : ''} encontrado${results.length !== 1 ? 's' : ''}
+
+ ${resultItems}
+
+ `;
+
+ // FASE 5: Make search result items keyboard accessible
+ this.makeCardsAccessible('#searchResultsContent', '.search-result-item');
+ }
+
+ // FASE 2: Show part detail from search results (closes search modal first)
+ showPartDetailFromSearch(partId) {
+ // Close search results modal
+ const searchModal = bootstrap.Modal.getInstance(document.getElementById('searchResultsModal'));
+ if (searchModal) {
+ searchModal.hide();
+ }
+
+ // Small delay to allow modal transition, then show part detail
+ setTimeout(() => {
+ this.showPartDetail(partId);
+ }, 300);
+ }
+
+ // FASE 3: Go to diagrams view for a group
+ async goToDiagrams(groupId) {
+ const container = document.getElementById('mainContent');
+ container.innerHTML = `
+
+
+
Cargando diagramas...
+
+ `;
+
+ try {
+ const response = await fetch(`/api/groups/${groupId}/diagrams`);
+
+ if (!response.ok) {
+ throw new Error('Error al cargar diagramas');
+ }
+
+ const diagrams = await response.json();
+ this.displayDiagramThumbnails(diagrams, groupId);
+
+ } catch (error) {
+ console.error('Error:', error);
+ container.innerHTML = `
+
+
+
Error al cargar diagramas
+
${error.message}
+
+
+ `;
+ }
+ }
+
+ // FASE 3: Display diagram thumbnails grid
+ displayDiagramThumbnails(diagrams, groupId) {
+ const container = document.getElementById('mainContent');
+
+ if (!diagrams || diagrams.length === 0) {
+ container.innerHTML = `
+
+
+
No hay diagramas disponibles
+
Este grupo no tiene diagramas registrados
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
+
+ ${diagrams.map(diagram => `
+
+
+ ${diagram.thumbnail_url
+ ? `

`
+ : `
`
+ }
+
+
${diagram.name_es || diagram.name || 'Diagrama'}
+
+ `).join('')}
+
+
+
+
+
+ `;
+
+ // FASE 5: Make diagram thumbnails keyboard accessible
+ this.makeCardsAccessible('#mainContent', '.diagram-thumbnail');
+ }
+
+ // FASE 3: Show diagram in modal with hotspots
+ async showDiagram(diagramId) {
+ // FASE 5: Use focus management for modal
+ const contentContainer = document.getElementById('diagramModalContent');
+ const modalTitle = document.getElementById('diagramModalLabel');
+
+ contentContainer.innerHTML = `
+
+
+
Cargando diagrama...
+
+ `;
+
+ const modal = this.openModalWithFocus('diagramModal');
+
+ try {
+ const response = await fetch(`/api/diagrams/${diagramId}`);
+
+ if (!response.ok) {
+ throw new Error('Error al cargar diagrama');
+ }
+
+ const diagram = await response.json();
+ modalTitle.innerHTML = ` ${diagram.name_es || diagram.name || 'Diagrama'}`;
+
+ this.currentDiagramZoom = 1;
+ this.renderDiagramWithHotspots(diagram);
+
+ } catch (error) {
+ console.error('Error:', error);
+ contentContainer.innerHTML = `
+
+
+
Error al cargar diagrama: ${error.message}
+
+ `;
+ }
+ }
+
+ // FASE 3: Render diagram with interactive hotspots
+ renderDiagramWithHotspots(diagram) {
+ const contentContainer = document.getElementById('diagramModalContent');
+ const hotspots = diagram.hotspots || [];
+
+ contentContainer.innerHTML = `
+
+
+
+ ${diagram.svg_content
+ ? diagram.svg_content
+ : diagram.image_url
+ ? `

`
+ : `
+
+
No hay imagen de diagrama disponible
+
`
+ }
+ ${hotspots.map((hotspot, index) => this.renderHotspot(hotspot, index)).join('')}
+
+
+ ${hotspots.length > 0 ? `
+
+
Leyenda de Partes
+
+ ${hotspots.map((hotspot, index) => `
+
+ ${index + 1}
+ ${hotspot.name_es || hotspot.name || hotspot.label || 'Parte ' + (index + 1)}
+
+ `).join('')}
+
+
+ ` : ''}
+ `;
+ }
+
+ // FASE 3: Render individual hotspot marker
+ renderHotspot(hotspot, index) {
+ const x = hotspot.x || hotspot.position_x || 0;
+ const y = hotspot.y || hotspot.position_y || 0;
+ const width = hotspot.width || 30;
+ const height = hotspot.height || 30;
+
+ return `
+
+
+ ${index + 1}
+
+
+
+ `;
+ }
+
+ // FASE 3: Handle hotspot click
+ onHotspotClick(hotspot) {
+ if (hotspot.part_id) {
+ // Close diagram modal first
+ const diagramModal = bootstrap.Modal.getInstance(document.getElementById('diagramModal'));
+ if (diagramModal) {
+ diagramModal.hide();
+ }
+
+ // Small delay to allow modal transition, then show part detail
+ setTimeout(() => {
+ this.showPartDetail(hotspot.part_id);
+ }, 300);
+ } else {
+ // Show tooltip or alert with hotspot info
+ const name = hotspot.name_es || hotspot.name || hotspot.label || 'Parte';
+ const description = hotspot.description_es || hotspot.description || '';
+
+ alert(`${name}${description ? '\n\n' + description : ''}`);
+ }
+ }
+
+ // FASE 3: Zoom diagram controls
+ zoomDiagram(delta) {
+ const wrapper = document.getElementById('diagramWrapper');
+ if (!wrapper) return;
+
+ if (delta === 0) {
+ // Reset zoom
+ this.currentDiagramZoom = 1;
+ } else {
+ // Adjust zoom with limits
+ this.currentDiagramZoom = Math.max(0.5, Math.min(2, this.currentDiagramZoom + delta));
+ }
+
+ wrapper.style.transform = `scale(${this.currentDiagramZoom})`;
+ }
+
+ // FASE 4: Open VIN decoder modal
+ openVinDecoder() {
+ // Clear previous results
+ document.getElementById('vinResult').innerHTML = '';
+ // FASE 5: Use focus management for modal
+ this.openModalWithFocus('vinDecoderModal');
+ }
+
+ // FASE 4: Decode VIN
+ async decodeVin() {
+ const vinInput = document.getElementById('vinInput');
+ const vin = vinInput.value.trim().toUpperCase();
+ const resultContainer = document.getElementById('vinResult');
+
+ // Validate VIN
+ if (!vin) {
+ resultContainer.innerHTML = `
+
+ Por favor ingresa un VIN
+
+ `;
+ return;
+ }
+
+ if (vin.length !== 17) {
+ resultContainer.innerHTML = `
+
+ El VIN debe tener exactamente 17 caracteres (actual: ${vin.length})
+
+ `;
+ return;
+ }
+
+ // Check for invalid characters (I, O, Q are not allowed in VINs)
+ if (/[IOQ]/i.test(vin)) {
+ resultContainer.innerHTML = `
+
+ El VIN contiene caracteres invalidos. Las letras I, O y Q no se permiten en VINs.
+
+ `;
+ return;
+ }
+
+ // Validate VIN format
+ if (!/^[A-HJ-NPR-Z0-9]{17}$/i.test(vin)) {
+ resultContainer.innerHTML = `
+
+ El VIN contiene caracteres invalidos. Solo se permiten letras (excepto I, O, Q) y numeros.
+
+ `;
+ return;
+ }
+
+ resultContainer.innerHTML = `
+
+
+
Decodificando VIN...
+
+ `;
+
+ try {
+ const response = await fetch(`/api/vin/decode/${encodeURIComponent(vin)}`);
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.detail || errorData.message || 'Error al decodificar VIN');
+ }
+
+ const data = await response.json();
+ this.showVinResult(data, vin);
+
+ } catch (error) {
+ console.error('Error:', error);
+ resultContainer.innerHTML = `
+
+ ${error.message}
+
+ `;
+ }
+ }
+
+ // FASE 4: Show VIN decode result
+ showVinResult(data, vin) {
+ const resultContainer = document.getElementById('vinResult');
+
+ // Build vehicle info from decoded data
+ const vehicleInfo = data.vehicle || data;
+ const make = vehicleInfo.make || vehicleInfo.brand || 'Desconocido';
+ const model = vehicleInfo.model || 'Desconocido';
+ const year = vehicleInfo.year || vehicleInfo.model_year || 'Desconocido';
+ const engine = vehicleInfo.engine || vehicleInfo.engine_description || 'N/A';
+ const trim = vehicleInfo.trim || vehicleInfo.trim_level || '';
+ const bodyType = vehicleInfo.body_type || vehicleInfo.body_class || 'N/A';
+ const driveType = vehicleInfo.drive_type || vehicleInfo.drivetrain || 'N/A';
+ const fuelType = vehicleInfo.fuel_type || 'N/A';
+ const transmission = vehicleInfo.transmission || 'N/A';
+ const country = vehicleInfo.country || vehicleInfo.plant_country || 'N/A';
+
+ // Check if we have a match in our database
+ const hasMatch = data.matched || data.database_match || vehicleInfo.mye_id;
+ const myeId = vehicleInfo.mye_id || data.mye_id;
+
+ let matchCard = '';
+ if (hasMatch && myeId) {
+ matchCard = `
+
+
+
+
+ Vehiculo encontrado en la base de datos
+
+
+
+
+ `;
+ } else {
+ matchCard = `
+
+
+
+
+ Vehiculo no encontrado en la base de datos
+
+
+
+
+ `;
+ }
+
+ resultContainer.innerHTML = `
+
+
+
+
${year} ${make} ${model} ${trim}
+
+
+
+
+ Marca
+ ${make}
+
+
+ Modelo
+ ${model}
+
+
+ Año
+ ${year}
+
+
+ Motor
+ ${engine}
+
+
+
+
+ Combustible
+ ${fuelType}
+
+
+ Transmision
+ ${transmission}
+
+
+ Traccion
+ ${driveType}
+
+
+ Pais
+ ${country}
+
+
+
+
+ ${matchCard}
+
+ `;
+ }
+
+ // FASE 4: View parts for a VIN
+ async viewVinParts(vin, myeId) {
+ // Close VIN modal
+ const vinModal = bootstrap.Modal.getInstance(document.getElementById('vinDecoderModal'));
+ if (vinModal) {
+ vinModal.hide();
+ }
+
+ // FASE 5: Use focus management for modal
+ const contentContainer = document.getElementById('searchResultsContent');
+ const modalTitle = document.getElementById('searchResultsModalLabel');
+
+ modalTitle.innerHTML = ` Partes para VIN: ${vin.substring(0, 8)}...`;
+
+ contentContainer.innerHTML = `
+
+ `;
+
+ const modal = this.openModalWithFocus('searchResultsModal');
+
+ try {
+ const response = await fetch(`/api/vin/${encodeURIComponent(vin)}/parts`);
+
+ if (!response.ok) {
+ throw new Error('Error al cargar partes');
+ }
+
+ const data = await response.json();
+ const parts = data.parts || data;
+
+ this.displayVinParts(parts, vin, myeId);
+
+ } catch (error) {
+ console.error('Error:', error);
+ // If VIN parts endpoint fails, try to use the mye_id to show categories
+ if (myeId) {
+ contentContainer.innerHTML = `
+
+ No se encontraron partes especificas para este VIN.
+
+
+
+ `;
+ } else {
+ contentContainer.innerHTML = `
+
+
+
Error al cargar partes: ${error.message}
+
+ `;
+ }
+ }
+ }
+
+ // FASE 4: Display parts from VIN lookup grouped by category
+ displayVinParts(parts, vin, myeId) {
+ const contentContainer = document.getElementById('searchResultsContent');
+
+ if (!parts || parts.length === 0) {
+ contentContainer.innerHTML = `
+
+
+
No se encontraron partes para este VIN
+ ${myeId ? `
+
+ ` : ''}
+
+ `;
+ return;
+ }
+
+ // Group parts by category
+ const grouped = {};
+ parts.forEach(part => {
+ const category = part.category_name_es || part.category_name || 'Sin Categoria';
+ if (!grouped[category]) {
+ grouped[category] = [];
+ }
+ grouped[category].push(part);
+ });
+
+ let html = `${parts.length} parte${parts.length !== 1 ? 's' : ''} encontrada${parts.length !== 1 ? 's' : ''}
`;
+
+ for (const [category, categoryParts] of Object.entries(grouped)) {
+ html += `
+
+
${category}
+
+ ${categoryParts.map(part => `
+
+
+
+
+ ${part.oem_part_number || part.part_number || 'N/A'}
+
+
${part.name_es || part.name || 'Sin nombre'}
+
+
+ ${part.quality_tier ? this.getQualityBadge(part.quality_tier) : ''}
+ ${part.group_name_es || part.group_name ? `
${part.group_name_es || part.group_name}
` : ''}
+
+
+
+ `).join('')}
+
+
+ `;
+ }
+
+ if (myeId) {
+ html += `
+
+
+
+ `;
+ }
+
+ contentContainer.innerHTML = html;
+ }
+
+ // FASE 4: Search manually from VIN (when no database match)
+ searchManuallyFromVin(make, model, year) {
+ // Close VIN modal
+ const vinModal = bootstrap.Modal.getInstance(document.getElementById('vinDecoderModal'));
+ if (vinModal) {
+ vinModal.hide();
+ }
+
+ // Navigate to brand/model if they exist in our database
+ setTimeout(async () => {
+ try {
+ // Check if brand exists
+ const brandsRes = await fetch('/api/brands');
+ if (brandsRes.ok) {
+ const brands = await brandsRes.json();
+ const matchedBrand = brands.find(b =>
+ b.toLowerCase() === make.toLowerCase() ||
+ b.toLowerCase().includes(make.toLowerCase()) ||
+ make.toLowerCase().includes(b.toLowerCase())
+ );
+
+ if (matchedBrand) {
+ // Brand exists, go to models
+ this.goToModels(matchedBrand);
+ return;
+ }
+ }
+
+ // Brand not found, show all brands
+ alert(`La marca "${make}" no se encontro en la base de datos. Mostrando todas las marcas disponibles.`);
+ this.goToBrands();
+
+ } catch (error) {
+ console.error('Error:', error);
+ this.goToBrands();
+ }
+ }, 300);
+ }
}
// Initialize dashboard globally
diff --git a/dashboard/index.html b/dashboard/index.html
index a4849da..0eba919 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -3,7 +3,7 @@
- Base de Datos de Vehículos
+ Catálogo de Autopartes
-
@@ -330,34 +994,124 @@
-
-
+
+
-
+ Filtrar por:
-
-
+
+
- 0 resultados
+ 0 resultados
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/server.py b/dashboard/server.py
index f8414af..c643cbb 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -231,10 +231,1696 @@ def api_vehicles():
model = request.args.get('model')
year = request.args.get('year')
engine = request.args.get('engine')
-
+
vehicles = search_vehicles(brand, model, year, engine)
return jsonify(vehicles)
+# ============================================================================
+# Parts Catalog API Endpoints
+# ============================================================================
+
+@app.route('/api/categories')
+def api_categories():
+ """API endpoint to get all part categories (hierarchical)"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Get all categories
+ cursor.execute("""
+ SELECT id, name, name_es, slug, icon_name, display_order, parent_id
+ FROM part_categories
+ ORDER BY display_order, name
+ """)
+ all_categories = cursor.fetchall()
+ conn.close()
+
+ # Build hierarchical structure
+ categories_dict = {}
+ root_categories = []
+
+ # First pass: create all category objects
+ for row in all_categories:
+ category = {
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'slug': row['slug'],
+ 'icon_name': row['icon_name'],
+ 'display_order': row['display_order'],
+ 'children': []
+ }
+ categories_dict[row['id']] = category
+
+ if row['parent_id'] is None:
+ root_categories.append(category)
+
+ # Second pass: build hierarchy
+ for row in all_categories:
+ if row['parent_id'] is not None and row['parent_id'] in categories_dict:
+ categories_dict[row['parent_id']]['children'].append(categories_dict[row['id']])
+
+ return jsonify(root_categories)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/categories/
/groups')
+def api_category_groups(category_id):
+ """API endpoint to get groups for a specific category"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT id, name, name_es, slug, display_order
+ FROM part_groups
+ WHERE category_id = ?
+ ORDER BY display_order, name
+ """, (category_id,))
+
+ groups = []
+ for row in cursor.fetchall():
+ groups.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'slug': row['slug'],
+ 'display_order': row['display_order']
+ })
+
+ conn.close()
+ return jsonify(groups)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/parts')
+def api_parts():
+ """API endpoint to list parts with optional filters and pagination"""
+ try:
+ group_id = request.args.get('group_id', type=int)
+ category_id = request.args.get('category_id', type=int)
+ search = request.args.get('search')
+ page = request.args.get('page', 1, type=int)
+ per_page = request.args.get('per_page', 50, type=int)
+ per_page = min(per_page, 100) # Max 100 per page
+ offset = (page - 1) * per_page
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Build base WHERE clause for both count and data queries
+ where_clause = " WHERE 1=1"
+ params = []
+
+ if group_id:
+ where_clause += " AND p.group_id = ?"
+ params.append(group_id)
+
+ if category_id:
+ where_clause += " AND pg.category_id = ?"
+ params.append(category_id)
+
+ if search:
+ where_clause += " AND (p.name LIKE ? OR p.name_es LIKE ? OR p.oem_part_number LIKE ?)"
+ search_term = f"%{search}%"
+ params.extend([search_term, search_term, search_term])
+
+ # Get total count
+ count_query = """
+ SELECT COUNT(*) as total
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ """ + where_clause
+ cursor.execute(count_query, params)
+ total_count = cursor.fetchone()['total']
+
+ # Get paginated data
+ data_query = """
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ p.group_id,
+ pg.name AS group_name,
+ pc.name AS category_name
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ """ + where_clause + " ORDER BY p.name LIMIT ? OFFSET ?"
+ params.extend([per_page, offset])
+
+ cursor.execute(data_query, params)
+
+ parts = []
+ for row in cursor.fetchall():
+ parts.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_id': row['group_id'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name']
+ })
+
+ conn.close()
+
+ total_pages = (total_count + per_page - 1) // per_page
+ return jsonify({
+ 'data': parts,
+ 'pagination': {
+ 'page': page,
+ 'per_page': per_page,
+ 'total': total_count,
+ 'total_pages': total_pages
+ }
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/parts/')
+def api_part_detail(part_id):
+ """API endpoint to get single part details"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ p.description,
+ p.description_es,
+ p.group_id,
+ pg.name AS group_name,
+ pg.name_es AS group_name_es,
+ pc.id AS category_id,
+ pc.name AS category_name,
+ pc.name_es AS category_name_es
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE p.id = ?
+ """, (part_id,))
+
+ row = cursor.fetchone()
+ conn.close()
+
+ if row is None:
+ return jsonify({'error': 'Part not found'}), 404
+
+ part = {
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'description': row['description'],
+ 'description_es': row['description_es'],
+ 'group_id': row['group_id'],
+ 'group_name': row['group_name'],
+ 'group_name_es': row['group_name_es'],
+ 'category_id': row['category_id'],
+ 'category_name': row['category_name'],
+ 'category_name_es': row['category_name_es']
+ }
+
+ return jsonify(part)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/vehicles//categories')
+def api_vehicle_categories(mye_id):
+ """API endpoint to get categories that have parts for a specific vehicle"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT DISTINCT
+ pc.id,
+ pc.name,
+ pc.name_es,
+ pc.slug,
+ pc.icon_name,
+ pc.display_order
+ FROM part_categories pc
+ JOIN part_groups pg ON pg.category_id = pc.id
+ JOIN parts p ON p.group_id = pg.id
+ JOIN vehicle_parts vp ON vp.part_id = p.id
+ WHERE vp.model_year_engine_id = ?
+ ORDER BY pc.display_order, pc.name
+ """, (mye_id,))
+
+ categories = []
+ for row in cursor.fetchall():
+ categories.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'slug': row['slug'],
+ 'icon_name': row['icon_name'],
+ 'display_order': row['display_order']
+ })
+
+ conn.close()
+ return jsonify(categories)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/vehicles//parts')
+def api_vehicle_parts(mye_id):
+ """API endpoint to get parts for a specific vehicle"""
+ try:
+ category_id = request.args.get('category_id', type=int)
+ group_id = request.args.get('group_id', type=int)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ vp.quantity_required,
+ vp.position,
+ pc.name AS category_name,
+ pg.name AS group_name
+ FROM vehicle_parts vp
+ JOIN parts p ON vp.part_id = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE vp.model_year_engine_id = ?
+ """
+ params = [mye_id]
+
+ if category_id:
+ query += " AND pc.id = ?"
+ params.append(category_id)
+
+ if group_id:
+ query += " AND pg.id = ?"
+ params.append(group_id)
+
+ query += " ORDER BY pc.display_order, pg.display_order, p.name"
+
+ cursor.execute(query, params)
+
+ parts = []
+ for row in cursor.fetchall():
+ parts.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'quantity_required': row['quantity_required'],
+ 'position': row['position'],
+ 'category_name': row['category_name'],
+ 'group_name': row['group_name']
+ })
+
+ conn.close()
+ return jsonify(parts)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@app.route('/api/model-year-engine')
+def api_model_year_engine():
+ """API endpoint to get model_year_engine records with filters"""
+ try:
+ brand = request.args.get('brand')
+ model = request.args.get('model')
+ year = request.args.get('year', type=int)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT
+ mye.id,
+ b.name AS brand,
+ m.name AS model,
+ y.year,
+ e.name AS engine,
+ mye.trim_level,
+ mye.drivetrain,
+ mye.transmission
+ FROM model_year_engine mye
+ JOIN models m ON mye.model_id = m.id
+ JOIN brands b ON m.brand_id = b.id
+ JOIN years y ON mye.year_id = y.id
+ JOIN engines e ON mye.engine_id = e.id
+ WHERE 1=1
+ """
+ params = []
+
+ if brand:
+ query += " AND b.name = ?"
+ params.append(brand)
+
+ if model:
+ query += " AND m.name = ?"
+ params.append(model)
+
+ if year:
+ query += " AND y.year = ?"
+ params.append(year)
+
+ query += " ORDER BY b.name, m.name, y.year, e.name"
+
+ cursor.execute(query, params)
+
+ records = []
+ for row in cursor.fetchall():
+ records.append({
+ 'id': row['id'],
+ 'brand': row['brand'],
+ 'model': row['model'],
+ 'year': row['year'],
+ 'engine': row['engine'],
+ 'trim_level': row['trim_level'],
+ 'drivetrain': row['drivetrain'],
+ 'transmission': row['transmission']
+ })
+
+ conn.close()
+ return jsonify(records)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+# ============================================================================
+# FASE 2: Cross-References and Aftermarket API Endpoints
+# ============================================================================
+
+@app.route('/api/manufacturers')
+def api_manufacturers():
+ """Get all manufacturers, optionally filtered by type"""
+ try:
+ manufacturer_type = request.args.get('type')
+ quality_tier = request.args.get('quality_tier')
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT id, name, type, quality_tier, country, logo_url, website
+ FROM manufacturers
+ WHERE 1=1
+ """
+ params = []
+
+ if manufacturer_type:
+ query += " AND type = ?"
+ params.append(manufacturer_type)
+
+ if quality_tier:
+ query += " AND quality_tier = ?"
+ params.append(quality_tier)
+
+ query += " ORDER BY name"
+
+ cursor.execute(query, params)
+
+ manufacturers = []
+ for row in cursor.fetchall():
+ manufacturers.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'type': row['type'],
+ 'quality_tier': row['quality_tier'],
+ 'country': row['country'],
+ 'logo_url': row['logo_url'],
+ 'website': row['website']
+ })
+
+ conn.close()
+ return jsonify(manufacturers)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/parts//alternatives')
+def api_part_alternatives(part_id):
+ """Get aftermarket alternatives for an OEM part"""
+ try:
+ quality_tier = request.args.get('quality_tier')
+ manufacturer_id = request.args.get('manufacturer_id', type=int)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT
+ ap.id,
+ ap.part_number,
+ ap.name,
+ ap.name_es,
+ m.name AS manufacturer_name,
+ ap.manufacturer_id,
+ ap.quality_tier,
+ ap.price_usd,
+ ap.warranty_months,
+ ap.in_stock
+ FROM aftermarket_parts ap
+ JOIN manufacturers m ON ap.manufacturer_id = m.id
+ WHERE ap.oem_part_id = ?
+ """
+ params = [part_id]
+
+ if quality_tier:
+ query += " AND ap.quality_tier = ?"
+ params.append(quality_tier)
+
+ if manufacturer_id:
+ query += " AND ap.manufacturer_id = ?"
+ params.append(manufacturer_id)
+
+ query += " ORDER BY ap.quality_tier DESC, ap.price_usd ASC"
+
+ cursor.execute(query, params)
+
+ alternatives = []
+ for row in cursor.fetchall():
+ alternatives.append({
+ 'id': row['id'],
+ 'part_number': row['part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'manufacturer_name': row['manufacturer_name'],
+ 'manufacturer_id': row['manufacturer_id'],
+ 'quality_tier': row['quality_tier'],
+ 'price_usd': row['price_usd'],
+ 'warranty_months': row['warranty_months'],
+ 'in_stock': bool(row['in_stock']) if row['in_stock'] is not None else None
+ })
+
+ conn.close()
+ return jsonify(alternatives)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/parts//cross-references')
+def api_part_cross_references(part_id):
+ """Get cross-reference numbers for a part"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT id, cross_reference_number, reference_type, source, notes
+ FROM part_cross_references
+ WHERE part_id = ?
+ ORDER BY reference_type, cross_reference_number
+ """, (part_id,))
+
+ cross_references = []
+ for row in cursor.fetchall():
+ cross_references.append({
+ 'id': row['id'],
+ 'cross_reference_number': row['cross_reference_number'],
+ 'reference_type': row['reference_type'],
+ 'source': row['source'],
+ 'notes': row['notes']
+ })
+
+ conn.close()
+ return jsonify(cross_references)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/search/part-number/')
+def api_search_part_number(part_number):
+ """Search for parts by any part number (OEM, aftermarket, or cross-ref)"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ results = []
+ search_term = f"%{part_number}%"
+
+ # Search in OEM parts
+ cursor.execute("""
+ SELECT id, oem_part_number, name, name_es
+ FROM parts
+ WHERE oem_part_number LIKE ?
+ """, (search_term,))
+
+ for row in cursor.fetchall():
+ results.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'match_type': 'oem',
+ 'matched_number': row['oem_part_number']
+ })
+
+ # Search in aftermarket parts
+ cursor.execute("""
+ SELECT p.id, p.oem_part_number, p.name, p.name_es, ap.part_number
+ FROM aftermarket_parts ap
+ JOIN parts p ON ap.oem_part_id = p.id
+ WHERE ap.part_number LIKE ?
+ """, (search_term,))
+
+ for row in cursor.fetchall():
+ results.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'match_type': 'aftermarket',
+ 'matched_number': row['part_number']
+ })
+
+ # Search in cross-references
+ cursor.execute("""
+ SELECT p.id, p.oem_part_number, p.name, p.name_es, pcr.cross_reference_number
+ FROM part_cross_references pcr
+ JOIN parts p ON pcr.part_id = p.id
+ WHERE pcr.cross_reference_number LIKE ?
+ """, (search_term,))
+
+ for row in cursor.fetchall():
+ results.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'match_type': 'cross_reference',
+ 'matched_number': row['cross_reference_number']
+ })
+
+ conn.close()
+ return jsonify(results)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/aftermarket')
+def api_aftermarket_parts():
+ """List aftermarket parts with filters and pagination"""
+ try:
+ manufacturer_id = request.args.get('manufacturer_id', type=int)
+ quality_tier = request.args.get('quality_tier')
+ search = request.args.get('search')
+ page = request.args.get('page', 1, type=int)
+ per_page = request.args.get('per_page', 50, type=int)
+ per_page = min(per_page, 100) # Max 100 per page
+ offset = (page - 1) * per_page
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Build base WHERE clause for both count and data queries
+ where_clause = " WHERE 1=1"
+ params = []
+
+ if manufacturer_id:
+ where_clause += " AND ap.manufacturer_id = ?"
+ params.append(manufacturer_id)
+
+ if quality_tier:
+ where_clause += " AND ap.quality_tier = ?"
+ params.append(quality_tier)
+
+ if search:
+ where_clause += " AND (ap.name LIKE ? OR ap.part_number LIKE ? OR p.oem_part_number LIKE ?)"
+ search_term = f"%{search}%"
+ params.extend([search_term, search_term, search_term])
+
+ # Get total count
+ count_query = """
+ SELECT COUNT(*) as total
+ FROM aftermarket_parts ap
+ JOIN parts p ON ap.oem_part_id = p.id
+ JOIN manufacturers m ON ap.manufacturer_id = m.id
+ """ + where_clause
+ cursor.execute(count_query, params)
+ total_count = cursor.fetchone()['total']
+
+ # Get paginated data
+ data_query = """
+ SELECT
+ ap.id,
+ ap.part_number,
+ ap.name,
+ p.oem_part_number,
+ m.name AS manufacturer_name,
+ ap.quality_tier,
+ ap.price_usd
+ FROM aftermarket_parts ap
+ JOIN parts p ON ap.oem_part_id = p.id
+ JOIN manufacturers m ON ap.manufacturer_id = m.id
+ """ + where_clause + " ORDER BY ap.name LIMIT ? OFFSET ?"
+ params.extend([per_page, offset])
+
+ cursor.execute(data_query, params)
+
+ parts = []
+ for row in cursor.fetchall():
+ parts.append({
+ 'id': row['id'],
+ 'part_number': row['part_number'],
+ 'name': row['name'],
+ 'oem_part_number': row['oem_part_number'],
+ 'manufacturer_name': row['manufacturer_name'],
+ 'quality_tier': row['quality_tier'],
+ 'price_usd': row['price_usd']
+ })
+
+ conn.close()
+
+ total_pages = (total_count + per_page - 1) // per_page
+ return jsonify({
+ 'data': parts,
+ 'pagination': {
+ 'page': page,
+ 'per_page': per_page,
+ 'total': total_count,
+ 'total_pages': total_pages
+ }
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+# ============================================================================
+# FASE 3: Exploded Diagrams API Endpoints
+# ============================================================================
+
+@app.route('/api/diagrams')
+def api_diagrams():
+ """Get all diagrams, optionally filtered by group_id"""
+ try:
+ group_id = request.args.get('group_id', type=int)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ query = """
+ SELECT
+ d.id,
+ d.name,
+ d.name_es,
+ d.group_id,
+ pg.name AS group_name,
+ d.thumbnail_path,
+ d.display_order
+ FROM diagrams d
+ JOIN part_groups pg ON d.group_id = pg.id
+ WHERE 1=1
+ """
+ params = []
+
+ if group_id:
+ query += " AND d.group_id = ?"
+ params.append(group_id)
+
+ query += " ORDER BY d.display_order, d.name"
+
+ cursor.execute(query, params)
+
+ diagrams = []
+ for row in cursor.fetchall():
+ diagrams.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_id': row['group_id'],
+ 'group_name': row['group_name'],
+ 'thumbnail_path': row['thumbnail_path'],
+ 'display_order': row['display_order']
+ })
+
+ conn.close()
+ return jsonify(diagrams)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/diagrams/')
+def api_diagram_detail(diagram_id):
+ """Get diagram details including SVG content and hotspots"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Get diagram details
+ cursor.execute("""
+ SELECT
+ d.id,
+ d.name,
+ d.name_es,
+ d.group_id,
+ pg.name AS group_name,
+ d.image_path,
+ d.svg_content,
+ d.width,
+ d.height
+ FROM diagrams d
+ JOIN part_groups pg ON d.group_id = pg.id
+ WHERE d.id = ?
+ """, (diagram_id,))
+
+ row = cursor.fetchone()
+
+ if row is None:
+ conn.close()
+ return jsonify({'error': 'Diagram not found'}), 404
+
+ diagram = {
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_id': row['group_id'],
+ 'group_name': row['group_name'],
+ 'image_path': row['image_path'],
+ 'svg_content': row['svg_content'],
+ 'width': row['width'],
+ 'height': row['height'],
+ 'hotspots': []
+ }
+
+ # Get hotspots with part info
+ cursor.execute("""
+ SELECT
+ h.id,
+ h.part_id,
+ h.callout_number,
+ h.label,
+ h.shape,
+ h.coords,
+ h.color,
+ p.name AS part_name,
+ p.oem_part_number AS part_number
+ FROM diagram_hotspots h
+ LEFT JOIN parts p ON h.part_id = p.id
+ WHERE h.diagram_id = ?
+ ORDER BY h.callout_number
+ """, (diagram_id,))
+
+ for hotspot_row in cursor.fetchall():
+ diagram['hotspots'].append({
+ 'id': hotspot_row['id'],
+ 'part_id': hotspot_row['part_id'],
+ 'callout_number': hotspot_row['callout_number'],
+ 'label': hotspot_row['label'],
+ 'shape': hotspot_row['shape'],
+ 'coords': hotspot_row['coords'],
+ 'color': hotspot_row['color'],
+ 'part_name': hotspot_row['part_name'],
+ 'part_number': hotspot_row['part_number']
+ })
+
+ conn.close()
+ return jsonify(diagram)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/groups//diagrams')
+def api_group_diagrams(group_id):
+ """Get all diagrams for a specific part group"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT
+ id,
+ name,
+ name_es,
+ thumbnail_path,
+ display_order
+ FROM diagrams
+ WHERE group_id = ?
+ ORDER BY display_order, name
+ """, (group_id,))
+
+ diagrams = []
+ for row in cursor.fetchall():
+ diagrams.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'thumbnail_path': row['thumbnail_path'],
+ 'display_order': row['display_order']
+ })
+
+ conn.close()
+ return jsonify(diagrams)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/vehicles//diagrams')
+def api_vehicle_diagrams(mye_id):
+ """Get diagrams available for a specific vehicle configuration"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT DISTINCT
+ d.id,
+ d.name,
+ d.name_es,
+ d.group_id,
+ pg.name AS group_name,
+ pc.name AS category_name,
+ d.thumbnail_path,
+ vd.notes
+ FROM vehicle_diagrams vd
+ JOIN diagrams d ON vd.diagram_id = d.id
+ JOIN part_groups pg ON d.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE vd.model_year_engine_id = ?
+ ORDER BY pc.display_order, pg.display_order, d.display_order
+ """, (mye_id,))
+
+ diagrams = []
+ for row in cursor.fetchall():
+ diagrams.append({
+ 'id': row['id'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_id': row['group_id'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'thumbnail_path': row['thumbnail_path'],
+ 'notes': row['notes']
+ })
+
+ conn.close()
+ return jsonify(diagrams)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/hotspots/')
+def api_hotspot_detail(hotspot_id):
+ """Get hotspot details including linked part info"""
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT
+ h.id,
+ h.diagram_id,
+ h.part_id,
+ h.callout_number,
+ h.label,
+ h.shape,
+ h.coords,
+ h.color
+ FROM diagram_hotspots h
+ WHERE h.id = ?
+ """, (hotspot_id,))
+
+ row = cursor.fetchone()
+
+ if row is None:
+ conn.close()
+ return jsonify({'error': 'Hotspot not found'}), 404
+
+ hotspot = {
+ 'id': row['id'],
+ 'diagram_id': row['diagram_id'],
+ 'part_id': row['part_id'],
+ 'callout_number': row['callout_number'],
+ 'label': row['label'],
+ 'shape': row['shape'],
+ 'coords': row['coords'],
+ 'color': row['color'],
+ 'part': None
+ }
+
+ # Get linked part info if part_id exists
+ if row['part_id']:
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ pg.name AS group_name,
+ pc.name AS category_name
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE p.id = ?
+ """, (row['part_id'],))
+
+ part_row = cursor.fetchone()
+ if part_row:
+ hotspot['part'] = {
+ 'id': part_row['id'],
+ 'oem_part_number': part_row['oem_part_number'],
+ 'name': part_row['name'],
+ 'name_es': part_row['name_es'],
+ 'group_name': part_row['group_name'],
+ 'category_name': part_row['category_name']
+ }
+
+ conn.close()
+ return jsonify(hotspot)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+# ============================================================================
+# FASE 4: Full-Text Search and VIN Decoder API Endpoints
+# ============================================================================
+
+import urllib.request
+import json as json_module
+import re
+from datetime import datetime, timedelta
+
+def validate_vin(vin):
+ """Validate VIN format: 17 alphanumeric characters, no I, O, Q"""
+ if not vin or len(vin) != 17:
+ return False
+ # VIN can only contain alphanumeric characters except I, O, Q
+ valid_pattern = re.compile(r'^[A-HJ-NPR-Z0-9]{17}$', re.IGNORECASE)
+ return bool(valid_pattern.match(vin))
+
+
+@app.route('/api/search')
+def api_search():
+ """Unified search across parts, cross-references, and aftermarket"""
+ try:
+ q = request.args.get('q', '').strip()
+ search_type = request.args.get('type', 'all')
+ limit = request.args.get('limit', 50, type=int)
+ offset = request.args.get('offset', 0, type=int)
+
+ if not q:
+ return jsonify({'error': 'Search query is required'}), 400
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ results = {
+ 'parts': [],
+ 'vehicles': [],
+ 'total_count': 0
+ }
+
+ # Search parts
+ if search_type in ('parts', 'all'):
+ search_term = f"%{q}%"
+
+ # Search in parts table
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ pg.name AS group_name,
+ pc.name AS category_name
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE p.name LIKE ? OR p.name_es LIKE ? OR p.oem_part_number LIKE ?
+ ORDER BY p.name
+ LIMIT ? OFFSET ?
+ """, (search_term, search_term, search_term, limit, offset))
+
+ for row in cursor.fetchall():
+ results['parts'].append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'match_type': 'oem'
+ })
+
+ # Also search in aftermarket parts
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ pg.name AS group_name,
+ pc.name AS category_name,
+ ap.part_number AS matched_number
+ FROM aftermarket_parts ap
+ JOIN parts p ON ap.oem_part_id = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE ap.part_number LIKE ?
+ LIMIT ? OFFSET ?
+ """, (search_term, limit, offset))
+
+ for row in cursor.fetchall():
+ results['parts'].append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'matched_number': row['matched_number'],
+ 'match_type': 'aftermarket'
+ })
+
+ # Search in cross-references
+ cursor.execute("""
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ pg.name AS group_name,
+ pc.name AS category_name,
+ pcr.cross_reference_number AS matched_number
+ FROM part_cross_references pcr
+ JOIN parts p ON pcr.part_id = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE pcr.cross_reference_number LIKE ?
+ LIMIT ? OFFSET ?
+ """, (search_term, limit, offset))
+
+ for row in cursor.fetchall():
+ results['parts'].append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'matched_number': row['matched_number'],
+ 'match_type': 'cross_reference'
+ })
+
+ # Search vehicles
+ if search_type in ('vehicles', 'all'):
+ search_term = f"%{q}%"
+ cursor.execute("""
+ SELECT
+ mye.id,
+ b.name AS brand,
+ m.name AS model,
+ y.year,
+ e.name AS engine
+ 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 b.name LIKE ? OR m.name LIKE ? OR e.name LIKE ?
+ ORDER BY b.name, m.name, y.year
+ LIMIT ? OFFSET ?
+ """, (search_term, search_term, search_term, limit, offset))
+
+ for row in cursor.fetchall():
+ results['vehicles'].append({
+ 'id': row['id'],
+ 'brand': row['brand'],
+ 'model': row['model'],
+ 'year': row['year'],
+ 'engine': row['engine']
+ })
+
+ results['total_count'] = len(results['parts']) + len(results['vehicles'])
+ conn.close()
+ return jsonify(results)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/search/parts')
+def api_search_parts():
+ """Full-text search in parts catalog with pagination"""
+ try:
+ q = request.args.get('q', '').strip()
+ category_id = request.args.get('category_id', type=int)
+ group_id = request.args.get('group_id', type=int)
+ page = request.args.get('page', 1, type=int)
+ per_page = request.args.get('per_page', 50, type=int)
+ per_page = min(per_page, 100) # Max 100 per page
+ offset = (page - 1) * per_page
+
+ if not q:
+ return jsonify({'error': 'Search query is required'}), 400
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Check if FTS5 table exists
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='parts_fts'
+ """)
+ fts_exists = cursor.fetchone() is not None
+
+ parts = []
+ total_count = 0
+
+ if fts_exists:
+ # Use FTS5 for full-text search
+ # Escape special FTS5 characters and prepare search term
+ fts_query = q.replace('"', '""')
+
+ # Build filter conditions
+ filter_clause = ""
+ filter_params = []
+
+ if category_id:
+ filter_clause += " AND pg.category_id = ?"
+ filter_params.append(category_id)
+
+ if group_id:
+ filter_clause += " AND p.group_id = ?"
+ filter_params.append(group_id)
+
+ # Get total count for FTS search
+ count_query = """
+ SELECT COUNT(*) as total
+ FROM parts_fts
+ JOIN parts p ON parts_fts.rowid = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE parts_fts MATCH ?
+ """ + filter_clause
+ cursor.execute(count_query, [fts_query] + filter_params)
+ total_count = cursor.fetchone()['total']
+
+ # Get paginated data
+ data_query = """
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ p.description,
+ pg.name AS group_name,
+ pc.name AS category_name,
+ bm25(parts_fts) AS rank
+ FROM parts_fts
+ JOIN parts p ON parts_fts.rowid = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE parts_fts MATCH ?
+ """ + filter_clause + " ORDER BY rank LIMIT ? OFFSET ?"
+
+ cursor.execute(data_query, [fts_query] + filter_params + [per_page, offset])
+
+ for row in cursor.fetchall():
+ parts.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'description': row['description'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'rank': row['rank']
+ })
+ else:
+ # Fallback to LIKE search if FTS5 table doesn't exist
+ search_term = f"%{q}%"
+
+ # Build filter conditions
+ filter_clause = ""
+ filter_params = []
+
+ if category_id:
+ filter_clause += " AND pg.category_id = ?"
+ filter_params.append(category_id)
+
+ if group_id:
+ filter_clause += " AND p.group_id = ?"
+ filter_params.append(group_id)
+
+ # Get total count for LIKE search
+ count_query = """
+ SELECT COUNT(*) as total
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE (p.name LIKE ? OR p.name_es LIKE ? OR p.oem_part_number LIKE ?
+ OR p.description LIKE ?)
+ """ + filter_clause
+ cursor.execute(count_query, [search_term, search_term, search_term, search_term] + filter_params)
+ total_count = cursor.fetchone()['total']
+
+ # Get paginated data
+ data_query = """
+ SELECT
+ p.id,
+ p.oem_part_number,
+ p.name,
+ p.name_es,
+ p.description,
+ pg.name AS group_name,
+ pc.name AS category_name
+ FROM parts p
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE (p.name LIKE ? OR p.name_es LIKE ? OR p.oem_part_number LIKE ?
+ OR p.description LIKE ?)
+ """ + filter_clause + " ORDER BY p.name LIMIT ? OFFSET ?"
+
+ cursor.execute(data_query, [search_term, search_term, search_term, search_term] + filter_params + [per_page, offset])
+
+ for row in cursor.fetchall():
+ parts.append({
+ 'id': row['id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['name'],
+ 'name_es': row['name_es'],
+ 'description': row['description'],
+ 'group_name': row['group_name'],
+ 'category_name': row['category_name'],
+ 'rank': 0
+ })
+
+ conn.close()
+
+ total_pages = (total_count + per_page - 1) // per_page
+ return jsonify({
+ 'data': parts,
+ 'pagination': {
+ 'page': page,
+ 'per_page': per_page,
+ 'total': total_count,
+ 'total_pages': total_pages
+ }
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/vin/decode/')
+def api_vin_decode(vin):
+ """Decode a VIN using NHTSA API with caching"""
+ try:
+ vin = vin.upper().strip()
+
+ # Validate VIN format
+ if not validate_vin(vin):
+ return jsonify({
+ 'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'
+ }), 400
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Check if vin_cache table exists
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='vin_cache'
+ """)
+ cache_exists = cursor.fetchone() is not None
+
+ cached_data = None
+ if cache_exists:
+ # Check for cached VIN data that hasn't expired
+ cursor.execute("""
+ SELECT
+ vin, make, model, year, engine_info,
+ body_class, drive_type,
+ model_year_engine_id, created_at, expires_at
+ FROM vin_cache
+ WHERE vin = ? AND expires_at > datetime('now')
+ """, (vin,))
+ cached_row = cursor.fetchone()
+
+ if cached_row:
+ # Parse engine_info JSON if it exists
+ engine_info_data = {}
+ if cached_row['engine_info']:
+ try:
+ engine_info_data = json_module.loads(cached_row['engine_info'])
+ except:
+ engine_info_data = {'raw': cached_row['engine_info']}
+
+ cached_data = {
+ 'vin': cached_row['vin'],
+ 'make': cached_row['make'],
+ 'model': cached_row['model'],
+ 'year': cached_row['year'],
+ 'engine_info': engine_info_data,
+ 'body_class': cached_row['body_class'],
+ 'drive_type': cached_row['drive_type'],
+ 'matched_vehicle': None,
+ 'cached': True
+ }
+
+ # Get matched vehicle info if available
+ if cached_row['model_year_engine_id']:
+ cursor.execute("""
+ SELECT
+ mye.id,
+ b.name AS brand,
+ m.name AS model,
+ y.year,
+ e.name AS engine
+ 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 mye.id = ?
+ """, (cached_row['model_year_engine_id'],))
+ mye_row = cursor.fetchone()
+ if mye_row:
+ cached_data['matched_vehicle'] = {
+ 'mye_id': mye_row['id'],
+ 'brand': mye_row['brand'],
+ 'model': mye_row['model'],
+ 'year': mye_row['year'],
+ 'engine': mye_row['engine']
+ }
+
+ conn.close()
+ return jsonify(cached_data)
+
+ # Call NHTSA API
+ nhtsa_url = f'https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin/{vin}?format=json'
+
+ try:
+ req = urllib.request.Request(nhtsa_url, headers={'User-Agent': 'AutopartesDB/1.0'})
+ with urllib.request.urlopen(req, timeout=10) as response:
+ nhtsa_data = json_module.loads(response.read().decode('utf-8'))
+ except urllib.error.URLError as e:
+ conn.close()
+ return jsonify({'error': f'Failed to connect to NHTSA API: {str(e)}'}), 503
+ except urllib.error.HTTPError as e:
+ conn.close()
+ return jsonify({'error': f'NHTSA API error: {e.code}'}), 502
+ except Exception as e:
+ conn.close()
+ return jsonify({'error': f'Error calling NHTSA API: {str(e)}'}), 500
+
+ # Parse NHTSA response
+ results = {item['Variable']: item['Value'] for item in nhtsa_data.get('Results', [])}
+
+ # Extract relevant fields
+ make = results.get('Make', '')
+ model = results.get('Model', '')
+ year_str = results.get('ModelYear', '')
+ year = int(year_str) if year_str and year_str.isdigit() else None
+ engine_config = results.get('EngineConfiguration', '')
+ cylinders_str = results.get('EngineCylinders', '')
+ cylinders = int(cylinders_str) if cylinders_str and cylinders_str.isdigit() else None
+ displacement_str = results.get('DisplacementL', '')
+ displacement_l = float(displacement_str) if displacement_str else None
+ fuel_type = results.get('FuelTypePrimary', '')
+ body_class = results.get('BodyClass', '')
+ drive_type = results.get('DriveType', '')
+
+ # Try to match to model_year_engine record
+ matched_mye_id = None
+ matched_vehicle = None
+
+ if make and model and year:
+ cursor.execute("""
+ SELECT
+ mye.id,
+ b.name AS brand,
+ m.name AS model,
+ y.year,
+ e.name AS engine
+ 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 = ?
+ LIMIT 1
+ """, (make, model, year))
+
+ mye_row = cursor.fetchone()
+ if mye_row:
+ matched_mye_id = mye_row['id']
+ matched_vehicle = {
+ 'mye_id': mye_row['id'],
+ 'brand': mye_row['brand'],
+ 'model': mye_row['model'],
+ 'year': mye_row['year'],
+ 'engine': mye_row['engine']
+ }
+
+ # Store in cache with 30-day expiry
+ if cache_exists:
+ expires_at = (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
+ # Combine engine info into JSON
+ engine_info = json_module.dumps({
+ 'configuration': engine_config,
+ 'cylinders': cylinders,
+ 'displacement_l': displacement_l,
+ 'fuel_type': fuel_type
+ })
+ cursor.execute("""
+ INSERT OR REPLACE INTO vin_cache
+ (vin, decoded_data, make, model, year, engine_info,
+ body_class, drive_type, model_year_engine_id, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (vin, json_module.dumps(results), make, model, year, engine_info,
+ body_class, drive_type, matched_mye_id, expires_at))
+ conn.commit()
+
+ result = {
+ 'vin': vin,
+ 'make': make,
+ 'model': model,
+ 'year': year,
+ 'engine_info': {
+ 'configuration': engine_config,
+ 'cylinders': cylinders,
+ 'displacement_l': displacement_l,
+ 'fuel_type': fuel_type
+ },
+ 'body_class': body_class,
+ 'drive_type': drive_type,
+ 'matched_vehicle': matched_vehicle,
+ 'cached': False
+ }
+
+ conn.close()
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/vin//parts')
+def api_vin_parts(vin):
+ """Get parts for a decoded VIN"""
+ try:
+ vin = vin.upper().strip()
+
+ if not validate_vin(vin):
+ return jsonify({
+ 'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'
+ }), 400
+
+ category_id = request.args.get('category_id', type=int)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Check if vin_cache table exists
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='vin_cache'
+ """)
+ cache_exists = cursor.fetchone() is not None
+
+ if not cache_exists:
+ conn.close()
+ return jsonify({
+ 'error': 'VIN cache not available. Please decode the VIN first.'
+ }), 400
+
+ # Look up VIN in cache
+ cursor.execute("""
+ SELECT
+ vin, make, model, year, model_year_engine_id
+ FROM vin_cache
+ WHERE vin = ?
+ """, (vin,))
+ cached_row = cursor.fetchone()
+
+ if not cached_row:
+ conn.close()
+ return jsonify({
+ 'error': 'VIN not found in cache. Please decode the VIN first using /api/vin/decode/'
+ }), 404
+
+ mye_id = cached_row['model_year_engine_id']
+
+ vehicle_info = {
+ 'vin': cached_row['vin'],
+ 'make': cached_row['make'],
+ 'model': cached_row['model'],
+ 'year': cached_row['year'],
+ 'mye_id': mye_id
+ }
+
+ if not mye_id:
+ conn.close()
+ return jsonify({
+ 'vin': vin,
+ 'vehicle_info': vehicle_info,
+ 'categories': [],
+ 'message': 'No matching vehicle configuration found in database. Use /api/vin//match to manually link.'
+ })
+
+ # Get parts for this vehicle grouped by category
+ query = """
+ SELECT
+ pc.id AS category_id,
+ pc.name AS category_name,
+ pc.name_es AS category_name_es,
+ p.id AS part_id,
+ p.oem_part_number,
+ p.name AS part_name,
+ p.name_es AS part_name_es,
+ pg.name AS group_name,
+ vp.quantity_required,
+ vp.position
+ FROM vehicle_parts vp
+ JOIN parts p ON vp.part_id = p.id
+ JOIN part_groups pg ON p.group_id = pg.id
+ JOIN part_categories pc ON pg.category_id = pc.id
+ WHERE vp.model_year_engine_id = ?
+ """
+ params = [mye_id]
+
+ if category_id:
+ query += " AND pc.id = ?"
+ params.append(category_id)
+
+ query += " ORDER BY pc.display_order, pg.display_order, p.name"
+
+ cursor.execute(query, params)
+
+ # Group parts by category
+ categories_dict = {}
+ for row in cursor.fetchall():
+ cat_id = row['category_id']
+ if cat_id not in categories_dict:
+ categories_dict[cat_id] = {
+ 'id': cat_id,
+ 'name': row['category_name'],
+ 'name_es': row['category_name_es'],
+ 'parts': []
+ }
+
+ categories_dict[cat_id]['parts'].append({
+ 'id': row['part_id'],
+ 'oem_part_number': row['oem_part_number'],
+ 'name': row['part_name'],
+ 'name_es': row['part_name_es'],
+ 'group_name': row['group_name'],
+ 'quantity_required': row['quantity_required'],
+ 'position': row['position']
+ })
+
+ conn.close()
+
+ return jsonify({
+ 'vin': vin,
+ 'vehicle_info': vehicle_info,
+ 'categories': list(categories_dict.values())
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/vin//match')
+def api_vin_match(vin):
+ """Manually match a VIN to a vehicle configuration"""
+ try:
+ vin = vin.upper().strip()
+
+ if not validate_vin(vin):
+ return jsonify({
+ 'error': 'Invalid VIN format. VIN must be 17 alphanumeric characters (no I, O, Q).'
+ }), 400
+
+ mye_id = request.args.get('mye_id', type=int)
+
+ if not mye_id:
+ return jsonify({'error': 'mye_id parameter is required'}), 400
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+
+ # Check if vin_cache table exists
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='vin_cache'
+ """)
+ cache_exists = cursor.fetchone() is not None
+
+ if not cache_exists:
+ conn.close()
+ return jsonify({
+ 'error': 'VIN cache table not available.'
+ }), 400
+
+ # Verify the mye_id exists
+ cursor.execute("""
+ SELECT id FROM model_year_engine WHERE id = ?
+ """, (mye_id,))
+ if not cursor.fetchone():
+ conn.close()
+ return jsonify({'error': f'model_year_engine_id {mye_id} not found'}), 404
+
+ # Check if VIN exists in cache
+ cursor.execute("""
+ SELECT vin FROM vin_cache WHERE vin = ?
+ """, (vin,))
+ vin_exists = cursor.fetchone() is not None
+
+ if vin_exists:
+ # Update existing cache entry
+ cursor.execute("""
+ UPDATE vin_cache
+ SET model_year_engine_id = ?
+ WHERE vin = ?
+ """, (mye_id, vin))
+ else:
+ # Create new cache entry with minimal info
+ expires_at = (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S')
+ cursor.execute("""
+ INSERT INTO vin_cache
+ (vin, model_year_engine_id, cached_at, expires_at)
+ VALUES (?, ?, datetime('now'), ?)
+ """, (vin, mye_id, expires_at))
+
+ conn.commit()
+ conn.close()
+
+ return jsonify({
+ 'success': True,
+ 'vin': vin,
+ 'mye_id': mye_id
+ })
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
if __name__ == '__main__':
# Check if database exists
if not os.path.exists(DATABASE_PATH):
diff --git a/dashboard/static/diagrams/brake_assembly.svg b/dashboard/static/diagrams/brake_assembly.svg
new file mode 100644
index 0000000..702335a
--- /dev/null
+++ b/dashboard/static/diagrams/brake_assembly.svg
@@ -0,0 +1,65 @@
+
+
\ No newline at end of file
diff --git a/dashboard/static/diagrams/oil_filter_system.svg b/dashboard/static/diagrams/oil_filter_system.svg
new file mode 100644
index 0000000..17722ba
--- /dev/null
+++ b/dashboard/static/diagrams/oil_filter_system.svg
@@ -0,0 +1,73 @@
+
+
\ No newline at end of file
diff --git a/dashboard/static/diagrams/suspension_assembly.svg b/dashboard/static/diagrams/suspension_assembly.svg
new file mode 100644
index 0000000..781515b
--- /dev/null
+++ b/dashboard/static/diagrams/suspension_assembly.svg
@@ -0,0 +1,84 @@
+
+
\ No newline at end of file
diff --git a/vehicle_database/docs/database_example_complete.sql b/vehicle_database/docs/database_example_complete.sql
new file mode 100644
index 0000000..6a79b0e
--- /dev/null
+++ b/vehicle_database/docs/database_example_complete.sql
@@ -0,0 +1,527 @@
+-- ============================================================================
+-- EJEMPLO COMPLETO DE BASE DE DATOS DE AUTOPARTES
+-- Muestra cómo quedará la base de datos al completar todas las fases
+-- ============================================================================
+
+-- ============================================================================
+-- TABLAS EXISTENTES (Ya implementadas)
+-- ============================================================================
+
+-- Marcas de vehículos
+INSERT INTO brands (id, name, country, founded_year) VALUES
+(1, 'Toyota', 'Japan', 1937),
+(2, 'Honda', 'Japan', 1948),
+(3, 'Ford', 'USA', 1903),
+(4, 'Chevrolet', 'USA', 1911),
+(5, 'Volkswagen', 'Germany', 1937);
+
+-- Años
+INSERT INTO years (id, year) VALUES
+(1, 2020), (2, 2021), (3, 2022), (4, 2023), (5, 2024);
+
+-- Motores
+INSERT INTO engines (id, name, displacement_cc, cylinders, fuel_type, power_hp, torque_nm, engine_code) VALUES
+(1, '2.5L 4-Cyl Dynamic Force', 2487, 4, 'gasoline', 203, 250, 'A25A-FKS'),
+(2, '2.5L Hybrid', 2487, 4, 'hybrid', 215, 221, 'A25A-FXS'),
+(3, '3.5L V6', 3456, 6, 'gasoline', 301, 362, '2GR-FKS'),
+(4, '1.5L Turbo', 1498, 4, 'gasoline', 192, 260, 'L15CA'),
+(5, '2.0L Turbo EcoBoost', 1999, 4, 'gasoline', 250, 373, 'EcoBoost');
+
+-- Modelos
+INSERT INTO models (id, brand_id, name, body_type, generation, production_start_year, production_end_year) VALUES
+(1, 1, 'Camry', 'sedan', 'XV70', 2018, NULL),
+(2, 1, 'Corolla', 'sedan', 'E210', 2019, NULL),
+(3, 1, 'RAV4', 'suv', 'XA50', 2019, NULL),
+(4, 2, 'Civic', 'sedan', '11th Gen', 2022, NULL),
+(5, 2, 'CR-V', 'suv', '6th Gen', 2023, NULL),
+(6, 3, 'F-150', 'truck', '14th Gen', 2021, NULL);
+
+-- Configuraciones Modelo-Año-Motor (model_year_engine)
+INSERT INTO model_year_engine (id, model_id, year_id, engine_id, trim_level, drivetrain, transmission) VALUES
+-- Toyota Camry 2023
+(1, 1, 4, 1, 'LE', 'FWD', 'automatic'),
+(2, 1, 4, 1, 'SE', 'FWD', 'automatic'),
+(3, 1, 4, 1, 'XLE', 'AWD', 'automatic'),
+(4, 1, 4, 2, 'SE Hybrid', 'FWD', 'CVT'),
+(5, 1, 4, 3, 'XSE V6', 'FWD', 'automatic'),
+-- Toyota Camry 2024
+(6, 1, 5, 1, 'LE', 'FWD', 'automatic'),
+(7, 1, 5, 2, 'SE Hybrid', 'FWD', 'CVT'),
+-- Honda Civic 2023
+(8, 4, 4, 4, 'Sport', 'FWD', 'CVT'),
+(9, 4, 4, 4, 'Touring', 'FWD', 'CVT');
+
+-- ============================================================================
+-- FASE 1: CATÁLOGO DE PARTES (Implementado)
+-- ============================================================================
+
+-- Categorías principales
+INSERT INTO part_categories (id, name, name_es, slug, icon_name, display_order) VALUES
+(1, 'Body & Lamp Assembly', 'Carrocería y Lámparas', 'body-lamp', 'fa-car-side', 1),
+(2, 'Brake & Wheel Hub', 'Frenos y Mazas', 'brake-wheel', 'fa-compact-disc', 2),
+(3, 'Cooling System', 'Sistema de Enfriamiento', 'cooling', 'fa-temperature-low', 3),
+(4, 'Drivetrain', 'Tren Motriz', 'drivetrain', 'fa-cogs', 4),
+(5, 'Electrical & Lighting', 'Eléctrico e Iluminación', 'electrical', 'fa-bolt', 5),
+(6, 'Engine', 'Motor', 'engine', 'fa-cog', 6),
+(7, 'Exhaust', 'Escape', 'exhaust', 'fa-wind', 7),
+(8, 'Fuel & Air', 'Combustible y Aire', 'fuel-air', 'fa-gas-pump', 8),
+(9, 'Heat & Air Conditioning', 'Calefacción y A/C', 'hvac', 'fa-snowflake', 9),
+(10, 'Steering', 'Dirección', 'steering', 'fa-dharmachakra', 10),
+(11, 'Suspension', 'Suspensión', 'suspension', 'fa-truck-monster', 11),
+(12, 'Transmission', 'Transmisión', 'transmission', 'fa-gears', 12);
+
+-- Grupos dentro de categorías (ejemplos)
+INSERT INTO part_groups (id, category_id, name, name_es, slug, display_order) VALUES
+-- Engine groups
+(1, 6, 'Oil Filters', 'Filtros de Aceite', 'oil-filters', 1),
+(2, 6, 'Air Filters', 'Filtros de Aire', 'air-filters', 2),
+(3, 6, 'Spark Plugs', 'Bujías', 'spark-plugs', 3),
+(4, 6, 'Timing Belt & Chain', 'Banda/Cadena de Tiempo', 'timing', 4),
+(5, 6, 'Gaskets & Seals', 'Juntas y Sellos', 'gaskets', 5),
+(6, 6, 'Engine Mounts', 'Soportes de Motor', 'mounts', 6),
+-- Brake groups
+(10, 2, 'Brake Pads', 'Balatas/Pastillas', 'brake-pads', 1),
+(11, 2, 'Brake Rotors', 'Discos de Freno', 'brake-rotors', 2),
+(12, 2, 'Brake Calipers', 'Calipers', 'brake-calipers', 3),
+(13, 2, 'Brake Lines', 'Líneas de Freno', 'brake-lines', 4),
+(14, 2, 'Wheel Bearings', 'Baleros de Rueda', 'wheel-bearings', 5),
+-- Suspension groups
+(20, 11, 'Shocks & Struts', 'Amortiguadores', 'shocks-struts', 1),
+(21, 11, 'Control Arms', 'Brazos de Control', 'control-arms', 2),
+(22, 11, 'Ball Joints', 'Rótulas', 'ball-joints', 3),
+(23, 11, 'Tie Rod Ends', 'Terminales', 'tie-rods', 4),
+(24, 11, 'Sway Bar Links', 'Ligas de Barra Estabilizadora', 'sway-bar', 5),
+-- Electrical groups
+(30, 5, 'Batteries', 'Baterías', 'batteries', 1),
+(31, 5, 'Alternators', 'Alternadores', 'alternators', 2),
+(32, 5, 'Starters', 'Marchas', 'starters', 3),
+(33, 5, 'Ignition Coils', 'Bobinas de Ignición', 'ignition-coils', 4),
+(34, 5, 'Sensors', 'Sensores', 'sensors', 5);
+
+-- Partes OEM (catálogo maestro)
+INSERT INTO parts (id, oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material) VALUES
+-- Filtros de aceite Toyota
+(1, '04152-YZZA1', 'Oil Filter Element', 'Elemento Filtro de Aceite', 1,
+ 'Genuine Toyota oil filter for 2.5L engines', 'Filtro de aceite genuino Toyota para motores 2.5L', 0.3, 'Paper/Metal'),
+(2, '04152-YZZA5', 'Oil Filter Element', 'Elemento Filtro de Aceite', 1,
+ 'Genuine Toyota oil filter for 3.5L V6 engines', 'Filtro de aceite genuino Toyota para motores 3.5L V6', 0.35, 'Paper/Metal'),
+
+-- Filtros de aire
+(3, '17801-0V020', 'Air Filter Element', 'Elemento Filtro de Aire', 2,
+ 'Engine air filter for Camry 2.5L', 'Filtro de aire motor para Camry 2.5L', 0.4, 'Paper'),
+(4, '17801-38051', 'Air Filter Element', 'Elemento Filtro de Aire', 2,
+ 'Engine air filter for Camry V6', 'Filtro de aire motor para Camry V6', 0.45, 'Paper'),
+
+-- Bujías
+(5, '90919-01275', 'Spark Plug - Iridium', 'Bujía - Iridio', 3,
+ 'Denso Iridium TT spark plug', 'Bujía Denso Iridium TT', 0.05, 'Iridium/Nickel'),
+(6, '90919-01253', 'Spark Plug - Standard', 'Bujía - Estándar', 3,
+ 'NGK Standard spark plug', 'Bujía NGK Estándar', 0.05, 'Nickel'),
+
+-- Pastillas de freno
+(10, '04465-06200', 'Front Brake Pads', 'Pastillas de Freno Delanteras', 10,
+ 'Genuine Toyota front brake pad set', 'Juego de pastillas delanteras genuinas Toyota', 1.2, 'Ceramic'),
+(11, '04466-06200', 'Rear Brake Pads', 'Pastillas de Freno Traseras', 10,
+ 'Genuine Toyota rear brake pad set', 'Juego de pastillas traseras genuinas Toyota', 0.9, 'Ceramic'),
+
+-- Discos de freno
+(12, '43512-06190', 'Front Brake Rotor', 'Disco de Freno Delantero', 11,
+ 'Genuine Toyota front brake rotor', 'Disco de freno delantero genuino Toyota', 8.5, 'Cast Iron'),
+(13, '42431-06190', 'Rear Brake Rotor', 'Disco de Freno Trasero', 11,
+ 'Genuine Toyota rear brake rotor', 'Disco de freno trasero genuino Toyota', 5.2, 'Cast Iron'),
+
+-- Amortiguadores
+(20, '48510-06780', 'Front Strut Assembly', 'Amortiguador Delantero Completo', 20,
+ 'Front strut assembly with spring', 'Amortiguador delantero con resorte', 12.5, 'Steel'),
+(21, '48530-06400', 'Rear Shock Absorber', 'Amortiguador Trasero', 20,
+ 'Rear shock absorber', 'Amortiguador trasero', 3.8, 'Steel'),
+
+-- Rótulas y terminales
+(22, '43330-09510', 'Lower Ball Joint', 'Rótula Inferior', 22,
+ 'Front lower ball joint', 'Rótula inferior delantera', 0.8, 'Steel'),
+(23, '45046-09631', 'Outer Tie Rod End', 'Terminal Exterior', 23,
+ 'Steering outer tie rod end', 'Terminal exterior de dirección', 0.5, 'Steel'),
+
+-- Sensores
+(30, '89467-06150', 'Oxygen Sensor - Upstream', 'Sensor de Oxígeno - Arriba', 34,
+ 'Primary oxygen sensor (Bank 1)', 'Sensor de oxígeno primario (Banco 1)', 0.15, 'Ceramic/Metal'),
+(31, '89467-06160', 'Oxygen Sensor - Downstream', 'Sensor de Oxígeno - Abajo', 34,
+ 'Secondary oxygen sensor (Bank 1)', 'Sensor de oxígeno secundario (Banco 1)', 0.15, 'Ceramic/Metal');
+
+-- Fitment: Qué partes van en qué vehículos
+INSERT INTO vehicle_parts (id, model_year_engine_id, part_id, quantity_required, position, fitment_notes) VALUES
+-- Toyota Camry 2023 2.5L LE (mye_id = 1)
+(1, 1, 1, 1, NULL, 'Use with 2.5L 4-Cyl engine only'),
+(2, 1, 3, 1, NULL, NULL),
+(3, 1, 5, 4, NULL, 'Gap: 0.043 inch'),
+(4, 1, 10, 1, 'front', NULL),
+(5, 1, 11, 1, 'rear', NULL),
+(6, 1, 12, 2, 'front', 'Left and Right'),
+(7, 1, 13, 2, 'rear', 'Left and Right'),
+(8, 1, 20, 2, 'front', 'Left and Right'),
+(9, 1, 21, 2, 'rear', 'Left and Right'),
+(10, 1, 22, 2, 'front-lower', 'Left and Right'),
+(11, 1, 23, 2, 'front', 'Left and Right'),
+(12, 1, 30, 1, 'upstream', 'Bank 1 Sensor 1'),
+(13, 1, 31, 1, 'downstream', 'Bank 1 Sensor 2'),
+
+-- Toyota Camry 2023 V6 XSE (mye_id = 5)
+(20, 5, 2, 1, NULL, 'Use with 3.5L V6 engine only'),
+(21, 5, 4, 1, NULL, NULL),
+(22, 5, 6, 6, NULL, 'V6 requires 6 spark plugs'),
+(23, 5, 10, 1, 'front', NULL),
+(24, 5, 11, 1, 'rear', NULL);
+
+-- ============================================================================
+-- FASE 2: CROSS-REFERENCES Y AFTERMARKET (Por implementar)
+-- ============================================================================
+
+-- Fabricantes (OEM y aftermarket)
+CREATE TABLE IF NOT EXISTS manufacturers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ type TEXT CHECK(type IN ('oem', 'aftermarket', 'remanufactured')),
+ quality_tier TEXT CHECK(quality_tier IN ('economy', 'standard', 'premium', 'oem')),
+ country TEXT,
+ logo_url TEXT,
+ website TEXT
+);
+
+INSERT INTO manufacturers (id, name, type, quality_tier, country, website) VALUES
+(1, 'Toyota', 'oem', 'oem', 'Japan', 'https://parts.toyota.com'),
+(2, 'Honda', 'oem', 'oem', 'Japan', 'https://parts.honda.com'),
+(3, 'Bosch', 'aftermarket', 'premium', 'Germany', 'https://www.boschparts.com'),
+(4, 'Denso', 'aftermarket', 'premium', 'Japan', 'https://www.denso.com'),
+(5, 'NGK', 'aftermarket', 'premium', 'Japan', 'https://www.ngk.com'),
+(6, 'Akebono', 'aftermarket', 'premium', 'Japan', 'https://www.akebono.com'),
+(7, 'Brembo', 'aftermarket', 'premium', 'Italy', 'https://www.brembo.com'),
+(8, 'Monroe', 'aftermarket', 'standard', 'USA', 'https://www.monroe.com'),
+(9, 'KYB', 'aftermarket', 'premium', 'Japan', 'https://www.kyb.com'),
+(10, 'Moog', 'aftermarket', 'premium', 'USA', 'https://www.moogparts.com'),
+(11, 'Fram', 'aftermarket', 'economy', 'USA', 'https://www.fram.com'),
+(12, 'WIX', 'aftermarket', 'standard', 'USA', 'https://www.wixfilters.com'),
+(13, 'K&N', 'aftermarket', 'premium', 'USA', 'https://www.knfilters.com'),
+(14, 'Motorcraft', 'oem', 'oem', 'USA', 'https://www.motorcraft.com'),
+(15, 'ACDelco', 'oem', 'oem', 'USA', 'https://www.acdelco.com');
+
+-- Partes aftermarket vinculadas a OEM
+CREATE TABLE IF NOT EXISTS aftermarket_parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ oem_part_id INTEGER NOT NULL,
+ manufacturer_id INTEGER NOT NULL,
+ part_number TEXT NOT NULL,
+ name TEXT,
+ name_es TEXT,
+ quality_tier TEXT CHECK(quality_tier IN ('economy', 'standard', 'premium')),
+ price_usd REAL,
+ warranty_months INTEGER,
+ FOREIGN KEY (oem_part_id) REFERENCES parts(id),
+ FOREIGN KEY (manufacturer_id) REFERENCES manufacturers(id)
+);
+
+INSERT INTO aftermarket_parts (id, oem_part_id, manufacturer_id, part_number, name, name_es, quality_tier, price_usd, warranty_months) VALUES
+-- Alternativas para filtro de aceite Toyota 04152-YZZA1
+(1, 1, 3, '3311', 'Premium Oil Filter', 'Filtro Aceite Premium', 'premium', 12.99, 12),
+(2, 1, 11, 'XG9972', 'Ultra Synthetic Oil Filter', 'Filtro Aceite Sintético', 'standard', 8.49, 12),
+(3, 1, 12, '57047', 'Oil Filter', 'Filtro de Aceite', 'standard', 7.99, 6),
+
+-- Alternativas para filtro de aire Toyota 17801-0V020
+(4, 3, 13, '33-5057', 'High-Flow Air Filter', 'Filtro Aire Alto Flujo', 'premium', 54.99, 120), -- K&N lifetime
+(5, 3, 11, 'CA11476', 'Extra Guard Air Filter', 'Filtro Aire Extra Guard', 'economy', 18.99, 12),
+(6, 3, 3, 'F00E164749', 'Workshop Air Filter', 'Filtro Aire Taller', 'premium', 24.99, 24),
+
+-- Alternativas para bujías
+(7, 5, 4, 'IK20TT', 'Iridium TT Spark Plug', 'Bujía Iridium TT', 'premium', 11.99, 60),
+(8, 5, 5, 'ILKAR7B11', 'Laser Iridium Spark Plug', 'Bujía Laser Iridium', 'premium', 13.99, 60),
+(9, 6, 3, 'FR7DC+', 'Super Plus Spark Plug', 'Bujía Super Plus', 'standard', 4.99, 24),
+
+-- Alternativas para pastillas de freno
+(10, 10, 6, 'ACT1293', 'ProACT Ultra-Premium Ceramic', 'Cerámica Ultra-Premium', 'premium', 89.99, 36),
+(11, 10, 7, 'P83124N', 'Premium NAO Ceramic Pads', 'Pastillas Cerámicas NAO', 'premium', 129.99, 24),
+(12, 10, 3, 'BC1293', 'QuietCast Ceramic Pads', 'Pastillas Cerámicas QuietCast', 'standard', 54.99, 24),
+
+-- Alternativas para amortiguadores
+(13, 20, 8, '72389', 'OESpectrum Strut Assembly', 'Ensamble Amortiguador OE', 'standard', 189.99, 24),
+(14, 20, 9, '339407', 'Excel-G Strut Assembly', 'Ensamble Amortiguador Excel-G', 'premium', 229.99, 36),
+(15, 21, 8, '37324', 'OESpectrum Shock', 'Amortiguador OESpectrum', 'standard', 54.99, 24),
+(16, 21, 9, '341461', 'Excel-G Shock', 'Amortiguador Excel-G', 'premium', 69.99, 36);
+
+-- Cross-references (números alternativos)
+CREATE TABLE IF NOT EXISTS part_cross_references (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ part_id INTEGER NOT NULL,
+ cross_reference_number TEXT NOT NULL,
+ reference_type TEXT CHECK(reference_type IN ('oem_alternate', 'supersession', 'interchange', 'competitor')),
+ source TEXT,
+ notes TEXT,
+ FOREIGN KEY (part_id) REFERENCES parts(id)
+);
+
+INSERT INTO part_cross_references (id, part_id, cross_reference_number, reference_type, source, notes) VALUES
+-- Oil filter cross-refs
+(1, 1, '04152-YZZA3', 'oem_alternate', 'Toyota', 'Earlier part number'),
+(2, 1, '04152-31090', 'oem_alternate', 'Toyota', 'Lexus equivalent'),
+(3, 1, 'L14476', 'interchange', 'Purolator', NULL),
+(4, 1, 'CH10358', 'interchange', 'Champion', NULL),
+
+-- Spark plug cross-refs
+(5, 5, 'SK20R11', 'oem_alternate', 'Denso', 'Denso part number'),
+(6, 5, '3297', 'interchange', 'NGK', 'ILKAR7B11 equivalent'),
+
+-- Brake pad cross-refs
+(7, 10, '04465-06201', 'supersession', 'Toyota', 'Superseded from'),
+(8, 10, '04465-33450', 'oem_alternate', 'Lexus', 'Lexus ES equivalent');
+
+-- ============================================================================
+-- FASE 3: DIAGRAMAS EXPLOSIONADOS (Por implementar)
+-- ============================================================================
+
+-- Diagramas
+CREATE TABLE IF NOT EXISTS diagrams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ group_id INTEGER NOT NULL,
+ image_path TEXT NOT NULL,
+ thumbnail_path TEXT,
+ display_order INTEGER DEFAULT 0,
+ source TEXT,
+ FOREIGN KEY (group_id) REFERENCES part_groups(id)
+);
+
+INSERT INTO diagrams (id, name, name_es, group_id, image_path, thumbnail_path, display_order, source) VALUES
+(1, 'Front Brake Assembly', 'Ensamble Freno Delantero', 10,
+ '/static/diagrams/toyota/camry/2023/front_brake_assembly.svg',
+ '/static/diagrams/toyota/camry/2023/front_brake_assembly_thumb.png', 1, 'Toyota TIS'),
+(2, 'Rear Brake Assembly', 'Ensamble Freno Trasero', 10,
+ '/static/diagrams/toyota/camry/2023/rear_brake_assembly.svg',
+ '/static/diagrams/toyota/camry/2023/rear_brake_assembly_thumb.png', 2, 'Toyota TIS'),
+(3, 'Front Suspension', 'Suspensión Delantera', 20,
+ '/static/diagrams/toyota/camry/2023/front_suspension.svg',
+ '/static/diagrams/toyota/camry/2023/front_suspension_thumb.png', 1, 'Toyota TIS'),
+(4, 'Engine Oil System', 'Sistema de Aceite Motor', 1,
+ '/static/diagrams/toyota/camry/2023/engine_oil_system.svg',
+ '/static/diagrams/toyota/camry/2023/engine_oil_system_thumb.png', 1, 'Toyota TIS'),
+(5, 'Ignition System', 'Sistema de Ignición', 3,
+ '/static/diagrams/toyota/camry/2023/ignition_system.svg',
+ '/static/diagrams/toyota/camry/2023/ignition_system_thumb.png', 1, 'Toyota TIS');
+
+-- Diagramas específicos por vehículo
+CREATE TABLE IF NOT EXISTS vehicle_diagrams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ diagram_id INTEGER NOT NULL,
+ model_year_engine_id INTEGER NOT NULL,
+ notes TEXT,
+ FOREIGN KEY (diagram_id) REFERENCES diagrams(id),
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id),
+ UNIQUE(diagram_id, model_year_engine_id)
+);
+
+INSERT INTO vehicle_diagrams (id, diagram_id, model_year_engine_id, notes) VALUES
+(1, 1, 1, NULL), -- Front brake diagram for Camry 2023 2.5L LE
+(2, 2, 1, NULL), -- Rear brake diagram
+(3, 3, 1, NULL), -- Front suspension diagram
+(4, 4, 1, 'For 2.5L 4-Cyl engines'),
+(5, 5, 1, NULL),
+(6, 1, 5, 'V6 uses same brakes'), -- Same brake for V6
+(7, 4, 5, 'For 3.5L V6 engines');
+
+-- Hotspots clickeables en diagramas
+CREATE TABLE IF NOT EXISTS diagram_hotspots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ diagram_id INTEGER NOT NULL,
+ part_id INTEGER NOT NULL,
+ callout_number INTEGER,
+ shape TEXT DEFAULT 'rect' CHECK(shape IN ('rect', 'circle', 'poly')),
+ coords TEXT NOT NULL, -- x1,y1,x2,y2 for rect; cx,cy,r for circle; x1,y1,x2,y2,... for poly
+ FOREIGN KEY (diagram_id) REFERENCES diagrams(id),
+ FOREIGN KEY (part_id) REFERENCES parts(id)
+);
+
+INSERT INTO diagram_hotspots (id, diagram_id, part_id, callout_number, shape, coords) VALUES
+-- Front Brake Assembly hotspots
+(1, 1, 10, 1, 'rect', '150,200,250,280'), -- Brake Pads
+(2, 1, 12, 2, 'rect', '100,150,300,350'), -- Brake Rotor
+(3, 1, NULL, 3, 'rect', '280,180,380,300'), -- Caliper (not in our parts yet)
+
+-- Front Suspension hotspots
+(4, 3, 20, 1, 'rect', '200,50,300,250'), -- Strut Assembly
+(5, 3, 22, 2, 'circle', '250,400,30'), -- Ball Joint
+(6, 3, 23, 3, 'circle', '150,350,25'), -- Tie Rod End
+(7, 3, NULL, 4, 'rect', '100,200,180,380'), -- Control Arm
+
+-- Engine Oil System hotspots
+(8, 4, 1, 1, 'rect', '300,250,400,350'), -- Oil Filter
+
+-- Ignition System hotspots
+(9, 5, 5, 1, 'rect', '150,100,200,180'), -- Spark Plug 1
+(10, 5, 5, 2, 'rect', '220,100,270,180'), -- Spark Plug 2
+(11, 5, 5, 3, 'rect', '290,100,340,180'), -- Spark Plug 3
+(12, 5, 5, 4, 'rect', '360,100,410,180'); -- Spark Plug 4
+
+-- ============================================================================
+-- FASE 4: BÚSQUEDA FULL-TEXT Y VIN DECODER (Por implementar)
+-- ============================================================================
+
+-- Full-Text Search (SQLite FTS5)
+CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
+ oem_part_number,
+ name,
+ name_es,
+ description,
+ description_es,
+ content='parts',
+ content_rowid='id'
+);
+
+-- Triggers para sincronización automática
+CREATE TRIGGER IF NOT EXISTS parts_ai AFTER INSERT ON parts BEGIN
+ INSERT INTO parts_fts(rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES (new.id, new.oem_part_number, new.name, new.name_es, new.description, new.description_es);
+END;
+
+CREATE TRIGGER IF NOT EXISTS parts_ad AFTER DELETE ON parts BEGIN
+ INSERT INTO parts_fts(parts_fts, rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES ('delete', old.id, old.oem_part_number, old.name, old.name_es, old.description, old.description_es);
+END;
+
+CREATE TRIGGER IF NOT EXISTS parts_au AFTER UPDATE ON parts BEGIN
+ INSERT INTO parts_fts(parts_fts, rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES ('delete', old.id, old.oem_part_number, old.name, old.name_es, old.description, old.description_es);
+ INSERT INTO parts_fts(rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES (new.id, new.oem_part_number, new.name, new.name_es, new.description, new.description_es);
+END;
+
+-- Cache de VINs decodificados
+CREATE TABLE IF NOT EXISTS vin_cache (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ vin TEXT NOT NULL UNIQUE,
+ decoded_data TEXT NOT NULL, -- JSON from NHTSA API
+ model_year_engine_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME,
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id)
+);
+
+INSERT INTO vin_cache (id, vin, decoded_data, model_year_engine_id, expires_at) VALUES
+(1, '4T1BF1FK5CU123456', '{
+ "Make": "TOYOTA",
+ "Model": "Camry",
+ "ModelYear": "2023",
+ "BodyClass": "Sedan/Saloon",
+ "DriveType": "FWD/Front-Wheel Drive",
+ "EngineConfiguration": "In-Line",
+ "EngineCylinders": "4",
+ "DisplacementL": "2.5",
+ "FuelTypePrimary": "Gasoline",
+ "TransmissionStyle": "Automatic"
+}', 1, '2026-03-05'),
+
+(2, '4T1K61AK5PU234567', '{
+ "Make": "TOYOTA",
+ "Model": "Camry",
+ "ModelYear": "2023",
+ "BodyClass": "Sedan/Saloon",
+ "DriveType": "FWD/Front-Wheel Drive",
+ "EngineConfiguration": "V-Type",
+ "EngineCylinders": "6",
+ "DisplacementL": "3.5",
+ "FuelTypePrimary": "Gasoline",
+ "TransmissionStyle": "Automatic"
+}', 5, '2026-03-05');
+
+-- ============================================================================
+-- FASE 5: OPTIMIZACIÓN - ÍNDICES ADICIONALES
+-- ============================================================================
+
+-- Índices compuestos para queries frecuentes
+CREATE INDEX IF NOT EXISTS idx_vehicle_parts_mye_part ON vehicle_parts(model_year_engine_id, part_id);
+CREATE INDEX IF NOT EXISTS idx_aftermarket_oem ON aftermarket_parts(oem_part_id);
+CREATE INDEX IF NOT EXISTS idx_aftermarket_manufacturer ON aftermarket_parts(manufacturer_id);
+CREATE INDEX IF NOT EXISTS idx_cross_ref_part ON part_cross_references(part_id);
+CREATE INDEX IF NOT EXISTS idx_cross_ref_number ON part_cross_references(cross_reference_number);
+CREATE INDEX IF NOT EXISTS idx_diagrams_group ON diagrams(group_id);
+CREATE INDEX IF NOT EXISTS idx_hotspots_diagram ON diagram_hotspots(diagram_id);
+CREATE INDEX IF NOT EXISTS idx_hotspots_part ON diagram_hotspots(part_id);
+CREATE INDEX IF NOT EXISTS idx_vin_cache_vin ON vin_cache(vin);
+
+-- ============================================================================
+-- QUERIES DE EJEMPLO
+-- ============================================================================
+
+-- 1. Buscar todas las partes para un vehículo específico
+/*
+SELECT
+ pc.name_es AS categoria,
+ pg.name_es AS grupo,
+ p.oem_part_number,
+ p.name_es AS nombre,
+ vp.quantity_required AS cantidad,
+ vp.position AS posicion
+FROM vehicle_parts vp
+JOIN parts p ON vp.part_id = p.id
+JOIN part_groups pg ON p.group_id = pg.id
+JOIN part_categories pc ON pg.category_id = pc.id
+WHERE vp.model_year_engine_id = 1
+ORDER BY pc.display_order, pg.display_order;
+*/
+
+-- 2. Buscar alternativas aftermarket para una parte OEM
+/*
+SELECT
+ m.name AS fabricante,
+ m.quality_tier AS calidad,
+ ap.part_number,
+ ap.name_es AS nombre,
+ ap.price_usd AS precio,
+ ap.warranty_months AS garantia_meses
+FROM aftermarket_parts ap
+JOIN manufacturers m ON ap.manufacturer_id = m.id
+WHERE ap.oem_part_id = 1
+ORDER BY m.quality_tier DESC, ap.price_usd;
+*/
+
+-- 3. Búsqueda full-text de partes
+/*
+SELECT p.*
+FROM parts p
+JOIN parts_fts ON p.id = parts_fts.rowid
+WHERE parts_fts MATCH 'filtro aceite'
+ORDER BY rank;
+*/
+
+-- 4. Obtener hotspots de un diagrama con info de partes
+/*
+SELECT
+ dh.callout_number,
+ dh.shape,
+ dh.coords,
+ p.oem_part_number,
+ p.name_es AS nombre
+FROM diagram_hotspots dh
+LEFT JOIN parts p ON dh.part_id = p.id
+WHERE dh.diagram_id = 1
+ORDER BY dh.callout_number;
+*/
+
+-- 5. Buscar por número de parte (incluye cross-references)
+/*
+SELECT DISTINCT
+ p.id,
+ p.oem_part_number,
+ p.name_es,
+ 'OEM' AS source
+FROM parts p
+WHERE p.oem_part_number LIKE '%04152%'
+
+UNION
+
+SELECT DISTINCT
+ p.id,
+ p.oem_part_number,
+ p.name_es,
+ 'Cross-Ref: ' || pcr.cross_reference_number AS source
+FROM parts p
+JOIN part_cross_references pcr ON p.id = pcr.part_id
+WHERE pcr.cross_reference_number LIKE '%04152%';
+*/
diff --git a/vehicle_database/docs/database_schema_diagram.md b/vehicle_database/docs/database_schema_diagram.md
new file mode 100644
index 0000000..7b93522
--- /dev/null
+++ b/vehicle_database/docs/database_schema_diagram.md
@@ -0,0 +1,208 @@
+# Diagrama de Base de Datos - Catálogo de Autopartes
+
+## Diagrama de Relaciones (ERD)
+
+```
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ VEHÍCULOS (Existente) │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+
+┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
+│ brands │ │ models │ │ years │ │ engines │
+├──────────────┤ ├──────────────┤ ├──────────────┤ ├──────────────┤
+│ id (PK) │◄────│ brand_id(FK) │ │ id (PK) │ │ id (PK) │
+│ name │ │ id (PK) │ │ year │ │ name │
+│ country │ │ name │ └──────┬───────┘ │ displacement │
+│ founded_year │ │ body_type │ │ │ cylinders │
+└──────────────┘ │ generation │ │ │ fuel_type │
+ └──────┬───────┘ │ │ power_hp │
+ │ │ └──────┬───────┘
+ │ │ │
+ ▼ ▼ ▼
+ ┌─────────────────────────────────────────────────────┐
+ │ model_year_engine (MYE) │
+ ├─────────────────────────────────────────────────────┤
+ │ id (PK) ◄─────── Identificador único de │
+ │ model_id (FK) configuración vehículo │
+ │ year_id (FK) │
+ │ engine_id (FK) │
+ │ trim_level │
+ │ drivetrain │
+ │ transmission │
+ └────────────────────────┬────────────────────────────┘
+ │
+ │ (1:N)
+ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ FASE 1: CATÁLOGO DE PARTES │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+
+┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ part_categories │ │ part_groups │ │ parts │
+├──────────────────┤ ├──────────────────┤ ├──────────────────┤
+│ id (PK) │◄────────│ category_id (FK) │◄────────│ group_id (FK) │
+│ name │ │ id (PK) │ │ id (PK) │
+│ name_es │ │ name │ │ oem_part_number │
+│ parent_id (FK)───┼─┐ │ name_es │ │ name │
+│ slug │ │ │ slug │ │ name_es │
+│ icon_name │ │ │ display_order │ │ description │
+│ display_order │◄┘ └──────────────────┘ │ weight_kg │
+└──────────────────┘ │ material │
+ │ │ is_discontinued │
+ │ (Ej: 12 categorías) │ superseded_by_id │
+ │ - Engine └────────┬─────────┘
+ │ - Brakes │
+ │ - Suspension │
+ │ - etc. │
+ │
+ ┌────────────────────────────────────────────┼────────────────┐
+ │ │ │
+ ▼ ▼ │
+ ┌──────────────────┐ ┌──────────────────┐ │
+ │ vehicle_parts │ │ (FASE 2) │ │
+ ├──────────────────┤ │ aftermarket_parts│ │
+ │ id (PK) │ ├──────────────────┤ │
+ │ mye_id (FK) ─────┼──► model_year_engine │ oem_part_id (FK)─┼─────────┤
+ │ part_id (FK) ────┼──► parts │ manufacturer_id │ │
+ │ quantity_required│ │ part_number │ │
+ │ position │ │ quality_tier │ │
+ │ fitment_notes │ │ price_usd │ │
+ └──────────────────┘ └──────────────────┘ │
+ │
+ ┌──────────────────┐ │
+ │ part_cross_refs │ │
+ ├──────────────────┤ │
+ │ part_id (FK) ────┼─────────┘
+ │ cross_ref_number │
+ │ reference_type │
+ └──────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ FASE 2: AFTERMARKET Y FABRICANTES │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+
+┌──────────────────┐ ┌──────────────────┐
+│ manufacturers │◄────────│ aftermarket_parts│
+├──────────────────┤ ├──────────────────┤
+│ id (PK) │ │ manufacturer_id │
+│ name │ │ oem_part_id (FK) │──► parts
+│ type │ │ part_number │
+│ quality_tier │ │ name │
+│ country │ │ price_usd │
+│ logo_url │ │ warranty_months │
+└──────────────────┘ └──────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ FASE 3: DIAGRAMAS EXPLOSIONADOS │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+
+┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ diagrams │ │ vehicle_diagrams │ │diagram_hotspots │
+├──────────────────┤ ├──────────────────┤ ├──────────────────┤
+│ id (PK) │◄────────│ diagram_id (FK) │ │ diagram_id (FK)──┼──► diagrams
+│ name │ │ mye_id (FK) ─────┼──► MYE │ part_id (FK) ────┼──► parts
+│ name_es │ └──────────────────┘ │ callout_number │
+│ group_id (FK)────┼──► part_groups │ shape │
+│ image_path │ │ coords │
+│ thumbnail_path │ └──────────────────┘
+└──────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────────────────────────┐
+│ FASE 4: BÚSQUEDA Y VIN │
+└─────────────────────────────────────────────────────────────────────────────────────────┘
+
+┌──────────────────┐ ┌──────────────────┐
+│ parts_fts │ │ vin_cache │
+│ (FTS5 Virtual) │ ├──────────────────┤
+├──────────────────┤ │ vin │
+│ oem_part_number │ │ decoded_data │──► JSON from NHTSA
+│ name │ │ mye_id (FK) ─────┼──► model_year_engine
+│ name_es │ │ expires_at │
+│ description │ └──────────────────┘
+└──────────────────┘
+```
+
+## Flujo de Navegación del Usuario
+
+```
+┌─────────┐ ┌─────────┐ ┌─────────────────┐ ┌────────────────┐
+│ Marcas │───►│ Modelos │───►│ Vehículos │───►│ Categorías │
+│ (brands)│ │(models) │ │(model_year_eng) │ │(part_categories│
+└─────────┘ └─────────┘ └─────────────────┘ └───────┬────────┘
+ │
+ ┌────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐
+│ Grupos │───►│ Partes │───►│ Detalle Parte │
+│(part_groups)│ │ (parts) │ │ + Alternativas Aftermarket │
+└─────────────┘ └─────────────┘ │ + Cross-References │
+ │ │ + Diagrama con Hotspots │
+ │ └─────────────────────────────┘
+ │
+ ▼
+ ┌─────────────────────┐
+ │ Filtrar por MYE │
+ │ (vehicle_parts) │
+ │ Cantidad, Posición │
+ └─────────────────────┘
+```
+
+## Resumen de Tablas por Fase
+
+| Fase | Tabla | Registros Esperados | Descripción |
+|------|-------|---------------------|-------------|
+| Base | brands | ~50 | Marcas de vehículos |
+| Base | models | ~2,000 | Modelos de vehículos |
+| Base | years | ~30 | Años (1995-2025) |
+| Base | engines | ~500 | Especificaciones de motores |
+| Base | model_year_engine | ~50,000 | Configuraciones únicas |
+| **F1** | part_categories | 12 | Categorías principales |
+| **F1** | part_groups | ~200 | Subcategorías |
+| **F1** | parts | ~100,000 | Catálogo maestro OEM |
+| **F1** | vehicle_parts | ~500,000 | Fitment por vehículo |
+| **F2** | manufacturers | ~50 | OEM y aftermarket |
+| **F2** | aftermarket_parts | ~300,000 | Alternativas aftermarket |
+| **F2** | part_cross_references | ~200,000 | Números alternativos |
+| **F3** | diagrams | ~5,000 | Diagramas explosionados |
+| **F3** | vehicle_diagrams | ~20,000 | Asignación a vehículos |
+| **F3** | diagram_hotspots | ~50,000 | Áreas clickeables |
+| **F4** | parts_fts | Virtual | Índice full-text |
+| **F4** | vin_cache | Variable | Cache de VINs |
+
+## Ejemplo de Datos Relacionados
+
+### Un Toyota Camry 2023 2.5L LE completo:
+
+```sql
+-- Vehículo
+brands.id = 1 (Toyota)
+models.id = 1 (Camry)
+years.id = 4 (2023)
+engines.id = 1 (2.5L 4-Cyl)
+model_year_engine.id = 1
+
+-- Sus partes (via vehicle_parts)
+┌────────────────┬───────────────────────────────┬──────┬──────────┐
+│ OEM # │ Parte │ Cant │ Posición │
+├────────────────┼───────────────────────────────┼──────┼──────────┤
+│ 04152-YZZA1 │ Filtro de Aceite │ 1 │ - │
+│ 17801-0V020 │ Filtro de Aire │ 1 │ - │
+│ 90919-01275 │ Bujía Iridium │ 4 │ - │
+│ 04465-06200 │ Pastillas Freno Delanteras │ 1 │ front │
+│ 04466-06200 │ Pastillas Freno Traseras │ 1 │ rear │
+│ 43512-06190 │ Disco Freno Delantero │ 2 │ front │
+│ 42431-06190 │ Disco Freno Trasero │ 2 │ rear │
+│ 48510-06780 │ Amortiguador Delantero │ 2 │ front │
+│ 48530-06400 │ Amortiguador Trasero │ 2 │ rear │
+└────────────────┴───────────────────────────────┴──────┴──────────┘
+
+-- Alternativas para el filtro de aceite (via aftermarket_parts)
+┌────────────────┬─────────┬─────────────────────────────┬─────────┐
+│ Fabricante │ Número │ Nombre │ Precio │
+├────────────────┼─────────┼─────────────────────────────┼─────────┤
+│ Bosch │ 3311 │ Premium Oil Filter │ $12.99 │
+│ Fram │ XG9972 │ Ultra Synthetic Oil Filter │ $8.49 │
+│ WIX │ 57047 │ Oil Filter │ $7.99 │
+└────────────────┴─────────┴─────────────────────────────┴─────────┘
+```
diff --git a/vehicle_database/scripts/populate_categories.py b/vehicle_database/scripts/populate_categories.py
new file mode 100644
index 0000000..3deb975
--- /dev/null
+++ b/vehicle_database/scripts/populate_categories.py
@@ -0,0 +1,548 @@
+#!/usr/bin/env python3
+"""
+Populate Part Categories and Groups
+
+This script populates the SQLite database with initial part categories
+and groups following the RockAuto style organization.
+
+The script is idempotent - it can be run multiple times safely.
+"""
+
+import sqlite3
+import os
+import re
+from pathlib import Path
+
+
+def create_slug(name: str) -> str:
+ """
+ Generate a URL-friendly slug from a name.
+
+ Args:
+ name: The name to convert to a slug
+
+ Returns:
+ Lowercase string with spaces replaced by hyphens
+ """
+ # Convert to lowercase
+ slug = name.lower()
+ # Remove special characters except spaces and hyphens
+ slug = re.sub(r'[^a-z0-9\s-]', '', slug)
+ # Replace spaces with hyphens
+ slug = re.sub(r'\s+', '-', slug)
+ # Remove multiple consecutive hyphens
+ slug = re.sub(r'-+', '-', slug)
+ # Strip leading/trailing hyphens
+ slug = slug.strip('-')
+ return slug
+
+
+def get_database_path() -> str:
+ """Get the path to the SQLite database."""
+ return "/home/Autopartes/vehicle_database/vehicle_database.db"
+
+
+def get_schema_path() -> str:
+ """Get the path to the schema SQL file."""
+ return "/home/Autopartes/vehicle_database/sql/schema.sql"
+
+
+def create_tables_if_not_exist(conn: sqlite3.Connection) -> None:
+ """
+ Create the necessary tables if they don't exist.
+ Reads and executes relevant CREATE TABLE statements from schema.sql.
+
+ Args:
+ conn: SQLite database connection
+ """
+ schema_path = get_schema_path()
+
+ if not os.path.exists(schema_path):
+ print(f"Warning: Schema file not found at {schema_path}")
+ print("Creating tables with embedded schema...")
+
+ # Fallback embedded schema for the parts catalog tables
+ embedded_schema = """
+ CREATE TABLE IF NOT EXISTS part_categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ parent_id INTEGER,
+ slug TEXT UNIQUE,
+ icon_name TEXT,
+ display_order INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (parent_id) REFERENCES part_categories(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS part_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ slug TEXT,
+ display_order INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (category_id) REFERENCES part_categories(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ oem_part_number TEXT NOT NULL,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ group_id INTEGER,
+ description TEXT,
+ description_es TEXT,
+ weight_kg REAL,
+ material TEXT,
+ is_discontinued BOOLEAN DEFAULT 0,
+ superseded_by_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (group_id) REFERENCES part_groups(id),
+ FOREIGN KEY (superseded_by_id) REFERENCES parts(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS vehicle_parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ model_year_engine_id INTEGER NOT NULL,
+ part_id INTEGER NOT NULL,
+ quantity_required INTEGER DEFAULT 1,
+ position TEXT,
+ fitment_notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id),
+ FOREIGN KEY (part_id) REFERENCES parts(id),
+ UNIQUE(model_year_engine_id, part_id, position)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_part_categories_parent ON part_categories(parent_id);
+ CREATE INDEX IF NOT EXISTS idx_part_categories_slug ON part_categories(slug);
+ CREATE INDEX IF NOT EXISTS idx_part_groups_category ON part_groups(category_id);
+ CREATE INDEX IF NOT EXISTS idx_parts_oem ON parts(oem_part_number);
+ CREATE INDEX IF NOT EXISTS idx_parts_group ON parts(group_id);
+ CREATE INDEX IF NOT EXISTS idx_vehicle_parts_mye ON vehicle_parts(model_year_engine_id);
+ CREATE INDEX IF NOT EXISTS idx_vehicle_parts_part ON vehicle_parts(part_id);
+ """
+ conn.executescript(embedded_schema)
+ else:
+ # Read the schema file
+ with open(schema_path, 'r') as f:
+ schema_sql = f.read()
+
+ # Execute the entire schema (CREATE TABLE IF NOT EXISTS is safe to re-run)
+ conn.executescript(schema_sql)
+
+ conn.commit()
+ print("Tables created/verified successfully.")
+
+
+def get_categories_data() -> list:
+ """
+ Return the list of categories to insert.
+
+ Returns:
+ List of tuples: (name, name_es, icon_name, display_order)
+ """
+ return [
+ ("Body & Lamp Assembly", "Carroceria y Lamparas", "fa-car-side", 1),
+ ("Brake & Wheel Hub", "Frenos y Mazas", "fa-compact-disc", 2),
+ ("Cooling System", "Sistema de Enfriamiento", "fa-temperature-low", 3),
+ ("Drivetrain", "Tren Motriz", "fa-cogs", 4),
+ ("Electrical & Lighting", "Electrico e Iluminacion", "fa-bolt", 5),
+ ("Engine", "Motor", "fa-cog", 6),
+ ("Exhaust", "Escape", "fa-wind", 7),
+ ("Fuel & Air", "Combustible y Aire", "fa-gas-pump", 8),
+ ("Heat & Air Conditioning", "Calefaccion y AC", "fa-snowflake", 9),
+ ("Steering", "Direccion", "fa-dharmachakra", 10),
+ ("Suspension", "Suspension", "fa-truck-monster", 11),
+ ("Transmission", "Transmision", "fa-gears", 12),
+ ]
+
+
+def get_groups_data() -> dict:
+ """
+ Return the groups for each category.
+
+ Returns:
+ Dictionary mapping category name to list of groups.
+ Each group is a tuple: (name, name_es, display_order)
+ """
+ return {
+ "Body & Lamp Assembly": [
+ ("Body Panels", "Paneles de Carroceria", 1),
+ ("Bumpers", "Defensas", 2),
+ ("Doors", "Puertas", 3),
+ ("Fenders", "Salpicaderas", 4),
+ ("Hoods", "Cofres", 5),
+ ("Trunk Lids", "Tapas de Cajuela", 6),
+ ("Grilles", "Parrillas", 7),
+ ("Mirrors", "Espejos", 8),
+ ("Headlights", "Faros Delanteros", 9),
+ ("Taillights", "Calaveras", 10),
+ ("Fog Lights", "Faros de Niebla", 11),
+ ("Turn Signal Lights", "Luces Direccionales", 12),
+ ("Windshield", "Parabrisas", 13),
+ ("Window Glass", "Cristales de Ventana", 14),
+ ("Moldings & Trim", "Molduras y Acabados", 15),
+ ],
+ "Brake & Wheel Hub": [
+ ("Brake Pads", "Balatas", 1),
+ ("Brake Rotors", "Discos de Freno", 2),
+ ("Brake Drums", "Tambores de Freno", 3),
+ ("Brake Shoes", "Zapatas de Freno", 4),
+ ("Brake Calipers", "Calibradores de Freno", 5),
+ ("Brake Lines", "Lineas de Freno", 6),
+ ("Brake Hoses", "Mangueras de Freno", 7),
+ ("Master Cylinder", "Cilindro Maestro", 8),
+ ("Wheel Cylinders", "Cilindros de Rueda", 9),
+ ("Brake Boosters", "Boosters de Freno", 10),
+ ("ABS Components", "Componentes ABS", 11),
+ ("Wheel Bearings", "Baleros de Rueda", 12),
+ ("Wheel Hubs", "Mazas de Rueda", 13),
+ ("Wheel Studs", "Birlos", 14),
+ ("Parking Brake", "Freno de Mano", 15),
+ ],
+ "Cooling System": [
+ ("Radiators", "Radiadores", 1),
+ ("Radiator Hoses", "Mangueras de Radiador", 2),
+ ("Water Pumps", "Bombas de Agua", 3),
+ ("Thermostats", "Termostatos", 4),
+ ("Cooling Fans", "Ventiladores", 5),
+ ("Fan Clutches", "Clutch de Ventilador", 6),
+ ("Coolant Reservoirs", "Depositos de Anticongelante", 7),
+ ("Radiator Caps", "Tapones de Radiador", 8),
+ ("Heater Cores", "Nucleos de Calefaccion", 9),
+ ("Heater Hoses", "Mangueras de Calefaccion", 10),
+ ("Coolant Sensors", "Sensores de Temperatura", 11),
+ ("Oil Coolers", "Enfriadores de Aceite", 12),
+ ("Intercoolers", "Intercoolers", 13),
+ ],
+ "Drivetrain": [
+ ("CV Axles", "Flechas CV", 1),
+ ("CV Joints", "Juntas CV", 2),
+ ("CV Boots", "Guardapolvos CV", 3),
+ ("U-Joints", "Crucetas", 4),
+ ("Drive Shafts", "Flechas Cardanes", 5),
+ ("Differentials", "Diferenciales", 6),
+ ("Axle Shafts", "Ejes", 7),
+ ("Transfer Cases", "Cajas de Transferencia", 8),
+ ("Wheel Bearings", "Baleros de Rueda", 9),
+ ("Hub Assemblies", "Ensambles de Maza", 10),
+ ],
+ "Electrical & Lighting": [
+ ("Batteries", "Baterias", 1),
+ ("Alternators", "Alternadores", 2),
+ ("Starters", "Marchas", 3),
+ ("Ignition Coils", "Bobinas de Ignicion", 4),
+ ("Spark Plug Wires", "Cables de Bujia", 5),
+ ("Distributors", "Distribuidores", 6),
+ ("Ignition Switches", "Switches de Encendido", 7),
+ ("Relays", "Relevadores", 8),
+ ("Fuses", "Fusibles", 9),
+ ("Switches", "Interruptores", 10),
+ ("Wiring Harnesses", "Arneses Electricos", 11),
+ ("Sensors", "Sensores", 12),
+ ("Headlight Bulbs", "Focos de Faros", 13),
+ ("Taillight Bulbs", "Focos de Calaveras", 14),
+ ("Interior Lights", "Luces Interiores", 15),
+ ("Horn", "Claxon", 16),
+ ],
+ "Engine": [
+ ("Oil Filters", "Filtros de Aceite", 1),
+ ("Air Filters", "Filtros de Aire", 2),
+ ("Spark Plugs", "Bujias", 3),
+ ("Belts", "Bandas", 4),
+ ("Timing Belts", "Bandas de Tiempo", 5),
+ ("Timing Chains", "Cadenas de Tiempo", 6),
+ ("Timing Components", "Componentes de Tiempo", 7),
+ ("Gaskets", "Juntas", 8),
+ ("Head Gaskets", "Juntas de Cabeza", 9),
+ ("Valve Cover Gaskets", "Juntas de Tapa de Punterias", 10),
+ ("Oil Pan Gaskets", "Juntas de Carter", 11),
+ ("Pistons", "Pistones", 12),
+ ("Piston Rings", "Anillos de Piston", 13),
+ ("Connecting Rods", "Bielas", 14),
+ ("Crankshafts", "Cigueñales", 15),
+ ("Camshafts", "Arboles de Levas", 16),
+ ("Valves", "Valvulas", 17),
+ ("Valve Springs", "Resortes de Valvula", 18),
+ ("Rocker Arms", "Balancines", 19),
+ ("Lifters", "Buzos", 20),
+ ("Oil Pumps", "Bombas de Aceite", 21),
+ ("Engine Mounts", "Soportes de Motor", 22),
+ ("Cylinder Heads", "Cabezas de Motor", 23),
+ ("Engine Blocks", "Bloques de Motor", 24),
+ ("Harmonic Balancers", "Dampers", 25),
+ ("Pulleys", "Poleas", 26),
+ ("Tensioners", "Tensores", 27),
+ ("Idler Pulleys", "Poleas Locas", 28),
+ ],
+ "Exhaust": [
+ ("Exhaust Manifolds", "Multiples de Escape", 1),
+ ("Catalytic Converters", "Convertidores Cataliticos", 2),
+ ("Mufflers", "Mofles", 3),
+ ("Resonators", "Resonadores", 4),
+ ("Exhaust Pipes", "Tubos de Escape", 5),
+ ("Exhaust Tips", "Terminales de Escape", 6),
+ ("Exhaust Gaskets", "Juntas de Escape", 7),
+ ("Exhaust Hangers", "Soportes de Escape", 8),
+ ("O2 Sensors", "Sensores de Oxigeno", 9),
+ ("EGR Valves", "Valvulas EGR", 10),
+ ("Headers", "Headers", 11),
+ ("Flex Pipes", "Flexibles", 12),
+ ],
+ "Fuel & Air": [
+ ("Fuel Pumps", "Bombas de Gasolina", 1),
+ ("Fuel Filters", "Filtros de Gasolina", 2),
+ ("Fuel Injectors", "Inyectores", 3),
+ ("Fuel Lines", "Lineas de Combustible", 4),
+ ("Fuel Tanks", "Tanques de Gasolina", 5),
+ ("Fuel Caps", "Tapones de Gasolina", 6),
+ ("Carburetors", "Carburadores", 7),
+ ("Throttle Bodies", "Cuerpos de Aceleracion", 8),
+ ("Intake Manifolds", "Multiples de Admision", 9),
+ ("Air Intake Hoses", "Mangueras de Admision", 10),
+ ("Mass Air Flow Sensors", "Sensores MAF", 11),
+ ("Throttle Position Sensors", "Sensores TPS", 12),
+ ("Fuel Pressure Regulators", "Reguladores de Presion", 13),
+ ("PCV Valves", "Valvulas PCV", 14),
+ ("Air Intake Systems", "Sistemas de Admision", 15),
+ ("Turbochargers", "Turbocargadores", 16),
+ ("Superchargers", "Supercargadores", 17),
+ ],
+ "Heat & Air Conditioning": [
+ ("AC Compressors", "Compresores de AC", 1),
+ ("AC Condensers", "Condensadores de AC", 2),
+ ("AC Evaporators", "Evaporadores de AC", 3),
+ ("AC Hoses", "Mangueras de AC", 4),
+ ("AC Accumulators", "Acumuladores de AC", 5),
+ ("AC Receiver Driers", "Filtros Deshidratadores", 6),
+ ("AC Expansion Valves", "Valvulas de Expansion", 7),
+ ("AC Clutches", "Clutch de AC", 8),
+ ("Blower Motors", "Motores de Ventilador", 9),
+ ("Blower Resistors", "Resistencias de Ventilador", 10),
+ ("Heater Control Valves", "Valvulas de Calefaccion", 11),
+ ("Cabin Air Filters", "Filtros de Cabina", 12),
+ ("AC Pressure Switches", "Switches de Presion AC", 13),
+ ("Climate Control Units", "Unidades de Control Climatico", 14),
+ ],
+ "Steering": [
+ ("Power Steering Pumps", "Bombas de Direccion Hidraulica", 1),
+ ("Power Steering Hoses", "Mangueras de Direccion", 2),
+ ("Power Steering Fluid Reservoirs", "Depositos de Direccion", 3),
+ ("Steering Racks", "Cremalleras de Direccion", 4),
+ ("Steering Gearboxes", "Cajas de Direccion", 5),
+ ("Tie Rods", "Terminales de Direccion", 6),
+ ("Tie Rod Ends", "Rotulas de Direccion", 7),
+ ("Inner Tie Rods", "Terminales Interiores", 8),
+ ("Steering Columns", "Columnas de Direccion", 9),
+ ("Steering Wheels", "Volantes", 10),
+ ("Pitman Arms", "Brazos Pitman", 11),
+ ("Idler Arms", "Brazos Locos", 12),
+ ("Center Links", "Barras Centrales", 13),
+ ("Drag Links", "Barras de Arrastre", 14),
+ ("Steering Knuckles", "Muñones", 15),
+ ],
+ "Suspension": [
+ ("Shocks", "Amortiguadores", 1),
+ ("Struts", "Struts", 2),
+ ("Strut Mounts", "Bases de Strut", 3),
+ ("Coil Springs", "Resortes", 4),
+ ("Leaf Springs", "Muelles", 5),
+ ("Control Arms", "Brazos de Control", 6),
+ ("Upper Control Arms", "Brazos Superiores", 7),
+ ("Lower Control Arms", "Brazos Inferiores", 8),
+ ("Ball Joints", "Rotulas", 9),
+ ("Bushings", "Bujes", 10),
+ ("Sway Bars", "Barras Estabilizadoras", 11),
+ ("Sway Bar Links", "Links de Barra Estabilizadora", 12),
+ ("Sway Bar Bushings", "Bujes de Barra Estabilizadora", 13),
+ ("Torsion Bars", "Barras de Torsion", 14),
+ ("Trailing Arms", "Brazos Traseros", 15),
+ ("Track Bars", "Barras Track", 16),
+ ("Radius Arms", "Brazos de Radio", 17),
+ ("Air Suspension", "Suspension Neumatica", 18),
+ ("Bump Stops", "Topes", 19),
+ ],
+ "Transmission": [
+ ("Transmission Filters", "Filtros de Transmision", 1),
+ ("Clutch Kits", "Kits de Clutch", 2),
+ ("Clutch Discs", "Discos de Clutch", 3),
+ ("Pressure Plates", "Platos de Presion", 4),
+ ("Throw-out Bearings", "Collares de Clutch", 5),
+ ("Clutch Masters", "Cilindros Maestros de Clutch", 6),
+ ("Clutch Slaves", "Cilindros Esclavos de Clutch", 7),
+ ("Flywheels", "Volantes de Motor", 8),
+ ("Flexplates", "Flexplates", 9),
+ ("Torque Converters", "Convertidores de Torque", 10),
+ ("Transmission Mounts", "Soportes de Transmision", 11),
+ ("Shift Cables", "Cables de Cambios", 12),
+ ("Shift Linkages", "Varillajes de Cambios", 13),
+ ("Speedometer Gears", "Engranes de Velocimetro", 14),
+ ("Transmission Gaskets", "Juntas de Transmision", 15),
+ ("Transmission Seals", "Sellos de Transmision", 16),
+ ],
+ }
+
+
+def insert_categories(conn: sqlite3.Connection) -> dict:
+ """
+ Insert categories into the database.
+
+ Args:
+ conn: SQLite database connection
+
+ Returns:
+ Dictionary mapping category name to category id
+ """
+ cursor = conn.cursor()
+ categories = get_categories_data()
+ category_ids = {}
+
+ for name, name_es, icon_name, display_order in categories:
+ slug = create_slug(name)
+
+ # Use INSERT OR IGNORE to make the script idempotent
+ cursor.execute("""
+ INSERT OR IGNORE INTO part_categories
+ (name, name_es, slug, icon_name, display_order)
+ VALUES (?, ?, ?, ?, ?)
+ """, (name, name_es, slug, icon_name, display_order))
+
+ # Get the id (whether it was just inserted or already existed)
+ cursor.execute("SELECT id FROM part_categories WHERE slug = ?", (slug,))
+ result = cursor.fetchone()
+ if result:
+ category_ids[name] = result[0]
+ print(f" Category: {name} (ID: {result[0]})")
+
+ conn.commit()
+ return category_ids
+
+
+def insert_groups(conn: sqlite3.Connection, category_ids: dict) -> None:
+ """
+ Insert groups into the database.
+
+ Args:
+ conn: SQLite database connection
+ category_ids: Dictionary mapping category name to category id
+ """
+ cursor = conn.cursor()
+ groups_data = get_groups_data()
+
+ for category_name, groups in groups_data.items():
+ if category_name not in category_ids:
+ print(f" Warning: Category '{category_name}' not found, skipping groups")
+ continue
+
+ category_id = category_ids[category_name]
+
+ for name, name_es, display_order in groups:
+ slug = create_slug(name)
+
+ # Check if group already exists for this category
+ cursor.execute("""
+ SELECT id FROM part_groups
+ WHERE category_id = ? AND slug = ?
+ """, (category_id, slug))
+
+ if cursor.fetchone() is None:
+ cursor.execute("""
+ INSERT INTO part_groups
+ (category_id, name, name_es, slug, display_order)
+ VALUES (?, ?, ?, ?, ?)
+ """, (category_id, name, name_es, slug, display_order))
+ print(f" Group: {name}")
+
+ conn.commit()
+
+
+def print_summary(conn: sqlite3.Connection) -> None:
+ """
+ Print a summary of the data in the database.
+
+ Args:
+ conn: SQLite database connection
+ """
+ cursor = conn.cursor()
+
+ # Count categories
+ cursor.execute("SELECT COUNT(*) FROM part_categories")
+ category_count = cursor.fetchone()[0]
+
+ # Count groups
+ cursor.execute("SELECT COUNT(*) FROM part_groups")
+ group_count = cursor.fetchone()[0]
+
+ print("\n" + "=" * 50)
+ print("SUMMARY")
+ print("=" * 50)
+ print(f"Total Categories: {category_count}")
+ print(f"Total Groups: {group_count}")
+ print()
+
+ # Show categories with group counts
+ cursor.execute("""
+ SELECT pc.name, pc.name_es, pc.icon_name, COUNT(pg.id) as group_count
+ FROM part_categories pc
+ LEFT JOIN part_groups pg ON pc.id = pg.category_id
+ GROUP BY pc.id
+ ORDER BY pc.display_order
+ """)
+
+ print("Categories and Group Counts:")
+ print("-" * 50)
+ for row in cursor.fetchall():
+ name, name_es, icon, count = row
+ print(f" {icon:20} {name:30} ({count} groups)")
+
+
+def main():
+ """Main function to populate categories and groups."""
+ db_path = get_database_path()
+
+ print("=" * 50)
+ print("POPULATE PART CATEGORIES AND GROUPS")
+ print("=" * 50)
+ print(f"Database: {db_path}")
+ print()
+
+ # Check if database exists
+ if not os.path.exists(db_path):
+ print(f"Warning: Database does not exist at {db_path}")
+ print("Creating new database...")
+
+ # Connect to database
+ conn = sqlite3.connect(db_path)
+
+ try:
+ # Create tables if they don't exist
+ print("Creating/verifying tables...")
+ create_tables_if_not_exist(conn)
+ print()
+
+ # Insert categories
+ print("Inserting categories...")
+ category_ids = insert_categories(conn)
+ print()
+
+ # Insert groups
+ print("Inserting groups...")
+ insert_groups(conn, category_ids)
+
+ # Print summary
+ print_summary(conn)
+
+ print("\nDone! Categories and groups populated successfully.")
+
+ except sqlite3.Error as e:
+ print(f"Database error: {e}")
+ raise
+ finally:
+ conn.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/vehicle_database/scripts/populate_fase2.py b/vehicle_database/scripts/populate_fase2.py
new file mode 100644
index 0000000..d0df215
--- /dev/null
+++ b/vehicle_database/scripts/populate_fase2.py
@@ -0,0 +1,574 @@
+#!/usr/bin/env python3
+"""
+FASE 2: Populate cross-references and aftermarket parts
+This script creates FASE 2 tables and populates them with manufacturers,
+aftermarket part alternatives, and cross-references.
+"""
+
+import sqlite3
+import os
+import random
+import string
+from typing import List, Dict, Tuple, Optional
+
+# Database path configuration
+DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'vehicle_database.db')
+SCHEMA_PATH = os.path.join(os.path.dirname(__file__), '..', 'sql', 'schema.sql')
+
+
+class Fase2Manager:
+ """Manager for FASE 2 tables: manufacturers, aftermarket_parts, and cross-references"""
+
+ def __init__(self, db_path: str = DB_PATH):
+ self.db_path = db_path
+ self.connection = None
+
+ def connect(self):
+ """Connect to the SQLite database"""
+ self.connection = sqlite3.connect(self.db_path)
+ self.connection.row_factory = sqlite3.Row
+ print(f"Connected to database: {self.db_path}")
+
+ def disconnect(self):
+ """Close the database connection"""
+ if self.connection:
+ self.connection.close()
+ print("Disconnected from database")
+
+ def create_fase2_tables(self):
+ """Create FASE 2 tables from schema file"""
+ if not os.path.exists(SCHEMA_PATH):
+ raise FileNotFoundError(f"Schema file not found: {SCHEMA_PATH}")
+
+ with open(SCHEMA_PATH, 'r') as f:
+ schema = f.read()
+
+ if self.connection:
+ cursor = self.connection.cursor()
+ cursor.executescript(schema)
+ self.connection.commit()
+ print("FASE 2 tables created successfully")
+
+ def get_manufacturer_by_name(self, name: str) -> Optional[int]:
+ """Get manufacturer ID by name, returns None if not found"""
+ cursor = self.connection.cursor()
+ cursor.execute("SELECT id FROM manufacturers WHERE name = ?", (name,))
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_manufacturer(self, name: str, type_: str, quality_tier: str,
+ country: str = None, logo_url: str = None,
+ website: str = None) -> int:
+ """Insert a manufacturer if it doesn't exist, return its ID"""
+ existing_id = self.get_manufacturer_by_name(name)
+ if existing_id:
+ print(f" Manufacturer '{name}' already exists (ID: {existing_id})")
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO manufacturers (name, type, quality_tier, country, logo_url, website)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (name, type_, quality_tier, country, logo_url, website)
+ )
+ self.connection.commit()
+ manufacturer_id = cursor.lastrowid
+ print(f" Inserted manufacturer: {name} (ID: {manufacturer_id})")
+ return manufacturer_id
+
+ def get_all_parts(self) -> List[Dict]:
+ """Get all parts from the parts table"""
+ cursor = self.connection.cursor()
+ cursor.execute("""
+ SELECT p.id, p.oem_part_number, p.name, p.name_es, p.group_id,
+ pg.name as group_name, pc.name as category_name
+ FROM parts p
+ LEFT JOIN part_groups pg ON p.group_id = pg.id
+ LEFT JOIN part_categories pc ON pg.category_id = pc.id
+ """)
+ return [dict(row) for row in cursor.fetchall()]
+
+ def get_aftermarket_part(self, oem_part_id: int, manufacturer_id: int) -> Optional[int]:
+ """Check if an aftermarket part already exists"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """SELECT id FROM aftermarket_parts
+ WHERE oem_part_id = ? AND manufacturer_id = ?""",
+ (oem_part_id, manufacturer_id)
+ )
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_aftermarket_part(self, oem_part_id: int, manufacturer_id: int,
+ part_number: str, name: str = None, name_es: str = None,
+ quality_tier: str = 'standard', price_usd: float = None,
+ warranty_months: int = 12, in_stock: bool = True) -> int:
+ """Insert an aftermarket part if it doesn't exist"""
+ existing_id = self.get_aftermarket_part(oem_part_id, manufacturer_id)
+ if existing_id:
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO aftermarket_parts
+ (oem_part_id, manufacturer_id, part_number, name, name_es,
+ quality_tier, price_usd, warranty_months, in_stock)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (oem_part_id, manufacturer_id, part_number, name, name_es,
+ quality_tier, price_usd, warranty_months, in_stock)
+ )
+ self.connection.commit()
+ return cursor.lastrowid
+
+ def get_cross_reference(self, part_id: int, cross_reference_number: str) -> Optional[int]:
+ """Check if a cross-reference already exists"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """SELECT id FROM part_cross_references
+ WHERE part_id = ? AND cross_reference_number = ?""",
+ (part_id, cross_reference_number)
+ )
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_cross_reference(self, part_id: int, cross_reference_number: str,
+ reference_type: str, source: str = None,
+ notes: str = None) -> int:
+ """Insert a cross-reference if it doesn't exist"""
+ existing_id = self.get_cross_reference(part_id, cross_reference_number)
+ if existing_id:
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO part_cross_references
+ (part_id, cross_reference_number, reference_type, source, notes)
+ VALUES (?, ?, ?, ?, ?)""",
+ (part_id, cross_reference_number, reference_type, source, notes)
+ )
+ self.connection.commit()
+ return cursor.lastrowid
+
+ def get_manufacturers_by_tier(self, quality_tier: str) -> List[Dict]:
+ """Get all manufacturers of a specific quality tier"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ "SELECT * FROM manufacturers WHERE quality_tier = ?",
+ (quality_tier,)
+ )
+ return [dict(row) for row in cursor.fetchall()]
+
+
+# Manufacturer data
+MANUFACTURERS_DATA = {
+ # OEM manufacturers
+ 'oem': [
+ {'name': 'Toyota', 'country': 'Japan', 'website': 'https://www.toyota.com'},
+ {'name': 'Honda', 'country': 'Japan', 'website': 'https://www.honda.com'},
+ {'name': 'Ford', 'country': 'USA', 'website': 'https://www.ford.com'},
+ {'name': 'GM/ACDelco', 'country': 'USA', 'website': 'https://www.acdelco.com'},
+ {'name': 'Volkswagen', 'country': 'Germany', 'website': 'https://www.vw.com'},
+ {'name': 'Nissan', 'country': 'Japan', 'website': 'https://www.nissan.com'},
+ {'name': 'Hyundai/Kia', 'country': 'South Korea', 'website': 'https://www.hyundai.com'},
+ ],
+ # Premium aftermarket
+ 'premium': [
+ {'name': 'Bosch', 'country': 'Germany', 'website': 'https://www.bosch.com'},
+ {'name': 'Denso', 'country': 'Japan', 'website': 'https://www.denso.com'},
+ {'name': 'NGK', 'country': 'Japan', 'website': 'https://www.ngk.com'},
+ {'name': 'Akebono', 'country': 'Japan', 'website': 'https://www.akebono.com'},
+ {'name': 'Brembo', 'country': 'Italy', 'website': 'https://www.brembo.com'},
+ {'name': 'KYB', 'country': 'Japan', 'website': 'https://www.kyb.com'},
+ {'name': 'Moog', 'country': 'USA', 'website': 'https://www.moogparts.com'},
+ {'name': 'Continental', 'country': 'Germany', 'website': 'https://www.continental.com'},
+ ],
+ # Standard aftermarket
+ 'standard': [
+ {'name': 'Monroe', 'country': 'USA', 'website': 'https://www.monroe.com'},
+ {'name': 'Raybestos', 'country': 'USA', 'website': 'https://www.raybestos.com'},
+ {'name': 'Wagner', 'country': 'USA', 'website': 'https://www.wagnerbrake.com'},
+ {'name': 'Cardone', 'country': 'USA', 'website': 'https://www.cardone.com'},
+ {'name': 'Standard Motor Products', 'country': 'USA', 'website': 'https://www.smpcorp.com'},
+ ],
+ # Economy aftermarket
+ 'economy': [
+ {'name': 'Fram', 'country': 'USA', 'website': 'https://www.fram.com'},
+ {'name': 'WIX', 'country': 'USA', 'website': 'https://www.wixfilters.com'},
+ {'name': 'Duralast', 'country': 'USA', 'website': 'https://www.autozone.com'},
+ {'name': 'AutoZone Valucraft', 'country': 'USA', 'website': 'https://www.autozone.com'},
+ ],
+}
+
+
+# Part number prefixes by manufacturer for realistic generation
+MANUFACTURER_PREFIXES = {
+ 'Bosch': ['0 280', '0 986', '1 457', 'F 00M'],
+ 'Denso': ['234-', '471-', '210-', '950-'],
+ 'NGK': ['ZFR', 'BKR', 'LFR', 'TR'],
+ 'Akebono': ['ACT', 'ASP', 'EUR', 'PRO'],
+ 'Brembo': ['P 85', 'P 06', 'P 23', 'P 50'],
+ 'KYB': ['332', '334', '343', '344'],
+ 'Moog': ['K', 'ES', 'RK', 'CK'],
+ 'Continental': ['49', '50', '51', 'A1'],
+ 'Monroe': ['32', '33', '34', '37'],
+ 'Raybestos': ['FRC', 'SGD', 'ATD', 'PGD'],
+ 'Wagner': ['QC', 'OEX', 'TQ', 'ZD'],
+ 'Cardone': ['18-', '19-', '20-', '21-'],
+ 'Standard Motor Products': ['FD', 'TM', 'AC', 'JH'],
+ 'Fram': ['PH', 'CA', 'TG', 'XG'],
+ 'WIX': ['51', '57', '46', '33'],
+ 'Duralast': ['DL', 'BP', 'AF', 'OF'],
+ 'AutoZone Valucraft': ['VC', 'VB', 'VA', 'VP'],
+}
+
+
+# Price multipliers by quality tier (relative to a base OEM price)
+PRICE_MULTIPLIERS = {
+ 'premium': (0.75, 1.10), # 75-110% of OEM price
+ 'standard': (0.50, 0.75), # 50-75% of OEM price
+ 'economy': (0.25, 0.50), # 25-50% of OEM price
+}
+
+
+# Warranty months by quality tier
+WARRANTY_MONTHS = {
+ 'premium': [24, 36, 48],
+ 'standard': [12, 18, 24],
+ 'economy': [6, 12],
+}
+
+
+def generate_part_number(manufacturer_name: str, oem_number: str) -> str:
+ """Generate a realistic aftermarket part number"""
+ prefixes = MANUFACTURER_PREFIXES.get(manufacturer_name, ['XX'])
+ prefix = random.choice(prefixes)
+
+ # Extract numeric portion from OEM number or generate random
+ numeric_part = ''.join(filter(str.isdigit, oem_number))
+ if len(numeric_part) < 4:
+ numeric_part = ''.join(random.choices(string.digits, k=5))
+ else:
+ # Modify slightly to make it different
+ numeric_part = numeric_part[:4] + str(random.randint(0, 99)).zfill(2)
+
+ return f"{prefix}{numeric_part}"
+
+
+def generate_base_price(part_name: str, category_name: str = None) -> float:
+ """Generate a realistic base price for a part based on category"""
+ # Base price ranges by category/keyword
+ price_ranges = {
+ 'spark plug': (5, 25),
+ 'filter': (8, 45),
+ 'oil filter': (5, 20),
+ 'air filter': (12, 35),
+ 'brake pad': (25, 80),
+ 'brake rotor': (40, 150),
+ 'shock': (50, 200),
+ 'strut': (80, 250),
+ 'sensor': (20, 120),
+ 'alternator': (100, 350),
+ 'starter': (80, 300),
+ 'water pump': (30, 120),
+ 'radiator': (100, 400),
+ 'thermostat': (10, 40),
+ 'belt': (15, 60),
+ 'hose': (10, 50),
+ 'gasket': (5, 80),
+ 'bearing': (15, 100),
+ 'cv joint': (40, 150),
+ 'tie rod': (25, 80),
+ 'ball joint': (30, 100),
+ 'control arm': (60, 200),
+ 'default': (20, 100),
+ }
+
+ # Find matching price range
+ part_name_lower = part_name.lower() if part_name else ''
+ category_lower = (category_name or '').lower()
+
+ for keyword, (min_price, max_price) in price_ranges.items():
+ if keyword in part_name_lower or keyword in category_lower:
+ return round(random.uniform(min_price, max_price), 2)
+
+ return round(random.uniform(*price_ranges['default']), 2)
+
+
+def generate_cross_reference_number(oem_number: str, ref_type: str) -> str:
+ """Generate a cross-reference number based on type"""
+ if ref_type == 'oem_alternate':
+ # Slight variation of OEM number
+ chars = list(oem_number)
+ if len(chars) > 2:
+ idx = random.randint(0, len(chars) - 1)
+ if chars[idx].isdigit():
+ chars[idx] = str((int(chars[idx]) + 1) % 10)
+ elif chars[idx].isalpha():
+ chars[idx] = random.choice(string.ascii_uppercase)
+ return ''.join(chars)
+
+ elif ref_type == 'supersession':
+ # New part number format
+ return f"SUP-{oem_number[-6:]}" if len(oem_number) > 6 else f"SUP-{oem_number}"
+
+ elif ref_type == 'interchange':
+ # Generic interchange format
+ numeric = ''.join(filter(str.isdigit, oem_number))
+ return f"INT-{numeric[:6] if len(numeric) > 6 else numeric}"
+
+ elif ref_type == 'competitor':
+ # Competitor format
+ return f"CMP-{random.choice(string.ascii_uppercase)}{random.randint(1000, 9999)}"
+
+ return oem_number
+
+
+def populate_manufacturers(manager: Fase2Manager) -> Dict[str, int]:
+ """Populate the manufacturers table and return a mapping of name to ID"""
+ print("\n=== Populating Manufacturers ===")
+ manufacturer_ids = {}
+
+ # Insert OEM manufacturers
+ print("\nOEM Manufacturers:")
+ for mfr in MANUFACTURERS_DATA['oem']:
+ mfr_id = manager.insert_manufacturer(
+ name=mfr['name'],
+ type_='oem',
+ quality_tier='oem',
+ country=mfr['country'],
+ website=mfr['website']
+ )
+ manufacturer_ids[mfr['name']] = mfr_id
+
+ # Insert Premium aftermarket
+ print("\nPremium Aftermarket Manufacturers:")
+ for mfr in MANUFACTURERS_DATA['premium']:
+ mfr_id = manager.insert_manufacturer(
+ name=mfr['name'],
+ type_='aftermarket',
+ quality_tier='premium',
+ country=mfr['country'],
+ website=mfr['website']
+ )
+ manufacturer_ids[mfr['name']] = mfr_id
+
+ # Insert Standard aftermarket
+ print("\nStandard Aftermarket Manufacturers:")
+ for mfr in MANUFACTURERS_DATA['standard']:
+ mfr_id = manager.insert_manufacturer(
+ name=mfr['name'],
+ type_='aftermarket',
+ quality_tier='standard',
+ country=mfr['country'],
+ website=mfr['website']
+ )
+ manufacturer_ids[mfr['name']] = mfr_id
+
+ # Insert Economy aftermarket
+ print("\nEconomy Aftermarket Manufacturers:")
+ for mfr in MANUFACTURERS_DATA['economy']:
+ mfr_id = manager.insert_manufacturer(
+ name=mfr['name'],
+ type_='aftermarket',
+ quality_tier='economy',
+ country=mfr['country'],
+ website=mfr['website']
+ )
+ manufacturer_ids[mfr['name']] = mfr_id
+
+ print(f"\nTotal manufacturers: {len(manufacturer_ids)}")
+ return manufacturer_ids
+
+
+def populate_aftermarket_parts(manager: Fase2Manager, manufacturer_ids: Dict[str, int]):
+ """Generate aftermarket parts for each OEM part in the database"""
+ print("\n=== Generating Aftermarket Parts ===")
+
+ parts = manager.get_all_parts()
+ if not parts:
+ print("No parts found in the database. Aftermarket parts will be generated when parts are added.")
+ return
+
+ total_aftermarket = 0
+
+ for part in parts:
+ oem_part_id = part['id']
+ oem_number = part['oem_part_number']
+ part_name = part['name']
+ category_name = part.get('category_name', '')
+
+ # Generate base price for this part
+ base_price = generate_base_price(part_name, category_name)
+
+ # Determine how many aftermarket alternatives (2-4)
+ num_alternatives = random.randint(2, 4)
+
+ # Select manufacturers from different tiers
+ tiers_to_use = ['premium', 'standard', 'economy']
+ random.shuffle(tiers_to_use)
+
+ alternatives_created = 0
+ for tier in tiers_to_use:
+ if alternatives_created >= num_alternatives:
+ break
+
+ # Get manufacturers for this tier
+ tier_manufacturers = [
+ name for name, data in
+ [(m['name'], m) for m in (
+ MANUFACTURERS_DATA.get(tier, [])
+ )]
+ ]
+
+ if not tier_manufacturers:
+ continue
+
+ # Pick 1-2 manufacturers from this tier
+ selected = random.sample(
+ tier_manufacturers,
+ min(2, len(tier_manufacturers), num_alternatives - alternatives_created)
+ )
+
+ for mfr_name in selected:
+ if alternatives_created >= num_alternatives:
+ break
+
+ mfr_id = manufacturer_ids.get(mfr_name)
+ if not mfr_id:
+ continue
+
+ # Generate aftermarket part number
+ am_part_number = generate_part_number(mfr_name, oem_number)
+
+ # Calculate price based on tier
+ price_range = PRICE_MULTIPLIERS.get(tier, (0.5, 0.8))
+ price_multiplier = random.uniform(*price_range)
+ am_price = round(base_price * price_multiplier, 2)
+
+ # Get warranty for tier
+ warranty = random.choice(WARRANTY_MONTHS.get(tier, [12]))
+
+ # Determine quality tier for the part
+ quality_tier = tier
+
+ # Insert aftermarket part
+ am_id = manager.insert_aftermarket_part(
+ oem_part_id=oem_part_id,
+ manufacturer_id=mfr_id,
+ part_number=am_part_number,
+ name=f"{mfr_name} {part_name}",
+ name_es=part.get('name_es'),
+ quality_tier=quality_tier,
+ price_usd=am_price,
+ warranty_months=warranty,
+ in_stock=random.random() > 0.1 # 90% in stock
+ )
+
+ if am_id:
+ alternatives_created += 1
+ total_aftermarket += 1
+
+ print(f" Part {oem_number}: {alternatives_created} aftermarket alternatives created")
+
+ print(f"\nTotal aftermarket parts created: {total_aftermarket}")
+
+
+def populate_cross_references(manager: Fase2Manager):
+ """Generate cross-references for OEM parts"""
+ print("\n=== Generating Cross-References ===")
+
+ parts = manager.get_all_parts()
+ if not parts:
+ print("No parts found in the database. Cross-references will be generated when parts are added.")
+ return
+
+ total_refs = 0
+ reference_types = ['oem_alternate', 'supersession', 'interchange', 'competitor']
+ sources = ['RockAuto', 'PartsGeek', 'AutoZone', 'OReilly', 'NAPA', 'Manufacturer']
+
+ for part in parts:
+ part_id = part['id']
+ oem_number = part['oem_part_number']
+
+ # Generate 1-3 cross-references per part
+ num_refs = random.randint(1, 3)
+ used_types = random.sample(reference_types, min(num_refs, len(reference_types)))
+
+ for ref_type in used_types:
+ cross_ref_number = generate_cross_reference_number(oem_number, ref_type)
+ source = random.choice(sources)
+
+ notes = None
+ if ref_type == 'supersession':
+ notes = "New part number supersedes original"
+ elif ref_type == 'interchange':
+ notes = "Interchangeable with original"
+
+ ref_id = manager.insert_cross_reference(
+ part_id=part_id,
+ cross_reference_number=cross_ref_number,
+ reference_type=ref_type,
+ source=source,
+ notes=notes
+ )
+
+ if ref_id:
+ total_refs += 1
+
+ print(f" Part {oem_number}: {len(used_types)} cross-references created")
+
+ print(f"\nTotal cross-references created: {total_refs}")
+
+
+def main():
+ """Main entry point for FASE 2 population"""
+ print("=" * 60)
+ print("FASE 2: Cross-References and Aftermarket Parts Population")
+ print("=" * 60)
+
+ manager = Fase2Manager()
+
+ try:
+ # Connect to database
+ manager.connect()
+
+ # Create FASE 2 tables (idempotent)
+ manager.create_fase2_tables()
+
+ # Populate manufacturers
+ manufacturer_ids = populate_manufacturers(manager)
+
+ # Generate aftermarket parts
+ populate_aftermarket_parts(manager, manufacturer_ids)
+
+ # Generate cross-references
+ populate_cross_references(manager)
+
+ print("\n" + "=" * 60)
+ print("FASE 2 population completed successfully!")
+ print("=" * 60)
+
+ # Print summary
+ cursor = manager.connection.cursor()
+ cursor.execute("SELECT COUNT(*) FROM manufacturers")
+ mfr_count = cursor.fetchone()[0]
+ cursor.execute("SELECT COUNT(*) FROM aftermarket_parts")
+ am_count = cursor.fetchone()[0]
+ cursor.execute("SELECT COUNT(*) FROM part_cross_references")
+ xref_count = cursor.fetchone()[0]
+
+ print(f"\nSummary:")
+ print(f" Manufacturers: {mfr_count}")
+ print(f" Aftermarket Parts: {am_count}")
+ print(f" Cross-References: {xref_count}")
+
+ except Exception as e:
+ print(f"\nError: {e}")
+ raise
+
+ finally:
+ manager.disconnect()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/vehicle_database/scripts/populate_fase3.py b/vehicle_database/scripts/populate_fase3.py
new file mode 100644
index 0000000..ad9d48b
--- /dev/null
+++ b/vehicle_database/scripts/populate_fase3.py
@@ -0,0 +1,697 @@
+#!/usr/bin/env python3
+"""
+FASE 3: Populate exploded diagrams and hotspots
+This script creates FASE 3 tables and populates them with sample diagrams,
+vehicle-diagram relationships, and clickable hotspots linked to parts.
+"""
+
+import sqlite3
+import os
+from typing import List, Dict, Optional
+
+# Database path configuration
+DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'vehicle_database.db')
+SCHEMA_PATH = os.path.join(os.path.dirname(__file__), '..', 'sql', 'schema.sql')
+DIAGRAMS_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'dashboard', 'static', 'diagrams')
+
+
+class Fase3Manager:
+ """Manager for FASE 3 tables: diagrams, vehicle_diagrams, and diagram_hotspots"""
+
+ def __init__(self, db_path: str = DB_PATH):
+ self.db_path = db_path
+ self.connection = None
+
+ def connect(self):
+ """Connect to the SQLite database"""
+ self.connection = sqlite3.connect(self.db_path)
+ self.connection.row_factory = sqlite3.Row
+ print(f"Connected to database: {self.db_path}")
+
+ def disconnect(self):
+ """Close the database connection"""
+ if self.connection:
+ self.connection.close()
+ print("Disconnected from database")
+
+ def create_fase3_tables(self):
+ """Create FASE 3 tables from schema file"""
+ if not os.path.exists(SCHEMA_PATH):
+ raise FileNotFoundError(f"Schema file not found: {SCHEMA_PATH}")
+
+ with open(SCHEMA_PATH, 'r') as f:
+ schema = f.read()
+
+ if self.connection:
+ cursor = self.connection.cursor()
+ cursor.executescript(schema)
+ self.connection.commit()
+ print("FASE 3 tables created successfully")
+
+ def create_diagrams_directory(self):
+ """Create the diagrams directory structure"""
+ if not os.path.exists(DIAGRAMS_DIR):
+ os.makedirs(DIAGRAMS_DIR)
+ print(f"Created diagrams directory: {DIAGRAMS_DIR}")
+ else:
+ print(f"Diagrams directory already exists: {DIAGRAMS_DIR}")
+
+ def get_diagram_by_name(self, name: str) -> Optional[int]:
+ """Get diagram ID by name, returns None if not found"""
+ cursor = self.connection.cursor()
+ cursor.execute("SELECT id FROM diagrams WHERE name = ?", (name,))
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_diagram(self, name: str, name_es: str, group_id: int, image_path: str,
+ thumbnail_path: str = None, svg_content: str = None,
+ width: int = 600, height: int = 400, display_order: int = 0,
+ source: str = None) -> int:
+ """Insert a diagram if it doesn't exist, return its ID"""
+ existing_id = self.get_diagram_by_name(name)
+ if existing_id:
+ print(f" Diagram '{name}' already exists (ID: {existing_id})")
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO diagrams (name, name_es, group_id, image_path, thumbnail_path,
+ svg_content, width, height, display_order, source)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (name, name_es, group_id, image_path, thumbnail_path, svg_content,
+ width, height, display_order, source)
+ )
+ self.connection.commit()
+ diagram_id = cursor.lastrowid
+ print(f" Inserted diagram: {name} (ID: {diagram_id})")
+ return diagram_id
+
+ def get_vehicle_diagram(self, diagram_id: int, model_year_engine_id: int) -> Optional[int]:
+ """Check if a vehicle-diagram link already exists"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """SELECT id FROM vehicle_diagrams
+ WHERE diagram_id = ? AND model_year_engine_id = ?""",
+ (diagram_id, model_year_engine_id)
+ )
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_vehicle_diagram(self, diagram_id: int, model_year_engine_id: int,
+ notes: str = None) -> int:
+ """Link a diagram to a vehicle configuration"""
+ existing_id = self.get_vehicle_diagram(diagram_id, model_year_engine_id)
+ if existing_id:
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO vehicle_diagrams (diagram_id, model_year_engine_id, notes)
+ VALUES (?, ?, ?)""",
+ (diagram_id, model_year_engine_id, notes)
+ )
+ self.connection.commit()
+ return cursor.lastrowid
+
+ def get_hotspot(self, diagram_id: int, callout_number: int) -> Optional[int]:
+ """Check if a hotspot already exists for this diagram and callout"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """SELECT id FROM diagram_hotspots
+ WHERE diagram_id = ? AND callout_number = ?""",
+ (diagram_id, callout_number)
+ )
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_hotspot(self, diagram_id: int, part_id: int = None, callout_number: int = None,
+ label: str = None, shape: str = 'rect', coords: str = '',
+ color: str = '#e74c3c') -> int:
+ """Insert a hotspot for a diagram"""
+ if callout_number:
+ existing_id = self.get_hotspot(diagram_id, callout_number)
+ if existing_id:
+ return existing_id
+
+ cursor = self.connection.cursor()
+ cursor.execute(
+ """INSERT INTO diagram_hotspots (diagram_id, part_id, callout_number, label,
+ shape, coords, color)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (diagram_id, part_id, callout_number, label, shape, coords, color)
+ )
+ self.connection.commit()
+ return cursor.lastrowid
+
+ def get_part_by_name_pattern(self, pattern: str) -> Optional[Dict]:
+ """Get a part by name pattern"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ "SELECT id, oem_part_number, name FROM parts WHERE name LIKE ?",
+ (f"%{pattern}%",)
+ )
+ result = cursor.fetchone()
+ return dict(result) if result else None
+
+ def get_group_by_name(self, name: str) -> Optional[int]:
+ """Get group ID by name"""
+ cursor = self.connection.cursor()
+ cursor.execute("SELECT id FROM part_groups WHERE name LIKE ?", (f"%{name}%",))
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def get_all_model_year_engines(self) -> List[int]:
+ """Get all model_year_engine IDs"""
+ cursor = self.connection.cursor()
+ cursor.execute("SELECT id FROM model_year_engine")
+ return [row[0] for row in cursor.fetchall()]
+
+
+# SVG Templates for diagrams
+def generate_brake_assembly_svg() -> str:
+ """Generate SVG for front brake assembly diagram"""
+ return '''
+'''
+
+
+def generate_oil_filter_system_svg() -> str:
+ """Generate SVG for oil filter system diagram"""
+ return '''
+'''
+
+
+def generate_suspension_diagram_svg() -> str:
+ """Generate SVG for front suspension diagram"""
+ return '''
+'''
+
+
+def save_svg_file(filename: str, content: str):
+ """Save SVG content to file"""
+ filepath = os.path.join(DIAGRAMS_DIR, filename)
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(content)
+ print(f" Saved SVG file: {filepath}")
+ return filepath
+
+
+def populate_diagrams(manager: Fase3Manager) -> Dict[str, int]:
+ """Populate the diagrams table with sample diagrams"""
+ print("\n=== Populating Diagrams ===")
+ diagram_ids = {}
+
+ # Get group IDs for different diagram types
+ brake_rotor_group = manager.get_group_by_name("Brake Rotors")
+ oil_filter_group = manager.get_group_by_name("Oil Filters")
+ struts_group = manager.get_group_by_name("Struts")
+
+ # Use fallback group IDs if not found
+ if not brake_rotor_group:
+ brake_rotor_group = 17 # Default Brake Rotors group
+ if not oil_filter_group:
+ oil_filter_group = 70 # Default Oil Filters group
+ if not struts_group:
+ struts_group = 157 # Default Struts group
+
+ diagrams_data = [
+ {
+ 'name': 'Front Brake Assembly',
+ 'name_es': 'Ensamble de Freno Delantero',
+ 'group_id': brake_rotor_group,
+ 'image_path': 'diagrams/brake_assembly.svg',
+ 'svg_generator': generate_brake_assembly_svg,
+ 'svg_filename': 'brake_assembly.svg',
+ 'source': 'System Generated'
+ },
+ {
+ 'name': 'Oil Filter System',
+ 'name_es': 'Sistema de Filtro de Aceite',
+ 'group_id': oil_filter_group,
+ 'image_path': 'diagrams/oil_filter_system.svg',
+ 'svg_generator': generate_oil_filter_system_svg,
+ 'svg_filename': 'oil_filter_system.svg',
+ 'source': 'System Generated'
+ },
+ {
+ 'name': 'Front Suspension Assembly',
+ 'name_es': 'Ensamble de Suspension Delantera',
+ 'group_id': struts_group,
+ 'image_path': 'diagrams/suspension_assembly.svg',
+ 'svg_generator': generate_suspension_diagram_svg,
+ 'svg_filename': 'suspension_assembly.svg',
+ 'source': 'System Generated'
+ }
+ ]
+
+ for diagram_data in diagrams_data:
+ # Generate SVG content
+ svg_content = diagram_data['svg_generator']()
+
+ # Save SVG file
+ save_svg_file(diagram_data['svg_filename'], svg_content)
+
+ # Insert diagram record
+ diagram_id = manager.insert_diagram(
+ name=diagram_data['name'],
+ name_es=diagram_data['name_es'],
+ group_id=diagram_data['group_id'],
+ image_path=diagram_data['image_path'],
+ thumbnail_path=None,
+ svg_content=svg_content,
+ width=600,
+ height=400,
+ display_order=0,
+ source=diagram_data['source']
+ )
+ diagram_ids[diagram_data['name']] = diagram_id
+
+ print(f"\nTotal diagrams created: {len(diagram_ids)}")
+ return diagram_ids
+
+
+def populate_vehicle_diagrams(manager: Fase3Manager, diagram_ids: Dict[str, int]):
+ """Link diagrams to vehicle configurations"""
+ print("\n=== Linking Diagrams to Vehicles ===")
+
+ # Get all model_year_engine entries
+ mye_ids = manager.get_all_model_year_engines()
+
+ if not mye_ids:
+ print("No vehicle configurations found. Skipping vehicle-diagram links.")
+ return
+
+ total_links = 0
+
+ # Link each diagram to all vehicle configurations (diagrams are generic)
+ for diagram_name, diagram_id in diagram_ids.items():
+ for mye_id in mye_ids:
+ manager.insert_vehicle_diagram(
+ diagram_id=diagram_id,
+ model_year_engine_id=mye_id,
+ notes=f"Standard {diagram_name.lower()} diagram"
+ )
+ total_links += 1
+
+ print(f" Created {total_links} vehicle-diagram links")
+
+
+def populate_hotspots(manager: Fase3Manager, diagram_ids: Dict[str, int]):
+ """Create hotspots for each diagram linking to actual parts"""
+ print("\n=== Creating Diagram Hotspots ===")
+
+ total_hotspots = 0
+
+ # Hotspots for Brake Assembly diagram
+ if 'Front Brake Assembly' in diagram_ids:
+ diagram_id = diagram_ids['Front Brake Assembly']
+ print(f"\n Creating hotspots for Front Brake Assembly (ID: {diagram_id})")
+
+ # Find parts to link
+ brake_rotor = manager.get_part_by_name_pattern("Brake Rotor")
+ brake_pads = manager.get_part_by_name_pattern("Brake Pads")
+ brake_caliper = manager.get_part_by_name_pattern("Caliper")
+
+ # Hotspot 1: Brake Rotor - rectangle around the rotor area
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=brake_rotor['id'] if brake_rotor else None,
+ callout_number=1,
+ label="Brake Rotor / Disco de Freno",
+ shape='circle',
+ coords='250,200,120', # cx,cy,r for circle
+ color='#3498db'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 1: Brake Rotor")
+
+ # Hotspot 2: Brake Caliper - rectangle around caliper
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=brake_caliper['id'] if brake_caliper else None,
+ callout_number=2,
+ label="Brake Caliper / Calibrador",
+ shape='rect',
+ coords='320,140,80,120', # x,y,width,height for rect
+ color='#e74c3c'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 2: Brake Caliper")
+
+ # Hotspot 3: Brake Pads - rectangle around pad area
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=brake_pads['id'] if brake_pads else None,
+ callout_number=3,
+ label="Brake Pads / Balatas",
+ shape='rect',
+ coords='300,160,120,80', # x,y,width,height
+ color='#8b7355'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 3: Brake Pads")
+
+ # Hotspots for Oil Filter System diagram
+ if 'Oil Filter System' in diagram_ids:
+ diagram_id = diagram_ids['Oil Filter System']
+ print(f"\n Creating hotspots for Oil Filter System (ID: {diagram_id})")
+
+ # Find oil filter part
+ oil_filter = manager.get_part_by_name_pattern("Oil Filter")
+
+ # Hotspot 1: Oil Filter
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=oil_filter['id'] if oil_filter else None,
+ callout_number=1,
+ label="Oil Filter / Filtro de Aceite",
+ shape='rect',
+ coords='320,140,80,120', # x,y,width,height
+ color='#2980b9'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 1: Oil Filter")
+
+ # Hotspots for Suspension Assembly diagram
+ if 'Front Suspension Assembly' in diagram_ids:
+ diagram_id = diagram_ids['Front Suspension Assembly']
+ print(f"\n Creating hotspots for Front Suspension Assembly (ID: {diagram_id})")
+
+ # Find parts
+ strut = manager.get_part_by_name_pattern("Strut")
+ ball_joint = manager.get_part_by_name_pattern("Ball Joint")
+ control_arm = manager.get_part_by_name_pattern("Control Arm")
+
+ # Hotspot 1: Strut Assembly
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=strut['id'] if strut else None,
+ callout_number=1,
+ label="Strut Assembly / Amortiguador",
+ shape='rect',
+ coords='275,95,50,150', # x,y,width,height
+ color='#27ae60'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 1: Strut Assembly")
+
+ # Hotspot 2: Ball Joint
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=ball_joint['id'] if ball_joint else None,
+ callout_number=2,
+ label="Ball Joint / Rotula",
+ shape='circle',
+ coords='300,280,20', # cx,cy,r
+ color='#c0392b'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 2: Ball Joint")
+
+ # Hotspot 3: Control Arm
+ manager.insert_hotspot(
+ diagram_id=diagram_id,
+ part_id=control_arm['id'] if control_arm else None,
+ callout_number=3,
+ label="Control Arm / Brazo de Control",
+ shape='poly',
+ coords='150,320,300,280,450,320,430,340,300,310,170,340', # polygon points
+ color='#444'
+ )
+ total_hotspots += 1
+ print(f" Added hotspot 3: Control Arm")
+
+ print(f"\nTotal hotspots created: {total_hotspots}")
+
+
+def main():
+ """Main entry point for FASE 3 population"""
+ print("=" * 60)
+ print("FASE 3: Exploded Diagrams and Hotspots Population")
+ print("=" * 60)
+
+ manager = Fase3Manager()
+
+ try:
+ # Connect to database
+ manager.connect()
+
+ # Create FASE 3 tables (idempotent)
+ manager.create_fase3_tables()
+
+ # Create diagrams directory
+ manager.create_diagrams_directory()
+
+ # Populate diagrams
+ diagram_ids = populate_diagrams(manager)
+
+ # Link diagrams to vehicles
+ populate_vehicle_diagrams(manager, diagram_ids)
+
+ # Create hotspots
+ populate_hotspots(manager, diagram_ids)
+
+ print("\n" + "=" * 60)
+ print("FASE 3 population completed successfully!")
+ print("=" * 60)
+
+ # Print summary
+ cursor = manager.connection.cursor()
+ cursor.execute("SELECT COUNT(*) FROM diagrams")
+ diagram_count = cursor.fetchone()[0]
+ cursor.execute("SELECT COUNT(*) FROM vehicle_diagrams")
+ vd_count = cursor.fetchone()[0]
+ cursor.execute("SELECT COUNT(*) FROM diagram_hotspots")
+ hotspot_count = cursor.fetchone()[0]
+
+ print(f"\nSummary:")
+ print(f" Diagrams: {diagram_count}")
+ print(f" Vehicle-Diagram Links: {vd_count}")
+ print(f" Hotspots: {hotspot_count}")
+ print(f"\nSVG files saved to: {DIAGRAMS_DIR}")
+
+ except Exception as e:
+ print(f"\nError: {e}")
+ raise
+
+ finally:
+ manager.disconnect()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/vehicle_database/scripts/populate_fase4.py b/vehicle_database/scripts/populate_fase4.py
new file mode 100755
index 0000000..8514cb0
--- /dev/null
+++ b/vehicle_database/scripts/populate_fase4.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+"""
+FASE 4: Full-Text Search and VIN Decoder
+This script creates FASE 4 tables (FTS5, triggers, vin_cache) and populates
+the parts_fts table with existing parts data.
+"""
+
+import sqlite3
+import os
+import json
+from typing import Optional
+from datetime import datetime, timedelta
+
+# Database path configuration
+DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'vehicle_database.db')
+SCHEMA_PATH = os.path.join(os.path.dirname(__file__), '..', 'sql', 'schema.sql')
+
+
+class Fase4Manager:
+ """Manager for FASE 4 tables: parts_fts, vin_cache, and related triggers"""
+
+ def __init__(self, db_path: str = DB_PATH):
+ self.db_path = db_path
+ self.connection = None
+
+ def connect(self):
+ """Connect to the SQLite database"""
+ self.connection = sqlite3.connect(self.db_path)
+ self.connection.row_factory = sqlite3.Row
+ print(f"Connected to database: {self.db_path}")
+
+ def disconnect(self):
+ """Close the database connection"""
+ if self.connection:
+ self.connection.close()
+ print("Disconnected from database")
+
+ def create_fase4_tables(self):
+ """Create FASE 4 tables from schema file"""
+ if not os.path.exists(SCHEMA_PATH):
+ raise FileNotFoundError(f"Schema file not found: {SCHEMA_PATH}")
+
+ with open(SCHEMA_PATH, 'r') as f:
+ schema = f.read()
+
+ if self.connection:
+ cursor = self.connection.cursor()
+ cursor.executescript(schema)
+ self.connection.commit()
+ print("FASE 4 tables created successfully")
+
+ def check_fts_table_exists(self) -> bool:
+ """Check if the parts_fts FTS5 table exists"""
+ cursor = self.connection.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='parts_fts'"
+ )
+ return cursor.fetchone() is not None
+
+ def check_fts_populated(self) -> bool:
+ """Check if the FTS table has any data"""
+ cursor = self.connection.cursor()
+ try:
+ cursor.execute("SELECT COUNT(*) FROM parts_fts")
+ count = cursor.fetchone()[0]
+ return count > 0
+ except sqlite3.OperationalError:
+ return False
+
+ def populate_fts_from_parts(self):
+ """Populate the parts_fts table with existing parts data"""
+ if not self.check_fts_table_exists():
+ print("FTS table does not exist, creating tables first...")
+ self.create_fase4_tables()
+
+ # Check if already populated
+ if self.check_fts_populated():
+ print("parts_fts table already has data, skipping population")
+ return
+
+ cursor = self.connection.cursor()
+
+ # Get count of parts
+ cursor.execute("SELECT COUNT(*) FROM parts")
+ parts_count = cursor.fetchone()[0]
+
+ if parts_count == 0:
+ print("No parts found in parts table, nothing to populate")
+ return
+
+ # Populate FTS table from parts
+ cursor.execute("""
+ INSERT INTO parts_fts(rowid, oem_part_number, name, name_es, description, description_es)
+ SELECT id, oem_part_number, name, name_es, description, description_es FROM parts
+ """)
+ self.connection.commit()
+
+ # Rebuild FTS index for proper search functionality
+ cursor.execute("INSERT INTO parts_fts(parts_fts) VALUES('rebuild')")
+ self.connection.commit()
+
+ # Verify population
+ cursor.execute("SELECT COUNT(*) FROM parts_fts")
+ fts_count = cursor.fetchone()[0]
+ print(f"Populated parts_fts with {fts_count} entries from {parts_count} parts")
+
+ def get_vin_by_vin(self, vin: str) -> Optional[int]:
+ """Get VIN cache entry ID by VIN, returns None if not found"""
+ cursor = self.connection.cursor()
+ cursor.execute("SELECT id FROM vin_cache WHERE vin = ?", (vin,))
+ result = cursor.fetchone()
+ return result[0] if result else None
+
+ def insert_vin_cache(self, vin: str, decoded_data: dict, make: str, model: str,
+ year: int, engine_info: str = None, body_class: str = None,
+ drive_type: str = None, model_year_engine_id: int = None,
+ expires_days: int = 30) -> int:
+ """Insert a VIN cache entry if it doesn't exist, return its ID"""
+ existing_id = self.get_vin_by_vin(vin)
+ if existing_id:
+ print(f" VIN '{vin}' already exists in cache (ID: {existing_id})")
+ return existing_id
+
+ cursor = self.connection.cursor()
+ expires_at = datetime.now() + timedelta(days=expires_days)
+
+ cursor.execute(
+ """INSERT INTO vin_cache
+ (vin, decoded_data, make, model, year, engine_info, body_class,
+ drive_type, model_year_engine_id, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (vin, json.dumps(decoded_data), make, model, year, engine_info,
+ body_class, drive_type, model_year_engine_id, expires_at.isoformat())
+ )
+ self.connection.commit()
+ vin_id = cursor.lastrowid
+ print(f" Inserted VIN cache: {vin} -> {make} {model} {year} (ID: {vin_id})")
+ return vin_id
+
+ def populate_sample_vins(self):
+ """Populate sample VIN cache entries for testing"""
+ print("\nPopulating sample VIN cache entries...")
+
+ sample_vins = [
+ {
+ 'vin': '4T1BF1FK5CU123456',
+ 'decoded_data': {
+ 'Make': 'TOYOTA',
+ 'Model': 'Camry',
+ 'ModelYear': '2023',
+ 'EngineModel': '2.5L I4',
+ 'BodyClass': 'Sedan/Saloon',
+ 'DriveType': 'FWD',
+ 'PlantCountry': 'UNITED STATES (USA)',
+ 'VehicleType': 'PASSENGER CAR'
+ },
+ 'make': 'Toyota',
+ 'model': 'Camry',
+ 'year': 2023,
+ 'engine_info': '2.5L I4 DOHC 16V',
+ 'body_class': 'Sedan',
+ 'drive_type': 'FWD'
+ },
+ {
+ 'vin': '1HGBH41JXMN109186',
+ 'decoded_data': {
+ 'Make': 'HONDA',
+ 'Model': 'Civic',
+ 'ModelYear': '2023',
+ 'EngineModel': '2.0L I4',
+ 'BodyClass': 'Sedan/Saloon',
+ 'DriveType': 'FWD',
+ 'PlantCountry': 'UNITED STATES (USA)',
+ 'VehicleType': 'PASSENGER CAR'
+ },
+ 'make': 'Honda',
+ 'model': 'Civic',
+ 'year': 2023,
+ 'engine_info': '2.0L I4 DOHC 16V',
+ 'body_class': 'Sedan',
+ 'drive_type': 'FWD'
+ },
+ {
+ 'vin': '1FA6P8CF5L5123456',
+ 'decoded_data': {
+ 'Make': 'FORD',
+ 'Model': 'Mustang',
+ 'ModelYear': '2020',
+ 'EngineModel': '5.0L V8',
+ 'BodyClass': 'Coupe',
+ 'DriveType': 'RWD',
+ 'PlantCountry': 'UNITED STATES (USA)',
+ 'VehicleType': 'PASSENGER CAR'
+ },
+ 'make': 'Ford',
+ 'model': 'Mustang',
+ 'year': 2020,
+ 'engine_info': '5.0L V8 Coyote',
+ 'body_class': 'Coupe',
+ 'drive_type': 'RWD'
+ }
+ ]
+
+ for vin_data in sample_vins:
+ self.insert_vin_cache(
+ vin=vin_data['vin'],
+ decoded_data=vin_data['decoded_data'],
+ make=vin_data['make'],
+ model=vin_data['model'],
+ year=vin_data['year'],
+ engine_info=vin_data['engine_info'],
+ body_class=vin_data['body_class'],
+ drive_type=vin_data['drive_type']
+ )
+
+ def verify_installation(self):
+ """Verify FASE 4 installation"""
+ cursor = self.connection.cursor()
+
+ print("\n" + "=" * 50)
+ print("FASE 4 Installation Verification")
+ print("=" * 50)
+
+ # Check FTS table
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='parts_fts'"
+ )
+ fts_exists = cursor.fetchone() is not None
+ print(f"parts_fts table: {'OK' if fts_exists else 'MISSING'}")
+
+ if fts_exists:
+ cursor.execute("SELECT COUNT(*) FROM parts_fts")
+ fts_count = cursor.fetchone()[0]
+ print(f" - FTS entries: {fts_count}")
+
+ # Check triggers
+ triggers = ['parts_fts_insert', 'parts_fts_delete', 'parts_fts_update']
+ for trigger_name in triggers:
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='trigger' AND name=?",
+ (trigger_name,)
+ )
+ trigger_exists = cursor.fetchone() is not None
+ print(f"Trigger {trigger_name}: {'OK' if trigger_exists else 'MISSING'}")
+
+ # Check vin_cache table
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='vin_cache'"
+ )
+ vin_exists = cursor.fetchone() is not None
+ print(f"vin_cache table: {'OK' if vin_exists else 'MISSING'}")
+
+ if vin_exists:
+ cursor.execute("SELECT COUNT(*) FROM vin_cache")
+ vin_count = cursor.fetchone()[0]
+ print(f" - VIN cache entries: {vin_count}")
+
+ cursor.execute("SELECT vin, make, model, year FROM vin_cache")
+ for row in cursor.fetchall():
+ print(f" - {row['vin']}: {row['make']} {row['model']} {row['year']}")
+
+ # Check indexes
+ indexes = ['idx_vin_cache_vin', 'idx_vin_cache_make_model']
+ for index_name in indexes:
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='index' AND name=?",
+ (index_name,)
+ )
+ index_exists = cursor.fetchone() is not None
+ print(f"Index {index_name}: {'OK' if index_exists else 'MISSING'}")
+
+ print("=" * 50)
+
+
+def main():
+ """Main function to populate FASE 4 tables"""
+ print("=" * 60)
+ print("FASE 4: Full-Text Search and VIN Decoder Population")
+ print("=" * 60)
+
+ manager = Fase4Manager()
+
+ try:
+ manager.connect()
+
+ # Step 1: Create FASE 4 tables (FTS5, triggers, vin_cache)
+ print("\n[1/4] Creating FASE 4 tables...")
+ manager.create_fase4_tables()
+
+ # Step 2: Populate FTS table with existing parts
+ print("\n[2/4] Populating Full-Text Search index...")
+ manager.populate_fts_from_parts()
+
+ # Step 3: Add sample VIN cache entries
+ print("\n[3/4] Adding sample VIN cache entries...")
+ manager.populate_sample_vins()
+
+ # Step 4: Verify installation
+ print("\n[4/4] Verifying FASE 4 installation...")
+ manager.verify_installation()
+
+ print("\nFASE 4 population completed successfully!")
+
+ except Exception as e:
+ print(f"\nError during FASE 4 population: {e}")
+ raise
+ finally:
+ manager.disconnect()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/vehicle_database/sql/schema.sql b/vehicle_database/sql/schema.sql
index ea3d866..fa5abba 100644
--- a/vehicle_database/sql/schema.sql
+++ b/vehicle_database/sql/schema.sql
@@ -63,4 +63,235 @@ CREATE TABLE IF NOT EXISTS model_year_engine (
CREATE INDEX IF NOT EXISTS idx_models_brand ON models(brand_id);
CREATE INDEX IF NOT EXISTS idx_model_year_engine_model ON model_year_engine(model_id);
CREATE INDEX IF NOT EXISTS idx_model_year_engine_year ON model_year_engine(year_id);
-CREATE INDEX IF NOT EXISTS idx_model_year_engine_engine ON model_year_engine(engine_id);
\ No newline at end of file
+CREATE INDEX IF NOT EXISTS idx_model_year_engine_engine ON model_year_engine(engine_id);
+
+-- =====================================================
+-- PARTS CATALOG SCHEMA (FASE 1)
+-- =====================================================
+
+-- Categorías de partes (Engine, Brakes, Suspension, etc.)
+CREATE TABLE IF NOT EXISTS part_categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ parent_id INTEGER,
+ slug TEXT UNIQUE,
+ icon_name TEXT,
+ display_order INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (parent_id) REFERENCES part_categories(id)
+);
+
+-- Grupos dentro de categorías (subcategorías más específicas)
+CREATE TABLE IF NOT EXISTS part_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ slug TEXT,
+ display_order INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (category_id) REFERENCES part_categories(id)
+);
+
+-- Catálogo maestro de partes
+CREATE TABLE IF NOT EXISTS parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ oem_part_number TEXT NOT NULL,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ group_id INTEGER,
+ description TEXT,
+ description_es TEXT,
+ weight_kg REAL,
+ material TEXT,
+ is_discontinued BOOLEAN DEFAULT 0,
+ superseded_by_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (group_id) REFERENCES part_groups(id),
+ FOREIGN KEY (superseded_by_id) REFERENCES parts(id)
+);
+
+-- Fitment: qué partes van en qué vehículos
+CREATE TABLE IF NOT EXISTS vehicle_parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ model_year_engine_id INTEGER NOT NULL,
+ part_id INTEGER NOT NULL,
+ quantity_required INTEGER DEFAULT 1,
+ position TEXT, -- e.g., 'front', 'rear', 'left', 'right'
+ fitment_notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id),
+ FOREIGN KEY (part_id) REFERENCES parts(id),
+ UNIQUE(model_year_engine_id, part_id, position)
+);
+
+-- Índices para el catálogo de partes
+CREATE INDEX IF NOT EXISTS idx_part_categories_parent ON part_categories(parent_id);
+CREATE INDEX IF NOT EXISTS idx_part_categories_slug ON part_categories(slug);
+CREATE INDEX IF NOT EXISTS idx_part_groups_category ON part_groups(category_id);
+CREATE INDEX IF NOT EXISTS idx_parts_oem ON parts(oem_part_number);
+CREATE INDEX IF NOT EXISTS idx_parts_group ON parts(group_id);
+CREATE INDEX IF NOT EXISTS idx_vehicle_parts_mye ON vehicle_parts(model_year_engine_id);
+CREATE INDEX IF NOT EXISTS idx_vehicle_parts_part ON vehicle_parts(part_id);
+
+-- =====================================================
+-- FASE 2: CROSS-REFERENCES Y AFTERMARKET
+-- =====================================================
+
+-- Fabricantes (OEM y aftermarket)
+CREATE TABLE IF NOT EXISTS manufacturers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ type TEXT CHECK(type IN ('oem', 'aftermarket', 'remanufactured')),
+ quality_tier TEXT CHECK(quality_tier IN ('economy', 'standard', 'premium', 'oem')),
+ country TEXT,
+ logo_url TEXT,
+ website TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Partes aftermarket vinculadas a OEM
+CREATE TABLE IF NOT EXISTS aftermarket_parts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ oem_part_id INTEGER NOT NULL,
+ manufacturer_id INTEGER NOT NULL,
+ part_number TEXT NOT NULL,
+ name TEXT,
+ name_es TEXT,
+ quality_tier TEXT CHECK(quality_tier IN ('economy', 'standard', 'premium')),
+ price_usd REAL,
+ warranty_months INTEGER,
+ in_stock BOOLEAN DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (oem_part_id) REFERENCES parts(id),
+ FOREIGN KEY (manufacturer_id) REFERENCES manufacturers(id)
+);
+
+-- Cross-references (números alternativos)
+CREATE TABLE IF NOT EXISTS part_cross_references (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ part_id INTEGER NOT NULL,
+ cross_reference_number TEXT NOT NULL,
+ reference_type TEXT CHECK(reference_type IN ('oem_alternate', 'supersession', 'interchange', 'competitor')),
+ source TEXT,
+ notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (part_id) REFERENCES parts(id)
+);
+
+-- Índices para FASE 2
+CREATE INDEX IF NOT EXISTS idx_aftermarket_oem ON aftermarket_parts(oem_part_id);
+CREATE INDEX IF NOT EXISTS idx_aftermarket_manufacturer ON aftermarket_parts(manufacturer_id);
+CREATE INDEX IF NOT EXISTS idx_aftermarket_part_number ON aftermarket_parts(part_number);
+CREATE INDEX IF NOT EXISTS idx_cross_ref_part ON part_cross_references(part_id);
+CREATE INDEX IF NOT EXISTS idx_cross_ref_number ON part_cross_references(cross_reference_number);
+
+-- =====================================================
+-- FASE 3: DIAGRAMAS EXPLOSIONADOS
+-- =====================================================
+
+-- Diagramas de partes
+CREATE TABLE IF NOT EXISTS diagrams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ name_es TEXT,
+ group_id INTEGER NOT NULL,
+ image_path TEXT NOT NULL,
+ thumbnail_path TEXT,
+ svg_content TEXT,
+ width INTEGER,
+ height INTEGER,
+ display_order INTEGER DEFAULT 0,
+ source TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (group_id) REFERENCES part_groups(id)
+);
+
+-- Diagramas específicos por vehículo (qué diagramas aplican a qué vehículos)
+CREATE TABLE IF NOT EXISTS vehicle_diagrams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ diagram_id INTEGER NOT NULL,
+ model_year_engine_id INTEGER NOT NULL,
+ notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (diagram_id) REFERENCES diagrams(id),
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id),
+ UNIQUE(diagram_id, model_year_engine_id)
+);
+
+-- Hotspots clickeables en diagramas
+CREATE TABLE IF NOT EXISTS diagram_hotspots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ diagram_id INTEGER NOT NULL,
+ part_id INTEGER,
+ callout_number INTEGER,
+ label TEXT,
+ shape TEXT DEFAULT 'rect' CHECK(shape IN ('rect', 'circle', 'poly')),
+ coords TEXT NOT NULL,
+ color TEXT DEFAULT '#e74c3c',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (diagram_id) REFERENCES diagrams(id),
+ FOREIGN KEY (part_id) REFERENCES parts(id)
+);
+
+-- Índices para FASE 3
+CREATE INDEX IF NOT EXISTS idx_diagrams_group ON diagrams(group_id);
+CREATE INDEX IF NOT EXISTS idx_vehicle_diagrams_diagram ON vehicle_diagrams(diagram_id);
+CREATE INDEX IF NOT EXISTS idx_vehicle_diagrams_mye ON vehicle_diagrams(model_year_engine_id);
+CREATE INDEX IF NOT EXISTS idx_hotspots_diagram ON diagram_hotspots(diagram_id);
+CREATE INDEX IF NOT EXISTS idx_hotspots_part ON diagram_hotspots(part_id);
+
+-- =====================================================
+-- FASE 4: BÚSQUEDA FULL-TEXT Y VIN DECODER
+-- =====================================================
+
+-- Full-Text Search virtual table (SQLite FTS5)
+CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
+ oem_part_number,
+ name,
+ name_es,
+ description,
+ description_es,
+ content='parts',
+ content_rowid='id'
+);
+
+-- Triggers para sincronización automática con parts table
+CREATE TRIGGER IF NOT EXISTS parts_fts_insert AFTER INSERT ON parts BEGIN
+ INSERT INTO parts_fts(rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES (new.id, new.oem_part_number, new.name, new.name_es, new.description, new.description_es);
+END;
+
+CREATE TRIGGER IF NOT EXISTS parts_fts_delete AFTER DELETE ON parts BEGIN
+ INSERT INTO parts_fts(parts_fts, rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES ('delete', old.id, old.oem_part_number, old.name, old.name_es, old.description, old.description_es);
+END;
+
+CREATE TRIGGER IF NOT EXISTS parts_fts_update AFTER UPDATE ON parts BEGIN
+ INSERT INTO parts_fts(parts_fts, rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES ('delete', old.id, old.oem_part_number, old.name, old.name_es, old.description, old.description_es);
+ INSERT INTO parts_fts(rowid, oem_part_number, name, name_es, description, description_es)
+ VALUES (new.id, new.oem_part_number, new.name, new.name_es, new.description, new.description_es);
+END;
+
+-- Cache de VINs decodificados
+CREATE TABLE IF NOT EXISTS vin_cache (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ vin TEXT NOT NULL UNIQUE,
+ decoded_data TEXT NOT NULL,
+ make TEXT,
+ model TEXT,
+ year INTEGER,
+ engine_info TEXT,
+ body_class TEXT,
+ drive_type TEXT,
+ model_year_engine_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME,
+ FOREIGN KEY (model_year_engine_id) REFERENCES model_year_engine(id)
+);
+
+-- Índices para FASE 4
+CREATE INDEX IF NOT EXISTS idx_vin_cache_vin ON vin_cache(vin);
+CREATE INDEX IF NOT EXISTS idx_vin_cache_make_model ON vin_cache(make, model, year);
\ No newline at end of file
diff --git a/vehicle_database/vehicle_database.db b/vehicle_database/vehicle_database.db
index 1b7afaa..6924ad7 100644
Binary files a/vehicle_database/vehicle_database.db and b/vehicle_database/vehicle_database.db differ