Compare commits
26 Commits
4af3a09b03
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6ea31100 | |||
| d269bc1ffb | |||
| 5e6bf788db | |||
| fe6542c45c | |||
| b1adf536f6 | |||
| eff04a5e60 | |||
| 4b01c57c88 | |||
| e5d074687a | |||
| 6c6a9eecd6 | |||
| 340d2fcef8 | |||
| 565f11aca6 | |||
| 744df6b3b8 | |||
| 09d3304b21 | |||
| c5e5f6ef7e | |||
| 6ef39d212c | |||
| f89d591fa9 | |||
| 2c6b6e0160 | |||
| 7b2a904498 | |||
| 7ecf1295a5 | |||
| 3ea2de61e2 | |||
| 7866194e65 | |||
| a3aa2a7608 | |||
| 8c7caf3969 | |||
| f5e0525dfc | |||
| 274cf30e79 | |||
| 5444cf660a |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -51,3 +51,13 @@ Thumbs.db
|
|||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
|
||||||
|
# Data files (TecDoc downloads, too large for git)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# SQLite WAL files
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Diagram images (served from static, too large for git)
|
||||||
|
dashboard/static/diagrams/
|
||||||
|
|||||||
443
README.md
443
README.md
@@ -1,342 +1,205 @@
|
|||||||
# Autoparts DB
|
# Nexus Autoparts
|
||||||
|
|
||||||
Sistema completo de gestión de base de datos de vehículos y autopartes con dashboard web, herramientas de web scraping y múltiples interfaces de consulta.
|
**Sistema de catalogo de autopartes con navegacion jerarquica, similar a 7zap.com/RockAuto.**
|
||||||
|
|
||||||
## Descripción
|
Plataforma SaaS que conecta talleres con bodegas/distribuidores. Permite buscar partes OEM y aftermarket por vehiculo (marca, modelo, ano, motor), gestionar inventario de bodegas, y consultar disponibilidad y precios en tiempo real.
|
||||||
|
|
||||||
**Autoparts DB** es una solución integral para la gestión de información de vehículos que incluye:
|
## Tech Stack
|
||||||
|
|
||||||
- Base de datos SQLite normalizada con información de marcas, modelos, motores y años
|
| Componente | Tecnologia |
|
||||||
- Dashboard web moderno y responsivo para consultar y explorar datos
|
|------------|-----------|
|
||||||
- Herramientas de web scraping para recopilar datos de RockAuto.com
|
| Backend | Python 3, Flask |
|
||||||
- Interfaces de línea de comandos (CLI) y programática
|
| Base de datos | PostgreSQL |
|
||||||
- Scripts de utilidad para gestión y mantenimiento de datos
|
| ORM / SQL | SQLAlchemy (`text()` raw SQL) |
|
||||||
|
| Autenticacion | JWT (PyJWT) + bcrypt |
|
||||||
|
| Data import | TecDoc via Apify, NHTSA VIN API |
|
||||||
|
| Frontend | HTML/CSS/JS vanilla (sin framework) |
|
||||||
|
| Dependencias extra | openpyxl (Excel), csv (CSV import) |
|
||||||
|
|
||||||
## Estadísticas de la Base de Datos
|
## Estadisticas de la Base de Datos
|
||||||
|
|
||||||
| Elemento | Cantidad |
|
- **1.4M+** partes OEM
|
||||||
|----------|----------|
|
- **300K+** partes aftermarket
|
||||||
| Marcas | 12 |
|
- **13M+** cross-references (numeros alternos, supersesiones, intercambios)
|
||||||
| Modelos | 10,923 |
|
- **12B+** vehicle-part links (fitment)
|
||||||
| Motores | 10,919 |
|
- **100+** marcas, miles de modelos, anos 1956-2026
|
||||||
| Combinaciones modelo-año-motor | 12,075 |
|
|
||||||
|
|
||||||
## Tecnologías Utilizadas
|
## Features
|
||||||
|
|
||||||
### Backend
|
- **Catalogo de autopartes** con navegacion jerarquica: Marca > Modelo > Ano > Motor > Categoria > Grupo > Parte
|
||||||
- **Python 3** - Lenguaje principal
|
- **TecDoc integration** (via Apify) para importar datos OEM y aftermarket de Europa/Mexico
|
||||||
- **SQLite 3** - Base de datos
|
- **SaaS multi-tenant** con roles: `ADMIN`, `OWNER`, `TALLER`, `BODEGA`
|
||||||
- **Flask 2.3.3** - Framework web
|
- **JWT authentication** con access tokens (15 min) y refresh tokens (30 dias)
|
||||||
- **BeautifulSoup4** - Web scraping
|
- **Gestion de inventario** para bodegas con mapeo flexible de columnas CSV/Excel
|
||||||
- **requests** - HTTP client
|
- **Disponibilidad de partes** en multiples bodegas con precios comparativos
|
||||||
- **lxml** - Parser XML/HTML
|
- **Alternativas aftermarket** con cross-references por cada parte OEM
|
||||||
|
- **Panel de administracion** con gestion de usuarios, import/export CSV, CRUD de categorias/grupos/partes/fabricantes/fitment
|
||||||
|
- **Busqueda full-text** en el catalogo de partes (PostgreSQL `tsvector`)
|
||||||
|
- **Busqueda combinada** vehiculo + parte (e.g., "Toyota Corolla 2020 frenos")
|
||||||
|
- **VIN decoder** via NHTSA API con cache en base de datos
|
||||||
|
- **Diagramas explosionados** con hotspots clickeables
|
||||||
|
- **Vehicle-to-part linking** (12B+ vehicle_parts links)
|
||||||
|
|
||||||
### Frontend
|
## Quick Start
|
||||||
- **HTML5** - Estructura
|
|
||||||
- **Bootstrap 5.3.0** - Framework CSS
|
|
||||||
- **JavaScript (ES6+)** - Lógica cliente
|
|
||||||
- **Font Awesome 6.0.0** - Iconos
|
|
||||||
|
|
||||||
## Estructura del Proyecto
|
### Requisitos previos
|
||||||
|
|
||||||
```
|
- Python 3.8+
|
||||||
Autopartes/
|
- PostgreSQL con la base `nexus_autoparts`
|
||||||
├── vehicle_database/ # Sistema principal de base de datos
|
|
||||||
│ ├── sql/
|
|
||||||
│ │ └── schema.sql # Esquema de la base de datos
|
|
||||||
│ ├── scripts/
|
|
||||||
│ │ ├── database_manager.py # Gestión de la BD
|
|
||||||
│ │ ├── query_interface.py # Interfaz CLI
|
|
||||||
│ │ └── csv_importer.py # Importador CSV
|
|
||||||
│ ├── data/
|
|
||||||
│ │ ├── brands.csv # Datos de marcas
|
|
||||||
│ │ ├── engines.csv # Datos de motores
|
|
||||||
│ │ └── models.csv # Datos de modelos
|
|
||||||
│ ├── vehicle_database.db # Base de datos SQLite
|
|
||||||
│ └── setup.sh # Script de inicialización
|
|
||||||
│
|
|
||||||
├── dashboard/ # Interfaz web
|
|
||||||
│ ├── server.py # Backend Flask
|
|
||||||
│ ├── index.html # Frontend HTML
|
|
||||||
│ ├── dashboard.js # Lógica JavaScript
|
|
||||||
│ └── start_dashboard.sh # Script de inicio
|
|
||||||
│
|
|
||||||
├── console/ # Consola Pick/VT220
|
|
||||||
│ ├── main.py # Punto de entrada
|
|
||||||
│ ├── db.py # Capa de datos abstracta
|
|
||||||
│ ├── core/ # Framework (app, screens, nav, keys)
|
|
||||||
│ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda)
|
|
||||||
│ ├── renderers/ # VT220 (curses) y moderno (Rich)
|
|
||||||
│ ├── utils/ # Formato y API VIN
|
|
||||||
│ └── tests/ # 116 tests
|
|
||||||
│
|
|
||||||
├── vehicle_scraper/ # Herramientas de web scraping
|
|
||||||
│ ├── rockauto_scraper.py # Scraper RockAuto
|
|
||||||
│ ├── rockauto_scraper_v2.py # Scraper mejorado
|
|
||||||
│ ├── scrape_toyota.py # Scraper Toyota
|
|
||||||
│ ├── scrape_nissan_ford_chevrolet.py
|
|
||||||
│ └── manual_input.py # Ingreso manual
|
|
||||||
│
|
|
||||||
├── add_*.py # Scripts para agregar datos
|
|
||||||
├── remove_*.py # Scripts de limpieza
|
|
||||||
└── QUICK_START.sh # Guía rápida de inicio
|
|
||||||
```
|
|
||||||
|
|
||||||
## Consola Pick/VT220
|
### Instalacion
|
||||||
|
|
||||||
Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Incluye dos modos de visualización:
|
|
||||||
|
|
||||||
- **VT220** (curses): Terminal clásica verde sobre negro con caracteres de caja
|
|
||||||
- **Modern** (Rich): Interfaz moderna con colores y estilos TUI
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Modo clásico VT220
|
cd /home/Autopartes
|
||||||
python -m console
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Modo moderno
|
|
||||||
python -m console --mode modern
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa.
|
### Ejecutar el servidor
|
||||||
|
|
||||||
116 tests automatizados. Ver [`console/README.md`](console/README.md) para documentación completa.
|
|
||||||
|
|
||||||
## Instalación
|
|
||||||
|
|
||||||
### Requisitos Previos
|
|
||||||
|
|
||||||
- Python 3.8 o superior
|
|
||||||
- pip (gestor de paquetes de Python)
|
|
||||||
|
|
||||||
### Pasos de Instalación
|
|
||||||
|
|
||||||
1. **Clonar el repositorio**
|
|
||||||
```bash
|
|
||||||
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git
|
|
||||||
cd Autoparts-DB
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Instalar dependencias**
|
|
||||||
```bash
|
|
||||||
pip install flask requests beautifulsoup4 lxml
|
|
||||||
pip install rich # Opcional: para modo moderno de consola
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Inicializar la base de datos (opcional - ya incluye datos)**
|
|
||||||
```bash
|
|
||||||
cd vehicle_database
|
|
||||||
./setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Uso
|
|
||||||
|
|
||||||
### Iniciar el Dashboard Web
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd dashboard
|
cd /home/Autopartes/dashboard
|
||||||
python3 server.py
|
python3 server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
El dashboard estará disponible en: `http://localhost:5000`
|
El servidor arranca en `http://localhost:5000`.
|
||||||
|
|
||||||
### Iniciar la Consola Pick/VT220
|
### Importar datos de TecDoc
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m console # Modo VT220 (clásico)
|
# Fase 1: descargar datos de TecDoc a JSON
|
||||||
python -m console --mode modern # Modo moderno (Rich)
|
python3 scripts/import_tecdoc.py download
|
||||||
|
|
||||||
|
# Fase 2: importar JSON a PostgreSQL
|
||||||
|
python3 scripts/import_tecdoc.py import
|
||||||
|
|
||||||
|
# Ver progreso
|
||||||
|
python3 scripts/import_tecdoc.py status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usar la Interfaz CLI Legacy
|
### Importar partes y linkar vehiculos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd vehicle_database/scripts
|
# Importar partes TecDoc (OEM + aftermarket)
|
||||||
python3 query_interface.py
|
python3 scripts/import_tecdoc_parts.py
|
||||||
|
|
||||||
|
# Importar datos en vivo desde TecDoc API
|
||||||
|
python3 scripts/import_live.py
|
||||||
|
|
||||||
|
# Crear links vehiculo-parte (fitment masivo)
|
||||||
|
python3 scripts/link_vehicle_parts.py
|
||||||
|
|
||||||
|
# Migrar datos aftermarket
|
||||||
|
python3 scripts/migrate_aftermarket.py
|
||||||
|
|
||||||
|
# Aplicar schema SaaS (roles, users, inventory tables)
|
||||||
|
python3 scripts/migrate_saas_schema.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ejecutar Web Scraping
|
## Paginas del Dashboard
|
||||||
|
|
||||||
```bash
|
| Ruta | Archivo | Descripcion |
|
||||||
cd vehicle_scraper
|
|------|---------|-------------|
|
||||||
python3 rockauto_scraper_v2.py
|
| `/login.html` | `login.html` | Login con JWT |
|
||||||
```
|
| `/demo.html` | `demo.html` | Catalogo publico / demo |
|
||||||
|
| `/admin` | `admin.html` | Panel de administracion (ADMIN/OWNER) |
|
||||||
|
| `/bodega.html` | `bodega.html` | Gestion de inventario para bodegas |
|
||||||
|
| `/tienda.html` | `tienda.html` | Vista de tienda/catalogo para talleres |
|
||||||
|
| `/pos.html` | `pos.html` | Punto de venta |
|
||||||
|
| `/captura.html` | `captura.html` | Captura de partes |
|
||||||
|
| `/cuentas.html` | `cuentas.html` | Gestion de cuentas |
|
||||||
|
|
||||||
### Agregar Datos Manualmente
|
## API Overview
|
||||||
|
|
||||||
```bash
|
Documentacion completa en [`docs/API.md`](docs/API.md).
|
||||||
cd vehicle_scraper
|
|
||||||
python3 manual_input.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## API REST
|
### Auth (`/api/auth/`)
|
||||||
|
- `POST /api/auth/register` - Registrar usuario (TALLER/BODEGA)
|
||||||
|
- `POST /api/auth/login` - Login, retorna access + refresh tokens
|
||||||
|
- `POST /api/auth/refresh` - Renovar access token
|
||||||
|
- `GET /api/auth/me` - Info del usuario autenticado
|
||||||
|
|
||||||
El dashboard expone los siguientes endpoints:
|
### Catalogo (`/api/`)
|
||||||
|
- `GET /api/brands` - Listar marcas
|
||||||
|
- `GET /api/models?brand=X` - Modelos por marca
|
||||||
|
- `GET /api/years?brand=X&model=Y` - Anos disponibles
|
||||||
|
- `GET /api/engines?brand=X&model=Y&year=Z` - Motores disponibles
|
||||||
|
- `GET /api/categories` - Categorias de partes (arbol jerarquico)
|
||||||
|
- `GET /api/parts?group_id=X` - Partes por grupo
|
||||||
|
- `GET /api/parts/{id}/alternatives` - Alternativas aftermarket
|
||||||
|
- `GET /api/parts/{id}/cross-references` - Cross-references
|
||||||
|
- `GET /api/search?q=...` - Busqueda combinada (vehiculos + partes + aftermarket)
|
||||||
|
|
||||||
| Endpoint | Método | Descripción |
|
### Inventario (`/api/inventory/`)
|
||||||
|----------|--------|-------------|
|
- `GET/PUT /api/inventory/mapping` - Mapeo de columnas CSV
|
||||||
| `/api/brands` | GET | Obtiene todas las marcas |
|
- `POST /api/inventory/upload` - Subir CSV/Excel de inventario
|
||||||
| `/api/models?brand=X` | GET | Obtiene modelos por marca |
|
- `GET /api/inventory/items` - Listar inventario propio
|
||||||
| `/api/years` | GET | Obtiene años disponibles |
|
- `DELETE /api/inventory/items` - Limpiar inventario
|
||||||
| `/api/engines` | GET | Obtiene motores disponibles |
|
|
||||||
| `/api/vehicles` | GET | Búsqueda con filtros |
|
|
||||||
|
|
||||||
### Ejemplo de Uso
|
### Disponibilidad y Aftermarket
|
||||||
|
- `GET /api/parts/{id}/availability` - Bodegas con stock (auth: TALLER/ADMIN/OWNER)
|
||||||
|
- `GET /api/parts/{id}/aftermarket` - Alternativas aftermarket + cross-refs (publico)
|
||||||
|
|
||||||
```bash
|
### Admin (`/api/admin/`)
|
||||||
# Obtener todas las marcas
|
- `GET /api/admin/users` - Listar usuarios (auth: ADMIN/OWNER)
|
||||||
curl http://localhost:5000/api/brands
|
- `PUT /api/admin/users/{id}/activate` - Activar/desactivar usuario
|
||||||
|
- `GET /api/admin/stats` - Estadisticas del catalogo
|
||||||
|
- CRUD completo: categories, groups, parts, manufacturers, aftermarket, crossref, fitment
|
||||||
|
- Import/Export CSV: `POST /api/admin/import/{type}`, `GET /api/admin/export/{type}`
|
||||||
|
|
||||||
# Buscar vehículos por marca y año
|
### VIN Decoder
|
||||||
curl "http://localhost:5000/api/vehicles?brand=Toyota&year=2020"
|
- `GET /api/vin/decode/{vin}` - Decodificar VIN via NHTSA API
|
||||||
```
|
- `GET /api/vin/{vin}/parts` - Partes para un VIN decodificado
|
||||||
|
- `GET /api/vin/{vin}/match?mye_id=X` - Vincular VIN manualmente a vehiculo
|
||||||
|
|
||||||
## Esquema de Base de Datos
|
## Scripts
|
||||||
|
|
||||||
### Tablas
|
| Script | Funcion |
|
||||||
|
|--------|---------|
|
||||||
|
| `import_tecdoc.py` | Descarga datos de TecDoc API (vehiculos, modelos, marcas) a JSON |
|
||||||
|
| `import_tecdoc_parts.py` | Importa partes OEM y aftermarket desde TecDoc |
|
||||||
|
| `import_live.py` | Importacion en vivo desde TecDoc API |
|
||||||
|
| `link_vehicle_parts.py` | Genera links vehiculo-parte (fitment masivo) |
|
||||||
|
| `migrate_aftermarket.py` | Migra datos aftermarket a la estructura normalizada |
|
||||||
|
| `migrate_saas_schema.py` | Crea tablas SaaS: sessions, warehouse_inventory, roles, etc. |
|
||||||
|
| `import_phase1.py` | Importacion inicial fase 1 |
|
||||||
|
| `run_all_brands.sh` | Script auxiliar para importar todas las marcas |
|
||||||
|
|
||||||
#### brands
|
## Configuracion
|
||||||
| Campo | Tipo | Descripción |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| id | INTEGER | Clave primaria |
|
|
||||||
| name | TEXT | Nombre de la marca |
|
|
||||||
| country | TEXT | País de origen |
|
|
||||||
| founded_year | INTEGER | Año de fundación |
|
|
||||||
|
|
||||||
#### models
|
Archivo principal: [`config.py`](config.py)
|
||||||
| Campo | Tipo | Descripción |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| id | INTEGER | Clave primaria |
|
|
||||||
| brand_id | INTEGER | FK a brands |
|
|
||||||
| name | TEXT | Nombre del modelo |
|
|
||||||
| body_type | TEXT | Tipo de carrocería |
|
|
||||||
| generation | TEXT | Generación |
|
|
||||||
| production_start_year | INTEGER | Año inicio producción |
|
|
||||||
| production_end_year | INTEGER | Año fin producción |
|
|
||||||
|
|
||||||
#### engines
|
| Variable | Default | Descripcion |
|
||||||
| Campo | Tipo | Descripción |
|
|----------|---------|-------------|
|
||||||
|-------|------|-------------|
|
| `DATABASE_URL` | `postgresql://nexus:...@localhost/nexus_autoparts` | PostgreSQL connection string |
|
||||||
| id | INTEGER | Clave primaria |
|
| `JWT_SECRET` | `nexus-saas-secret-change-in-prod-2026` | Secreto para firmar tokens JWT |
|
||||||
| name | TEXT | Nombre del motor |
|
| `JWT_ACCESS_EXPIRES` | `900` (15 min) | Duracion del access token en segundos |
|
||||||
| displacement_cc | INTEGER | Cilindrada en cc |
|
| `JWT_REFRESH_EXPIRES` | `2592000` (30 dias) | Duracion del refresh token en segundos |
|
||||||
| cylinders | INTEGER | Número de cilindros |
|
|
||||||
| fuel_type | TEXT | Tipo de combustible |
|
|
||||||
| power_hp | INTEGER | Potencia en HP |
|
|
||||||
| torque_nm | INTEGER | Torque en Nm |
|
|
||||||
| engine_code | TEXT | Código del motor |
|
|
||||||
|
|
||||||
#### years
|
## Arquitectura
|
||||||
| Campo | Tipo | Descripción |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| id | INTEGER | Clave primaria |
|
|
||||||
| year | INTEGER | Año |
|
|
||||||
|
|
||||||
#### model_year_engine
|
Documentacion detallada en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||||
| Campo | Tipo | Descripción |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| id | INTEGER | Clave primaria |
|
|
||||||
| model_id | INTEGER | FK a models |
|
|
||||||
| year_id | INTEGER | FK a years |
|
|
||||||
| engine_id | INTEGER | FK a engines |
|
|
||||||
| trim_level | TEXT | Nivel de equipamiento |
|
|
||||||
| drivetrain | TEXT | Tracción |
|
|
||||||
| transmission | TEXT | Transmisión |
|
|
||||||
|
|
||||||
### Diagrama de Relaciones
|
|
||||||
|
|
||||||
```
|
```
|
||||||
brands ──┐
|
+------------------+
|
||||||
│
|
| TecDoc (Apify) |
|
||||||
├──< models ──┐
|
+--------+---------+
|
||||||
│ │
|
|
|
||||||
years ───┼─────────────┼──< model_year_engine
|
download/import
|
||||||
│ │
|
|
|
||||||
engines ─┴─────────────┘
|
v
|
||||||
|
+----------+ +--------+---------+ +----------------+
|
||||||
|
| Frontend |<--->| Flask Server |<--->| PostgreSQL |
|
||||||
|
| (HTML/JS)| | (server.py) | | nexus_autoparts|
|
||||||
|
+----------+ +--------+---------+ +----------------+
|
||||||
|
|
|
||||||
|
JWT auth (PyJWT)
|
||||||
|
|
|
||||||
|
+------------+------------+
|
||||||
|
| | |
|
||||||
|
TALLER BODEGA ADMIN
|
||||||
|
(consulta) (inventario) (gestion)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scripts Disponibles
|
|
||||||
|
|
||||||
### Scripts de Datos
|
|
||||||
|
|
||||||
| Script | Descripción |
|
|
||||||
|--------|-------------|
|
|
||||||
| `add_toyota_data.py` | Agrega datos de Toyota |
|
|
||||||
| `add_honda_data.py` | Agrega datos de Honda |
|
|
||||||
| `add_nissan_data.py` | Agrega datos de Nissan |
|
|
||||||
| `add_ford_data.py` | Agrega datos de Ford |
|
|
||||||
| `add_chevrolet_data.py` | Agrega datos de Chevrolet |
|
|
||||||
| `add_audi_data.py` | Agrega datos de Audi |
|
|
||||||
| `add_acura_data.py` | Agrega datos de Acura |
|
|
||||||
| ... | Y más marcas |
|
|
||||||
|
|
||||||
### Scripts de Mantenimiento
|
|
||||||
|
|
||||||
| Script | Descripción |
|
|
||||||
|--------|-------------|
|
|
||||||
| `remove_brands_and_cleanup.py` | Limpia marcas innecesarias |
|
|
||||||
| `check_and_remove_brands.py` | Verifica y elimina marcas |
|
|
||||||
|
|
||||||
## Funcionalidades del Dashboard
|
|
||||||
|
|
||||||
### Panel de Filtros
|
|
||||||
- Selección de marca
|
|
||||||
- Selección de modelo (dinámico según marca)
|
|
||||||
- Filtro por año
|
|
||||||
- Filtro por motor
|
|
||||||
|
|
||||||
### Panel de Resultados
|
|
||||||
- Visualización en tarjetas
|
|
||||||
- Información detallada del vehículo
|
|
||||||
- Especificaciones del motor
|
|
||||||
- Datos de transmisión y tracción
|
|
||||||
|
|
||||||
### Características
|
|
||||||
- Diseño responsivo
|
|
||||||
- Actualización en tiempo real
|
|
||||||
- Animaciones y transiciones suaves
|
|
||||||
- Soporte para múltiples idiomas
|
|
||||||
|
|
||||||
## Arquitectura del Sistema
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐
|
|
||||||
│ RockAuto.com │────>│ Web Scraper │
|
|
||||||
└─────────────────┘ └────────┬─────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────┐ ┌──────────────────┐
|
|
||||||
│ Manual Input │────>│ SQLite Database │
|
|
||||||
└─────────────────┘ └────────┬─────────┘
|
|
||||||
│
|
|
||||||
┌───────────────────────┼───────────────────────┐
|
|
||||||
│ │ │
|
|
||||||
v v v
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ Flask API │ │ Pick Console │ │ CSV Importer │
|
|
||||||
└────────┬────────┘ │ (VT220/Rich) │ └──────────────────┘
|
|
||||||
│ └──────────────────┘
|
|
||||||
v
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Web Dashboard │
|
|
||||||
│ (Browser) │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contribuir
|
|
||||||
|
|
||||||
1. Fork el repositorio
|
|
||||||
2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`)
|
|
||||||
3. Commit tus cambios (`git commit -am 'Agrega nueva funcionalidad'`)
|
|
||||||
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
|
|
||||||
5. Crea un Pull Request
|
|
||||||
|
|
||||||
## Licencia
|
|
||||||
|
|
||||||
Este proyecto es de uso interno.
|
|
||||||
|
|
||||||
## Contacto
|
|
||||||
|
|
||||||
Para más información, contactar al equipo de desarrollo.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Autoparts DB** - Sistema de Gestión de Base de Datos de Vehículos
|
**Nexus Autoparts** - Tu conexion directa con las partes que necesitas
|
||||||
|
|||||||
25
config.py
Normal file
25
config.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Central configuration for Nexus Autoparts.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_URL = os.environ.get(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy SQLite path (used only by migration script)
|
||||||
|
SQLITE_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"vehicle_database", "vehicle_database.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
# JWT Authentication
|
||||||
|
JWT_SECRET = os.environ.get("JWT_SECRET", "nexus-saas-secret-change-in-prod-2026")
|
||||||
|
JWT_ACCESS_EXPIRES = 900 # 15 minutes
|
||||||
|
JWT_REFRESH_EXPIRES = 2592000 # 30 days
|
||||||
|
|
||||||
|
# Application identity
|
||||||
|
APP_NAME = "NEXUS AUTOPARTS"
|
||||||
|
APP_SLOGAN = "Tu conexión directa con las partes que necesitas"
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
# AUTOPARTES Console - Sistema Pick/VT220
|
# NEXUS AUTOPARTS Console - Sistema Pick/VT220
|
||||||
|
|
||||||
Interfaz de consola para el catálogo de autopartes, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado.
|
Interfaz de consola para el catálogo de nexus-autoparts, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado, verde sobre negro.
|
||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- Python 3.8+
|
- Python 3.8+
|
||||||
- SQLite 3 (incluido con Python)
|
- SQLite 3 (incluido con Python)
|
||||||
- Paquete `rich` (solo para modo moderno)
|
|
||||||
|
|
||||||
```bash
|
No requiere dependencias externas.
|
||||||
pip install rich # Opcional, solo para --mode modern
|
|
||||||
```
|
|
||||||
|
|
||||||
## Inicio Rápido
|
## Inicio Rápido
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Modo VT220 (clásico, verde sobre negro)
|
# Iniciar la consola
|
||||||
python -m console
|
python -m console
|
||||||
|
|
||||||
# Modo moderno (Rich/TUI con colores)
|
|
||||||
python -m console --mode modern
|
|
||||||
|
|
||||||
# Especificar base de datos
|
# Especificar base de datos
|
||||||
python -m console --db /ruta/a/vehicle_database.db
|
python -m console --db /ruta/a/vehicle_database.db
|
||||||
|
|
||||||
@@ -28,41 +22,25 @@ python -m console --db /ruta/a/vehicle_database.db
|
|||||||
python -m console --version
|
python -m console --version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Modos de Visualización
|
|
||||||
|
|
||||||
### VT220 (por defecto)
|
|
||||||
- Terminal clásica verde sobre negro
|
|
||||||
- Caracteres de dibujo de cajas (box-drawing)
|
|
||||||
- Compatible con cualquier terminal
|
|
||||||
- Usa la librería `curses` (incluida en Python)
|
|
||||||
|
|
||||||
### Modern
|
|
||||||
- Interfaz moderna con colores y estilos Rich
|
|
||||||
- Tema azul/cian
|
|
||||||
- Requiere `pip install rich`
|
|
||||||
- Si `rich` no está instalado, cae automáticamente a modo VT220
|
|
||||||
|
|
||||||
## Menú Principal
|
## Menú Principal
|
||||||
|
|
||||||
```
|
```
|
||||||
╔══════════════════════════════════════╗
|
┌──────────────────────────────────────────┐
|
||||||
║ AUTOPARTES v1.0.0 ║
|
│ MENU PRINCIPAL │
|
||||||
║ Sistema de Catalogo de Autopartes ║
|
├──────────────────────────────────────────┤
|
||||||
╠══════════════════════════════════════╣
|
│ ▸ 1. Consulta por Vehiculo │
|
||||||
║ 1. Buscar por Vehiculo ║
|
│ 2. Busqueda por Numero de Parte │
|
||||||
║ 2. Buscar por Numero de Parte ║
|
│ 3. Busqueda por Descripcion │
|
||||||
║ 3. Buscar por Texto ║
|
│ 4. Decodificador VIN │
|
||||||
║ 4. Decodificar VIN ║
|
│ 5. Catalogo de Categorias │
|
||||||
║ 5. Catalogo por Categoria ║
|
├──────────────────────────────────────────┤
|
||||||
║ ────────────────────────── ║
|
│ 6. Administracion de Partes │
|
||||||
║ 6. Admin: Partes ║
|
│ 7. Administracion de Fabricantes │
|
||||||
║ 7. Admin: Fabricantes ║
|
│ 8. Cross-References │
|
||||||
║ 8. Admin: Referencias Cruzadas ║
|
│ 9. Importar / Exportar Datos │
|
||||||
║ 9. Import/Export ║
|
├──────────────────────────────────────────┤
|
||||||
║ ────────────────────────── ║
|
│ 0. Estadisticas del Sistema │
|
||||||
║ S. Estadisticas del Sistema ║
|
└──────────────────────────────────────────┘
|
||||||
║ 0. Salir ║
|
|
||||||
╚══════════════════════════════════════╝
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Teclas de Función
|
## Teclas de Función
|
||||||
@@ -122,7 +100,7 @@ Dashboard con contadores de la base de datos (marcas, modelos, partes, etc.) y m
|
|||||||
|
|
||||||
```
|
```
|
||||||
console/
|
console/
|
||||||
├── main.py # Punto de entrada, --mode vt220|modern
|
├── main.py # Punto de entrada
|
||||||
├── config.py # Configuración (DB, colores, paginación)
|
├── config.py # Configuración (DB, colores, paginación)
|
||||||
├── db.py # Capa de datos abstracta (SQLite)
|
├── db.py # Capa de datos abstracta (SQLite)
|
||||||
│
|
│
|
||||||
@@ -149,8 +127,7 @@ console/
|
|||||||
│
|
│
|
||||||
├── renderers/
|
├── renderers/
|
||||||
│ ├── base.py # Interfaz abstracta BaseRenderer
|
│ ├── base.py # Interfaz abstracta BaseRenderer
|
||||||
│ ├── curses_renderer.py # Modo VT220 (curses)
|
│ └── curses_renderer.py # Renderer VT220 (curses)
|
||||||
│ └── textual_renderer.py # Modo moderno (Rich)
|
|
||||||
│
|
│
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── formatting.py # Formato de tablas, moneda, números
|
│ ├── formatting.py # Formato de tablas, moneda, números
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Configuration settings for the AUTOPARTES console application.
|
Configuration settings for the NEXUS AUTOPARTS console application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
# Application metadata
|
# Application metadata
|
||||||
VERSION = "1.0.0"
|
VERSION = "2.0.0"
|
||||||
APP_NAME = "AUTOPARTES"
|
APP_NAME = "NEXUS AUTOPARTS"
|
||||||
APP_SUBTITLE = "Sistema de Catalogo de Autopartes"
|
APP_SUBTITLE = "Tu conexión directa con las partes que necesitas"
|
||||||
|
|
||||||
# Database path (relative to the console/ directory, resolved to absolute)
|
# Database URL (PostgreSQL)
|
||||||
_CONSOLE_DIR = os.path.dirname(os.path.abspath(__file__))
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||||
DB_PATH = os.path.join(_CONSOLE_DIR, "..", "vehicle_database", "vehicle_database.db")
|
from config import DB_URL
|
||||||
DB_PATH = os.path.normpath(DB_PATH)
|
|
||||||
|
|
||||||
# NHTSA VIN Decoder API
|
# NHTSA VIN Decoder API
|
||||||
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
|
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
|
||||||
VIN_CACHE_DAYS = 30
|
VIN_CACHE_DAYS = 30
|
||||||
|
|
||||||
# Display defaults
|
# Display defaults
|
||||||
DEFAULT_MODE = "vt220"
|
|
||||||
PAGE_SIZE = 15
|
PAGE_SIZE = 15
|
||||||
|
|
||||||
# VT220 color pairs: (foreground, background)
|
# VT220 color pairs: (foreground, background)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Main application controller for the AUTOPARTES console application.
|
Main application controller for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
The :class:`App` class owns the screen lifecycle loop: it renders the
|
The :class:`App` class owns the screen lifecycle loop: it renders the
|
||||||
current screen, reads a keypress, dispatches it, and follows any
|
current screen, reads a keypress, dispatches it, and follows any
|
||||||
@@ -193,3 +193,4 @@ class App:
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self.renderer.cleanup()
|
self.renderer.cleanup()
|
||||||
|
self.db.close()
|
||||||
|
|||||||
880
console/db.py
880
console/db.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Entry point for the AUTOPARTES Pick/VT220-style console application.
|
Entry point for the NEXUS AUTOPARTS Pick/VT220-style console application.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m console # via package
|
python -m console # via package
|
||||||
@@ -8,10 +8,11 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_PATH, DEFAULT_MODE
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_URL
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv=None):
|
def parse_args(argv=None):
|
||||||
@@ -20,12 +21,6 @@ def parse_args(argv=None):
|
|||||||
prog=APP_NAME.lower(),
|
prog=APP_NAME.lower(),
|
||||||
description=f"{APP_NAME} - {APP_SUBTITLE}",
|
description=f"{APP_NAME} - {APP_SUBTITLE}",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--mode",
|
|
||||||
choices=["vt220", "modern"],
|
|
||||||
default=DEFAULT_MODE,
|
|
||||||
help=f"Display mode (default: {DEFAULT_MODE})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version",
|
"--version",
|
||||||
action="version",
|
action="version",
|
||||||
@@ -33,21 +28,29 @@ def parse_args(argv=None):
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--db",
|
"--db",
|
||||||
default=DB_PATH,
|
default=DB_URL,
|
||||||
help="Path to the vehicle database (default: auto-detected)",
|
help="PostgreSQL connection URL (default: from config)",
|
||||||
)
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def _print_banner(mode, db_path):
|
def _print_banner(db_url):
|
||||||
"""Print a startup banner before entering terminal mode."""
|
"""Print a startup banner before entering terminal mode."""
|
||||||
|
# Mask password in display
|
||||||
|
display_url = db_url
|
||||||
|
if '@' in db_url:
|
||||||
|
pre_at = db_url.split('@')[0]
|
||||||
|
post_at = db_url.split('@', 1)[1]
|
||||||
|
if ':' in pre_at.split('//')[-1]:
|
||||||
|
user = pre_at.split('//')[-1].split(':')[0]
|
||||||
|
display_url = f"postgresql://{user}:****@{post_at}"
|
||||||
|
|
||||||
border = "=" * 58
|
border = "=" * 58
|
||||||
print(border)
|
print(border)
|
||||||
print(f" {APP_NAME} v{VERSION}")
|
print(f" {APP_NAME} v{VERSION}")
|
||||||
print(f" {APP_SUBTITLE}")
|
print(f" {APP_SUBTITLE}")
|
||||||
print(border)
|
print(border)
|
||||||
print(f" Mode : {mode}")
|
print(f" DB : {display_url}")
|
||||||
print(f" DB : {db_path}")
|
|
||||||
print(border)
|
print(border)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -56,21 +59,7 @@ def main(argv=None):
|
|||||||
"""Main entry point: parse args, set up renderer, DB, and launch the app."""
|
"""Main entry point: parse args, set up renderer, DB, and launch the app."""
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
|
|
||||||
db_path = args.db
|
db_url = args.db
|
||||||
mode = args.mode
|
|
||||||
|
|
||||||
# Verify the database file exists before proceeding
|
|
||||||
if not os.path.isfile(db_path):
|
|
||||||
print(
|
|
||||||
f"Error: Database not found at '{db_path}'.\n"
|
|
||||||
f"\n"
|
|
||||||
f"Make sure the vehicle database exists. You can specify a\n"
|
|
||||||
f"custom path with the --db flag:\n"
|
|
||||||
f"\n"
|
|
||||||
f" python -m console --db /path/to/vehicle_database.db\n",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Lazy imports so the module can be loaded without curses available
|
# Lazy imports so the module can be loaded without curses available
|
||||||
# (e.g. during tests or when just checking --version).
|
# (e.g. during tests or when just checking --version).
|
||||||
@@ -79,26 +68,31 @@ def main(argv=None):
|
|||||||
from console.core.app import App
|
from console.core.app import App
|
||||||
|
|
||||||
# Print startup banner
|
# Print startup banner
|
||||||
_print_banner(mode, db_path)
|
_print_banner(db_url)
|
||||||
|
|
||||||
db = Database(db_path)
|
# Test database connection before entering curses mode
|
||||||
|
db = Database(db_url)
|
||||||
# Select renderer based on mode
|
try:
|
||||||
if mode == "modern":
|
db._get_engine()
|
||||||
try:
|
session = db._session()
|
||||||
from console.renderers.textual_renderer import TextualRenderer
|
session.execute(text("SELECT 1"))
|
||||||
renderer = TextualRenderer()
|
session.close()
|
||||||
except ImportError:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
"Warning: 'modern' mode requires the 'rich' package.\n"
|
f"Error: Cannot connect to database.\n"
|
||||||
"Falling back to vt220 mode.\n"
|
f"\n"
|
||||||
"Install with: pip install rich\n",
|
f" URL: {db_url}\n"
|
||||||
file=sys.stderr,
|
f" Error: {e}\n"
|
||||||
)
|
f"\n"
|
||||||
renderer = CursesRenderer()
|
f"Make sure PostgreSQL is running and the connection URL is correct.\n"
|
||||||
else:
|
f"You can specify a custom URL with the --db flag:\n"
|
||||||
renderer = CursesRenderer()
|
f"\n"
|
||||||
|
f" python -m console --db postgresql://user:pass@host/dbname\n",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
renderer = CursesRenderer()
|
||||||
app = App(renderer=renderer, db=db)
|
app = App(renderer=renderer, db=db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Abstract base renderer interface for the AUTOPARTES console application.
|
Abstract base renderer interface for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Every renderer (curses VT220, Textual/Rich, etc.) must subclass
|
Every renderer (curses VT220, Textual/Rich, etc.) must subclass
|
||||||
:class:`BaseRenderer` and implement all of its methods. Screens call
|
:class:`BaseRenderer` and implement all of its methods. Screens call
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Curses-based VT220 renderer for the AUTOPARTES console application.
|
Curses-based VT220 renderer for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired
|
Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired
|
||||||
by classic Pick/UNIX VT220 terminals. All drawing is done through Python's
|
by classic Pick/UNIX VT220 terminals. All drawing is done through Python's
|
||||||
@@ -7,6 +7,11 @@ built-in :mod:`curses` library.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Reduce ESC key delay from default 1000ms to 25ms.
|
||||||
|
# Must be set BEFORE curses.initscr() is called.
|
||||||
|
os.environ.setdefault('ESCDELAY', '25')
|
||||||
|
|
||||||
from console.config import COLORS_VT220
|
from console.config import COLORS_VT220
|
||||||
from console.renderers.base import BaseRenderer
|
from console.renderers.base import BaseRenderer
|
||||||
@@ -40,6 +45,7 @@ class CursesRenderer(BaseRenderer):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._screen = None
|
self._screen = None
|
||||||
self._color_pairs: dict[str, int] = {}
|
self._color_pairs: dict[str, int] = {}
|
||||||
|
self._size_cache: tuple = (24, 80)
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────
|
# ── Lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -68,12 +74,13 @@ class CursesRenderer(BaseRenderer):
|
|||||||
# ── Screen queries ───────────────────────────────────────────────
|
# ── Screen queries ───────────────────────────────────────────────
|
||||||
|
|
||||||
def get_size(self) -> tuple:
|
def get_size(self) -> tuple:
|
||||||
"""Return ``(height, width)``."""
|
"""Return ``(height, width)`` with cached value per render cycle."""
|
||||||
return self._screen.getmaxyx()
|
return self._size_cache
|
||||||
|
|
||||||
# ── Primitive operations ─────────────────────────────────────────
|
# ── Primitive operations ─────────────────────────────────────────
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
|
self._size_cache = self._screen.getmaxyx()
|
||||||
self._screen.erase()
|
self._screen.erase()
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
@@ -160,17 +167,42 @@ class CursesRenderer(BaseRenderer):
|
|||||||
|
|
||||||
def draw_menu(self, items, selected_index=0, title=''):
|
def draw_menu(self, items, selected_index=0, title=''):
|
||||||
h, w = self.get_size()
|
h, w = self.get_size()
|
||||||
start_row = 3
|
|
||||||
|
|
||||||
|
# Calculate menu dimensions for centering
|
||||||
|
item_count = len(items)
|
||||||
|
# Find widest label for box sizing
|
||||||
|
max_label = 0
|
||||||
|
for num, label in items:
|
||||||
|
if num != "---" and num != "\u2500":
|
||||||
|
max_label = max(max_label, len(f" {num}. {label} "))
|
||||||
|
box_w = max(max_label + 8, 44)
|
||||||
|
box_w = min(box_w, w - 4)
|
||||||
|
box_h = item_count + 4 # top/bottom border + title + blank line
|
||||||
if title:
|
if title:
|
||||||
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
box_h += 2
|
||||||
start_row += 2
|
|
||||||
|
|
||||||
visible = h - start_row - 3 # leave room for footer
|
# Center the box vertically and horizontally
|
||||||
if visible < 1:
|
start_row = max((h - box_h) // 2 - 1, 2)
|
||||||
return
|
start_col = max((w - box_w) // 2, 1)
|
||||||
|
|
||||||
# Scrolling offset
|
# Draw box
|
||||||
|
self.draw_box(start_row, start_col, box_h, box_w, "")
|
||||||
|
|
||||||
|
row = start_row + 1
|
||||||
|
if title:
|
||||||
|
# Title centered inside the box
|
||||||
|
title_col = start_col + max((box_w - len(title)) // 2, 2)
|
||||||
|
self._safe_addstr(row, title_col, title,
|
||||||
|
self._attr("title"))
|
||||||
|
row += 1
|
||||||
|
self._hline(row, start_col + 1, box_w - 2)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Menu items inside the box
|
||||||
|
inner_left = start_col + 3
|
||||||
|
inner_w = box_w - 6
|
||||||
|
|
||||||
|
visible = box_h - (row - start_row) - 1
|
||||||
offset = 0
|
offset = 0
|
||||||
if selected_index >= visible:
|
if selected_index >= visible:
|
||||||
offset = selected_index - visible + 1
|
offset = selected_index - visible + 1
|
||||||
@@ -181,19 +213,20 @@ class CursesRenderer(BaseRenderer):
|
|||||||
break
|
break
|
||||||
if idx < offset:
|
if idx < offset:
|
||||||
continue
|
continue
|
||||||
row = start_row + drawn
|
|
||||||
|
|
||||||
# Separator
|
# Separator
|
||||||
if num == "\u2500" or num == "---":
|
if num == "\u2500" or num == "---":
|
||||||
self._hline(row, 2, w - 4)
|
self._hline(row, start_col + 1, box_w - 2)
|
||||||
|
row += 1
|
||||||
drawn += 1
|
drawn += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
marker = "\u25b8 " if idx == selected_index else " "
|
marker = "\u25b8 " if idx == selected_index else " "
|
||||||
text = f"{marker}{num}. {label}"
|
text = f"{marker}{num}. {label}"
|
||||||
style = "highlight" if idx == selected_index else "normal"
|
style = "highlight" if idx == selected_index else "normal"
|
||||||
self._safe_addstr(row, 2, pad_right(text, w - 4),
|
self._safe_addstr(row, inner_left, pad_right(text, inner_w),
|
||||||
self._attr(style))
|
self._attr(style))
|
||||||
|
row += 1
|
||||||
drawn += 1
|
drawn += 1
|
||||||
|
|
||||||
def draw_table(self, headers, rows, widths, page_info=None,
|
def draw_table(self, headers, rows, widths, page_info=None,
|
||||||
|
|||||||
@@ -1,712 +0,0 @@
|
|||||||
"""
|
|
||||||
Rich-based modern renderer for the AUTOPARTES console application.
|
|
||||||
|
|
||||||
Implements :class:`BaseRenderer` using the ``rich`` library for a modern
|
|
||||||
dark-themed TUI with blue/cyan accents. Keyboard input is handled via
|
|
||||||
raw terminal mode using :mod:`tty` / :mod:`termios` since Rich is a
|
|
||||||
display-only library.
|
|
||||||
|
|
||||||
NOTE: Despite the module name (historical), this uses **Rich** only --
|
|
||||||
not the Textual framework.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import tty
|
|
||||||
import termios
|
|
||||||
import select
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
|
||||||
from rich.text import Text
|
|
||||||
from rich import box as rich_box
|
|
||||||
|
|
||||||
from console.core.keybindings import Key
|
|
||||||
from console.renderers.base import BaseRenderer
|
|
||||||
from console.utils.formatting import pad_right, truncate
|
|
||||||
|
|
||||||
|
|
||||||
class TextualRenderer(BaseRenderer):
|
|
||||||
"""Rich-based modern renderer with blue/cyan colour scheme."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._console = None
|
|
||||||
self._old_term_settings = None
|
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def init_screen(self):
|
|
||||||
"""Create a Rich Console and put the terminal into raw mode."""
|
|
||||||
self._console = Console(highlight=False, force_terminal=True)
|
|
||||||
# Save terminal state *before* entering raw mode
|
|
||||||
try:
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
self._old_term_settings = termios.tcgetattr(fd)
|
|
||||||
except (termios.error, ValueError, OSError):
|
|
||||||
self._old_term_settings = None
|
|
||||||
# Hide cursor
|
|
||||||
sys.stdout.write("\033[?25l")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Restore the terminal to its original state."""
|
|
||||||
# Show cursor
|
|
||||||
sys.stdout.write("\033[?25h")
|
|
||||||
sys.stdout.flush()
|
|
||||||
# Restore original terminal attributes
|
|
||||||
if self._old_term_settings is not None:
|
|
||||||
try:
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN,
|
|
||||||
self._old_term_settings)
|
|
||||||
except (termios.error, ValueError, OSError):
|
|
||||||
pass
|
|
||||||
self._old_term_settings = None
|
|
||||||
self._console = None
|
|
||||||
|
|
||||||
# ── Screen queries ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_size(self) -> tuple:
|
|
||||||
"""Return ``(height, width)`` of the terminal."""
|
|
||||||
size = self._console.size
|
|
||||||
return (size.height, size.width)
|
|
||||||
|
|
||||||
# ── Primitive operations ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Clear the screen."""
|
|
||||||
self._console.clear()
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
"""No-op -- Rich prints immediately to stdout."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_key(self) -> int:
|
|
||||||
"""Read a single key from stdin using raw terminal mode.
|
|
||||||
|
|
||||||
Escape sequences (arrows, F-keys, etc.) are decoded and mapped
|
|
||||||
to the same integer constants used by :class:`Key` (which mirror
|
|
||||||
curses key codes).
|
|
||||||
"""
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
|
|
||||||
if ch == "\x1b":
|
|
||||||
# Check if more bytes are available (escape sequence)
|
|
||||||
if _has_data(fd):
|
|
||||||
ch2 = sys.stdin.read(1)
|
|
||||||
if ch2 == "[":
|
|
||||||
ch3 = sys.stdin.read(1)
|
|
||||||
# Arrow keys
|
|
||||||
if ch3 == "A":
|
|
||||||
return Key.UP
|
|
||||||
if ch3 == "B":
|
|
||||||
return Key.DOWN
|
|
||||||
if ch3 == "C":
|
|
||||||
return Key.RIGHT
|
|
||||||
if ch3 == "D":
|
|
||||||
return Key.LEFT
|
|
||||||
if ch3 == "H":
|
|
||||||
return Key.HOME
|
|
||||||
if ch3 == "F":
|
|
||||||
return Key.END
|
|
||||||
# Page Up / Page Down / Home / End / Insert / Delete
|
|
||||||
if ch3 == "5":
|
|
||||||
sys.stdin.read(1) # consume '~'
|
|
||||||
return Key.PGUP
|
|
||||||
if ch3 == "6":
|
|
||||||
sys.stdin.read(1) # consume '~'
|
|
||||||
return Key.PGDN
|
|
||||||
if ch3 == "1":
|
|
||||||
# Could be: Home (1~), F5-F8 (15~,17~,18~,19~)
|
|
||||||
ch4 = sys.stdin.read(1)
|
|
||||||
if ch4 == "~":
|
|
||||||
return Key.HOME
|
|
||||||
if ch4 == "5":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F5
|
|
||||||
if ch4 == "7":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F6
|
|
||||||
if ch4 == "8":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F7
|
|
||||||
if ch4 == "9":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F8
|
|
||||||
# Consume trailing ~ if present
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
return Key.HOME
|
|
||||||
if ch3 == "2":
|
|
||||||
ch4 = sys.stdin.read(1)
|
|
||||||
if ch4 == "0":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F9
|
|
||||||
if ch4 == "1":
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.F10
|
|
||||||
# 2~ = Insert -- map to escape for now
|
|
||||||
return Key.ESCAPE
|
|
||||||
if ch3 == "3":
|
|
||||||
# Delete key: 3~
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.BACKSPACE
|
|
||||||
if ch3 == "4":
|
|
||||||
if _has_data(fd):
|
|
||||||
sys.stdin.read(1) # ~
|
|
||||||
return Key.END
|
|
||||||
# Drain any remaining escape sequence bytes
|
|
||||||
while _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
return Key.ESCAPE
|
|
||||||
elif ch2 == "O":
|
|
||||||
# SS3 sequences (F1-F4, sometimes Home/End)
|
|
||||||
ch3 = sys.stdin.read(1)
|
|
||||||
if ch3 == "P":
|
|
||||||
return Key.F1
|
|
||||||
if ch3 == "Q":
|
|
||||||
return Key.F2
|
|
||||||
if ch3 == "R":
|
|
||||||
return Key.F3
|
|
||||||
if ch3 == "S":
|
|
||||||
return Key.F4
|
|
||||||
if ch3 == "H":
|
|
||||||
return Key.HOME
|
|
||||||
if ch3 == "F":
|
|
||||||
return Key.END
|
|
||||||
return Key.ESCAPE
|
|
||||||
# Unknown escape -- drain and return ESC
|
|
||||||
while _has_data(fd):
|
|
||||||
sys.stdin.read(1)
|
|
||||||
return Key.ESCAPE
|
|
||||||
# Bare ESC (no further bytes)
|
|
||||||
return Key.ESCAPE
|
|
||||||
|
|
||||||
if ch == "\r" or ch == "\n":
|
|
||||||
return Key.ENTER
|
|
||||||
if ch == "\t":
|
|
||||||
return Key.TAB
|
|
||||||
if ch == "\x7f" or ch == "\x08":
|
|
||||||
return Key.BACKSPACE
|
|
||||||
if ch == "\x03":
|
|
||||||
# Ctrl-C -- treat as escape so the app can exit gracefully
|
|
||||||
return Key.ESCAPE
|
|
||||||
return ord(ch)
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
||||||
|
|
||||||
# ── High-level widgets ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def draw_header(self, title, subtitle=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
header = Text()
|
|
||||||
header.append(title, style="bold cyan")
|
|
||||||
if subtitle:
|
|
||||||
padding = w - len(title) - len(subtitle)
|
|
||||||
if padding > 0:
|
|
||||||
header.append(" " * padding)
|
|
||||||
header.append(subtitle, style="dim white")
|
|
||||||
# Pad to full width
|
|
||||||
if header.cell_len < w:
|
|
||||||
header.append(" " * (w - header.cell_len))
|
|
||||||
header.stylize("on rgb(20,40,80)")
|
|
||||||
self._console.print(header, end="")
|
|
||||||
# Separator line
|
|
||||||
sep = Text("─" * w, style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
def draw_footer(self, key_labels):
|
|
||||||
h, w = self.get_size()
|
|
||||||
# Separator
|
|
||||||
sep = Text("─" * w, style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
# Key labels
|
|
||||||
footer = Text()
|
|
||||||
for i, (key, desc) in enumerate(key_labels):
|
|
||||||
if i > 0:
|
|
||||||
footer.append(" ", style="dim white on rgb(20,40,80)")
|
|
||||||
footer.append(f" {key} ", style="bold white on rgb(40,80,120)")
|
|
||||||
footer.append(f" {desc}", style="white on rgb(20,40,80)")
|
|
||||||
# Pad to full width
|
|
||||||
if footer.cell_len < w:
|
|
||||||
footer.append(
|
|
||||||
" " * (w - footer.cell_len),
|
|
||||||
style="on rgb(20,40,80)",
|
|
||||||
)
|
|
||||||
self._console.print(footer, end="")
|
|
||||||
|
|
||||||
def draw_menu(self, items, selected_index=0, title=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
visible_lines = h - 6 # header(2) + footer(2) + margins
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_text = Text()
|
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
visible_lines -= 2
|
|
||||||
|
|
||||||
if visible_lines < 1:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Scrolling offset
|
|
||||||
offset = 0
|
|
||||||
if selected_index >= visible_lines:
|
|
||||||
offset = selected_index - visible_lines + 1
|
|
||||||
|
|
||||||
drawn = 0
|
|
||||||
for idx, (num, label) in enumerate(items):
|
|
||||||
if drawn >= visible_lines:
|
|
||||||
break
|
|
||||||
if idx < offset:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Separator
|
|
||||||
if num == "\u2500" or num == "---":
|
|
||||||
sep = Text(" " + "─" * (w - 4), style="dim blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
drawn += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
line = Text()
|
|
||||||
marker = "\u25b8 " if idx == selected_index else " "
|
|
||||||
|
|
||||||
if idx == selected_index:
|
|
||||||
entry = f"{marker}{num}. {label}"
|
|
||||||
entry = pad_right(entry, w - 4)
|
|
||||||
line.append(f" {entry}", style="bold white on rgb(30,60,120)")
|
|
||||||
else:
|
|
||||||
line.append(f" {marker}{num}. {label}", style="white")
|
|
||||||
|
|
||||||
self._console.print(line, end="")
|
|
||||||
drawn += 1
|
|
||||||
|
|
||||||
def draw_table(self, headers, rows, widths, page_info=None,
|
|
||||||
selected_row=-1):
|
|
||||||
h, w = self.get_size()
|
|
||||||
|
|
||||||
table = Table(
|
|
||||||
box=rich_box.SIMPLE_HEAD,
|
|
||||||
show_edge=False,
|
|
||||||
pad_edge=False,
|
|
||||||
expand=True,
|
|
||||||
style="white",
|
|
||||||
header_style="bold cyan",
|
|
||||||
row_styles=["white", "dim white"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Row number column
|
|
||||||
table.add_column("#", style="dim cyan", width=4, justify="right")
|
|
||||||
|
|
||||||
for hdr, wd in zip(headers, widths):
|
|
||||||
table.add_column(hdr, width=wd, no_wrap=True, overflow="ellipsis")
|
|
||||||
|
|
||||||
visible = h - 8 # header, table header, separator, footer, page info
|
|
||||||
if visible < 1:
|
|
||||||
visible = 1
|
|
||||||
|
|
||||||
for i, row_data in enumerate(rows):
|
|
||||||
if i >= visible:
|
|
||||||
break
|
|
||||||
cells = [str(v) for v in row_data]
|
|
||||||
row_style = ("bold white on rgb(30,60,120)"
|
|
||||||
if i == selected_row else None)
|
|
||||||
table.add_row(str(i + 1), *cells, style=row_style)
|
|
||||||
|
|
||||||
self._console.print(table, end="")
|
|
||||||
|
|
||||||
if page_info:
|
|
||||||
page = page_info.get("page", 1)
|
|
||||||
total = page_info.get("total_pages", 1)
|
|
||||||
total_rows = page_info.get("total_rows", len(rows))
|
|
||||||
info = Text()
|
|
||||||
info.append(
|
|
||||||
f" Pagina {page}/{total} ({total_rows} registros)",
|
|
||||||
style="dim cyan",
|
|
||||||
)
|
|
||||||
self._console.print(info, end="")
|
|
||||||
|
|
||||||
def draw_detail(self, fields, title=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_text = Text()
|
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
|
||||||
dot_total = max_label + 4
|
|
||||||
|
|
||||||
lines_available = h - 6
|
|
||||||
if title:
|
|
||||||
lines_available -= 3
|
|
||||||
|
|
||||||
for i, (label, value) in enumerate(fields):
|
|
||||||
if i >= lines_available:
|
|
||||||
break
|
|
||||||
dots = "." * (dot_total - len(label))
|
|
||||||
line = Text()
|
|
||||||
line.append(f" {label}{dots}: ", style="cyan")
|
|
||||||
line.append(str(value), style="bold white")
|
|
||||||
self._console.print(line, end="")
|
|
||||||
|
|
||||||
def draw_form(self, fields, focused_index=0, title=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_text = Text()
|
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
max_label = max((len(f.get("label", "")) for f in fields), default=10)
|
|
||||||
dot_total = max_label + 4
|
|
||||||
|
|
||||||
for i, field in enumerate(fields):
|
|
||||||
label = field.get("label", "")
|
|
||||||
value = field.get("value", "")
|
|
||||||
fw = field.get("width", 20)
|
|
||||||
hint = field.get("hint", "")
|
|
||||||
|
|
||||||
dots = "." * (dot_total - len(label))
|
|
||||||
num_str = f"{i + 1}. "
|
|
||||||
|
|
||||||
line = Text()
|
|
||||||
line.append(f" {num_str}{label}{dots}: ", style="cyan")
|
|
||||||
|
|
||||||
display_val = pad_right(str(value), fw)
|
|
||||||
if i == focused_index:
|
|
||||||
line.append(f"[{display_val}]",
|
|
||||||
style="bold white on rgb(0,100,140)")
|
|
||||||
else:
|
|
||||||
line.append(f"[{display_val}]", style="white")
|
|
||||||
|
|
||||||
if hint:
|
|
||||||
line.append(f" {hint}", style="dim cyan")
|
|
||||||
|
|
||||||
self._console.print(line, end="")
|
|
||||||
# Blank line between fields for spacing
|
|
||||||
self._console.print("", end="")
|
|
||||||
|
|
||||||
def draw_filter_list(self, items, filter_text, selected_index,
|
|
||||||
title=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_text = Text()
|
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
|
|
||||||
# Separator
|
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
# Filter input
|
|
||||||
filter_line = Text()
|
|
||||||
filter_line.append(" Filtro: ", style="cyan")
|
|
||||||
filter_line.append(filter_text, style="bold white on rgb(0,100,140)")
|
|
||||||
filter_line.append("_", style="bold white on rgb(0,100,140)")
|
|
||||||
self._console.print(filter_line, end="")
|
|
||||||
|
|
||||||
# Separator
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
|
|
||||||
# Scrollable list
|
|
||||||
visible = h - 10 # header, title, filter, separators, footer, count
|
|
||||||
if visible < 1:
|
|
||||||
visible = 1
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
if selected_index >= visible:
|
|
||||||
offset = selected_index - visible + 1
|
|
||||||
|
|
||||||
drawn = 0
|
|
||||||
for idx, (num, label) in enumerate(items):
|
|
||||||
if drawn >= visible:
|
|
||||||
break
|
|
||||||
if idx < offset:
|
|
||||||
continue
|
|
||||||
|
|
||||||
marker = "\u25b8 " if idx == selected_index else " "
|
|
||||||
line = Text()
|
|
||||||
if idx == selected_index:
|
|
||||||
entry = f"{marker}{num}. {label}"
|
|
||||||
entry = pad_right(entry, w - 4)
|
|
||||||
line.append(f" {entry}",
|
|
||||||
style="bold white on rgb(30,60,120)")
|
|
||||||
else:
|
|
||||||
line.append(f" {marker}{num}. {label}", style="white")
|
|
||||||
|
|
||||||
self._console.print(line, end="")
|
|
||||||
drawn += 1
|
|
||||||
|
|
||||||
# Count at bottom
|
|
||||||
count_line = Text()
|
|
||||||
count_line.append(f" {len(items)} elementos", style="dim cyan")
|
|
||||||
self._console.print(count_line, end="")
|
|
||||||
|
|
||||||
def draw_comparison(self, columns, title=''):
|
|
||||||
h, w = self.get_size()
|
|
||||||
|
|
||||||
if title:
|
|
||||||
title_text = Text()
|
|
||||||
title_text.append(f" {title}", style="bold white")
|
|
||||||
self._console.print(title_text, end="")
|
|
||||||
sep = Text(" " + "─" * (w - 4), style="blue")
|
|
||||||
self._console.print(sep, end="")
|
|
||||||
self._console.print("", end="") # blank line
|
|
||||||
|
|
||||||
n_cols = len(columns)
|
|
||||||
if n_cols == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Build a Rich Table for the comparison
|
|
||||||
table = Table(
|
|
||||||
box=rich_box.SIMPLE_HEAD,
|
|
||||||
show_edge=False,
|
|
||||||
pad_edge=True,
|
|
||||||
expand=True,
|
|
||||||
style="white",
|
|
||||||
header_style="bold cyan",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Label column
|
|
||||||
table.add_column("", style="cyan", no_wrap=True)
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
table.add_column(
|
|
||||||
col.get("header", ""),
|
|
||||||
style="white",
|
|
||||||
no_wrap=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Data rows -- use the first column's labels as the canonical set
|
|
||||||
if not columns[0].get("rows"):
|
|
||||||
self._console.print(table, end="")
|
|
||||||
return
|
|
||||||
|
|
||||||
n_rows = len(columns[0]["rows"])
|
|
||||||
max_rows = h - 8
|
|
||||||
for i in range(min(n_rows, max_rows)):
|
|
||||||
lbl = (columns[0]["rows"][i][0]
|
|
||||||
if i < len(columns[0]["rows"]) else "")
|
|
||||||
vals = []
|
|
||||||
for col in columns:
|
|
||||||
rows_data = col.get("rows", [])
|
|
||||||
val = rows_data[i][1] if i < len(rows_data) else ""
|
|
||||||
vals.append(str(val))
|
|
||||||
table.add_row(lbl, *vals)
|
|
||||||
|
|
||||||
self._console.print(table, end="")
|
|
||||||
|
|
||||||
# ── Low-level drawing ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def draw_text(self, row, col, text, style='normal'):
|
|
||||||
"""Draw text using Rich styling.
|
|
||||||
|
|
||||||
Since Rich does not support absolute cursor positioning the way
|
|
||||||
curses does, we approximate by printing the text preceded by
|
|
||||||
ANSI escape codes that move the cursor to the requested row/col.
|
|
||||||
"""
|
|
||||||
style_map = {
|
|
||||||
"normal": "white",
|
|
||||||
"header": "bold cyan",
|
|
||||||
"footer": "white on rgb(20,40,80)",
|
|
||||||
"highlight": "bold white on rgb(30,60,120)",
|
|
||||||
"border": "blue",
|
|
||||||
"title": "bold white",
|
|
||||||
"error": "bold red",
|
|
||||||
"info": "dim cyan",
|
|
||||||
"field_label": "cyan",
|
|
||||||
"field_value": "bold white",
|
|
||||||
"field_active": "bold white on rgb(0,100,140)",
|
|
||||||
}
|
|
||||||
rich_style = style_map.get(style, "white")
|
|
||||||
styled = Text(text, style=rich_style)
|
|
||||||
# Use ANSI escape to position cursor
|
|
||||||
sys.stdout.write(f"\033[{row + 1};{col + 1}H")
|
|
||||||
sys.stdout.flush()
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
def draw_box(self, top, left, height, width, title=''):
|
|
||||||
"""Draw a box using Rich's Panel.
|
|
||||||
|
|
||||||
Since Rich Panel does not support absolute positioning, we build
|
|
||||||
the box manually with Unicode line-drawing characters and ANSI
|
|
||||||
cursor movement for precise placement.
|
|
||||||
"""
|
|
||||||
BOX_H = "\u2500"
|
|
||||||
BOX_V = "\u2502"
|
|
||||||
BOX_TL = "\u256d" # rounded corners for modern look
|
|
||||||
BOX_TR = "\u256e"
|
|
||||||
BOX_BL = "\u2570"
|
|
||||||
BOX_BR = "\u256f"
|
|
||||||
|
|
||||||
if height < 2 or width < 2:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Top border
|
|
||||||
if title:
|
|
||||||
t = truncate(title, width - 4)
|
|
||||||
top_line = (BOX_TL + BOX_H + t
|
|
||||||
+ BOX_H * (width - 3 - len(t)) + BOX_TR)
|
|
||||||
else:
|
|
||||||
top_line = BOX_TL + BOX_H * (width - 2) + BOX_TR
|
|
||||||
|
|
||||||
sys.stdout.write(f"\033[{top + 1};{left + 1}H")
|
|
||||||
styled = Text(top_line, style="blue")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Side borders
|
|
||||||
for r in range(1, height - 1):
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 1}H")
|
|
||||||
styled = Text(BOX_V, style="blue")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + width}H")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Bottom border
|
|
||||||
bottom_line = BOX_BL + BOX_H * (width - 2) + BOX_BR
|
|
||||||
sys.stdout.write(f"\033[{top + height};{left + 1}H")
|
|
||||||
styled = Text(bottom_line, style="blue")
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# ── Dialogs ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def show_message(self, text, msg_type='info') -> bool:
|
|
||||||
h, w = self.get_size()
|
|
||||||
lines = text.split("\n")
|
|
||||||
box_w = max(max((len(l) for l in lines), default=20) + 6, 30)
|
|
||||||
box_h = len(lines) + 4
|
|
||||||
top = max((h - box_h) // 2, 0)
|
|
||||||
left = max((w - box_w) // 2, 0)
|
|
||||||
|
|
||||||
# Determine style
|
|
||||||
if msg_type == "error":
|
|
||||||
border_style = "bold red"
|
|
||||||
text_style = "bold red"
|
|
||||||
title_label = " Error "
|
|
||||||
elif msg_type == "confirm":
|
|
||||||
border_style = "bold yellow"
|
|
||||||
text_style = "white"
|
|
||||||
title_label = " Confirmar "
|
|
||||||
else:
|
|
||||||
border_style = "bold cyan"
|
|
||||||
text_style = "white"
|
|
||||||
title_label = " Info "
|
|
||||||
|
|
||||||
# Draw box
|
|
||||||
self.draw_box(top, left, box_h, box_w, title_label)
|
|
||||||
|
|
||||||
# Fill interior and draw message lines
|
|
||||||
interior_style = text_style
|
|
||||||
for r in range(1, box_h - 1):
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
|
|
||||||
fill = Text(" " * (box_w - 2), style=interior_style)
|
|
||||||
self._console.print(fill, end="")
|
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
x = left + max((box_w - len(line)) // 2, 2)
|
|
||||||
sys.stdout.write(f"\033[{top + 2 + i};{x + 1}H")
|
|
||||||
styled = Text(line, style=text_style)
|
|
||||||
self._console.print(styled, end="")
|
|
||||||
|
|
||||||
# Prompt line
|
|
||||||
if msg_type == "confirm":
|
|
||||||
prompt = "[S]i / [N]o"
|
|
||||||
else:
|
|
||||||
prompt = "Presione cualquier tecla..."
|
|
||||||
px = left + max((box_w - len(prompt)) // 2, 2)
|
|
||||||
sys.stdout.write(f"\033[{top + box_h};{px + 1}H")
|
|
||||||
prompt_styled = Text(prompt, style="bold cyan")
|
|
||||||
self._console.print(prompt_styled, end="")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
# Wait for key
|
|
||||||
if msg_type == "confirm":
|
|
||||||
while True:
|
|
||||||
key = self.get_key()
|
|
||||||
if key in (ord("s"), ord("S")):
|
|
||||||
return True
|
|
||||||
if key in (ord("n"), ord("N"), Key.ESCAPE):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.get_key()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def show_input(self, prompt, max_len=40):
|
|
||||||
h, w = self.get_size()
|
|
||||||
box_w = max(len(prompt) + max_len + 8, 30)
|
|
||||||
box_h = 5
|
|
||||||
top = max((h - box_h) // 2, 0)
|
|
||||||
left = max((w - box_w) // 2, 0)
|
|
||||||
|
|
||||||
buf = []
|
|
||||||
|
|
||||||
# Show cursor during input
|
|
||||||
sys.stdout.write("\033[?25h")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
self.draw_box(top, left, box_h, box_w, " Entrada ")
|
|
||||||
|
|
||||||
# Fill interior
|
|
||||||
for r in range(1, box_h - 1):
|
|
||||||
sys.stdout.write(f"\033[{top + r + 1};{left + 2}H")
|
|
||||||
fill = Text(" " * (box_w - 2))
|
|
||||||
self._console.print(fill, end="")
|
|
||||||
|
|
||||||
# Prompt label
|
|
||||||
sys.stdout.write(f"\033[{top + 2};{left + 3}H")
|
|
||||||
label = Text(prompt, style="cyan")
|
|
||||||
self._console.print(label, end="")
|
|
||||||
|
|
||||||
# Input field
|
|
||||||
val = "".join(buf)
|
|
||||||
display = pad_right(val, max_len)
|
|
||||||
sys.stdout.write(f"\033[{top + 3};{left + 3}H")
|
|
||||||
field = Text(f"[{display}]",
|
|
||||||
style="bold white on rgb(0,100,140)")
|
|
||||||
self._console.print(field, end="")
|
|
||||||
|
|
||||||
# Hint
|
|
||||||
hint = "ENTER=Aceptar ESC=Cancelar"
|
|
||||||
hx = left + max((box_w - len(hint)) // 2, 2)
|
|
||||||
sys.stdout.write(f"\033[{top + 4};{hx + 1}H")
|
|
||||||
hint_styled = Text(hint, style="dim cyan")
|
|
||||||
self._console.print(hint_styled, end="")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
key = self.get_key()
|
|
||||||
if key == Key.ESCAPE:
|
|
||||||
sys.stdout.write("\033[?25l")
|
|
||||||
sys.stdout.flush()
|
|
||||||
return None
|
|
||||||
elif key == Key.ENTER:
|
|
||||||
sys.stdout.write("\033[?25l")
|
|
||||||
sys.stdout.flush()
|
|
||||||
return "".join(buf)
|
|
||||||
elif key == Key.BACKSPACE:
|
|
||||||
if buf:
|
|
||||||
buf.pop()
|
|
||||||
elif 32 <= key <= 126:
|
|
||||||
if len(buf) < max_len:
|
|
||||||
buf.append(chr(key))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Module-level helpers ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _has_data(fd, timeout=0.05):
|
|
||||||
"""Return True if there is data waiting on file descriptor *fd*."""
|
|
||||||
r, _, _ = select.select([fd], [], [], timeout)
|
|
||||||
return bool(r)
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Admin CRUD screen for Cross-References in the AUTOPARTES console application.
|
Admin CRUD screen for Cross-References in the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Provides a paginated list view with create (F3), edit (ENTER), and
|
Provides a paginated list view with create (F3), edit (ENTER), and
|
||||||
delete (F8/Del) operations for the part_cross_references table.
|
delete (F8/Del) operations for the part_cross_references table.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Admin CRUD screen for Manufacturers in the AUTOPARTES console application.
|
Admin CRUD screen for Manufacturers in the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Provides a list view with create (F3), edit (ENTER), and delete (F8/Del)
|
Provides a list view with create (F3), edit (ENTER), and delete (F8/Del)
|
||||||
operations for the manufacturers table.
|
operations for the manufacturers table.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Import/Export screen for the AUTOPARTES console application.
|
Import/Export screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Provides a simple menu flow to import CSV files into the database or
|
Provides a simple menu flow to import CSV files into the database or
|
||||||
export data to JSON files. Uses the renderer's show_input and
|
export data to JSON files. Uses the renderer's show_input and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Admin CRUD screen for Parts in the AUTOPARTES console application.
|
Admin CRUD screen for Parts in the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Provides a paginated list view with create (F3), edit (ENTER), and
|
Provides a paginated list view with create (F3), edit (ENTER), and
|
||||||
delete (F8/Del) operations. Form editing is handled inline with
|
delete (F8/Del) operations. Form editing is handled inline with
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Part number search screen for the AUTOPARTES console application.
|
Part number search screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Prompts the user for a part number (OEM, aftermarket, or cross-reference)
|
Prompts the user for a part number (OEM, aftermarket, or cross-reference)
|
||||||
and displays matching results in a table. Selecting a result navigates
|
and displays matching results in a table. Selecting a result navigates
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Full-text search screen for the AUTOPARTES console application.
|
Full-text search screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Prompts the user for a search query and displays matching parts using
|
Prompts the user for a search query and displays matching parts using
|
||||||
the FTS5 full-text search engine (with LIKE fallback). Results are
|
the FTS5 full-text search engine (with LIKE fallback). Results are
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Catalog navigation screen for the AUTOPARTES console application.
|
Catalog navigation screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Provides a three-level drill-down through the parts hierarchy:
|
Provides a three-level drill-down through the parts hierarchy:
|
||||||
Categories -> Groups -> Parts. An optional vehicle filter (mye_id)
|
Categories -> Groups -> Parts. An optional vehicle filter (mye_id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Part comparator screen for the AUTOPARTES console application.
|
Part comparator screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Displays a side-by-side comparison of an OEM part against its aftermarket
|
Displays a side-by-side comparison of an OEM part against its aftermarket
|
||||||
alternatives. The first column is always the OEM part; subsequent columns
|
alternatives. The first column is always the OEM part; subsequent columns
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Statistics dashboard screen for the AUTOPARTES console application.
|
Statistics dashboard screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Displays database table counts and coverage metrics retrieved via
|
Displays database table counts and coverage metrics retrieved via
|
||||||
:meth:`Database.get_stats`.
|
:meth:`Database.get_stats`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Main menu screen for the AUTOPARTES console application.
|
Main menu screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Displays a numbered Pick-style menu with navigation options for all
|
Displays a numbered Pick-style menu with navigation options for all
|
||||||
application sections. Number keys jump directly; arrow keys move the
|
application sections. Number keys jump directly; arrow keys move the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Part detail screen for the AUTOPARTES console application.
|
Part detail screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Shows full part information (OEM number, name, group, category, etc.)
|
Shows full part information (OEM number, name, group, category, etc.)
|
||||||
with a table of aftermarket alternatives. Number keys navigate to
|
with a table of aftermarket alternatives. Number keys navigate to
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Vehicle drill-down navigation screen for the AUTOPARTES console application.
|
Vehicle drill-down navigation screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Guides the user through a four-level hierarchy:
|
Guides the user through a four-level hierarchy:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
VIN decoder screen for the AUTOPARTES console application.
|
VIN decoder screen for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Prompts for a 17-character Vehicle Identification Number, decodes it
|
Prompts for a 17-character Vehicle Identification Number, decodes it
|
||||||
via the NHTSA vPIC API (with local caching), and displays the decoded
|
via the NHTSA vPIC API (with local caching), and displays the decoded
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for the AUTOPARTES console application.
|
Integration tests for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Uses a MockRenderer that records draw calls instead of painting to a real
|
Uses a MockRenderer that records draw calls instead of painting to a real
|
||||||
terminal, allowing end-to-end testing of the screen -> renderer pipeline
|
terminal, allowing end-to-end testing of the screen -> renderer pipeline
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Display formatting utilities for the AUTOPARTES console application.
|
Display formatting utilities for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Functions for currency, numbers, text truncation, table layout, and
|
Functions for currency, numbers, text truncation, table layout, and
|
||||||
quality-tier visual bars.
|
quality-tier visual bars.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
NHTSA VIN Decoder API client for the AUTOPARTES console application.
|
NHTSA VIN Decoder API client for the NEXUS AUTOPARTS console application.
|
||||||
|
|
||||||
Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle
|
Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle
|
||||||
Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle
|
Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle
|
||||||
|
|||||||
@@ -3,78 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Panel - Autopartes DB</title>
|
<title>Admin Panel - Nexus Autoparts</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
<style>
|
<style>
|
||||||
|
/* Admin-specific variable overrides */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0a0a0f;
|
|
||||||
--bg-secondary: #12121a;
|
|
||||||
--bg-tertiary: #1a1a25;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #8888aa;
|
--text-secondary: #8888aa;
|
||||||
--accent: #ff6b35;
|
|
||||||
--accent-hover: #ff8555;
|
|
||||||
--success: #00d68f;
|
--success: #00d68f;
|
||||||
--warning: #ffaa00;
|
--warning: #ffaa00;
|
||||||
--danger: #ff4444;
|
|
||||||
--border: #2a2a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-family: 'Orbitron', sans-serif;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav a {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav a:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
|
padding-top: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
@@ -257,39 +201,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Forms */
|
/* Forms - admin-specific */
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input::placeholder {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
select.form-input {
|
select.form-input {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -451,28 +363,6 @@
|
|||||||
.badge-premium { background: #5a5a2a; color: #ffff7f; }
|
.badge-premium { background: #5a5a2a; color: #ffff7f; }
|
||||||
.badge-oem { background: #2a2a5a; color: #7f7fff; }
|
.badge-oem { background: #2a2a5a; color: #7f7fff; }
|
||||||
|
|
||||||
/* Alert */
|
|
||||||
.alert {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background: rgba(0, 214, 143, 0.1);
|
|
||||||
border: 1px solid var(--success);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error {
|
|
||||||
background: rgba(255, 68, 68, 0.1);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
/* Pagination */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -701,21 +591,12 @@
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- Shared Navigation -->
|
||||||
<header class="header">
|
<div id="shared-nav"></div>
|
||||||
<a href="/" class="logo">AUTOPARTES DB</a>
|
<script src="/nav.js"></script>
|
||||||
<nav class="header-nav">
|
|
||||||
<a href="/">Catálogo</a>
|
|
||||||
<a href="/customer-landing.html">Landing</a>
|
|
||||||
<a href="/admin.html" style="color: var(--accent);">Admin</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
@@ -768,6 +649,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Diagramas</h3>
|
||||||
|
<div class="sidebar-item" data-section="diagrams">
|
||||||
|
<span class="icon">📐</span>
|
||||||
|
<span>Hotspot Editor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Importar/Exportar</h3>
|
<h3>Importar/Exportar</h3>
|
||||||
<div class="sidebar-item" data-section="import">
|
<div class="sidebar-item" data-section="import">
|
||||||
@@ -779,6 +668,15 @@
|
|||||||
<span>Exportar CSV</span>
|
<span>Exportar CSV</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Sistema</h3>
|
||||||
|
<div class="sidebar-item" data-section="users">
|
||||||
|
<span class="icon">👤</span>
|
||||||
|
<span>Usuarios</span>
|
||||||
|
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
@@ -1237,6 +1135,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Diagrams / Hotspot Editor Section -->
|
||||||
|
<section id="section-diagrams" class="admin-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Editor de Hotspots</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||||
|
Busca un diagrama por código y haz clic en la imagen para agregar hotspots vinculados a partes.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
|
||||||
|
<input type="text" class="form-input" id="diagramSearchInput"
|
||||||
|
placeholder="Buscar diagrama (ej: F200, S341...)"
|
||||||
|
style="max-width: 300px;"
|
||||||
|
onkeypress="if(event.key==='Enter') searchDiagramsAdmin()">
|
||||||
|
<button class="btn btn-primary" onclick="searchDiagramsAdmin()">
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Diagram search results grid -->
|
||||||
|
<div id="diagramSearchResults" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;"></div>
|
||||||
|
|
||||||
|
<!-- Hotspot Editor Area (shown when a diagram is selected) -->
|
||||||
|
<div id="hotspotEditorArea" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h2 id="hotspotEditorTitle" style="margin: 0; font-size: 1.1rem;">Diagrama</h2>
|
||||||
|
<button class="btn btn-secondary" onclick="closeHotspotEditor()">Cerrar Editor</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap;">
|
||||||
|
<!-- Image with click-to-place -->
|
||||||
|
<div style="flex: 1; min-width: 400px; position: relative; background: #f0f0f0; border-radius: 8px; overflow: hidden; cursor: crosshair;" id="hotspotImageContainer">
|
||||||
|
<img id="hotspotEditorImg" src="" alt="Diagram"
|
||||||
|
style="width: 100%; display: block;"
|
||||||
|
onclick="onHotspotImageClick(event)">
|
||||||
|
<div id="hotspotMarkersContainer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hotspot form + list -->
|
||||||
|
<div style="width: 320px; flex-shrink: 0;">
|
||||||
|
<h3 style="font-size: 0.95rem; margin-bottom: 0.75rem;">Agregar / Editar Hotspot</h3>
|
||||||
|
<form id="hotspotForm" style="margin-bottom: 1rem;">
|
||||||
|
<input type="hidden" id="hsEditId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Posición (x%, y%)</label>
|
||||||
|
<input type="text" class="form-input" id="hsCoords" placeholder="Clic en imagen..." readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label"># Callout</label>
|
||||||
|
<input type="number" class="form-input" id="hsCallout" min="1" value="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Parte OEM (buscar)</label>
|
||||||
|
<input type="text" class="form-input" id="hsPartSearch"
|
||||||
|
placeholder="Buscar parte por nombre o #..."
|
||||||
|
oninput="searchPartsForHotspot(this.value)">
|
||||||
|
<select class="form-input" id="hsPartSelect" size="4" style="margin-top: 0.25rem; display: none;">
|
||||||
|
</select>
|
||||||
|
<input type="hidden" id="hsPartId">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Etiqueta</label>
|
||||||
|
<input type="text" class="form-input" id="hsLabel" placeholder="Opcional">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveHotspot()">Guardar</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="clearHotspotForm()">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 style="font-size: 0.95rem; margin-bottom: 0.5rem;">Hotspots Existentes</h3>
|
||||||
|
<div id="hotspotsList" style="max-height: 300px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Users Section -->
|
||||||
|
<section id="section-users" class="admin-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Usuarios</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Negocio</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Activo</th>
|
||||||
|
<th>Último Login</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersTable">
|
||||||
|
<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Admin Panel JavaScript
|
* Admin Panel JavaScript
|
||||||
* CRUD operations and CSV import/export for Autopartes DB
|
* CRUD operations and CSV import/export for Nexus Autoparts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -115,6 +115,12 @@ function showSection(sectionId) {
|
|||||||
case 'fitment':
|
case 'fitment':
|
||||||
loadFitment();
|
loadFitment();
|
||||||
break;
|
break;
|
||||||
|
case 'diagrams':
|
||||||
|
// Just show section, user uses search
|
||||||
|
break;
|
||||||
|
case 'users':
|
||||||
|
loadUsers();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,11 +1181,12 @@ async function loadVehiclesForSelect(selectId) {
|
|||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/model-year-engine');
|
const response = await fetch('/api/model-year-engine?per_page=100');
|
||||||
const vehicles = await response.json();
|
const result = await response.json();
|
||||||
|
const vehicles = result.data || result;
|
||||||
|
|
||||||
select.innerHTML = '<option value="">Selecciona vehículo...</option>' +
|
select.innerHTML = '<option value="">Selecciona vehículo...</option>' +
|
||||||
vehicles.slice(0, 100).map(v =>
|
vehicles.map(v =>
|
||||||
`<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>`
|
`<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1222,18 +1229,18 @@ function renderPagination(containerId, pagination, pageKey, loadFunction) {
|
|||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Previous button
|
// Previous button
|
||||||
html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, ${loadFunction.name})">← Anterior</button>`;
|
html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, '${loadFunction.name}')">← Anterior</button>`;
|
||||||
|
|
||||||
// Page numbers
|
// Page numbers
|
||||||
const startPage = Math.max(1, page - 2);
|
const startPage = Math.max(1, page - 2);
|
||||||
const endPage = Math.min(total_pages, page + 2);
|
const endPage = Math.min(total_pages, page + 2);
|
||||||
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, ${loadFunction.name})">${i}</button>`;
|
html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, '${loadFunction.name}')">${i}</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next button
|
// Next button
|
||||||
html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, ${loadFunction.name})">Siguiente →</button>`;
|
html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, '${loadFunction.name}')">Siguiente →</button>`;
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -1558,8 +1565,9 @@ async function loadBulkEngines() {
|
|||||||
const engines = await response.json();
|
const engines = await response.json();
|
||||||
|
|
||||||
// Get MYE IDs for each engine
|
// Get MYE IDs for each engine
|
||||||
const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}`);
|
const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}&per_page=100`);
|
||||||
const myeData = await myeResponse.json();
|
const myeResult = await myeResponse.json();
|
||||||
|
const myeData = myeResult.data || myeResult;
|
||||||
|
|
||||||
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>' +
|
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>' +
|
||||||
myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).join('');
|
myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).join('');
|
||||||
@@ -1707,3 +1715,362 @@ showSection = function(sectionId) {
|
|||||||
initBulkEditor();
|
initBulkEditor();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Diagram Hotspot Editor
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let currentEditorDiagramId = null;
|
||||||
|
let currentEditorHotspots = [];
|
||||||
|
let partSearchTimeout = null;
|
||||||
|
|
||||||
|
async function searchDiagramsAdmin() {
|
||||||
|
const q = document.getElementById('diagramSearchInput').value.trim();
|
||||||
|
const container = document.getElementById('diagramSearchResults');
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1">Ingresa un código de diagrama para buscar</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1"><i class="fas fa-spinner fa-spin"></i> Buscando...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/diagrams/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const diagrams = await res.json();
|
||||||
|
|
||||||
|
if (diagrams.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1">No se encontraron diagramas</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = diagrams.map(d => {
|
||||||
|
const imgSrc = d.image_path ? '/' + d.image_path : `/static/diagrams/moog/${d.name}.jpg`;
|
||||||
|
return `
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;overflow:hidden;cursor:pointer;transition:border-color 0.2s"
|
||||||
|
onclick="openHotspotEditor(${d.id})"
|
||||||
|
onmouseover="this.style.borderColor='var(--accent)'"
|
||||||
|
onmouseout="this.style.borderColor='var(--border)'">
|
||||||
|
<img src="${imgSrc}" alt="${d.name}" style="width:100%;height:120px;object-fit:contain;background:#f0f0f0;display:block"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<div style="padding:0.5rem 0.65rem">
|
||||||
|
<div style="font-weight:600;color:var(--accent)">${d.name}</div>
|
||||||
|
<div style="font-size:0.8rem;color:var(--text-secondary)">${d.name_es || d.source || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<p style="color:#e74c3c;grid-column:1/-1">Error al buscar diagramas</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHotspotEditor(diagramId) {
|
||||||
|
currentEditorDiagramId = diagramId;
|
||||||
|
document.getElementById('hotspotEditorArea').style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/diagrams/${diagramId}`);
|
||||||
|
const diagram = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('hotspotEditorTitle').textContent = `${diagram.name} - ${diagram.name_es || diagram.group_name || ''}`;
|
||||||
|
|
||||||
|
const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : '');
|
||||||
|
document.getElementById('hotspotEditorImg').src = imgSrc;
|
||||||
|
|
||||||
|
currentEditorHotspots = diagram.hotspots || [];
|
||||||
|
renderEditorHotspots();
|
||||||
|
clearHotspotForm();
|
||||||
|
|
||||||
|
// Auto-set next callout number
|
||||||
|
const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0);
|
||||||
|
document.getElementById('hsCallout').value = maxCallout + 1;
|
||||||
|
|
||||||
|
// Scroll to editor
|
||||||
|
document.getElementById('hotspotEditorArea').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
} catch (e) {
|
||||||
|
showAlert('Error al cargar diagrama', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHotspotEditor() {
|
||||||
|
document.getElementById('hotspotEditorArea').style.display = 'none';
|
||||||
|
currentEditorDiagramId = null;
|
||||||
|
currentEditorHotspots = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHotspotImageClick(event) {
|
||||||
|
const img = event.target;
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const xPct = ((event.clientX - rect.left) / rect.width * 100).toFixed(2);
|
||||||
|
const yPct = ((event.clientY - rect.top) / rect.height * 100).toFixed(2);
|
||||||
|
|
||||||
|
document.getElementById('hsCoords').value = `${xPct},${yPct}`;
|
||||||
|
|
||||||
|
// Show temporary marker
|
||||||
|
renderEditorHotspots();
|
||||||
|
const container = document.getElementById('hotspotMarkersContainer');
|
||||||
|
const tempMarker = document.createElement('div');
|
||||||
|
tempMarker.style.cssText = `position:absolute;left:${xPct}%;top:${yPct}%;width:24px;height:24px;border-radius:50%;background:rgba(46,204,113,0.5);border:2px solid #2ecc71;transform:translate(-50%,-50%);pointer-events:none;z-index:10`;
|
||||||
|
container.appendChild(tempMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorHotspots() {
|
||||||
|
const container = document.getElementById('hotspotMarkersContainer');
|
||||||
|
const list = document.getElementById('hotspotsList');
|
||||||
|
|
||||||
|
// Markers on image
|
||||||
|
container.innerHTML = currentEditorHotspots.map(h => {
|
||||||
|
const coords = (h.coords || '').split(',');
|
||||||
|
if (coords.length < 2) return '';
|
||||||
|
return `<div style="position:absolute;left:${coords[0]}%;top:${coords[1]}%;width:24px;height:24px;border-radius:50%;background:rgba(231,76,60,0.4);border:2px solid #e74c3c;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;font-size:0.6rem;font-weight:700;color:white;pointer-events:auto;cursor:pointer" onclick="editHotspot(${h.id})" title="${h.label || h.part_name || ''}">${h.callout_number || ''}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// List
|
||||||
|
if (currentEditorHotspots.length === 0) {
|
||||||
|
list.innerHTML = '<p style="color:var(--text-secondary);font-size:0.85rem">No hay hotspots</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = currentEditorHotspots.map(h => `
|
||||||
|
<div style="background:var(--bg-hover);border:1px solid var(--border);border-radius:6px;padding:0.5rem;margin-bottom:0.4rem;display:flex;align-items:center;gap:0.5rem">
|
||||||
|
<span style="background:var(--accent);color:white;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0">${h.callout_number || '?'}</span>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:0.82rem;font-weight:500">${h.part_name || h.label || 'Sin parte'}</div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-secondary)">${h.part_number || ''} | ${h.coords}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem" onclick="editHotspot(${h.id})">Editar</button>
|
||||||
|
<button class="btn" style="padding:0.2rem 0.5rem;font-size:0.75rem;background:#e74c3c;color:white;border:none;border-radius:4px;cursor:pointer" onclick="deleteHotspot(${h.id})">Borrar</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editHotspot(hotspotId) {
|
||||||
|
const hs = currentEditorHotspots.find(h => h.id === hotspotId);
|
||||||
|
if (!hs) return;
|
||||||
|
|
||||||
|
document.getElementById('hsEditId').value = hs.id;
|
||||||
|
document.getElementById('hsCoords').value = hs.coords || '';
|
||||||
|
document.getElementById('hsCallout').value = hs.callout_number || '';
|
||||||
|
document.getElementById('hsLabel').value = hs.label || '';
|
||||||
|
document.getElementById('hsPartId').value = hs.part_id || '';
|
||||||
|
document.getElementById('hsPartSearch').value = hs.part_name ? `${hs.part_number} - ${hs.part_name}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHotspotForm() {
|
||||||
|
document.getElementById('hsEditId').value = '';
|
||||||
|
document.getElementById('hsCoords').value = '';
|
||||||
|
document.getElementById('hsLabel').value = '';
|
||||||
|
document.getElementById('hsPartId').value = '';
|
||||||
|
document.getElementById('hsPartSearch').value = '';
|
||||||
|
document.getElementById('hsPartSelect').style.display = 'none';
|
||||||
|
|
||||||
|
// Keep callout at next number
|
||||||
|
const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0);
|
||||||
|
document.getElementById('hsCallout').value = maxCallout + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPartsForHotspot(query) {
|
||||||
|
clearTimeout(partSearchTimeout);
|
||||||
|
const select = document.getElementById('hsPartSelect');
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
select.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
partSearchTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/parts?search=${encodeURIComponent(query)}&per_page=20`);
|
||||||
|
const data = await res.json();
|
||||||
|
const parts = data.data || data;
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
select.innerHTML = '<option disabled>Sin resultados</option>';
|
||||||
|
} else {
|
||||||
|
select.innerHTML = parts.map(p =>
|
||||||
|
`<option value="${p.id}">${p.oem_part_number} - ${p.name_es || p.name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
select.style.display = 'block';
|
||||||
|
|
||||||
|
select.onchange = function() {
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
document.getElementById('hsPartId').value = opt.value;
|
||||||
|
document.getElementById('hsPartSearch').value = opt.textContent;
|
||||||
|
select.style.display = 'none';
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
select.innerHTML = '<option disabled>Error buscando</option>';
|
||||||
|
select.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHotspot() {
|
||||||
|
const editId = document.getElementById('hsEditId').value;
|
||||||
|
const coords = document.getElementById('hsCoords').value.trim();
|
||||||
|
const callout = parseInt(document.getElementById('hsCallout').value) || null;
|
||||||
|
const partId = parseInt(document.getElementById('hsPartId').value) || null;
|
||||||
|
const label = document.getElementById('hsLabel').value.trim();
|
||||||
|
|
||||||
|
if (!coords) {
|
||||||
|
showAlert('Haz clic en la imagen para seleccionar posición', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
diagram_id: currentEditorDiagramId,
|
||||||
|
coords: coords,
|
||||||
|
callout_number: callout,
|
||||||
|
part_id: partId,
|
||||||
|
label: label,
|
||||||
|
shape: 'circle',
|
||||||
|
color: '#e74c3c'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (editId) {
|
||||||
|
res = await fetch(`/api/admin/hotspots/${editId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch('/api/admin/hotspots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) throw new Error(result.error || 'Error al guardar');
|
||||||
|
|
||||||
|
showAlert(editId ? 'Hotspot actualizado' : 'Hotspot creado');
|
||||||
|
|
||||||
|
// Reload diagram to refresh hotspots
|
||||||
|
await openHotspotEditor(currentEditorDiagramId);
|
||||||
|
} catch (e) {
|
||||||
|
showAlert(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHotspot(hotspotId) {
|
||||||
|
if (!confirm('Eliminar este hotspot?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/hotspots/${hotspotId}`, { method: 'DELETE' });
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) throw new Error(result.error || 'Error al eliminar');
|
||||||
|
|
||||||
|
showAlert('Hotspot eliminado');
|
||||||
|
await openHotspotEditor(currentEditorDiagramId);
|
||||||
|
} catch (e) {
|
||||||
|
showAlert(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// User Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const roleBadgeColors = {
|
||||||
|
ADMIN: '#3b82f6',
|
||||||
|
OWNER: '#8b5cf6',
|
||||||
|
TALLER: '#22c55e',
|
||||||
|
BODEGA: '#f59e0b'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '<span style="color:var(--text-secondary)">Nunca</span>';
|
||||||
|
var d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
+ ' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleBadge(role) {
|
||||||
|
var color = roleBadgeColors[role] || '#6b7280';
|
||||||
|
return '<span style="background:' + color + '; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">' + (role || 'N/A') + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBadge(isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
return '<span style="background:var(--success); color:#000; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Activo</span>';
|
||||||
|
}
|
||||||
|
return '<span style="background:#ef4444; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Inactivo</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
var token = localStorage.getItem('access_token');
|
||||||
|
var tbody = document.getElementById('usersTable');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/admin/users', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Error al cargar usuarios (' + res.status + ')');
|
||||||
|
var data = await res.json();
|
||||||
|
var users = Array.isArray(data) ? data : (data.data || []);
|
||||||
|
|
||||||
|
// Update pending badge
|
||||||
|
var pending = users.filter(function(u) { return !u.is_active; }).length;
|
||||||
|
var badge = document.getElementById('pendingUsersBadge');
|
||||||
|
if (badge) {
|
||||||
|
if (pending > 0) {
|
||||||
|
badge.textContent = pending;
|
||||||
|
badge.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay usuarios registrados</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = users.map(function(u) {
|
||||||
|
var toggleLabel = u.is_active ? 'Desactivar' : 'Activar';
|
||||||
|
var toggleClass = u.is_active ? 'btn-secondary' : 'btn-primary';
|
||||||
|
return '<tr>' +
|
||||||
|
'<td>' + (u.name || u.nombre || '-') + '</td>' +
|
||||||
|
'<td>' + (u.email || '-') + '</td>' +
|
||||||
|
'<td>' + (u.business_name || u.negocio || '-') + '</td>' +
|
||||||
|
'<td>' + getRoleBadge(u.role || u.rol) + '</td>' +
|
||||||
|
'<td>' + getActiveBadge(u.is_active) + '</td>' +
|
||||||
|
'<td>' + formatDate(u.last_login || u.ultimo_login) + '</td>' +
|
||||||
|
'<td><button class="btn ' + toggleClass + '" style="font-size:0.8rem; padding:4px 10px;" onclick="toggleUserActive(' + u.id + ', ' + u.is_active + ')">' + toggleLabel + '</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserActive(userId, currentActive) {
|
||||||
|
var token = localStorage.getItem('access_token');
|
||||||
|
var action = currentActive ? 'desactivar' : 'activar';
|
||||||
|
if (!confirm('¿Seguro que deseas ' + action + ' este usuario?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/admin/users/' + userId + '/activate', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: !currentActive })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
var err = await res.json();
|
||||||
|
throw new Error(err.error || 'Error al actualizar usuario');
|
||||||
|
}
|
||||||
|
showAlert('Usuario ' + (currentActive ? 'desactivado' : 'activado') + ' correctamente');
|
||||||
|
loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
showAlert(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
98
dashboard/auth.py
Normal file
98
dashboard/auth.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
JWT authentication module for Nexus Autoparts.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import jwt
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
sys.path.insert(0, '/home/Autopartes')
|
||||||
|
from config import DB_URL, JWT_SECRET, JWT_ACCESS_EXPIRES, JWT_REFRESH_EXPIRES
|
||||||
|
|
||||||
|
from flask import request, g, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
"""Hash a password using bcrypt."""
|
||||||
|
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(password, hashed):
|
||||||
|
"""Verify a password against a bcrypt hash."""
|
||||||
|
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id, role, business_name):
|
||||||
|
"""Create a JWT access token with 15-minute expiry."""
|
||||||
|
payload = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'role': role,
|
||||||
|
'business_name': business_name,
|
||||||
|
'type': 'access',
|
||||||
|
'exp': datetime.utcnow() + timedelta(seconds=JWT_ACCESS_EXPIRES),
|
||||||
|
'iat': datetime.utcnow()
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id):
|
||||||
|
"""Create a random refresh token and store it in the sessions table."""
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
expires_at = datetime.utcnow() + timedelta(seconds=JWT_REFRESH_EXPIRES)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO sessions (user_id, refresh_token, expires_at, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s)""",
|
||||||
|
(user_id, token, expires_at, datetime.utcnow())
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token):
|
||||||
|
"""Decode a JWT token. Returns the payload dict or None if invalid/expired."""
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||||
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(*allowed_roles):
|
||||||
|
"""Flask decorator that validates Bearer token, checks role, and sets g.user."""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if not auth_header.startswith('Bearer '):
|
||||||
|
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
||||||
|
|
||||||
|
token = auth_header[7:] # strip "Bearer "
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return jsonify({'error': 'Invalid or expired token'}), 401
|
||||||
|
|
||||||
|
if payload.get('type') != 'access':
|
||||||
|
return jsonify({'error': 'Invalid token type'}), 401
|
||||||
|
|
||||||
|
if allowed_roles and payload.get('role') not in allowed_roles:
|
||||||
|
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||||
|
|
||||||
|
g.user = {
|
||||||
|
'user_id': payload['user_id'],
|
||||||
|
'role': payload['role'],
|
||||||
|
'business_name': payload.get('business_name')
|
||||||
|
}
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
return decorator
|
||||||
485
dashboard/bodega.css
Normal file
485
dashboard/bodega.css
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
/* ============================================================
|
||||||
|
bodega.css -- Styles for Nexus Autoparts Warehouse (Bodega)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Layout --- */
|
||||||
|
.bodega-container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 5.5rem 2rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
.bodega-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-tab {
|
||||||
|
padding: 0.8rem 1.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Section Intro --- */
|
||||||
|
.section-intro {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mapping Form --- */
|
||||||
|
.mapping-form {
|
||||||
|
max-width: 550px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-form .form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-form .form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Upload Zone --- */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: var(--bg-card);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover,
|
||||||
|
.upload-zone.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 107, 53, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Selected File --- */
|
||||||
|
.selected-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Upload Result --- */
|
||||||
|
.upload-result {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result h4 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat.ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat.err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-samples {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-samples p {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- History --- */
|
||||||
|
.history-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tables --- */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody td {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status Badges --- */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-processing {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Inventory Toolbar --- */
|
||||||
|
.inventory-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pagination --- */
|
||||||
|
.bodega-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-pagination button {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-pagination button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-pagination button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-pagination button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Confirm Modal --- */
|
||||||
|
.confirm-box {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-box h3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-box p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toast --- */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bodega-container {
|
||||||
|
padding: 5rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodega-tab {
|
||||||
|
padding: 0.7rem 1.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
dashboard/bodega.html
Normal file
164
dashboard/bodega.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bodega — NEXUS AUTOPARTS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/bodega.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shared-nav"></div>
|
||||||
|
|
||||||
|
<div class="bodega-container">
|
||||||
|
<!-- Main Tabs -->
|
||||||
|
<div class="bodega-tabs">
|
||||||
|
<button class="bodega-tab active" data-tab="mapeo">Mapeo de Columnas</button>
|
||||||
|
<button class="bodega-tab" data-tab="subir">Subir Inventario</button>
|
||||||
|
<button class="bodega-tab" data-tab="inventario">Mi Inventario</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- TAB 1: Mapeo de Columnas -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-mapeo" class="bodega-section active">
|
||||||
|
<div class="section-intro">
|
||||||
|
<h2>Mapeo de Columnas</h2>
|
||||||
|
<p>Configura como se mapean las columnas de tu archivo CSV/Excel a los campos del sistema. Escribe el nombre exacto de la columna en tu archivo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapping-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Numero de Parte <span class="required">*</span></label>
|
||||||
|
<input id="map-part-number" type="text" class="form-input" placeholder="Ej: PartNo, SKU, Numero...">
|
||||||
|
<span class="form-hint">Campo del sistema: part_number</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Precio <span class="required">*</span></label>
|
||||||
|
<input id="map-price" type="text" class="form-input" placeholder="Ej: Precio, Price, Costo...">
|
||||||
|
<span class="form-hint">Campo del sistema: price</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Existencias <span class="required">*</span></label>
|
||||||
|
<input id="map-stock" type="text" class="form-input" placeholder="Ej: Stock, Qty, Existencia...">
|
||||||
|
<span class="form-hint">Campo del sistema: stock</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ubicacion / Sucursal <span class="optional">(opcional)</span></label>
|
||||||
|
<input id="map-location" type="text" class="form-input" placeholder="Ej: Sucursal, Bodega, Location...">
|
||||||
|
<span class="form-hint">Campo del sistema: location</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="btn-save-mapping" class="btn btn-primary">Guardar Mapeo</button>
|
||||||
|
<span id="mapping-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- TAB 2: Subir Inventario -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-subir" class="bodega-section">
|
||||||
|
<div class="section-intro">
|
||||||
|
<h2>Subir Inventario</h2>
|
||||||
|
<p>Sube un archivo CSV o Excel con tu inventario. Asegurate de haber configurado el mapeo de columnas primero.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="drop-zone">
|
||||||
|
<div class="upload-icon">📦</div>
|
||||||
|
<p class="upload-text">Arrastra tu archivo aqui o haz clic para seleccionar</p>
|
||||||
|
<p class="upload-hint">CSV, XLS, XLSX — Max 10MB</p>
|
||||||
|
<input type="file" id="file-input" accept=".csv,.xls,.xlsx" style="display:none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="selected-file" class="selected-file" style="display:none;">
|
||||||
|
<span id="selected-file-name"></span>
|
||||||
|
<button id="btn-clear-file" class="btn-icon" title="Quitar archivo">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="btn-upload" class="btn btn-primary" disabled>Subir Archivo</button>
|
||||||
|
<span id="upload-status" class="status-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-result" class="upload-result" style="display:none;"></div>
|
||||||
|
|
||||||
|
<div class="history-section">
|
||||||
|
<h3>Historial de Cargas</h3>
|
||||||
|
<div id="upload-history" class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Importados</th>
|
||||||
|
<th>Errores</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body">
|
||||||
|
<tr><td colspan="5" class="empty-row">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- TAB 3: Mi Inventario -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-inventario" class="bodega-section">
|
||||||
|
<div class="section-intro">
|
||||||
|
<h2>Mi Inventario</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inventory-toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<input id="inv-search" type="text" class="form-input" placeholder="Buscar por numero de parte o nombre...">
|
||||||
|
<button id="btn-inv-search" class="btn btn-primary">Buscar</button>
|
||||||
|
</div>
|
||||||
|
<button id="btn-clear-all" class="btn btn-danger">Limpiar Todo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Numero de Parte</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>Existencias</th>
|
||||||
|
<th>Ubicacion</th>
|
||||||
|
<th>Actualizado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="inv-body">
|
||||||
|
<tr><td colspan="6" class="empty-row">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="inv-pagination" class="bodega-pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Modal -->
|
||||||
|
<div id="confirm-modal" class="modal-overlay">
|
||||||
|
<div class="confirm-box">
|
||||||
|
<h3 id="confirm-title">Confirmar</h3>
|
||||||
|
<p id="confirm-msg"></p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button id="confirm-cancel" class="btn btn-secondary">Cancelar</button>
|
||||||
|
<button id="confirm-ok" class="btn btn-danger">Confirmar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/nav.js"></script>
|
||||||
|
<script src="/bodega.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
522
dashboard/bodega.js
Normal file
522
dashboard/bodega.js
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
/**
|
||||||
|
* bodega.js — Warehouse (Bodega) dashboard for Nexus Autoparts
|
||||||
|
* Tabs: Mapeo de Columnas | Subir Inventario | Mi Inventario
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '';
|
||||||
|
var selectedFile = null;
|
||||||
|
var invPage = 1;
|
||||||
|
var invQuery = '';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Auth helpers
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('access_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole() {
|
||||||
|
var token = getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.role || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(extra) {
|
||||||
|
var h = { 'Authorization': 'Bearer ' + getToken() };
|
||||||
|
if (extra) {
|
||||||
|
for (var k in extra) { h[k] = extra[k]; }
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAuth() {
|
||||||
|
var token = getToken();
|
||||||
|
var role = getRole();
|
||||||
|
if (!token || (role !== 'BODEGA' && role !== 'ADMIN')) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRefreshToken() {
|
||||||
|
var refresh = localStorage.getItem('refresh_token');
|
||||||
|
if (!refresh) return Promise.reject(new Error('No refresh token'));
|
||||||
|
return fetch(API + '/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refresh })
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Refresh failed');
|
||||||
|
return r.json();
|
||||||
|
}).then(function (data) {
|
||||||
|
if (data.access_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// API helper with 401 retry
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (!opts.headers) opts.headers = {};
|
||||||
|
opts.headers['Authorization'] = 'Bearer ' + getToken();
|
||||||
|
|
||||||
|
return fetch(API + path, opts).then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
return tryRefreshToken().then(function () {
|
||||||
|
opts.headers['Authorization'] = 'Bearer ' + getToken();
|
||||||
|
return fetch(API + path, opts);
|
||||||
|
}).then(function (r2) {
|
||||||
|
if (!r2.ok) return r2.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||||
|
return r2.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Utilities
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, type) {
|
||||||
|
var container = document.getElementById('toast-container');
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + (type || 'success');
|
||||||
|
el.textContent = msg;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(function () { el.remove(); }, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
var d = new Date(s);
|
||||||
|
return d.toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
var n = parseFloat(v);
|
||||||
|
if (isNaN(n)) return '—';
|
||||||
|
return '$' + n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
var map = {
|
||||||
|
'completed': { cls: 'badge-success', label: 'Completado' },
|
||||||
|
'success': { cls: 'badge-success', label: 'Completado' },
|
||||||
|
'error': { cls: 'badge-error', label: 'Error' },
|
||||||
|
'failed': { cls: 'badge-error', label: 'Fallido' },
|
||||||
|
'pending': { cls: 'badge-pending', label: 'Pendiente' },
|
||||||
|
'processing': { cls: 'badge-processing', label: 'Procesando' }
|
||||||
|
};
|
||||||
|
var info = map[(status || '').toLowerCase()] || { cls: 'badge-pending', label: status || 'Desconocido' };
|
||||||
|
return '<span class="badge ' + info.cls + '">' + esc(info.label) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Tab Switching
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
document.querySelectorAll('.bodega-tab').forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.bodega-tab').forEach(function (t) { t.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.bodega-section').forEach(function (s) { s.classList.remove('active'); });
|
||||||
|
tab.classList.add('active');
|
||||||
|
var section = document.getElementById('section-' + tab.getAttribute('data-tab'));
|
||||||
|
if (section) section.classList.add('active');
|
||||||
|
|
||||||
|
// Load data when switching tabs
|
||||||
|
var target = tab.getAttribute('data-tab');
|
||||||
|
if (target === 'subir') loadUploadHistory();
|
||||||
|
if (target === 'inventario') loadInventory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// TAB 1: Mapeo de Columnas
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function loadMapping() {
|
||||||
|
api('/api/inventory/mapping').then(function (data) {
|
||||||
|
if (data.part_number) document.getElementById('map-part-number').value = data.part_number;
|
||||||
|
if (data.price) document.getElementById('map-price').value = data.price;
|
||||||
|
if (data.stock) document.getElementById('map-stock').value = data.stock;
|
||||||
|
if (data.location) document.getElementById('map-location').value = data.location;
|
||||||
|
}).catch(function () {
|
||||||
|
// No mapping yet — fields stay empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-save-mapping').addEventListener('click', function () {
|
||||||
|
var partNumber = document.getElementById('map-part-number').value.trim();
|
||||||
|
var price = document.getElementById('map-price').value.trim();
|
||||||
|
var stock = document.getElementById('map-stock').value.trim();
|
||||||
|
var location = document.getElementById('map-location').value.trim();
|
||||||
|
var statusEl = document.getElementById('mapping-status');
|
||||||
|
|
||||||
|
if (!partNumber || !price || !stock) {
|
||||||
|
statusEl.textContent = 'Completa los campos obligatorios.';
|
||||||
|
statusEl.className = 'status-msg error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = {
|
||||||
|
part_number: partNumber,
|
||||||
|
price: price,
|
||||||
|
stock: stock,
|
||||||
|
location: location || null
|
||||||
|
};
|
||||||
|
|
||||||
|
statusEl.textContent = 'Guardando...';
|
||||||
|
statusEl.className = 'status-msg';
|
||||||
|
|
||||||
|
api('/api/inventory/mapping', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function () {
|
||||||
|
statusEl.textContent = 'Mapeo guardado correctamente.';
|
||||||
|
statusEl.className = 'status-msg success';
|
||||||
|
toast('Mapeo guardado', 'success');
|
||||||
|
}).catch(function (err) {
|
||||||
|
statusEl.textContent = err.message || 'Error al guardar.';
|
||||||
|
statusEl.className = 'status-msg error';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// TAB 2: Subir Inventario
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var dropZone = document.getElementById('drop-zone');
|
||||||
|
var fileInput = document.getElementById('file-input');
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', function () { fileInput.click(); });
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragleave', function () {
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
selectFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
if (fileInput.files.length) {
|
||||||
|
selectFile(fileInput.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectFile(file) {
|
||||||
|
var validTypes = [
|
||||||
|
'text/csv',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
];
|
||||||
|
var ext = (file.name || '').split('.').pop().toLowerCase();
|
||||||
|
if (validTypes.indexOf(file.type) === -1 && ['csv', 'xls', 'xlsx'].indexOf(ext) === -1) {
|
||||||
|
toast('Formato no soportado. Usa CSV o Excel.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast('El archivo excede 10MB.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile = file;
|
||||||
|
document.getElementById('selected-file-name').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
||||||
|
document.getElementById('selected-file').style.display = 'flex';
|
||||||
|
document.getElementById('btn-upload').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-file').addEventListener('click', function () {
|
||||||
|
selectedFile = null;
|
||||||
|
fileInput.value = '';
|
||||||
|
document.getElementById('selected-file').style.display = 'none';
|
||||||
|
document.getElementById('btn-upload').disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-upload').addEventListener('click', function () {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('btn-upload');
|
||||||
|
var statusEl = document.getElementById('upload-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
statusEl.textContent = 'Subiendo...';
|
||||||
|
statusEl.className = 'status-msg';
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', selectedFile);
|
||||||
|
|
||||||
|
fetch(API + '/api/inventory/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||||
|
body: fd
|
||||||
|
}).then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
return tryRefreshToken().then(function () {
|
||||||
|
return fetch(API + '/api/inventory/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (data) {
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Error al subir');
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}).then(function (data) {
|
||||||
|
statusEl.textContent = '';
|
||||||
|
showUploadResult(data);
|
||||||
|
toast('Archivo procesado correctamente.', 'success');
|
||||||
|
loadUploadHistory();
|
||||||
|
// Clear file selection
|
||||||
|
selectedFile = null;
|
||||||
|
fileInput.value = '';
|
||||||
|
document.getElementById('selected-file').style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
}).catch(function (err) {
|
||||||
|
statusEl.textContent = err.message || 'Error al subir archivo.';
|
||||||
|
statusEl.className = 'status-msg error';
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function showUploadResult(data) {
|
||||||
|
var el = document.getElementById('upload-result');
|
||||||
|
var imported = data.imported || data.imported_count || 0;
|
||||||
|
var errors = data.errors || data.error_count || 0;
|
||||||
|
var samples = data.error_samples || [];
|
||||||
|
|
||||||
|
var html = '<h4>Resultado de la Carga</h4>';
|
||||||
|
html += '<div class="result-stats">';
|
||||||
|
html += '<span class="result-stat ok">Importados: ' + imported + '</span>';
|
||||||
|
html += '<span class="result-stat err">Errores: ' + errors + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (samples.length) {
|
||||||
|
html += '<div class="error-samples">';
|
||||||
|
html += '<strong>Ejemplos de errores:</strong>';
|
||||||
|
samples.forEach(function (s) {
|
||||||
|
html += '<p>' + esc(typeof s === 'string' ? s : JSON.stringify(s)) + '</p>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUploadHistory() {
|
||||||
|
api('/api/inventory/uploads').then(function (data) {
|
||||||
|
var rows = data.uploads || data.data || data || [];
|
||||||
|
var tbody = document.getElementById('history-body');
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-row">Sin cargas previas</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(function (r) {
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td>' + esc(r.filename || r.archivo || '—') + '</td>'
|
||||||
|
+ '<td>' + statusBadge(r.status || r.estado) + '</td>'
|
||||||
|
+ '<td>' + (r.imported_count != null ? r.imported_count : (r.importados != null ? r.importados : '—')) + '</td>'
|
||||||
|
+ '<td>' + (r.error_count != null ? r.error_count : (r.errores != null ? r.errores : '—')) + '</td>'
|
||||||
|
+ '<td>' + fmtDate(r.created_at || r.fecha) + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('');
|
||||||
|
}).catch(function () {
|
||||||
|
document.getElementById('history-body').innerHTML =
|
||||||
|
'<tr><td colspan="5" class="empty-row">Error al cargar historial</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// TAB 3: Mi Inventario
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function loadInventory() {
|
||||||
|
var params = '?page=' + invPage;
|
||||||
|
if (invQuery) params += '&q=' + encodeURIComponent(invQuery);
|
||||||
|
|
||||||
|
api('/api/inventory/items' + params).then(function (data) {
|
||||||
|
var items = data.data || data.items || [];
|
||||||
|
var pagination = data.pagination || {};
|
||||||
|
var tbody = document.getElementById('inv-body');
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-row">Sin articulos en inventario</td></tr>';
|
||||||
|
renderPagination(pagination);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(function (it) {
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td><strong>' + esc(it.part_number) + '</strong></td>'
|
||||||
|
+ '<td>' + esc(it.name || it.nombre || '—') + '</td>'
|
||||||
|
+ '<td>' + fmtPrice(it.price || it.precio) + '</td>'
|
||||||
|
+ '<td>' + (it.stock != null ? it.stock : (it.existencias != null ? it.existencias : '—')) + '</td>'
|
||||||
|
+ '<td>' + esc(it.location || it.ubicacion || '—') + '</td>'
|
||||||
|
+ '<td>' + fmtDate(it.updated_at || it.actualizado) + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
renderPagination(pagination);
|
||||||
|
}).catch(function () {
|
||||||
|
document.getElementById('inv-body').innerHTML =
|
||||||
|
'<tr><td colspan="6" class="empty-row">Error al cargar inventario</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(pg) {
|
||||||
|
var container = document.getElementById('inv-pagination');
|
||||||
|
if (!pg || !pg.total_pages || pg.total_pages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = pg.page || pg.current_page || 1;
|
||||||
|
var total = pg.total_pages;
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
html += '<button ' + (current <= 1 ? 'disabled' : '') + ' data-page="' + (current - 1) + '">Anterior</button>';
|
||||||
|
|
||||||
|
var start = Math.max(1, current - 2);
|
||||||
|
var end = Math.min(total, current + 2);
|
||||||
|
|
||||||
|
if (start > 1) {
|
||||||
|
html += '<button data-page="1">1</button>';
|
||||||
|
if (start > 2) html += '<button disabled>...</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = start; i <= end; i++) {
|
||||||
|
html += '<button data-page="' + i + '"' + (i === current ? ' class="active"' : '') + '>' + i + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end < total) {
|
||||||
|
if (end < total - 1) html += '<button disabled>...</button>';
|
||||||
|
html += '<button data-page="' + total + '">' + total + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<button ' + (current >= total ? 'disabled' : '') + ' data-page="' + (current + 1) + '">Siguiente</button>';
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('button[data-page]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
invPage = parseInt(btn.getAttribute('data-page'), 10);
|
||||||
|
loadInventory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
document.getElementById('btn-inv-search').addEventListener('click', function () {
|
||||||
|
invQuery = document.getElementById('inv-search').value.trim();
|
||||||
|
invPage = 1;
|
||||||
|
loadInventory();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('inv-search').addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
invQuery = this.value.trim();
|
||||||
|
invPage = 1;
|
||||||
|
loadInventory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear All
|
||||||
|
document.getElementById('btn-clear-all').addEventListener('click', function () {
|
||||||
|
showConfirm(
|
||||||
|
'Limpiar Inventario',
|
||||||
|
'Se eliminaran todos los articulos de tu inventario. Esta accion no se puede deshacer.',
|
||||||
|
function () {
|
||||||
|
api('/api/inventory/items', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
|
}).then(function () {
|
||||||
|
toast('Inventario limpiado correctamente.', 'success');
|
||||||
|
loadInventory();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message || 'Error al limpiar inventario.', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Confirm Modal
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var confirmCallback = null;
|
||||||
|
|
||||||
|
function showConfirm(title, msg, onConfirm) {
|
||||||
|
document.getElementById('confirm-title').textContent = title;
|
||||||
|
document.getElementById('confirm-msg').textContent = msg;
|
||||||
|
document.getElementById('confirm-modal').classList.add('active');
|
||||||
|
confirmCallback = onConfirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('confirm-cancel').addEventListener('click', function () {
|
||||||
|
document.getElementById('confirm-modal').classList.remove('active');
|
||||||
|
confirmCallback = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirm-ok').addEventListener('click', function () {
|
||||||
|
document.getElementById('confirm-modal').classList.remove('active');
|
||||||
|
if (confirmCallback) {
|
||||||
|
confirmCallback();
|
||||||
|
confirmCallback = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirm-modal').addEventListener('click', function (e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
this.classList.remove('active');
|
||||||
|
confirmCallback = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Init
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (checkAuth()) {
|
||||||
|
loadMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
660
dashboard/captura.css
Normal file
660
dashboard/captura.css
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
/* ============================================================
|
||||||
|
captura.css -- Styles for Nexus Autoparts Data Entry
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
.captura-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-tab {
|
||||||
|
padding: 0.8rem 1.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-tab .tab-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vehicle Selector (Section 1) --- */
|
||||||
|
.vehicle-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-filters .filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-filters label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-filters select,
|
||||||
|
.vehicle-filters input {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-filters select:focus,
|
||||||
|
.vehicle-filters input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vehicle List --- */
|
||||||
|
.vehicle-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card .vc-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card .vc-model {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card .vc-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-card .vc-parts-count {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Vehicle Header (when editing) --- */
|
||||||
|
.vehicle-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-header .vh-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-header .vh-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-header .vh-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-header .vh-brand { color: var(--accent); }
|
||||||
|
|
||||||
|
.vehicle-header .vh-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Part Groups Table --- */
|
||||||
|
.category-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header .cat-toggle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header.collapsed .cat-toggle {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-body {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-body.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-section {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Part Rows --- */
|
||||||
|
.part-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-oem {
|
||||||
|
width: 160px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-qty {
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-btn {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-save {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-save:hover { background: #1ea34e; }
|
||||||
|
|
||||||
|
.part-row .pr-delete {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row .pr-delete:hover { background: #cc3333; }
|
||||||
|
|
||||||
|
.part-row.saved {
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-row.saved input {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-part {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-part:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Progress Bar --- */
|
||||||
|
.progress-bar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar .progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Section 2: Intercambios --- */
|
||||||
|
.part-detail-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-detail-card .pdc-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-detail-card .pdc-oem {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-detail-card .pdc-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-detail-card .pdc-group {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-table td {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-form .af-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-form label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-form select,
|
||||||
|
.aftermarket-form input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftermarket-form select:focus,
|
||||||
|
.aftermarket-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Section 3: Imágenes --- */
|
||||||
|
.image-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-preview {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-oem {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-upload {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card .ic-upload input[type="file"] {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Search bar --- */
|
||||||
|
.captura-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-search input {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pagination --- */
|
||||||
|
.captura-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-pagination button {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-pagination button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-pagination button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captura-pagination .page-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty state --- */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .es-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .es-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toast notifications --- */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toastIn 0.3s ease;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading spinner --- */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Layout --- */
|
||||||
|
.captura-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status tabs for vehicles --- */
|
||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tab {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tab:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.status-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
99
dashboard/captura.html
Normal file
99
dashboard/captura.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Captura de Datos — NEXUS AUTOPARTS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/captura.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shared-nav"></div>
|
||||||
|
|
||||||
|
<div class="captura-container">
|
||||||
|
<!-- Main Tabs -->
|
||||||
|
<div class="captura-tabs">
|
||||||
|
<button class="captura-tab active" data-tab="oem">Partes OEM</button>
|
||||||
|
<button class="captura-tab" data-tab="aftermarket">Intercambios</button>
|
||||||
|
<button class="captura-tab" data-tab="images">Imagenes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- SECTION 1: OEM Parts Entry -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-oem" class="captura-section active">
|
||||||
|
<!-- Vehicle selection view -->
|
||||||
|
<div id="oem-vehicle-select">
|
||||||
|
<div class="status-tabs">
|
||||||
|
<button class="status-tab active" data-status="pending">Pendientes</button>
|
||||||
|
<button class="status-tab" data-status="in_progress">En progreso</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vehicle-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Marca</label>
|
||||||
|
<select id="oem-brand-filter">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Modelo</label>
|
||||||
|
<input id="oem-model-filter" type="text" placeholder="Buscar modelo...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="oem-vehicle-list" class="vehicle-list">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
<div id="oem-vehicle-pagination" class="captura-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part entry view (hidden until vehicle selected) -->
|
||||||
|
<div id="oem-part-entry" style="display: none;">
|
||||||
|
<div id="oem-vehicle-header" class="vehicle-header"></div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<div class="progress-bar" style="width: 200px;">
|
||||||
|
<div id="oem-progress-fill" class="progress-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<span id="oem-progress-text" class="progress-text">0 partes registradas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="oem-groups-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- SECTION 2: Aftermarket / Interchange Entry -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-aftermarket" class="captura-section">
|
||||||
|
<div class="captura-search">
|
||||||
|
<input id="aftermarket-search" type="text" placeholder="Buscar por # OEM o nombre...">
|
||||||
|
<button class="btn btn-primary" onclick="loadPartsWithoutAftermarket()">Buscar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="aftermarket-list"></div>
|
||||||
|
<div id="aftermarket-pagination" class="captura-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<!-- SECTION 3: Image Upload -->
|
||||||
|
<!-- ============================================ -->
|
||||||
|
<div id="section-images" class="captura-section">
|
||||||
|
<div class="captura-search">
|
||||||
|
<input id="image-search" type="text" placeholder="Buscar por # OEM o nombre...">
|
||||||
|
<button class="btn btn-primary" onclick="loadPartsWithoutImage()">Buscar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="image-list"></div>
|
||||||
|
<div id="image-pagination" class="captura-pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nav.js"></script>
|
||||||
|
<script src="/captura.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
707
dashboard/captura.js
Normal file
707
dashboard/captura.js
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
/**
|
||||||
|
* captura.js — Data entry logic for Nexus Autoparts
|
||||||
|
* 3 sections: OEM Parts, Aftermarket/Interchange, Images
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '';
|
||||||
|
var currentMye = null; // selected vehicle MYE id
|
||||||
|
var currentVehicle = null; // vehicle info object
|
||||||
|
var vehicleParts = []; // existing parts for current vehicle
|
||||||
|
var manufacturers = []; // cached manufacturer list
|
||||||
|
var vehicleStatus = 'pending';
|
||||||
|
var vehiclePage = 1;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Utility
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function toast(msg, type) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + (type || 'success');
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function () { el.remove(); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(API + path, opts).then(function (r) {
|
||||||
|
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Tab Switching
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
document.querySelectorAll('.captura-tab').forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.captura-tab').forEach(function (t) { t.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.captura-section').forEach(function (s) { s.classList.remove('active'); });
|
||||||
|
tab.classList.add('active');
|
||||||
|
var target = tab.getAttribute('data-tab');
|
||||||
|
document.getElementById('section-' + target).classList.add('active');
|
||||||
|
|
||||||
|
if (target === 'aftermarket') loadPartsWithoutAftermarket();
|
||||||
|
if (target === 'images') loadPartsWithoutImage();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// SECTION 1: OEM Parts
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
// --- Status tabs ---
|
||||||
|
document.querySelectorAll('.status-tab').forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.status-tab').forEach(function (t) { t.classList.remove('active'); });
|
||||||
|
tab.classList.add('active');
|
||||||
|
vehicleStatus = tab.getAttribute('data-status');
|
||||||
|
vehiclePage = 1;
|
||||||
|
loadVehicles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Brand filter ---
|
||||||
|
function loadBrands() {
|
||||||
|
api('/api/brands').then(function (brands) {
|
||||||
|
var sel = document.getElementById('oem-brand-filter');
|
||||||
|
brands.forEach(function (b) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = b;
|
||||||
|
opt.textContent = b;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('oem-brand-filter').addEventListener('change', function () {
|
||||||
|
vehiclePage = 1;
|
||||||
|
loadVehicles();
|
||||||
|
});
|
||||||
|
|
||||||
|
var modelTimer = null;
|
||||||
|
document.getElementById('oem-model-filter').addEventListener('input', function () {
|
||||||
|
clearTimeout(modelTimer);
|
||||||
|
modelTimer = setTimeout(function () {
|
||||||
|
vehiclePage = 1;
|
||||||
|
loadVehicles();
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Load vehicles ---
|
||||||
|
function loadVehicles() {
|
||||||
|
var brand = document.getElementById('oem-brand-filter').value;
|
||||||
|
var model = document.getElementById('oem-model-filter').value;
|
||||||
|
var list = document.getElementById('oem-vehicle-list');
|
||||||
|
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
var endpoint = vehicleStatus === 'pending'
|
||||||
|
? '/api/captura/vehicles/pending'
|
||||||
|
: '/api/captura/vehicles/in-progress';
|
||||||
|
|
||||||
|
var params = '?page=' + vehiclePage + '&per_page=30';
|
||||||
|
if (brand) params += '&brand=' + encodeURIComponent(brand);
|
||||||
|
if (model) params += '&model=' + encodeURIComponent(model);
|
||||||
|
|
||||||
|
api(endpoint + params).then(function (res) {
|
||||||
|
var data = res.data || [];
|
||||||
|
if (data.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div><div class="es-text">No hay vehiculos ' +
|
||||||
|
(vehicleStatus === 'pending' ? 'pendientes' : 'en progreso') + '</div></div>';
|
||||||
|
document.getElementById('oem-vehicle-pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = data.map(function (v) {
|
||||||
|
return '<div class="vehicle-card" data-mye="' + v.id_mye + '">' +
|
||||||
|
'<div class="vc-brand">' + esc(v.brand) + '</div>' +
|
||||||
|
'<div class="vc-model">' + esc(v.model) + '</div>' +
|
||||||
|
'<div class="vc-details">' + v.year + ' · ' + esc(v.engine) +
|
||||||
|
(v.trim_level ? ' · ' + esc(v.trim_level) : '') + '</div>' +
|
||||||
|
(v.parts_count ? '<div class="vc-parts-count">' + v.parts_count + ' partes registradas</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Click handler for vehicle cards
|
||||||
|
list.querySelectorAll('.vehicle-card').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
selectVehicle(parseInt(card.getAttribute('data-mye')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
renderPagination('oem-vehicle-pagination', res.pagination, function (p) {
|
||||||
|
vehiclePage = p;
|
||||||
|
loadVehicles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(containerId, pag, onPage) {
|
||||||
|
var c = document.getElementById(containerId);
|
||||||
|
if (!pag || pag.total_pages <= 1) { c.innerHTML = ''; return; }
|
||||||
|
c.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">« Anterior</button>' +
|
||||||
|
'<span class="page-info">Pag ' + pag.page + ' de ' + pag.total_pages + ' (' + pag.total + ' total)</span>' +
|
||||||
|
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">Siguiente »</button>';
|
||||||
|
c.querySelectorAll('button').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
onPage(parseInt(btn.getAttribute('data-p')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Select vehicle and show part entry ---
|
||||||
|
function selectVehicle(myeId) {
|
||||||
|
currentMye = myeId;
|
||||||
|
document.getElementById('oem-vehicle-select').style.display = 'none';
|
||||||
|
document.getElementById('oem-part-entry').style.display = 'block';
|
||||||
|
|
||||||
|
// Mark as in_progress
|
||||||
|
api('/api/captura/vehicles/' + myeId + '/status', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'in_progress' })
|
||||||
|
});
|
||||||
|
|
||||||
|
loadVehicleParts(myeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVehicleParts(myeId) {
|
||||||
|
api('/api/captura/vehicles/' + myeId + '/parts').then(function (res) {
|
||||||
|
currentVehicle = res.vehicle;
|
||||||
|
vehicleParts = res.parts || [];
|
||||||
|
|
||||||
|
// Render vehicle header
|
||||||
|
var hdr = document.getElementById('oem-vehicle-header');
|
||||||
|
hdr.innerHTML = '<div class="vh-info">' +
|
||||||
|
'<div><div class="vh-label">Marca</div><div class="vh-value vh-brand">' + esc(currentVehicle.brand) + '</div></div>' +
|
||||||
|
'<div><div class="vh-label">Modelo</div><div class="vh-value">' + esc(currentVehicle.model) + '</div></div>' +
|
||||||
|
'<div><div class="vh-label">Ano</div><div class="vh-value">' + currentVehicle.year + '</div></div>' +
|
||||||
|
'<div><div class="vh-label">Motor</div><div class="vh-value">' + esc(currentVehicle.engine) + '</div></div>' +
|
||||||
|
(currentVehicle.trim_level ? '<div><div class="vh-label">Trim</div><div class="vh-value">' + esc(currentVehicle.trim_level) + '</div></div>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="vh-actions">' +
|
||||||
|
'<button class="btn btn-secondary" id="btn-back-vehicles">◀ Volver</button>' +
|
||||||
|
'<button class="btn btn-primary" id="btn-complete-vehicle">Terminado ✓</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('btn-back-vehicles').addEventListener('click', backToVehicles);
|
||||||
|
document.getElementById('btn-complete-vehicle').addEventListener('click', completeVehicle);
|
||||||
|
|
||||||
|
// Build groups by category
|
||||||
|
renderGroups(res.groups, vehicleParts);
|
||||||
|
updateProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToVehicles() {
|
||||||
|
document.getElementById('oem-vehicle-select').style.display = 'block';
|
||||||
|
document.getElementById('oem-part-entry').style.display = 'none';
|
||||||
|
currentMye = null;
|
||||||
|
loadVehicles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeVehicle() {
|
||||||
|
if (vehicleParts.length === 0) {
|
||||||
|
toast('Registra al menos una parte antes de marcar como terminado', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/api/captura/vehicles/' + currentMye + '/status', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'completed' })
|
||||||
|
}).then(function () {
|
||||||
|
toast('Vehiculo completado');
|
||||||
|
backToVehicles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render groups/categories ---
|
||||||
|
function renderGroups(groups, parts) {
|
||||||
|
var container = document.getElementById('oem-groups-container');
|
||||||
|
// Group by category
|
||||||
|
var categories = {};
|
||||||
|
groups.forEach(function (g) {
|
||||||
|
if (!categories[g.category]) {
|
||||||
|
categories[g.category] = { id: g.id_part_category, groups: [] };
|
||||||
|
}
|
||||||
|
categories[g.category].groups.push(g);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
Object.keys(categories).forEach(function (catName) {
|
||||||
|
var cat = categories[catName];
|
||||||
|
var catParts = parts.filter(function (p) {
|
||||||
|
return cat.groups.some(function (g) { return g.id_part_group === p.group_id; });
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '<div class="category-section">' +
|
||||||
|
'<div class="category-header" data-cat="' + cat.id + '">' +
|
||||||
|
'<h3>' + esc(catName) + ' (' + catParts.length + ')</h3>' +
|
||||||
|
'<span class="cat-toggle">▼</span></div>' +
|
||||||
|
'<div class="category-body" data-cat-body="' + cat.id + '">';
|
||||||
|
|
||||||
|
cat.groups.forEach(function (g) {
|
||||||
|
var groupParts = parts.filter(function (p) { return p.group_id === g.id_part_group; });
|
||||||
|
html += '<div class="group-section" data-group="' + g.id_part_group + '">' +
|
||||||
|
'<div class="group-name">' + esc(g.group_name) + '</div>' +
|
||||||
|
'<div class="part-rows" data-group-parts="' + g.id_part_group + '">';
|
||||||
|
|
||||||
|
groupParts.forEach(function (p) {
|
||||||
|
html += savedPartRow(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>' +
|
||||||
|
'<button class="btn-add-part" data-group-id="' + g.id_part_group + '">+ Agregar pieza</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Category toggle
|
||||||
|
container.querySelectorAll('.category-header').forEach(function (ch) {
|
||||||
|
ch.addEventListener('click', function () {
|
||||||
|
var catId = ch.getAttribute('data-cat');
|
||||||
|
var body = container.querySelector('[data-cat-body="' + catId + '"]');
|
||||||
|
ch.classList.toggle('collapsed');
|
||||||
|
body.classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add part buttons
|
||||||
|
container.querySelectorAll('.btn-add-part').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
addPartRow(parseInt(btn.getAttribute('data-group-id')), btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedPartRow(p) {
|
||||||
|
return '<div class="part-row saved" data-fitment-id="' + p.id_vehicle_part + '">' +
|
||||||
|
'<input class="pr-oem" value="' + esc(p.oem_part_number) + '" readonly>' +
|
||||||
|
'<input class="pr-name" value="' + esc(p.name_part || '') + '" readonly>' +
|
||||||
|
'<input class="pr-qty" value="' + (p.quantity_required || 1) + '" readonly>' +
|
||||||
|
'<button class="pr-btn pr-delete" title="Eliminar">✕</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPartRow(groupId, addBtn) {
|
||||||
|
var rowsContainer = document.querySelector('[data-group-parts="' + groupId + '"]');
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'part-row';
|
||||||
|
row.innerHTML = '<input class="pr-oem" placeholder="# OEM" data-group="' + groupId + '">' +
|
||||||
|
'<input class="pr-name" placeholder="Nombre pieza">' +
|
||||||
|
'<input class="pr-qty" value="1" type="number" min="1">' +
|
||||||
|
'<button class="pr-btn pr-save" title="Guardar">✓</button>' +
|
||||||
|
'<button class="pr-btn pr-delete" title="Quitar">✕</button>';
|
||||||
|
|
||||||
|
rowsContainer.appendChild(row);
|
||||||
|
|
||||||
|
// Focus OEM field
|
||||||
|
row.querySelector('.pr-oem').focus();
|
||||||
|
|
||||||
|
// OEM blur: check if exists
|
||||||
|
row.querySelector('.pr-oem').addEventListener('blur', function () {
|
||||||
|
var oem = this.value.trim();
|
||||||
|
if (!oem) return;
|
||||||
|
api('/api/captura/parts/check-oem?oem=' + encodeURIComponent(oem)).then(function (res) {
|
||||||
|
if (res.exists) {
|
||||||
|
row.querySelector('.pr-name').value = res.part.name_part || '';
|
||||||
|
row.querySelector('.pr-name').style.borderColor = 'var(--success)';
|
||||||
|
row.dataset.existingPartId = res.part.id_part;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save
|
||||||
|
row.querySelector('.pr-save').addEventListener('click', function () {
|
||||||
|
savePart(row, groupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete (unsaved)
|
||||||
|
row.querySelector('.pr-delete').addEventListener('click', function () {
|
||||||
|
row.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePart(row, groupId) {
|
||||||
|
var oem = row.querySelector('.pr-oem').value.trim();
|
||||||
|
var name = row.querySelector('.pr-name').value.trim();
|
||||||
|
var qty = parseInt(row.querySelector('.pr-qty').value) || 1;
|
||||||
|
|
||||||
|
if (!oem) {
|
||||||
|
toast('Ingresa el numero OEM', 'error');
|
||||||
|
row.querySelector('.pr-oem').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saveBtn = row.querySelector('.pr-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '...';
|
||||||
|
|
||||||
|
// Check if part already exists
|
||||||
|
var existingId = row.dataset.existingPartId;
|
||||||
|
|
||||||
|
function createFitment(partId) {
|
||||||
|
api('/api/admin/fitment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_year_engine_id: currentMye,
|
||||||
|
part_id: partId,
|
||||||
|
quantity_required: qty
|
||||||
|
})
|
||||||
|
}).then(function (res) {
|
||||||
|
// Replace row with saved version
|
||||||
|
var newPart = {
|
||||||
|
id_vehicle_part: res.id,
|
||||||
|
part_id: partId,
|
||||||
|
oem_part_number: oem,
|
||||||
|
name_part: name,
|
||||||
|
quantity_required: qty,
|
||||||
|
group_id: groupId
|
||||||
|
};
|
||||||
|
vehicleParts.push(newPart);
|
||||||
|
row.outerHTML = savedPartRow(newPart);
|
||||||
|
updateProgress();
|
||||||
|
toast('Parte guardada: ' + oem);
|
||||||
|
|
||||||
|
// Re-attach delete handlers
|
||||||
|
attachDeleteHandlers();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '\u2713';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
createFitment(parseInt(existingId));
|
||||||
|
} else {
|
||||||
|
// Create part first
|
||||||
|
api('/api/admin/parts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
oem_part_number: oem,
|
||||||
|
name: name || oem,
|
||||||
|
group_id: groupId
|
||||||
|
})
|
||||||
|
}).then(function (res) {
|
||||||
|
createFitment(res.id);
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '\u2713';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDeleteHandlers() {
|
||||||
|
document.querySelectorAll('.part-row.saved .pr-delete').forEach(function (btn) {
|
||||||
|
btn.onclick = function () {
|
||||||
|
var row = btn.closest('.part-row');
|
||||||
|
var fitmentId = row.getAttribute('data-fitment-id');
|
||||||
|
if (!fitmentId) { row.remove(); return; }
|
||||||
|
|
||||||
|
api('/api/admin/fitment/' + fitmentId, { method: 'DELETE' }).then(function () {
|
||||||
|
vehicleParts = vehicleParts.filter(function (p) {
|
||||||
|
return p.id_vehicle_part !== parseInt(fitmentId);
|
||||||
|
});
|
||||||
|
row.remove();
|
||||||
|
updateProgress();
|
||||||
|
toast('Parte eliminada');
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
var count = vehicleParts.length;
|
||||||
|
var totalGroups = 63;
|
||||||
|
var pct = Math.min(100, Math.round((count / totalGroups) * 100));
|
||||||
|
document.getElementById('oem-progress-fill').style.width = pct + '%';
|
||||||
|
document.getElementById('oem-progress-text').textContent = count + ' partes registradas';
|
||||||
|
|
||||||
|
// Update category counts
|
||||||
|
document.querySelectorAll('.category-header h3').forEach(function (h3) {
|
||||||
|
var catSection = h3.closest('.category-section');
|
||||||
|
var rows = catSection.querySelectorAll('.part-row.saved');
|
||||||
|
var catName = h3.textContent.replace(/\s*\(\d+\)$/, '');
|
||||||
|
h3.textContent = catName + ' (' + rows.length + ')';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// SECTION 2: Aftermarket / Interchange
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var aftermarketPage = 1;
|
||||||
|
|
||||||
|
function loadPartsWithoutAftermarket(page) {
|
||||||
|
page = page || 1;
|
||||||
|
aftermarketPage = page;
|
||||||
|
var search = document.getElementById('aftermarket-search').value;
|
||||||
|
var list = document.getElementById('aftermarket-list');
|
||||||
|
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
var params = '?page=' + page + '&per_page=20';
|
||||||
|
if (search) params += '&search=' + encodeURIComponent(search);
|
||||||
|
|
||||||
|
api('/api/captura/parts/without-aftermarket' + params).then(function (res) {
|
||||||
|
var data = res.data || [];
|
||||||
|
if (data.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state"><div class="es-icon">✅</div><div class="es-text">No hay piezas sin intercambios</div></div>';
|
||||||
|
document.getElementById('aftermarket-pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = data.map(function (p) {
|
||||||
|
return '<div class="part-detail-card" data-part-id="' + p.id_part + '">' +
|
||||||
|
'<div class="pdc-header">' +
|
||||||
|
'<div><span class="pdc-oem">' + esc(p.oem_part_number) + '</span>' +
|
||||||
|
' <span class="pdc-name">' + esc(p.name_part) + '</span></div>' +
|
||||||
|
'<span class="pdc-group">' + esc(p.category) + ' › ' + esc(p.group_name) + '</span></div>' +
|
||||||
|
'<div class="aftermarket-existing" data-af-list="' + p.id_part + '"></div>' +
|
||||||
|
'<div class="aftermarket-form" data-af-form="' + p.id_part + '">' +
|
||||||
|
'<div class="af-field"><label>Fabricante</label>' +
|
||||||
|
'<select class="af-manufacturer">' + manufacturerOptions() + '</select></div>' +
|
||||||
|
'<div class="af-field"><label># Aftermarket</label>' +
|
||||||
|
'<input class="af-partnum" placeholder="Ej: MK1234"></div>' +
|
||||||
|
'<div class="af-field"><label>Nombre</label>' +
|
||||||
|
'<input class="af-name" placeholder="Nombre pieza"></div>' +
|
||||||
|
'<div class="af-field"><label>Calidad</label>' +
|
||||||
|
'<select class="af-quality">' +
|
||||||
|
'<option value="standard">Standard</option>' +
|
||||||
|
'<option value="economy">Economy</option>' +
|
||||||
|
'<option value="oem">OEM</option>' +
|
||||||
|
'<option value="premium">Premium</option></select></div>' +
|
||||||
|
'<div class="af-field"><label>Precio USD</label>' +
|
||||||
|
'<input class="af-price" type="number" step="0.01" placeholder="0.00" style="width:80px"></div>' +
|
||||||
|
'<div class="af-field"><label>Garantia (meses)</label>' +
|
||||||
|
'<input class="af-warranty" type="number" placeholder="12" style="width:70px"></div>' +
|
||||||
|
'<button class="btn btn-primary af-save-btn" style="padding:0.4rem 1rem">+ Agregar</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Load existing aftermarket for each part
|
||||||
|
data.forEach(function (p) {
|
||||||
|
loadPartAftermarket(p.id_part);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save handlers
|
||||||
|
list.querySelectorAll('.af-save-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var card = btn.closest('.part-detail-card');
|
||||||
|
saveAftermarket(card);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPagination('aftermarket-pagination', res.pagination, function (p) {
|
||||||
|
loadPartsWithoutAftermarket(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function manufacturerOptions() {
|
||||||
|
return manufacturers.map(function (m) {
|
||||||
|
return '<option value="' + m.id + '">' + esc(m.name) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPartAftermarket(partId) {
|
||||||
|
api('/api/captura/parts/' + partId + '/aftermarket').then(function (items) {
|
||||||
|
var container = document.querySelector('[data-af-list="' + partId + '"]');
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:0.5rem">Sin intercambios registrados</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table class="aftermarket-table"><thead><tr>' +
|
||||||
|
'<th>Fabricante</th><th># Parte</th><th>Nombre</th><th>Calidad</th><th>Precio</th><th>Garantia</th></tr></thead><tbody>';
|
||||||
|
items.forEach(function (a) {
|
||||||
|
html += '<tr><td>' + esc(a.manufacturer) + '</td><td>' + esc(a.part_number) +
|
||||||
|
'</td><td>' + esc(a.name || '') + '</td><td>' + esc(a.quality || '') +
|
||||||
|
'</td><td>' + (a.price_usd ? '$' + a.price_usd : '') +
|
||||||
|
'</td><td>' + (a.warranty_months || '') + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAftermarket(card) {
|
||||||
|
var partId = card.getAttribute('data-part-id');
|
||||||
|
var manufacturer = card.querySelector('.af-manufacturer').value;
|
||||||
|
var partNumber = card.querySelector('.af-partnum').value.trim();
|
||||||
|
var name = card.querySelector('.af-name').value.trim();
|
||||||
|
var quality = card.querySelector('.af-quality').value;
|
||||||
|
var price = card.querySelector('.af-price').value;
|
||||||
|
var warranty = card.querySelector('.af-warranty').value;
|
||||||
|
|
||||||
|
if (!partNumber) {
|
||||||
|
toast('Ingresa el numero de parte aftermarket', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api('/api/admin/aftermarket', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
oem_part_id: parseInt(partId),
|
||||||
|
manufacturer_id: parseInt(manufacturer),
|
||||||
|
part_number: partNumber,
|
||||||
|
name: name,
|
||||||
|
quality_tier: quality,
|
||||||
|
price_usd: price ? parseFloat(price) : null,
|
||||||
|
warranty_months: warranty ? parseInt(warranty) : null
|
||||||
|
})
|
||||||
|
}).then(function () {
|
||||||
|
toast('Intercambio guardado: ' + partNumber);
|
||||||
|
// Clear form
|
||||||
|
card.querySelector('.af-partnum').value = '';
|
||||||
|
card.querySelector('.af-name').value = '';
|
||||||
|
card.querySelector('.af-price').value = '';
|
||||||
|
card.querySelector('.af-warranty').value = '';
|
||||||
|
// Reload aftermarket list
|
||||||
|
loadPartAftermarket(parseInt(partId));
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// SECTION 3: Images
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var imagePage = 1;
|
||||||
|
|
||||||
|
function loadPartsWithoutImage(page) {
|
||||||
|
page = page || 1;
|
||||||
|
imagePage = page;
|
||||||
|
var search = document.getElementById('image-search').value;
|
||||||
|
var list = document.getElementById('image-list');
|
||||||
|
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
var params = '?page=' + page + '&per_page=20';
|
||||||
|
if (search) params += '&search=' + encodeURIComponent(search);
|
||||||
|
|
||||||
|
api('/api/captura/parts/without-image' + params).then(function (res) {
|
||||||
|
var data = res.data || [];
|
||||||
|
if (data.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state"><div class="es-icon">📷</div><div class="es-text">No hay piezas sin imagen</div></div>';
|
||||||
|
document.getElementById('image-pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = data.map(function (p) {
|
||||||
|
return '<div class="image-card" data-part-id="' + p.id_part + '">' +
|
||||||
|
'<div class="ic-preview"><span>Sin imagen</span></div>' +
|
||||||
|
'<div class="ic-info">' +
|
||||||
|
'<div class="ic-oem">' + esc(p.oem_part_number) + '</div>' +
|
||||||
|
'<div class="ic-name">' + esc(p.name_part) + ' · ' + esc(p.group_name) + '</div>' +
|
||||||
|
'<div class="ic-upload">' +
|
||||||
|
'<input type="file" accept="image/jpeg,image/png,image/webp" class="ic-file-input">' +
|
||||||
|
'<button class="btn btn-primary ic-upload-btn" style="padding:0.3rem 0.8rem;font-size:0.8rem" disabled>Subir</button>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// File input change → enable upload button and show preview
|
||||||
|
list.querySelectorAll('.ic-file-input').forEach(function (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var card = input.closest('.image-card');
|
||||||
|
var btn = card.querySelector('.ic-upload-btn');
|
||||||
|
var preview = card.querySelector('.ic-preview');
|
||||||
|
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
preview.innerHTML = '<img src="' + e.target.result + '">';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(input.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload button
|
||||||
|
list.querySelectorAll('.ic-upload-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var card = btn.closest('.image-card');
|
||||||
|
uploadImage(card);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPagination('image-pagination', res.pagination, function (p) {
|
||||||
|
loadPartsWithoutImage(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadImage(card) {
|
||||||
|
var partId = card.getAttribute('data-part-id');
|
||||||
|
var fileInput = card.querySelector('.ic-file-input');
|
||||||
|
var btn = card.querySelector('.ic-upload-btn');
|
||||||
|
|
||||||
|
if (!fileInput.files || !fileInput.files[0]) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Subiendo...';
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('image', fileInput.files[0]);
|
||||||
|
|
||||||
|
fetch(API + '/api/captura/parts/' + partId + '/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}).then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
toast('Imagen subida correctamente');
|
||||||
|
// Remove card from list
|
||||||
|
card.style.opacity = '0.3';
|
||||||
|
setTimeout(function () { card.remove(); }, 500);
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subir';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Init
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadBrands();
|
||||||
|
loadVehicles();
|
||||||
|
|
||||||
|
// Pre-load manufacturers for Section 2
|
||||||
|
api('/api/captura/manufacturers').then(function (data) {
|
||||||
|
manufacturers = data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible for inline onclick handlers
|
||||||
|
window.loadPartsWithoutAftermarket = loadPartsWithoutAftermarket;
|
||||||
|
window.loadPartsWithoutImage = loadPartsWithoutImage;
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
282
dashboard/cuentas.css
Normal file
282
dashboard/cuentas.css
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/* ============================================================
|
||||||
|
cuentas.css -- Accounts receivable styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.cuentas-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Customer list --- */
|
||||||
|
.cuentas-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cuentas-search input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cuentas-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card-item {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cci-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cci-rfc {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cci-balance-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cci-balance {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cci-balance.positive { color: var(--danger); }
|
||||||
|
.cci-balance.zero { color: var(--success); }
|
||||||
|
|
||||||
|
.cci-limit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Customer detail view --- */
|
||||||
|
.detail-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-field .dh-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-field .dh-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-field .dh-value.accent { color: var(--accent); }
|
||||||
|
.dh-field .dh-value.danger { color: var(--danger); }
|
||||||
|
.dh-field .dh-value.success { color: var(--success); }
|
||||||
|
|
||||||
|
/* --- Two-column layout for invoices/payments --- */
|
||||||
|
.detail-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.detail-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card h3 {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||||
|
.status-badge.partial { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||||
|
.status-badge.paid { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||||
|
.status-badge.cancelled { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
|
||||||
|
|
||||||
|
/* --- Payment form --- */
|
||||||
|
.payment-form {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-form h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-field label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-field input,
|
||||||
|
.pf-field select {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-field input:focus,
|
||||||
|
.pf-field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toast --- */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toastIn 0.3s ease;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pagination --- */
|
||||||
|
.cuentas-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cuentas-pagination button {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cuentas-pagination button:hover { border-color: var(--accent); }
|
||||||
|
.cuentas-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.cuentas-pagination .page-info { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
102
dashboard/cuentas.html
Normal file
102
dashboard/cuentas.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cuentas por Cobrar — NEXUS AUTOPARTS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/cuentas.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shared-nav"></div>
|
||||||
|
|
||||||
|
<div class="cuentas-container">
|
||||||
|
<!-- Customer List View -->
|
||||||
|
<div id="list-view">
|
||||||
|
<div class="cuentas-search">
|
||||||
|
<input id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
|
||||||
|
</div>
|
||||||
|
<div id="customer-grid" class="customer-grid"></div>
|
||||||
|
<div id="customer-pagination" class="cuentas-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Detail View -->
|
||||||
|
<div id="detail-view" class="detail-view">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="dh-info">
|
||||||
|
<div class="dh-field"><div class="dh-label">Cliente</div><div class="dh-value accent" id="dh-name"></div></div>
|
||||||
|
<div class="dh-field"><div class="dh-label">RFC</div><div class="dh-value" id="dh-rfc"></div></div>
|
||||||
|
<div class="dh-field"><div class="dh-label">Saldo</div><div class="dh-value" id="dh-balance"></div></div>
|
||||||
|
<div class="dh-field"><div class="dh-label">Limite</div><div class="dh-value" id="dh-limit"></div></div>
|
||||||
|
<div class="dh-field"><div class="dh-label">Plazo</div><div class="dh-value" id="dh-terms"></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" id="btn-back-list">« Volver</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-columns">
|
||||||
|
<!-- Invoices -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Facturas</h3>
|
||||||
|
<table class="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Folio</th><th>Fecha</th><th>Total</th><th>Pagado</th><th>Estado</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="invoice-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payments + Form -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Pagos</h3>
|
||||||
|
<table class="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Fecha</th><th>Monto</th><th>Metodo</th><th>Ref</th><th>Factura</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="payment-list"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="payment-form">
|
||||||
|
<h4>Registrar Pago</h4>
|
||||||
|
<div class="pf-row">
|
||||||
|
<div class="pf-field">
|
||||||
|
<label>Monto *</label>
|
||||||
|
<input id="pay-amount" type="number" step="0.01" min="0.01" placeholder="0.00" style="width:120px">
|
||||||
|
</div>
|
||||||
|
<div class="pf-field">
|
||||||
|
<label>Metodo</label>
|
||||||
|
<select id="pay-method">
|
||||||
|
<option value="efectivo">Efectivo</option>
|
||||||
|
<option value="transferencia">Transferencia</option>
|
||||||
|
<option value="cheque">Cheque</option>
|
||||||
|
<option value="tarjeta">Tarjeta</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pf-field">
|
||||||
|
<label>Referencia</label>
|
||||||
|
<input id="pay-reference" placeholder="# ref" style="width:120px">
|
||||||
|
</div>
|
||||||
|
<div class="pf-field">
|
||||||
|
<label>Aplicar a factura</label>
|
||||||
|
<select id="pay-invoice">
|
||||||
|
<option value="">Abono general</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-row">
|
||||||
|
<div class="pf-field" style="flex:1">
|
||||||
|
<label>Notas</label>
|
||||||
|
<input id="pay-notes" placeholder="Notas del pago" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="btn-pay" style="align-self:flex-end;padding:0.4rem 1.2rem">Registrar Pago</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nav.js"></script>
|
||||||
|
<script src="/cuentas.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
222
dashboard/cuentas.js
Normal file
222
dashboard/cuentas.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* cuentas.js — Accounts receivable logic for Nexus Autoparts
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '';
|
||||||
|
var currentCustomerId = null;
|
||||||
|
var customerPage = 1;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Utility
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function toast(msg, type) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + (type || 'success');
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function () { el.remove(); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(API + path, opts).then(function (r) {
|
||||||
|
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
var dt = new Date(d);
|
||||||
|
return dt.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Customer List
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var searchTimer = null;
|
||||||
|
document.getElementById('customer-search').addEventListener('input', function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(function () {
|
||||||
|
customerPage = 1;
|
||||||
|
loadCustomers();
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadCustomers() {
|
||||||
|
var search = document.getElementById('customer-search').value;
|
||||||
|
var grid = document.getElementById('customer-grid');
|
||||||
|
grid.innerHTML = '<div class="empty-state">Cargando...</div>';
|
||||||
|
|
||||||
|
var params = '?page=' + customerPage + '&per_page=30';
|
||||||
|
if (search) params += '&search=' + encodeURIComponent(search);
|
||||||
|
|
||||||
|
api('/api/pos/customers' + params).then(function (res) {
|
||||||
|
var data = res.data || [];
|
||||||
|
if (data.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="empty-state">No se encontraron clientes</div>';
|
||||||
|
document.getElementById('customer-pagination').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = data.map(function (c) {
|
||||||
|
return '<div class="customer-card-item" data-id="' + c.id_customer + '">' +
|
||||||
|
'<div class="cci-name">' + esc(c.name) + '</div>' +
|
||||||
|
'<div class="cci-rfc">' + esc(c.rfc || 'Sin RFC') + '</div>' +
|
||||||
|
'<div class="cci-balance-row">' +
|
||||||
|
'<span class="cci-balance ' + (c.balance > 0 ? 'positive' : 'zero') + '">' + fmt(c.balance) + '</span>' +
|
||||||
|
'<span class="cci-limit">Limite: ' + fmt(c.credit_limit) + '</span></div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
grid.querySelectorAll('.customer-card-item').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
showCustomerDetail(parseInt(card.getAttribute('data-id')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
var pag = res.pagination;
|
||||||
|
var pagEl = document.getElementById('customer-pagination');
|
||||||
|
if (pag.total_pages <= 1) { pagEl.innerHTML = ''; return; }
|
||||||
|
pagEl.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">«</button>' +
|
||||||
|
'<span class="page-info">Pag ' + pag.page + '/' + pag.total_pages + '</span>' +
|
||||||
|
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">»</button>';
|
||||||
|
pagEl.querySelectorAll('button').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
customerPage = parseInt(btn.getAttribute('data-p'));
|
||||||
|
loadCustomers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('Error loading customers:', err);
|
||||||
|
grid.innerHTML = '<div class="empty-state">Error al cargar clientes</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Customer Detail
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function showCustomerDetail(customerId) {
|
||||||
|
currentCustomerId = customerId;
|
||||||
|
document.getElementById('list-view').style.display = 'none';
|
||||||
|
document.getElementById('detail-view').style.display = 'block';
|
||||||
|
|
||||||
|
api('/api/pos/customers/' + customerId + '/statement').then(function (res) {
|
||||||
|
var c = res.customer;
|
||||||
|
document.getElementById('dh-name').textContent = c.name;
|
||||||
|
document.getElementById('dh-rfc').textContent = c.rfc || 'Sin RFC';
|
||||||
|
|
||||||
|
var balEl = document.getElementById('dh-balance');
|
||||||
|
balEl.textContent = fmt(c.balance);
|
||||||
|
balEl.className = 'dh-value ' + (c.balance > 0 ? 'danger' : 'success');
|
||||||
|
|
||||||
|
document.getElementById('dh-limit').textContent = fmt(c.credit_limit);
|
||||||
|
document.getElementById('dh-terms').textContent = c.payment_terms + ' dias';
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
var invBody = document.getElementById('invoice-list');
|
||||||
|
if (res.invoices.length === 0) {
|
||||||
|
invBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin facturas</td></tr>';
|
||||||
|
} else {
|
||||||
|
invBody.innerHTML = res.invoices.map(function (i) {
|
||||||
|
return '<tr>' +
|
||||||
|
'<td style="font-family:monospace;font-weight:600">' + esc(i.folio) + '</td>' +
|
||||||
|
'<td>' + fmtDate(i.date_issued) + '</td>' +
|
||||||
|
'<td>' + fmt(i.total) + '</td>' +
|
||||||
|
'<td>' + fmt(i.amount_paid) + '</td>' +
|
||||||
|
'<td><span class="status-badge ' + i.status + '">' + i.status + '</span></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
var payBody = document.getElementById('payment-list');
|
||||||
|
if (res.payments.length === 0) {
|
||||||
|
payBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin pagos</td></tr>';
|
||||||
|
} else {
|
||||||
|
payBody.innerHTML = res.payments.map(function (p) {
|
||||||
|
return '<tr>' +
|
||||||
|
'<td>' + fmtDate(p.date_payment) + '</td>' +
|
||||||
|
'<td style="font-weight:600;color:var(--success)">' + fmt(p.amount) + '</td>' +
|
||||||
|
'<td>' + esc(p.payment_method) + '</td>' +
|
||||||
|
'<td>' + esc(p.reference || '') + '</td>' +
|
||||||
|
'<td>' + esc(p.invoice_folio || 'General') + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate invoice dropdown for payment form
|
||||||
|
var invSelect = document.getElementById('pay-invoice');
|
||||||
|
invSelect.innerHTML = '<option value="">Abono general</option>';
|
||||||
|
res.invoices.filter(function (i) { return i.status !== 'paid' && i.status !== 'cancelled'; })
|
||||||
|
.forEach(function (i) {
|
||||||
|
invSelect.innerHTML += '<option value="' + i.id_invoice + '">' +
|
||||||
|
i.folio + ' — ' + fmt(i.total - i.amount_paid) + ' pendiente</option>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-back-list').addEventListener('click', function () {
|
||||||
|
document.getElementById('detail-view').style.display = 'none';
|
||||||
|
document.getElementById('list-view').style.display = 'block';
|
||||||
|
currentCustomerId = null;
|
||||||
|
loadCustomers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Register Payment
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
document.getElementById('btn-pay').addEventListener('click', function () {
|
||||||
|
var amount = parseFloat(document.getElementById('pay-amount').value);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
toast('Ingresa un monto valido', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var invoiceId = document.getElementById('pay-invoice').value;
|
||||||
|
|
||||||
|
api('/api/pos/payments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: currentCustomerId,
|
||||||
|
amount: amount,
|
||||||
|
payment_method: document.getElementById('pay-method').value,
|
||||||
|
reference: document.getElementById('pay-reference').value.trim() || null,
|
||||||
|
invoice_id: invoiceId ? parseInt(invoiceId) : null,
|
||||||
|
notes: document.getElementById('pay-notes').value.trim() || null
|
||||||
|
})
|
||||||
|
}).then(function () {
|
||||||
|
toast('Pago de ' + fmt(amount) + ' registrado');
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('pay-amount').value = '';
|
||||||
|
document.getElementById('pay-reference').value = '';
|
||||||
|
document.getElementById('pay-notes').value = '';
|
||||||
|
// Refresh detail
|
||||||
|
showCustomerDetail(currentCustomerId);
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Init
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
loadCustomers();
|
||||||
|
})();
|
||||||
@@ -3,127 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AutoParts DB - Tienda de Autopartes</title>
|
<title>Nexus Autoparts - Tu conexión directa con las partes que necesitas</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
/* Landing page-specific header extras */
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-primary: #0a0a0f;
|
|
||||||
--bg-secondary: #12121a;
|
|
||||||
--bg-card: #1a1a24;
|
|
||||||
--bg-hover: #252532;
|
|
||||||
--accent: #ff6b35;
|
|
||||||
--accent-hover: #ff8555;
|
|
||||||
--accent-glow: rgba(255, 107, 53, 0.3);
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0b0;
|
|
||||||
--border: #2a2a3a;
|
|
||||||
--success: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
background: rgba(18, 18, 26, 0.95);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1rem 3rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
box-shadow: 0 4px 20px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-family: 'Orbitron', sans-serif;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
display: flex;
|
|
||||||
gap: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: color 0.3s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a:hover,
|
|
||||||
.nav-links a.active {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -5px;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a:hover::after,
|
|
||||||
.nav-links a.active::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a.admin-link {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links a.admin-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -165,29 +49,34 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
/* Footer logo (reuses .logo classes) */
|
||||||
padding: 0.7rem 1.5rem;
|
.footer .logo {
|
||||||
border-radius: 10px;
|
display: flex;
|
||||||
border: none;
|
align-items: center;
|
||||||
font-weight: 600;
|
gap: 0.75rem;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
}
|
||||||
|
|
||||||
|
.footer .logo-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.footer .logo-text {
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
font-family: 'Orbitron', sans-serif;
|
||||||
color: white;
|
font-size: 1.4rem;
|
||||||
box-shadow: 0 4px 15px var(--accent-glow);
|
font-weight: 700;
|
||||||
}
|
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
.btn-primary:hover {
|
-webkit-text-fill-color: transparent;
|
||||||
transform: translateY(-2px);
|
background-clip: text;
|
||||||
box-shadow: 0 6px 25px var(--accent-glow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero Section */
|
/* Hero Section */
|
||||||
@@ -1060,30 +949,23 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- Shared Navigation -->
|
||||||
<header class="header">
|
<div id="shared-nav"></div>
|
||||||
<a href="customer-landing.html" class="logo">
|
<script src="/nav.js"></script>
|
||||||
<div class="logo-icon">⚙️</div>
|
<script>
|
||||||
<div class="logo-text">AUTOPARTS DB</div>
|
// Inject landing-page-specific header extras (search, cart, dashboard btn)
|
||||||
</a>
|
(function() {
|
||||||
<nav class="nav-links">
|
var extra = document.getElementById('shared-nav-extra');
|
||||||
<a href="customer-landing.html" class="active">Inicio</a>
|
if (!extra) return;
|
||||||
<a href="index.html">Catálogo</a>
|
extra.innerHTML = ''
|
||||||
<a href="#brands-section">Marcas</a>
|
+ '<div class="header-actions">'
|
||||||
<a href="#featured-section">Productos</a>
|
+ '<button class="search-btn" onclick="openSearchModal()">\uD83D\uDD0D</button>'
|
||||||
<a href="#cta-section">Contacto</a>
|
+ '<button class="cart-btn">\uD83D\uDED2<span class="cart-badge" id="cart-count">0</span></button>'
|
||||||
<a href="admin.html" class="admin-link">⚡ Admin</a>
|
+ '<a href="index.html" class="btn btn-primary">Dashboard</a>'
|
||||||
</nav>
|
+ '<button class="mobile-menu-btn">\u2630</button>'
|
||||||
<div class="header-actions">
|
+ '</div>';
|
||||||
<button class="search-btn" onclick="openSearchModal()">🔍</button>
|
})();
|
||||||
<button class="cart-btn">
|
</script>
|
||||||
🛒
|
|
||||||
<span class="cart-badge" id="cart-count">0</span>
|
|
||||||
</button>
|
|
||||||
<a href="index.html" class="btn btn-primary">Dashboard</a>
|
|
||||||
<button class="mobile-menu-btn">☰</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Search Modal -->
|
<!-- Search Modal -->
|
||||||
<div class="search-modal" id="searchModal" onclick="closeSearchModal(event)">
|
<div class="search-modal" id="searchModal" onclick="closeSearchModal(event)">
|
||||||
@@ -1213,7 +1095,7 @@
|
|||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<div class="logo-icon">⚙️</div>
|
<div class="logo-icon">⚙️</div>
|
||||||
<div class="logo-text">AUTOPARTS DB</div>
|
<div class="logo-text">NEXUS AUTOPARTS</div>
|
||||||
</div>
|
</div>
|
||||||
<p>Sistema de catálogo de autopartes con navegación jerárquica, diagramas explosionados y decodificador de VIN.</p>
|
<p>Sistema de catálogo de autopartes con navegación jerárquica, diagramas explosionados y decodificador de VIN.</p>
|
||||||
<div class="social-links">
|
<div class="social-links">
|
||||||
@@ -1249,7 +1131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>© 2024 AutoParts DB. Sistema de Catálogo de Autopartes.</p>
|
<p>© 2026 Nexus Autoparts. Tu conexión directa con las partes que necesitas.</p>
|
||||||
<p>Desarrollado con Flask + SQLite</p>
|
<p>Desarrollado con Flask + SQLite</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -41,36 +41,31 @@ class VehicleDashboard {
|
|||||||
|
|
||||||
async loadStats() {
|
async loadStats() {
|
||||||
try {
|
try {
|
||||||
const [brandsRes, vehiclesRes, partsRes, categoriesRes] = await Promise.all([
|
const [statsRes, brandsRes, categoriesRes] = await Promise.all([
|
||||||
|
fetch('/api/catalog/stats'),
|
||||||
fetch('/api/brands'),
|
fetch('/api/brands'),
|
||||||
fetch('/api/vehicles'),
|
|
||||||
fetch('/api/parts'),
|
|
||||||
fetch('/api/categories')
|
fetch('/api/categories')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (brandsRes.ok && vehiclesRes.ok) {
|
if (statsRes.ok) {
|
||||||
const brands = await brandsRes.json();
|
const s = await statsRes.json();
|
||||||
const vehicles = await vehiclesRes.json();
|
this.stats.brands = s.brands;
|
||||||
|
this.stats.models = s.models;
|
||||||
// Contar modelos únicos
|
this.stats.vehicles = s.vehicles;
|
||||||
const uniqueModels = new Set(vehicles.map(v => `${v.brand}-${v.model}`));
|
this.stats.parts = s.parts;
|
||||||
|
|
||||||
this.stats.brands = brands.length;
|
|
||||||
this.stats.models = uniqueModels.size;
|
|
||||||
this.stats.vehicles = vehicles.length;
|
|
||||||
|
|
||||||
|
const fmt = n => n > 1000 ? Math.floor(n/1000) + 'K+' : n;
|
||||||
const brandsEl = document.getElementById('totalBrands');
|
const brandsEl = document.getElementById('totalBrands');
|
||||||
const modelsEl = document.getElementById('totalModels');
|
const modelsEl = document.getElementById('totalModels');
|
||||||
if (brandsEl) brandsEl.textContent = this.stats.brands;
|
const partsEl = document.getElementById('totalParts');
|
||||||
if (modelsEl) modelsEl.textContent = this.stats.models > 1000 ? Math.floor(this.stats.models/1000) + 'K+' : this.stats.models;
|
if (brandsEl) brandsEl.textContent = fmt(this.stats.brands);
|
||||||
|
if (modelsEl) modelsEl.textContent = fmt(this.stats.models);
|
||||||
|
if (partsEl) partsEl.textContent = fmt(this.stats.parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partsRes.ok) {
|
if (brandsRes.ok) {
|
||||||
const partsData = await partsRes.json();
|
// Still needed for brand list rendering
|
||||||
// Handle paginated response
|
await brandsRes.json();
|
||||||
this.stats.parts = partsData.pagination ? partsData.pagination.total : (partsData.data ? partsData.data.length : partsData.length || 0);
|
|
||||||
const partsEl = document.getElementById('totalParts');
|
|
||||||
if (partsEl) partsEl.textContent = this.stats.parts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoriesRes.ok) {
|
if (categoriesRes.ok) {
|
||||||
@@ -300,29 +295,18 @@ class VehicleDashboard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [brandsRes, vehiclesRes] = await Promise.all([
|
const brandsRes = await fetch('/api/brands?detailed=true');
|
||||||
fetch('/api/brands'),
|
|
||||||
fetch('/api/vehicles')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!brandsRes.ok || !vehiclesRes.ok) {
|
if (!brandsRes.ok) {
|
||||||
throw new Error('Error al cargar datos');
|
throw new Error('Error al cargar datos');
|
||||||
}
|
}
|
||||||
|
|
||||||
const brands = await brandsRes.json();
|
const brands = await brandsRes.json();
|
||||||
const vehicles = await vehiclesRes.json();
|
|
||||||
|
|
||||||
// Contar modelos y vehículos por marca
|
// Build brandStats from detailed response
|
||||||
const brandStats = {};
|
const brandStats = {};
|
||||||
brands.forEach(brand => {
|
brands.forEach(b => {
|
||||||
brandStats[brand] = { models: new Set(), vehicles: 0 };
|
brandStats[b.name] = { models: { size: b.model_count }, vehicles: b.vehicle_count };
|
||||||
});
|
|
||||||
|
|
||||||
vehicles.forEach(v => {
|
|
||||||
if (brandStats[v.brand]) {
|
|
||||||
brandStats[v.brand].models.add(v.model);
|
|
||||||
brandStats[v.brand].vehicles++;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (brands.length === 0) {
|
if (brands.length === 0) {
|
||||||
@@ -337,17 +321,17 @@ class VehicleDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div class="content-grid brands-grid">
|
container.innerHTML = `<div class="content-grid brands-grid">
|
||||||
${brands.map(brand => `
|
${brands.map(b => `
|
||||||
<div class="brand-card" onclick="dashboard.goToModels('${brand}')">
|
<div class="brand-card" onclick="dashboard.goToModels('${b.name}')">
|
||||||
<div class="brand-icon">
|
<div class="brand-icon">
|
||||||
<i class="fas fa-car"></i>
|
<i class="fas fa-car"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-name">${brand}</div>
|
<div class="brand-name">${b.name}</div>
|
||||||
<div class="brand-count">
|
<div class="brand-count">
|
||||||
${brandStats[brand].models.size} modelos
|
${b.model_count} modelos
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-count">
|
<div class="brand-count">
|
||||||
${brandStats[brand].vehicles} vehículos
|
${b.vehicle_count} vehículos
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -386,31 +370,13 @@ class VehicleDashboard {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [modelsRes, vehiclesRes] = await Promise.all([
|
const modelsRes = await fetch(`/api/models?brand=${encodeURIComponent(brand)}&detailed=true`);
|
||||||
fetch(`/api/models?brand=${encodeURIComponent(brand)}`),
|
|
||||||
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!modelsRes.ok || !vehiclesRes.ok) {
|
if (!modelsRes.ok) {
|
||||||
throw new Error('Error al cargar datos');
|
throw new Error('Error al cargar datos');
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await modelsRes.json();
|
const models = await modelsRes.json();
|
||||||
const vehicles = await vehiclesRes.json();
|
|
||||||
|
|
||||||
// Contar vehículos y años por modelo
|
|
||||||
const modelStats = {};
|
|
||||||
models.forEach(model => {
|
|
||||||
modelStats[model] = { years: new Set(), vehicles: 0, engines: new Set() };
|
|
||||||
});
|
|
||||||
|
|
||||||
vehicles.forEach(v => {
|
|
||||||
if (modelStats[v.model]) {
|
|
||||||
modelStats[v.model].years.add(v.year);
|
|
||||||
modelStats[v.model].vehicles++;
|
|
||||||
modelStats[v.model].engines.add(v.engine);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -427,26 +393,22 @@ class VehicleDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div class="content-grid models-grid">
|
container.innerHTML = `<div class="content-grid models-grid">
|
||||||
${models.map(model => {
|
${models.map(m => {
|
||||||
const stats = modelStats[model];
|
const yearRange = m.year_count > 1
|
||||||
const yearsArray = Array.from(stats.years).sort((a, b) => b - a);
|
? `${m.year_min} - ${m.year_max}`
|
||||||
const yearRange = yearsArray.length > 0
|
: `${m.year_min}`;
|
||||||
? (yearsArray.length > 1
|
|
||||||
? `${yearsArray[yearsArray.length - 1]} - ${yearsArray[0]}`
|
|
||||||
: `${yearsArray[0]}`)
|
|
||||||
: 'N/A';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${model}')">
|
<div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${m.name}')">
|
||||||
<div class="model-name">${model}</div>
|
<div class="model-name">${m.name}</div>
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<i class="fas fa-calendar-alt"></i> ${yearRange}
|
<i class="fas fa-calendar-alt"></i> ${yearRange}
|
||||||
</div>
|
</div>
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<i class="fas fa-cogs"></i> ${stats.engines.size} motores
|
<i class="fas fa-cogs"></i> ${m.engine_count} motores
|
||||||
</div>
|
</div>
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<i class="fas fa-list"></i> ${stats.vehicles} variantes
|
<i class="fas fa-list"></i> ${m.vehicle_count} variantes
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -491,16 +453,18 @@ class VehicleDashboard {
|
|||||||
try {
|
try {
|
||||||
// Fetch both vehicles info and model_year_engine IDs
|
// Fetch both vehicles info and model_year_engine IDs
|
||||||
const [vehiclesRes, myeRes] = await Promise.all([
|
const [vehiclesRes, myeRes] = await Promise.all([
|
||||||
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`),
|
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`),
|
||||||
fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`)
|
fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!vehiclesRes.ok || !myeRes.ok) {
|
if (!vehiclesRes.ok || !myeRes.ok) {
|
||||||
throw new Error('Error al cargar vehículos');
|
throw new Error('Error al cargar vehículos');
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicles = await vehiclesRes.json();
|
const vehiclesData = await vehiclesRes.json();
|
||||||
const myeRecords = await myeRes.json();
|
const myeData = await myeRes.json();
|
||||||
|
const vehicles = vehiclesData.data || vehiclesData;
|
||||||
|
const myeRecords = myeData.data || myeData;
|
||||||
|
|
||||||
// Merge mye_id into vehicles based on matching fields
|
// Merge mye_id into vehicles based on matching fields
|
||||||
// Only keep vehicles that have a matching mye_id (i.e., have parts)
|
// Only keep vehicles that have a matching mye_id (i.e., have parts)
|
||||||
@@ -911,7 +875,24 @@ class VehicleDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups = await response.json();
|
const groups = await response.json();
|
||||||
this.displayGroups(groups, categoryId);
|
|
||||||
|
// Fetch diagrams for Suspension (11) or Steering (10) when vehicle is selected
|
||||||
|
let vehicleDiagrams = [];
|
||||||
|
if (this.selectedVehicleId && (categoryId === 10 || categoryId === 11)) {
|
||||||
|
try {
|
||||||
|
const diagRes = await fetch(`/api/vehicles/${this.selectedVehicleId}/diagrams/by-category?category_id=${categoryId}`);
|
||||||
|
if (diagRes.ok) {
|
||||||
|
const catGroups = await diagRes.json();
|
||||||
|
for (const cg of catGroups) {
|
||||||
|
vehicleDiagrams.push(...cg.diagrams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading diagrams for strip:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.displayGroups(groups, categoryId, vehicleDiagrams);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -928,10 +909,10 @@ class VehicleDashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
displayGroups(groups, categoryId) {
|
displayGroups(groups, categoryId, vehicleDiagrams = []) {
|
||||||
const container = document.getElementById('mainContent');
|
const container = document.getElementById('mainContent');
|
||||||
|
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0 && vehicleDiagrams.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
@@ -944,8 +925,42 @@ class VehicleDashboard {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build diagram strip HTML if diagrams are available
|
||||||
|
let diagramStripHtml = '';
|
||||||
|
if (vehicleDiagrams.length > 0) {
|
||||||
|
// Store diagram list for the viewer
|
||||||
|
this._currentDiagramList = vehicleDiagrams;
|
||||||
|
|
||||||
|
diagramStripHtml = `
|
||||||
|
<div class="diagrams-strip">
|
||||||
|
<div class="diagrams-strip-header">
|
||||||
|
<h5><i class="fas fa-drafting-compass"></i> Diagramas MOOG para tu vehículo</h5>
|
||||||
|
<span class="strip-badge">${vehicleDiagrams.length} diagrama${vehicleDiagrams.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagrams-strip-scroll">
|
||||||
|
${vehicleDiagrams.map((d, idx) => {
|
||||||
|
const type = (d.name || '')[0];
|
||||||
|
const typeLabel = type === 'F' ? 'Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Trasera' : '';
|
||||||
|
const imgSrc = d.image_url || '/static/diagrams/moog/' + d.name + '.jpg';
|
||||||
|
return `
|
||||||
|
<div class="strip-card" onclick="dashboard.openDiagramViewer(${d.id}, ${idx})"
|
||||||
|
title="${d.name_es || d.name}">
|
||||||
|
<img class="strip-card-img" src="${imgSrc}" alt="${d.name}"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.style.display='none';this.parentElement.querySelector('.strip-card-body').style.paddingTop='3rem'">
|
||||||
|
<div class="strip-card-body">
|
||||||
|
<div class="strip-card-title">${d.name}</div>
|
||||||
|
<div class="strip-card-type">${typeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<h4 class="mb-3">${this.selectedCategory.name_es || this.selectedCategory.name}</h4>
|
<h4 class="mb-3">${this.selectedCategory.name_es || this.selectedCategory.name}</h4>
|
||||||
|
${diagramStripHtml}
|
||||||
<div class="content-grid categories-grid">
|
<div class="content-grid categories-grid">
|
||||||
${groups.map(group => `
|
${groups.map(group => `
|
||||||
<div class="category-card">
|
<div class="category-card">
|
||||||
@@ -1123,6 +1138,11 @@ class VehicleDashboard {
|
|||||||
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
|
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${part.image_url ? `
|
||||||
|
<div style="text-align:center;margin-bottom:1rem;">
|
||||||
|
<img src="${part.image_url}" alt="${part.oem_part_number || ''}" style="max-width:100%;max-height:300px;border-radius:8px;object-fit:contain;" />
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="part-detail-row">
|
<div class="part-detail-row">
|
||||||
<span class="part-detail-label">Número OEM</span>
|
<span class="part-detail-label">Número OEM</span>
|
||||||
<span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span>
|
<span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span>
|
||||||
@@ -1602,6 +1622,305 @@ class VehicleDashboard {
|
|||||||
wrapper.style.transform = `scale(${this.currentDiagramZoom})`;
|
wrapper.style.transform = `scale(${this.currentDiagramZoom})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// FASE 6: Full-screen Diagram Viewer (split layout)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
openDiagramViewer(diagramId, indexInList) {
|
||||||
|
this._dvCurrentIndex = typeof indexInList === 'number' ? indexInList : -1;
|
||||||
|
this._dvDiagramList = this._currentDiagramList || [];
|
||||||
|
this._dvZoom = 1;
|
||||||
|
this._dvDragging = false;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('diagramViewerOverlay');
|
||||||
|
overlay.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
this._loadDiagramInViewer(diagramId);
|
||||||
|
this._bindDiagramViewerEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDiagramViewer() {
|
||||||
|
const overlay = document.getElementById('diagramViewerOverlay');
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
this._unbindDiagramViewerEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadDiagramInViewer(diagramId) {
|
||||||
|
const titleEl = document.getElementById('dvTitle');
|
||||||
|
const subtitleEl = document.getElementById('dvSubtitle');
|
||||||
|
const imgWrapper = document.getElementById('dvImgWrapper');
|
||||||
|
const img = document.getElementById('dvImg');
|
||||||
|
const partsList = document.getElementById('dvPartsList');
|
||||||
|
const partsCount = document.getElementById('dvPartsCount');
|
||||||
|
|
||||||
|
// Show loading in parts
|
||||||
|
partsList.innerHTML = '<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-spinner fa-spin" style="font-size:1.5rem"></i><p style="margin-top:0.5rem">Cargando...</p></div>';
|
||||||
|
partsCount.textContent = '...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch diagram detail + parts in parallel
|
||||||
|
const [diagRes, partsRes] = await Promise.all([
|
||||||
|
fetch(`/api/diagrams/${diagramId}`),
|
||||||
|
fetch(`/api/diagrams/${diagramId}/parts${this.selectedVehicleId ? '?mye_id=' + this.selectedVehicleId : ''}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diagram = await diagRes.json();
|
||||||
|
const parts = await partsRes.json();
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
const type = (diagram.name || '')[0];
|
||||||
|
const typeLabel = type === 'F' ? 'Suspensión Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Suspensión Trasera' : diagram.group_name || '';
|
||||||
|
titleEl.textContent = diagram.name || 'Diagrama';
|
||||||
|
subtitleEl.textContent = diagram.name_es || typeLabel;
|
||||||
|
|
||||||
|
// Update image
|
||||||
|
const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : '');
|
||||||
|
img.src = imgSrc;
|
||||||
|
img.alt = diagram.name_es || diagram.name;
|
||||||
|
this._dvZoom = 1;
|
||||||
|
imgWrapper.style.transform = '';
|
||||||
|
imgWrapper.classList.remove('zoomed');
|
||||||
|
document.getElementById('dvZoomLevel').textContent = '100%';
|
||||||
|
|
||||||
|
// Render hotspots on image
|
||||||
|
this._renderViewerHotspots(diagram.hotspots || [], imgWrapper);
|
||||||
|
|
||||||
|
// Render parts list
|
||||||
|
this._renderViewerParts(parts, diagram.hotspots || []);
|
||||||
|
|
||||||
|
// Update nav button states
|
||||||
|
const prevBtn = document.getElementById('dvPrevBtn');
|
||||||
|
const nextBtn = document.getElementById('dvNextBtn');
|
||||||
|
prevBtn.disabled = this._dvCurrentIndex <= 0;
|
||||||
|
nextBtn.disabled = this._dvCurrentIndex < 0 || this._dvCurrentIndex >= this._dvDiagramList.length - 1;
|
||||||
|
prevBtn.style.opacity = prevBtn.disabled ? '0.3' : '1';
|
||||||
|
nextBtn.style.opacity = nextBtn.disabled ? '0.3' : '1';
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading diagram in viewer:', e);
|
||||||
|
partsList.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary)"><i class="fas fa-exclamation-triangle" style="font-size:1.5rem;color:#f59e0b"></i><p style="margin-top:0.5rem">Error cargando diagrama</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderViewerHotspots(hotspots, wrapper) {
|
||||||
|
// Remove existing hotspot markers
|
||||||
|
wrapper.querySelectorAll('.hotspot-marker').forEach(el => el.remove());
|
||||||
|
|
||||||
|
if (!hotspots || hotspots.length === 0) return;
|
||||||
|
|
||||||
|
hotspots.forEach((hs, idx) => {
|
||||||
|
// coords stored as "x%,y%" (percentage-based)
|
||||||
|
const coords = (hs.coords || '').split(',');
|
||||||
|
if (coords.length < 2) return;
|
||||||
|
|
||||||
|
const xPct = parseFloat(coords[0]);
|
||||||
|
const yPct = parseFloat(coords[1]);
|
||||||
|
if (isNaN(xPct) || isNaN(yPct)) return;
|
||||||
|
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.className = 'hotspot-marker pulse';
|
||||||
|
marker.style.left = xPct + '%';
|
||||||
|
marker.style.top = yPct + '%';
|
||||||
|
marker.dataset.partId = hs.part_id || '';
|
||||||
|
marker.dataset.callout = hs.callout_number || (idx + 1);
|
||||||
|
marker.title = hs.part_name || hs.label || 'Parte ' + (idx + 1);
|
||||||
|
marker.innerHTML = `<span class="hotspot-number">${hs.callout_number || (idx + 1)}</span>`;
|
||||||
|
|
||||||
|
marker.addEventListener('click', () => {
|
||||||
|
this._highlightPartInList(hs.part_id);
|
||||||
|
// Highlight this marker
|
||||||
|
wrapper.querySelectorAll('.hotspot-marker').forEach(m => m.classList.remove('active'));
|
||||||
|
marker.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderViewerParts(parts, hotspots) {
|
||||||
|
const listEl = document.getElementById('dvPartsList');
|
||||||
|
const countEl = document.getElementById('dvPartsCount');
|
||||||
|
|
||||||
|
countEl.textContent = parts.length;
|
||||||
|
|
||||||
|
if (!parts || parts.length === 0) {
|
||||||
|
listEl.innerHTML = '<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem"></i><p>No hay partes vinculadas</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a hotspot lookup by part_id
|
||||||
|
const hotspotMap = {};
|
||||||
|
(hotspots || []).forEach((hs, idx) => {
|
||||||
|
if (hs.part_id) hotspotMap[hs.part_id] = hs.callout_number || (idx + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by group_name
|
||||||
|
const grouped = {};
|
||||||
|
parts.forEach(p => {
|
||||||
|
const g = p.group_name_es || p.group_name || 'Otros';
|
||||||
|
if (!grouped[g]) grouped[g] = [];
|
||||||
|
grouped[g].push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const [group, groupParts] of Object.entries(grouped)) {
|
||||||
|
html += `<div class="dv-group-label">${group}</div>`;
|
||||||
|
for (const p of groupParts) {
|
||||||
|
const callout = hotspotMap[p.id];
|
||||||
|
let xrefHtml = '';
|
||||||
|
if (p.cross_references && p.cross_references.length > 0) {
|
||||||
|
xrefHtml = `<div class="dv-xref-list">${p.cross_references.map(x => `<span class="dv-xref-tag">${x.number}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<div class="dv-part-item" data-part-id="${p.id}" onclick="dashboard._onViewerPartClick(${p.id})">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||||
|
${callout ? `<span style="background:var(--accent);color:white;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0">${callout}</span>` : ''}
|
||||||
|
<div class="dv-part-number">${p.part_number || p.oem_part_number}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dv-part-name">${p.name_es || p.name || ''}</div>
|
||||||
|
${xrefHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
_highlightPartInList(partId) {
|
||||||
|
if (!partId) return;
|
||||||
|
const listEl = document.getElementById('dvPartsList');
|
||||||
|
listEl.querySelectorAll('.dv-part-item').forEach(el => el.classList.remove('highlighted'));
|
||||||
|
const target = listEl.querySelector(`.dv-part-item[data-part-id="${partId}"]`);
|
||||||
|
if (target) {
|
||||||
|
target.classList.add('highlighted');
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onViewerPartClick(partId) {
|
||||||
|
// Highlight in list
|
||||||
|
this._highlightPartInList(partId);
|
||||||
|
|
||||||
|
// Highlight matching hotspot on image
|
||||||
|
const wrapper = document.getElementById('dvImgWrapper');
|
||||||
|
wrapper.querySelectorAll('.hotspot-marker').forEach(m => {
|
||||||
|
m.classList.remove('active');
|
||||||
|
if (m.dataset.partId == partId) {
|
||||||
|
m.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_dvNavigate(delta) {
|
||||||
|
const newIdx = this._dvCurrentIndex + delta;
|
||||||
|
if (newIdx < 0 || newIdx >= this._dvDiagramList.length) return;
|
||||||
|
this._dvCurrentIndex = newIdx;
|
||||||
|
const d = this._dvDiagramList[newIdx];
|
||||||
|
if (d) this._loadDiagramInViewer(d.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_dvSetZoom(level) {
|
||||||
|
this._dvZoom = Math.max(0.25, Math.min(4, level));
|
||||||
|
const wrapper = document.getElementById('dvImgWrapper');
|
||||||
|
if (this._dvZoom !== 1) {
|
||||||
|
wrapper.classList.add('zoomed');
|
||||||
|
wrapper.style.transform = `scale(${this._dvZoom})`;
|
||||||
|
} else {
|
||||||
|
wrapper.classList.remove('zoomed');
|
||||||
|
wrapper.style.transform = '';
|
||||||
|
}
|
||||||
|
document.getElementById('dvZoomLevel').textContent = `${Math.round(this._dvZoom * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDiagramViewerEvents() {
|
||||||
|
// Avoid duplicate bindings
|
||||||
|
if (this._dvBound) return;
|
||||||
|
this._dvBound = true;
|
||||||
|
|
||||||
|
this._dvHandlers = {
|
||||||
|
close: () => this.closeDiagramViewer(),
|
||||||
|
prev: () => this._dvNavigate(-1),
|
||||||
|
next: () => this._dvNavigate(1),
|
||||||
|
zoomIn: () => this._dvSetZoom(this._dvZoom + 0.25),
|
||||||
|
zoomOut: () => this._dvSetZoom(this._dvZoom - 0.25),
|
||||||
|
zoomFit: () => this._dvSetZoom(1),
|
||||||
|
keydown: (e) => {
|
||||||
|
const overlay = document.getElementById('diagramViewerOverlay');
|
||||||
|
if (!overlay.classList.contains('active')) return;
|
||||||
|
if (e.key === 'Escape') this.closeDiagramViewer();
|
||||||
|
if (e.key === 'ArrowLeft') this._dvNavigate(-1);
|
||||||
|
if (e.key === 'ArrowRight') this._dvNavigate(1);
|
||||||
|
if (e.key === '+' || e.key === '=') this._dvSetZoom(this._dvZoom + 0.25);
|
||||||
|
if (e.key === '-') this._dvSetZoom(this._dvZoom - 0.25);
|
||||||
|
},
|
||||||
|
wheel: (e) => {
|
||||||
|
const overlay = document.getElementById('diagramViewerOverlay');
|
||||||
|
if (!overlay.classList.contains('active')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||||
|
this._dvSetZoom(this._dvZoom + delta);
|
||||||
|
},
|
||||||
|
partsFilter: (e) => {
|
||||||
|
const q = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('#dvPartsList .dv-part-item').forEach(el => {
|
||||||
|
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mousedown: (e) => {
|
||||||
|
if (this._dvZoom <= 1) return;
|
||||||
|
this._dvDragging = true;
|
||||||
|
this._dvDragStart = { x: e.clientX, y: e.clientY };
|
||||||
|
const container = document.getElementById('dvImgContainer');
|
||||||
|
this._dvScrollStart = { x: container.scrollLeft, y: container.scrollTop };
|
||||||
|
container.style.cursor = 'grabbing';
|
||||||
|
},
|
||||||
|
mousemove: (e) => {
|
||||||
|
if (!this._dvDragging) return;
|
||||||
|
const container = document.getElementById('dvImgContainer');
|
||||||
|
container.scrollLeft = this._dvScrollStart.x - (e.clientX - this._dvDragStart.x);
|
||||||
|
container.scrollTop = this._dvScrollStart.y - (e.clientY - this._dvDragStart.y);
|
||||||
|
},
|
||||||
|
mouseup: () => {
|
||||||
|
this._dvDragging = false;
|
||||||
|
const container = document.getElementById('dvImgContainer');
|
||||||
|
if (container) container.style.cursor = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('dvCloseBtn').addEventListener('click', this._dvHandlers.close);
|
||||||
|
document.getElementById('dvPrevBtn').addEventListener('click', this._dvHandlers.prev);
|
||||||
|
document.getElementById('dvNextBtn').addEventListener('click', this._dvHandlers.next);
|
||||||
|
document.getElementById('dvZoomIn').addEventListener('click', this._dvHandlers.zoomIn);
|
||||||
|
document.getElementById('dvZoomOut').addEventListener('click', this._dvHandlers.zoomOut);
|
||||||
|
document.getElementById('dvZoomFit').addEventListener('click', this._dvHandlers.zoomFit);
|
||||||
|
document.getElementById('dvPartsFilter').addEventListener('input', this._dvHandlers.partsFilter);
|
||||||
|
document.addEventListener('keydown', this._dvHandlers.keydown);
|
||||||
|
document.getElementById('dvImgContainer').addEventListener('wheel', this._dvHandlers.wheel, { passive: false });
|
||||||
|
document.getElementById('dvImgContainer').addEventListener('mousedown', this._dvHandlers.mousedown);
|
||||||
|
window.addEventListener('mousemove', this._dvHandlers.mousemove);
|
||||||
|
window.addEventListener('mouseup', this._dvHandlers.mouseup);
|
||||||
|
}
|
||||||
|
|
||||||
|
_unbindDiagramViewerEvents() {
|
||||||
|
if (!this._dvBound) return;
|
||||||
|
this._dvBound = false;
|
||||||
|
|
||||||
|
document.getElementById('dvCloseBtn')?.removeEventListener('click', this._dvHandlers.close);
|
||||||
|
document.getElementById('dvPrevBtn')?.removeEventListener('click', this._dvHandlers.prev);
|
||||||
|
document.getElementById('dvNextBtn')?.removeEventListener('click', this._dvHandlers.next);
|
||||||
|
document.getElementById('dvZoomIn')?.removeEventListener('click', this._dvHandlers.zoomIn);
|
||||||
|
document.getElementById('dvZoomOut')?.removeEventListener('click', this._dvHandlers.zoomOut);
|
||||||
|
document.getElementById('dvZoomFit')?.removeEventListener('click', this._dvHandlers.zoomFit);
|
||||||
|
document.getElementById('dvPartsFilter')?.removeEventListener('input', this._dvHandlers.partsFilter);
|
||||||
|
document.removeEventListener('keydown', this._dvHandlers.keydown);
|
||||||
|
document.getElementById('dvImgContainer')?.removeEventListener('wheel', this._dvHandlers.wheel);
|
||||||
|
document.getElementById('dvImgContainer')?.removeEventListener('mousedown', this._dvHandlers.mousedown);
|
||||||
|
window.removeEventListener('mousemove', this._dvHandlers.mousemove);
|
||||||
|
window.removeEventListener('mouseup', this._dvHandlers.mouseup);
|
||||||
|
}
|
||||||
|
|
||||||
// FASE 4: Open VIN decoder modal
|
// FASE 4: Open VIN decoder modal
|
||||||
openVinDecoder() {
|
openVinDecoder() {
|
||||||
// Clear previous results
|
// Clear previous results
|
||||||
|
|||||||
1221
dashboard/demo.html
Normal file
1221
dashboard/demo.html
Normal file
File diff suppressed because it is too large
Load Diff
1089
dashboard/diagrams.html
Normal file
1089
dashboard/diagrams.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ const enhancedSearch = {
|
|||||||
debounceMs: 300,
|
debounceMs: 300,
|
||||||
maxResults: 8,
|
maxResults: 8,
|
||||||
maxRecent: 5,
|
maxRecent: 5,
|
||||||
storageKey: 'autopartes_recent_searches'
|
storageKey: 'nexus_recent_searches'
|
||||||
},
|
},
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|||||||
@@ -3,92 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Catálogo de Autopartes - AutoParts DB</title>
|
<title>Catálogo - Nexus Autoparts</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔧</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔧</text></svg>">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
/* Search & Header extras (page-specific) */
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-primary: #0a0a0f;
|
|
||||||
--bg-secondary: #12121a;
|
|
||||||
--bg-card: #1a1a24;
|
|
||||||
--bg-hover: #252532;
|
|
||||||
--accent: #ff6b35;
|
|
||||||
--accent-hover: #ff8555;
|
|
||||||
--accent-glow: rgba(255, 107, 53, 0.3);
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0b0;
|
|
||||||
--border: #2a2a3a;
|
|
||||||
--success: #22c55e;
|
|
||||||
--warning: #f59e0b;
|
|
||||||
--info: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
|
||||||
background: rgba(18, 18, 26, 0.95);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
max-width: 1600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
text-decoration: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
box-shadow: 0 4px 20px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-family: 'Orbitron', sans-serif;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -637,43 +558,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.7rem 1.5rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 15px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 25px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
@@ -1168,40 +1052,7 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quality Badges */
|
|
||||||
.quality-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-economy { background: var(--warning); color: #000; }
|
|
||||||
.quality-standard { background: var(--info); color: white; }
|
|
||||||
.quality-premium { background: var(--success); color: white; }
|
|
||||||
.quality-oem { background: #9b59b6; color: white; }
|
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
z-index: 2000;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.active {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -1539,45 +1390,6 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading & Empty States */
|
|
||||||
.state-container {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-container i {
|
|
||||||
font-size: 4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-container h4 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back Button */
|
|
||||||
.btn-back {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-back:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.header-stats {
|
.header-stats {
|
||||||
@@ -1652,167 +1464,532 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skip link */
|
/* ========== Diagram Strip (horizontal scroll above groups) ========== */
|
||||||
.skip-link {
|
.diagrams-strip {
|
||||||
position: absolute;
|
margin-bottom: 1.5rem;
|
||||||
top: -50px;
|
background: var(--bg-card);
|
||||||
left: 0;
|
border: 1px solid var(--border);
|
||||||
background: var(--accent);
|
border-radius: 12px;
|
||||||
color: white;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
z-index: 3000;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(0, 0, 0, 0);
|
}
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
.diagrams-strip-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-header h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-header .strip-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: rgba(255, 107, 53, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--accent) var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-scroll::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-scroll::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrams-strip-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card {
|
||||||
|
flex: 0 0 180px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #e8e8e8;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card-body {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card-title {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-card-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Diagram Viewer Overlay (full-screen split) ========== */
|
||||||
|
.diagram-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
z-index: 3000;
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-viewer-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left: Diagram image panel */
|
||||||
|
.dv-image-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-toolbar .dv-title {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-toolbar .dv-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-toolbar-btn {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-toolbar-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-close-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-close-btn:hover { background: var(--accent); }
|
||||||
|
|
||||||
|
.dv-img-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #e0e0e0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-img-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-img-wrapper img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-img-wrapper.zoomed img {
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-zoom-btn {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-zoom-btn:hover { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dv-zoom-level {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav arrows */
|
||||||
|
.dv-nav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-nav-btn:hover { background: var(--accent); }
|
||||||
|
.dv-nav-btn.prev { left: 0.75rem; }
|
||||||
|
.dv-nav-btn.next { right: 0.75rem; }
|
||||||
|
|
||||||
|
/* Right: Parts panel */
|
||||||
|
.dv-parts-panel {
|
||||||
|
width: 400px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-header h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-header .dv-parts-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-search {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-search input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-parts-search input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dv-parts-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-group-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.5rem 0.25rem 0.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-part-item {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-part-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-part-item.highlighted {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 107, 53, 0.1);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-part-number {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-part-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-xref-list {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-xref-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #60a5fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Hotspot markers ========== */
|
||||||
|
.hotspot-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 107, 53, 0.35);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotspot-marker:hover,
|
||||||
|
.hotspot-marker.active {
|
||||||
|
background: rgba(255, 107, 53, 0.6);
|
||||||
|
transform: translate(-50%, -50%) scale(1.25);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 107, 53, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotspot-marker .hotspot-number {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hotspot-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 107, 53, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(255, 107, 53, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotspot-marker.pulse {
|
||||||
|
animation: hotspot-pulse 1.5s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Responsive ========== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dv-layout { flex-direction: column; }
|
||||||
|
.dv-parts-panel { width: 100%; height: 45%; }
|
||||||
|
.strip-card { flex: 0 0 150px; }
|
||||||
|
.strip-card-img { height: 90px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a href="#mainContent" class="skip-link">Saltar al contenido</a>
|
<a href="#mainContent" class="skip-link">Saltar al contenido</a>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Shared Navigation -->
|
||||||
<header class="header">
|
<div id="shared-nav"></div>
|
||||||
<div class="header-content">
|
<script src="/nav.js"></script>
|
||||||
<a href="customer-landing.html" class="logo">
|
<script>
|
||||||
<div class="logo-icon">⚙️</div>
|
// Inject page-specific search bar and stats into the shared nav header
|
||||||
<div class="logo-text">AUTOPARTS DB</div>
|
(function() {
|
||||||
</a>
|
var extra = document.getElementById('shared-nav-extra');
|
||||||
|
if (!extra) return;
|
||||||
<div class="search-container">
|
extra.innerHTML = ''
|
||||||
<div class="search-box-enhanced">
|
+ '<div class="search-container">'
|
||||||
<div class="search-input-wrapper">
|
+ '<div class="search-box-enhanced">'
|
||||||
<i class="fas fa-search search-icon"></i>
|
+ '<div class="search-input-wrapper">'
|
||||||
<input type="text" class="search-input" id="searchInput"
|
+ '<i class="fas fa-search search-icon"></i>'
|
||||||
placeholder="Buscar partes, números OEM, vehículos... (presiona /)"
|
+ '<input type="text" class="search-input" id="searchInput"'
|
||||||
aria-label="Buscar partes"
|
+ ' placeholder="Buscar partes, n\u00fameros OEM, veh\u00edculos... (presiona /)"'
|
||||||
autocomplete="off"
|
+ ' aria-label="Buscar partes"'
|
||||||
oninput="enhancedSearch.onInput(this.value)"
|
+ ' autocomplete="off"'
|
||||||
onkeydown="enhancedSearch.onKeydown(event)"
|
+ ' oninput="enhancedSearch.onInput(this.value)"'
|
||||||
onfocus="enhancedSearch.onFocus()">
|
+ ' onkeydown="enhancedSearch.onKeydown(event)"'
|
||||||
<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">
|
+ ' onfocus="enhancedSearch.onFocus()">'
|
||||||
<i class="fas fa-sliders-h"></i>
|
+ '<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">'
|
||||||
</div>
|
+ '<i class="fas fa-sliders-h"></i>'
|
||||||
<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">
|
+ '</div>'
|
||||||
<i class="fas fa-barcode"></i>
|
+ '<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">'
|
||||||
</button>
|
+ '<i class="fas fa-barcode"></i>'
|
||||||
<div class="search-loading" id="searchLoading" style="display: none;">
|
+ '</button>'
|
||||||
<div class="search-spinner"></div>
|
+ '<div class="search-loading" id="searchLoading" style="display: none;">'
|
||||||
</div>
|
+ '<div class="search-spinner"></div>'
|
||||||
</div>
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
<!-- Dropdown de resultados -->
|
+ '<div class="search-dropdown" id="searchDropdown">'
|
||||||
<div class="search-dropdown" id="searchDropdown">
|
+ '<div class="search-filters" id="searchFilters" style="display: none;">'
|
||||||
<!-- Filtros -->
|
+ '<div class="filter-group"><label>Categor\u00eda</label>'
|
||||||
<div class="search-filters" id="searchFilters" style="display: none;">
|
+ '<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()"><option value="">Todas</option></select>'
|
||||||
<div class="filter-group">
|
+ '</div>'
|
||||||
<label>Categoría</label>
|
+ '<div class="filter-group"><label>Buscar en</label>'
|
||||||
<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()">
|
+ '<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">'
|
||||||
<option value="">Todas</option>
|
+ '<option value="all">Todo</option><option value="parts">Solo Partes</option><option value="vehicles">Solo Veh\u00edculos</option>'
|
||||||
</select>
|
+ '</select>'
|
||||||
</div>
|
+ '</div>'
|
||||||
<div class="filter-group">
|
+ '</div>'
|
||||||
<label>Buscar en</label>
|
+ '<div class="search-recent" id="searchRecent">'
|
||||||
<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">
|
+ '<div class="search-section-title"><i class="fas fa-history"></i> B\u00fasquedas recientes '
|
||||||
<option value="all">Todo</option>
|
+ '<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span></div>'
|
||||||
<option value="parts">Solo Partes</option>
|
+ '<div class="search-recent-items" id="searchRecentItems"></div>'
|
||||||
<option value="vehicles">Solo Vehículos</option>
|
+ '</div>'
|
||||||
</select>
|
+ '<div class="search-results-container" id="searchResultsContainer">'
|
||||||
</div>
|
+ '<div class="search-results-section" id="partsResults" style="display: none;">'
|
||||||
</div>
|
+ '<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>'
|
||||||
|
+ '<div class="search-results-list" id="partsResultsList"></div>'
|
||||||
<!-- Búsquedas recientes -->
|
+ '</div>'
|
||||||
<div class="search-recent" id="searchRecent">
|
+ '<div class="search-results-section" id="vehiclesResults" style="display: none;">'
|
||||||
<div class="search-section-title">
|
+ '<div class="search-section-title"><i class="fas fa-car"></i> Veh\u00edculos</div>'
|
||||||
<i class="fas fa-history"></i> Búsquedas recientes
|
+ '<div class="search-results-list" id="vehiclesResultsList"></div>'
|
||||||
<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span>
|
+ '</div>'
|
||||||
</div>
|
+ '<div class="search-no-results" id="searchNoResults" style="display: none;">'
|
||||||
<div class="search-recent-items" id="searchRecentItems"></div>
|
+ '<i class="fas fa-search"></i><p>No se encontraron resultados</p>'
|
||||||
</div>
|
+ '<span>Intenta con otros t\u00e9rminos de b\u00fasqueda</span>'
|
||||||
|
+ '<div class="search-suggestions" style="margin-top: 1rem;">'
|
||||||
<!-- Resultados -->
|
+ '<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">B\u00fasquedas populares:</span>'
|
||||||
<div class="search-results-container" id="searchResultsContainer">
|
+ '<div class="search-suggestion-tags">'
|
||||||
<!-- Parts results -->
|
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'brake\')">brake</span>'
|
||||||
<div class="search-results-section" id="partsResults" style="display: none;">
|
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'filter\')">filter</span>'
|
||||||
<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>
|
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'spark plug\')">spark plug</span>'
|
||||||
<div class="search-results-list" id="partsResultsList"></div>
|
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'camry\')">camry</span>'
|
||||||
</div>
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
<!-- Vehicles results -->
|
+ '</div>'
|
||||||
<div class="search-results-section" id="vehiclesResults" style="display: none;">
|
+ '</div>'
|
||||||
<div class="search-section-title"><i class="fas fa-car"></i> Vehículos</div>
|
+ '<div class="search-dropdown-footer" id="searchFooter" style="display: none;">'
|
||||||
<div class="search-results-list" id="vehiclesResultsList"></div>
|
+ '<span class="search-hint"><kbd>\u2191\u2193</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar</span>'
|
||||||
</div>
|
+ '<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">Ver todos los resultados <i class="fas fa-arrow-right"></i></button>'
|
||||||
|
+ '</div>'
|
||||||
<!-- No results -->
|
+ '</div>'
|
||||||
<div class="search-no-results" id="searchNoResults" style="display: none;">
|
+ '</div>'
|
||||||
<i class="fas fa-search"></i>
|
+ '</div>'
|
||||||
<p>No se encontraron resultados</p>
|
+ '<div class="header-actions">'
|
||||||
<span>Intenta con otros términos de búsqueda</span>
|
+ '<div class="header-stats">'
|
||||||
<div class="search-suggestions" style="margin-top: 1rem;">
|
+ '<div class="header-stat"><div class="header-stat-value" id="totalBrands">0</div><div class="header-stat-label">Marcas</div></div>'
|
||||||
<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">Búsquedas populares:</span>
|
+ '<div class="header-stat"><div class="header-stat-value" id="totalModels">0</div><div class="header-stat-label">Modelos</div></div>'
|
||||||
<div class="search-suggestion-tags">
|
+ '<div class="header-stat"><div class="header-stat-value" id="totalParts">0</div><div class="header-stat-label">Partes</div></div>'
|
||||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('brake')">brake</span>
|
+ '</div>'
|
||||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('filter')">filter</span>
|
+ '<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio"><i class="fas fa-home"></i></a>'
|
||||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('spark plug')">spark plug</span>
|
+ '<a href="admin.html" class="btn btn-primary btn-icon" title="Panel de administraci\u00f3n"><i class="fas fa-cog"></i></a>'
|
||||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('camry')">camry</span>
|
+ '</div>';
|
||||||
</div>
|
})();
|
||||||
</div>
|
</script>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer con acciones -->
|
|
||||||
<div class="search-dropdown-footer" id="searchFooter" style="display: none;">
|
|
||||||
<span class="search-hint">
|
|
||||||
<kbd>↑↓</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar
|
|
||||||
</span>
|
|
||||||
<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">
|
|
||||||
Ver todos los resultados <i class="fas fa-arrow-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-actions">
|
|
||||||
<div class="header-stats">
|
|
||||||
<div class="header-stat">
|
|
||||||
<div class="header-stat-value" id="totalBrands">0</div>
|
|
||||||
<div class="header-stat-label">Marcas</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-stat">
|
|
||||||
<div class="header-stat-value" id="totalModels">0</div>
|
|
||||||
<div class="header-stat-label">Modelos</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-stat">
|
|
||||||
<div class="header-stat-value" id="totalParts">0</div>
|
|
||||||
<div class="header-stat-label">Partes</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio">
|
|
||||||
<i class="fas fa-home"></i>
|
|
||||||
</a>
|
|
||||||
<a href="admin.html" class="btn btn-primary btn-icon" title="Panel de administración">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main Container -->
|
<!-- Main Container -->
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
@@ -1924,6 +2101,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Diagram Viewer Overlay (split layout) -->
|
||||||
|
<div class="diagram-viewer-overlay" id="diagramViewerOverlay">
|
||||||
|
<div class="dv-layout">
|
||||||
|
<!-- Left: Diagram image -->
|
||||||
|
<div class="dv-image-panel">
|
||||||
|
<div class="dv-toolbar">
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="dv-title" id="dvTitle">F200</div>
|
||||||
|
<div class="dv-subtitle" id="dvSubtitle">Suspension Delantera</div>
|
||||||
|
</div>
|
||||||
|
<button class="dv-toolbar-btn" id="dvPrevBtn" title="Anterior"><i class="fas fa-chevron-left"></i></button>
|
||||||
|
<button class="dv-toolbar-btn" id="dvNextBtn" title="Siguiente"><i class="fas fa-chevron-right"></i></button>
|
||||||
|
<button class="dv-close-btn" id="dvCloseBtn" title="Cerrar"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="dv-img-container" id="dvImgContainer">
|
||||||
|
<div class="dv-img-wrapper" id="dvImgWrapper">
|
||||||
|
<img id="dvImg" src="" alt="Diagram">
|
||||||
|
<!-- Hotspot markers rendered here -->
|
||||||
|
</div>
|
||||||
|
<div class="dv-zoom-controls">
|
||||||
|
<button class="dv-zoom-btn" id="dvZoomOut"><i class="fas fa-minus"></i></button>
|
||||||
|
<span class="dv-zoom-level" id="dvZoomLevel">100%</span>
|
||||||
|
<button class="dv-zoom-btn" id="dvZoomIn"><i class="fas fa-plus"></i></button>
|
||||||
|
<button class="dv-zoom-btn" id="dvZoomFit"><i class="fas fa-expand"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Parts panel -->
|
||||||
|
<div class="dv-parts-panel">
|
||||||
|
<div class="dv-parts-header">
|
||||||
|
<i class="fas fa-list-ul" style="color: var(--accent)"></i>
|
||||||
|
<h3>Partes del Diagrama</h3>
|
||||||
|
<span class="dv-parts-count" id="dvPartsCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="dv-parts-search">
|
||||||
|
<input type="text" id="dvPartsFilter" placeholder="Filtrar partes...">
|
||||||
|
</div>
|
||||||
|
<div class="dv-parts-list" id="dvPartsList">
|
||||||
|
<div style="text-align:center;padding:3rem;color:var(--text-secondary)">
|
||||||
|
<i class="fas fa-spinner fa-spin" style="font-size:1.5rem;margin-bottom:0.5rem"></i>
|
||||||
|
<p>Cargando partes...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="dashboard.js"></script>
|
<script src="dashboard.js"></script>
|
||||||
<script src="enhanced-search.js"></script>
|
<script src="enhanced-search.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
211
dashboard/login.css
Normal file
211
dashboard/login.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/* ============================================================
|
||||||
|
login.css -- Login / Register page styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Card --- */
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
animation: fadeIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Brand header --- */
|
||||||
|
.login-brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand .logo-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand .slogan {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form panel visibility --- */
|
||||||
|
.form-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-panel.active {
|
||||||
|
display: block;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form title --- */
|
||||||
|
.form-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Select (dropdown) --- */
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Submit button (full width) --- */
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toggle link --- */
|
||||||
|
.toggle-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-link a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-link a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Alert messages --- */
|
||||||
|
.login-alert {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert.error {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert.success {
|
||||||
|
background: rgba(0, 214, 143, 0.1);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading spinner on button --- */
|
||||||
|
.btn-submit.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit .spinner {
|
||||||
|
display: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit.loading .spinner {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit.loading .btn-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Row layout for two fields side by side --- */
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 1.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
dashboard/login.html
Normal file
108
dashboard/login.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nexus Autoparts - Iniciar Sesion</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/login.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="logo-icon">⚙</div>
|
||||||
|
<h1>NEXUS <span>AUTOPARTS</span></h1>
|
||||||
|
<p class="slogan">Tu conexion directa con las partes que necesitas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert (shared between forms) -->
|
||||||
|
<div id="alert" class="login-alert" role="alert"></div>
|
||||||
|
|
||||||
|
<!-- LOGIN FORM -->
|
||||||
|
<div id="loginPanel" class="form-panel active">
|
||||||
|
<h2 class="form-title">Iniciar Sesion</h2>
|
||||||
|
<form id="loginForm" autocomplete="on">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="loginEmail">Correo electronico</label>
|
||||||
|
<input class="form-input" type="email" id="loginEmail" name="email"
|
||||||
|
placeholder="tu@correo.com" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="loginPassword">Contrasena</label>
|
||||||
|
<input class="form-input" type="password" id="loginPassword" name="password"
|
||||||
|
placeholder="Tu contrasena" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-submit">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="btn-label">Iniciar Sesion</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="toggle-link">
|
||||||
|
¿No tienes cuenta? <a onclick="showPanel('register')">Registrate</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REGISTER FORM -->
|
||||||
|
<div id="registerPanel" class="form-panel">
|
||||||
|
<h2 class="form-title">Crear Cuenta</h2>
|
||||||
|
<form id="registerForm" autocomplete="on">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regName">Nombre completo</label>
|
||||||
|
<input class="form-input" type="text" id="regName" name="name"
|
||||||
|
placeholder="Juan Perez" required autocomplete="name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regEmail">Correo electronico</label>
|
||||||
|
<input class="form-input" type="email" id="regEmail" name="email"
|
||||||
|
placeholder="tu@correo.com" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regPassword">Contrasena</label>
|
||||||
|
<input class="form-input" type="password" id="regPassword" name="password"
|
||||||
|
placeholder="Min. 8 caracteres" required minlength="8"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regConfirm">Confirmar contrasena</label>
|
||||||
|
<input class="form-input" type="password" id="regConfirm" name="confirm"
|
||||||
|
placeholder="Repetir contrasena" required minlength="8"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regBusiness">Nombre del negocio</label>
|
||||||
|
<input class="form-input" type="text" id="regBusiness" name="business_name"
|
||||||
|
placeholder="Taller / Refaccionaria" required autocomplete="organization">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regPhone">Telefono</label>
|
||||||
|
<input class="form-input" type="tel" id="regPhone" name="phone"
|
||||||
|
placeholder="(555) 123-4567" required autocomplete="tel">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="regRole">Tipo de cuenta</label>
|
||||||
|
<select class="form-select" id="regRole" name="role" required>
|
||||||
|
<option value="TALLER">Taller</option>
|
||||||
|
<option value="BODEGA">Bodega</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-submit">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="btn-label">Crear Cuenta</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="toggle-link">
|
||||||
|
¿Ya tienes cuenta? <a onclick="showPanel('login')">Inicia Sesion</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
227
dashboard/login.js
Normal file
227
dashboard/login.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/* ============================================================
|
||||||
|
login.js -- Login / Register logic for Nexus Autoparts
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---- DOM refs ----
|
||||||
|
const loginPanel = document.getElementById('loginPanel');
|
||||||
|
const registerPanel = document.getElementById('registerPanel');
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
const registerForm = document.getElementById('registerForm');
|
||||||
|
const alertBox = document.getElementById('alert');
|
||||||
|
|
||||||
|
// ---- Role-based redirect map ----
|
||||||
|
const ROLE_REDIRECTS = {
|
||||||
|
ADMIN: '/demo',
|
||||||
|
OWNER: '/demo',
|
||||||
|
BODEGA: '/bodega',
|
||||||
|
TALLER: '/demo',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Check existing session on load ----
|
||||||
|
(function checkSession() {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const role = localStorage.getItem('user_role');
|
||||||
|
if (token && role) {
|
||||||
|
const dest = ROLE_REDIRECTS[role] || '/index.html';
|
||||||
|
window.location.replace(dest);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---- Panel toggling ----
|
||||||
|
window.showPanel = function (panel) {
|
||||||
|
hideAlert();
|
||||||
|
if (panel === 'register') {
|
||||||
|
loginPanel.classList.remove('active');
|
||||||
|
registerPanel.classList.add('active');
|
||||||
|
} else {
|
||||||
|
registerPanel.classList.remove('active');
|
||||||
|
loginPanel.classList.add('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Alert helpers ----
|
||||||
|
function showAlert(msg, type) {
|
||||||
|
alertBox.textContent = msg;
|
||||||
|
alertBox.className = 'login-alert show ' + type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAlert() {
|
||||||
|
alertBox.className = 'login-alert';
|
||||||
|
alertBox.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(btn, loading) {
|
||||||
|
btn.classList.toggle('loading', loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Login ----
|
||||||
|
loginForm.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert();
|
||||||
|
|
||||||
|
const email = document.getElementById('loginEmail').value.trim();
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
const btn = loginForm.querySelector('.btn-submit');
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
showAlert('Completa todos los campos.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(btn, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showAlert(data.error || data.message || 'Credenciales incorrectas.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist tokens & user info
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token || '');
|
||||||
|
localStorage.setItem('user_role', data.role || data.user?.role || '');
|
||||||
|
localStorage.setItem('user_name', data.name || data.user?.name || '');
|
||||||
|
|
||||||
|
const role = (data.role || data.user?.role || '').toUpperCase();
|
||||||
|
const dest = ROLE_REDIRECTS[role] || '/index.html';
|
||||||
|
window.location.replace(dest);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Error de conexion. Intenta de nuevo.', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Register ----
|
||||||
|
registerForm.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert();
|
||||||
|
|
||||||
|
const name = document.getElementById('regName').value.trim();
|
||||||
|
const email = document.getElementById('regEmail').value.trim();
|
||||||
|
const password = document.getElementById('regPassword').value;
|
||||||
|
const confirm = document.getElementById('regConfirm').value;
|
||||||
|
const business_name = document.getElementById('regBusiness').value.trim();
|
||||||
|
const phone = document.getElementById('regPhone').value.trim();
|
||||||
|
const role = document.getElementById('regRole').value;
|
||||||
|
const btn = registerForm.querySelector('.btn-submit');
|
||||||
|
|
||||||
|
// Validations
|
||||||
|
if (!name || !email || !password || !confirm || !business_name || !phone) {
|
||||||
|
showAlert('Completa todos los campos.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
showAlert('La contrasena debe tener al menos 8 caracteres.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirm) {
|
||||||
|
showAlert('Las contrasenas no coinciden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(btn, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, password, role, business_name, phone }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showAlert(data.error || data.message || 'Error al crear la cuenta.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert('Cuenta creada. Pendiente de aprobacion por administrador.', 'success');
|
||||||
|
registerForm.reset();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Error de conexion. Intenta de nuevo.', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// authFetch -- Authenticated fetch wrapper (exported globally)
|
||||||
|
// ================================================================
|
||||||
|
window.authFetch = async function authFetch(url, options = {}) {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.assign({}, options.headers || {}, {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await fetch(url, Object.assign({}, options, { headers }));
|
||||||
|
|
||||||
|
// If 401, try refreshing the token once
|
||||||
|
if (res.status === 401) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + localStorage.getItem('access_token');
|
||||||
|
res = await fetch(url, Object.assign({}, options, { headers }));
|
||||||
|
} else {
|
||||||
|
// Refresh failed — log out
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function tryRefreshToken() {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
if (!refreshToken) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return false;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
if (data.refresh_token) {
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.logout = function logout() {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user_role');
|
||||||
|
localStorage.removeItem('user_name');
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
155
dashboard/nav.js
Normal file
155
dashboard/nav.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* nav.js -- Shared navigation component for NEXUS AUTOPARTS
|
||||||
|
*
|
||||||
|
* Injects a consistent header/nav bar into <div id="shared-nav"></div>.
|
||||||
|
* Auto-highlights the current page link based on window.location.pathname.
|
||||||
|
*
|
||||||
|
* The injected header includes a <div id="shared-nav-extra"></div> slot
|
||||||
|
* that pages can populate with additional header content (search bars, stats, etc.)
|
||||||
|
* after this script runs.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var path = window.location.pathname;
|
||||||
|
|
||||||
|
function isActive(href) {
|
||||||
|
var h = href.replace(/\/+$/, '') || '/';
|
||||||
|
var p = path.replace(/\/+$/, '') || '/';
|
||||||
|
if (h === p) return true;
|
||||||
|
if ((h === '/' || h === '/index.html') && (p === '/' || p === '/index.html')) return true;
|
||||||
|
if ((h === '/admin.html' || h === '/admin') && (p === '/admin.html' || p === '/admin')) return true;
|
||||||
|
if ((h === '/diagramas' || h === '/diagrams.html') && (p === '/diagramas' || p === '/diagrams.html')) return true;
|
||||||
|
if ((h === '/customer-landing.html') && (p === '/customer-landing.html')) return true;
|
||||||
|
if ((h === '/captura') && (p === '/captura')) return true;
|
||||||
|
if ((h === '/pos') && (p === '/pos')) return true;
|
||||||
|
if ((h === '/cuentas') && (p === '/cuentas')) return true;
|
||||||
|
if ((h === '/tienda') && (p === '/tienda')) return true;
|
||||||
|
if ((h === '/bodega') && (p === '/bodega')) return true;
|
||||||
|
if ((h === '/demo' || h === '/demo.html') && (p === '/demo' || p === '/demo.html')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var navLinks = [
|
||||||
|
{ label: 'Demo', href: '/demo' },
|
||||||
|
{ label: 'Tienda', href: '/tienda' },
|
||||||
|
{ label: 'Cat\u00e1logo', href: '/index.html' },
|
||||||
|
{ label: 'Captura', href: '/captura' },
|
||||||
|
{ label: 'POS', href: '/pos' },
|
||||||
|
{ label: 'Cuentas', href: '/cuentas' },
|
||||||
|
{ label: 'Bodega', href: '/bodega' },
|
||||||
|
{ label: 'Admin', href: '/admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
var linksHTML = navLinks.map(function (link) {
|
||||||
|
var baseStyle = 'text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: color 0.2s;';
|
||||||
|
if (isActive(link.href)) {
|
||||||
|
baseStyle += ' color: var(--accent);';
|
||||||
|
} else {
|
||||||
|
baseStyle += ' color: var(--text-secondary);';
|
||||||
|
}
|
||||||
|
return '<a href="' + link.href + '" style="' + baseStyle + '"'
|
||||||
|
+ ' onmouseover="this.style.color=\'var(--accent)\'"'
|
||||||
|
+ ' onmouseout="' + (isActive(link.href) ? '' : 'this.style.color=\'var(--text-secondary)\'') + '"'
|
||||||
|
+ '>' + link.label + '</a>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var html = ''
|
||||||
|
+ '<header id="shared-nav-header" style="'
|
||||||
|
+ 'background: rgba(18, 18, 26, 0.95);'
|
||||||
|
+ 'backdrop-filter: blur(20px);'
|
||||||
|
+ '-webkit-backdrop-filter: blur(20px);'
|
||||||
|
+ 'border-bottom: 1px solid var(--border);'
|
||||||
|
+ 'padding: 1rem 2rem;'
|
||||||
|
+ 'position: fixed;'
|
||||||
|
+ 'top: 0; left: 0; right: 0;'
|
||||||
|
+ 'z-index: 1000;'
|
||||||
|
+ '">'
|
||||||
|
+ '<div style="'
|
||||||
|
+ 'max-width: 1600px;'
|
||||||
|
+ 'margin: 0 auto;'
|
||||||
|
+ 'display: flex;'
|
||||||
|
+ 'justify-content: space-between;'
|
||||||
|
+ 'align-items: center;'
|
||||||
|
+ 'gap: 2rem;'
|
||||||
|
+ '">'
|
||||||
|
// Logo
|
||||||
|
+ '<a href="/" style="'
|
||||||
|
+ 'display: flex;'
|
||||||
|
+ 'align-items: center;'
|
||||||
|
+ 'gap: 0.75rem;'
|
||||||
|
+ 'text-decoration: none;'
|
||||||
|
+ 'flex-shrink: 0;'
|
||||||
|
+ '">'
|
||||||
|
+ '<div style="'
|
||||||
|
+ 'width: 42px; height: 42px;'
|
||||||
|
+ 'background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);'
|
||||||
|
+ 'border-radius: 10px;'
|
||||||
|
+ 'display: flex; align-items: center; justify-content: center;'
|
||||||
|
+ 'font-size: 1.5rem;'
|
||||||
|
+ 'box-shadow: 0 4px 20px var(--accent-glow);'
|
||||||
|
+ '">\u2699\uFE0F</div>'
|
||||||
|
+ '<span style="'
|
||||||
|
+ 'font-family: Orbitron, sans-serif;'
|
||||||
|
+ 'font-size: 1.3rem;'
|
||||||
|
+ 'font-weight: 700;'
|
||||||
|
+ 'background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);'
|
||||||
|
+ '-webkit-background-clip: text;'
|
||||||
|
+ '-webkit-text-fill-color: transparent;'
|
||||||
|
+ 'background-clip: text;'
|
||||||
|
+ '">NEXUS AUTOPARTS</span>'
|
||||||
|
+ '</a>'
|
||||||
|
// Slot for extra page-specific content (search bars, stats, etc.)
|
||||||
|
+ '<div id="shared-nav-extra" style="display: contents;"></div>'
|
||||||
|
// Nav links
|
||||||
|
+ '<nav id="shared-nav-links" style="'
|
||||||
|
+ 'display: flex;'
|
||||||
|
+ 'gap: 1.5rem;'
|
||||||
|
+ 'align-items: center;'
|
||||||
|
+ 'flex-shrink: 0;'
|
||||||
|
+ '">'
|
||||||
|
+ linksHTML
|
||||||
|
+ '</nav>'
|
||||||
|
// Auth section
|
||||||
|
+ '<div id="nav-auth" style="display:flex;align-items:center;gap:0.75rem;flex-shrink:0;">'
|
||||||
|
+ '<span id="nav-user-name" style="color:var(--text-secondary);font-size:0.85rem;"></span>'
|
||||||
|
+ '<a id="nav-auth-btn" href="/login.html" style="'
|
||||||
|
+ 'text-decoration:none;font-size:0.85rem;font-weight:500;'
|
||||||
|
+ 'color:var(--bg);background:var(--accent);'
|
||||||
|
+ 'padding:0.4rem 1rem;border-radius:6px;'
|
||||||
|
+ 'transition:opacity 0.2s;'
|
||||||
|
+ '">Iniciar Sesi\u00f3n</a>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</header>';
|
||||||
|
|
||||||
|
var target = document.getElementById('shared-nav');
|
||||||
|
if (target) {
|
||||||
|
target.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
var token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
var nameEl = document.getElementById('nav-user-name');
|
||||||
|
var btnEl = document.getElementById('nav-auth-btn');
|
||||||
|
if (nameEl && payload.business_name) {
|
||||||
|
nameEl.textContent = payload.business_name;
|
||||||
|
} else if (nameEl) {
|
||||||
|
nameEl.textContent = payload.role || '';
|
||||||
|
}
|
||||||
|
if (btnEl) {
|
||||||
|
btnEl.textContent = 'Salir';
|
||||||
|
btnEl.href = '#';
|
||||||
|
btnEl.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
418
dashboard/pos.css
Normal file
418
dashboard/pos.css
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
/* ============================================================
|
||||||
|
pos.css -- Point of Sale styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.pos-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Layout: 2 columns --- */
|
||||||
|
.pos-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 360px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Left: Search + Cart --- */
|
||||||
|
.pos-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Customer bar --- */
|
||||||
|
.customer-bar {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-search {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-rfc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-bar .cb-balance {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb-balance.positive { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
|
||||||
|
.cb-balance.zero { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||||
|
|
||||||
|
/* --- Customer dropdown --- */
|
||||||
|
.customer-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-dropdown-item {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-dropdown-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-dropdown-item .cdi-name { font-weight: 600; }
|
||||||
|
.customer-dropdown-item .cdi-rfc { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* --- Part search --- */
|
||||||
|
.part-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-results {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-result-item {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-result-item:hover,
|
||||||
|
.part-result-item.part-result-active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-result-item .pri-number {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-result-item .pri-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-result-item .pri-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pri-type.oem { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||||
|
.pri-type.aftermarket { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||||
|
|
||||||
|
/* --- Cart table --- */
|
||||||
|
.cart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-card h3 {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table input {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 70px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-table .cart-desc { max-width: 250px; }
|
||||||
|
.cart-table .cart-qty { width: 45px; text-align: center; }
|
||||||
|
.cart-table .cart-cost { width: 80px; }
|
||||||
|
.cart-table .cart-margin { width: 55px; }
|
||||||
|
.cart-table .cart-price { width: 80px; }
|
||||||
|
|
||||||
|
.cart-table .cart-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Right sidebar: Invoice summary --- */
|
||||||
|
.pos-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-summary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-summary h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row.total {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row .sr-label { color: var(--text-secondary); }
|
||||||
|
.summary-row .sr-value { font-weight: 600; }
|
||||||
|
.summary-row.total .sr-value { color: var(--accent); }
|
||||||
|
|
||||||
|
.btn-facturar {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #ff4500);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-facturar:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 25px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-facturar:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-notes {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-notes:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- New customer modal --- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 450px;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toast (reuse from captura) --- */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toastIn 0.3s ease;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
113
dashboard/pos.html
Normal file
113
dashboard/pos.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Punto de Venta — NEXUS AUTOPARTS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/pos.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shared-nav"></div>
|
||||||
|
|
||||||
|
<div class="pos-container">
|
||||||
|
<div class="pos-layout">
|
||||||
|
<!-- LEFT: Main area -->
|
||||||
|
<div class="pos-main">
|
||||||
|
<!-- Customer selection -->
|
||||||
|
<div class="customer-bar" style="position:relative">
|
||||||
|
<div id="customer-select" style="flex:1;position:relative">
|
||||||
|
<input class="cb-search" id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
|
||||||
|
<div id="customer-dropdown" class="customer-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div id="customer-info" class="cb-selected" style="display:none">
|
||||||
|
<span class="cb-name" id="sel-customer-name"></span>
|
||||||
|
<span class="cb-rfc" id="sel-customer-rfc"></span>
|
||||||
|
<span class="cb-balance" id="sel-customer-balance"></span>
|
||||||
|
<button class="btn btn-secondary" style="padding:0.3rem 0.6rem;font-size:0.8rem" id="btn-change-customer">Cambiar</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" id="btn-new-customer" style="padding:0.5rem 0.8rem;font-size:0.85rem">+ Nuevo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part search -->
|
||||||
|
<div class="part-search-wrap">
|
||||||
|
<input class="part-search" id="part-search" type="text" placeholder="Buscar parte por # OEM, # aftermarket o nombre...">
|
||||||
|
<div id="part-results" class="part-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart -->
|
||||||
|
<div class="cart-card">
|
||||||
|
<h3>Carrito</h3>
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Descripcion</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Cant</th>
|
||||||
|
<th>Costo</th>
|
||||||
|
<th>Margen%</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cart-body">
|
||||||
|
<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Summary -->
|
||||||
|
<div class="pos-sidebar">
|
||||||
|
<div class="invoice-summary">
|
||||||
|
<h3>Resumen de Factura</h3>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="sr-label">Articulos</span>
|
||||||
|
<span class="sr-value" id="sum-items">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="sr-label">Subtotal</span>
|
||||||
|
<span class="sr-value" id="sum-subtotal">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="sr-label">IVA (16%)</span>
|
||||||
|
<span class="sr-value" id="sum-tax">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row total">
|
||||||
|
<span class="sr-label">Total</span>
|
||||||
|
<span class="sr-value" id="sum-total">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<textarea class="invoice-notes" id="invoice-notes" placeholder="Notas de la factura (opcional)"></textarea>
|
||||||
|
<button class="btn-facturar" id="btn-facturar" disabled>Facturar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Customer Modal -->
|
||||||
|
<div id="modal-new-customer" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Nuevo Cliente</h3>
|
||||||
|
<div class="modal-field"><label>Nombre *</label><input id="nc-name" required></div>
|
||||||
|
<div class="modal-field"><label>RFC</label><input id="nc-rfc" maxlength="13" placeholder="XAXX010101000"></div>
|
||||||
|
<div class="modal-field"><label>Razon Social</label><input id="nc-business"></div>
|
||||||
|
<div class="modal-field"><label>Telefono</label><input id="nc-phone"></div>
|
||||||
|
<div class="modal-field"><label>Email</label><input id="nc-email" type="email"></div>
|
||||||
|
<div class="modal-field"><label>Direccion</label><input id="nc-address"></div>
|
||||||
|
<div style="display:flex;gap:1rem">
|
||||||
|
<div class="modal-field" style="flex:1"><label>Limite de Credito</label><input id="nc-credit" type="number" value="0"></div>
|
||||||
|
<div class="modal-field" style="flex:1"><label>Dias de Credito</label><input id="nc-terms" type="number" value="30"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="nc-cancel">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" id="nc-save">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/nav.js"></script>
|
||||||
|
<script src="/pos.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
413
dashboard/pos.js
Normal file
413
dashboard/pos.js
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* pos.js — Point of Sale logic for Nexus Autoparts
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '';
|
||||||
|
var selectedCustomer = null;
|
||||||
|
var cart = [];
|
||||||
|
var defaultMargin = 30;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Utility
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function toast(msg, type) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + (type || 'success');
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function () { el.remove(); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(API + path, opts).then(function (r) {
|
||||||
|
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Customer Selection
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var customerSearchTimer = null;
|
||||||
|
var customerSearchEl = document.getElementById('customer-search');
|
||||||
|
var customerDropdown = document.getElementById('customer-dropdown');
|
||||||
|
|
||||||
|
customerSearchEl.addEventListener('input', function () {
|
||||||
|
clearTimeout(customerSearchTimer);
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (q.length < 2) { customerDropdown.style.display = 'none'; return; }
|
||||||
|
customerSearchTimer = setTimeout(function () {
|
||||||
|
api('/api/pos/customers?search=' + encodeURIComponent(q) + '&per_page=10')
|
||||||
|
.then(function (res) {
|
||||||
|
var data = res.data || [];
|
||||||
|
if (data.length === 0) {
|
||||||
|
customerDropdown.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron clientes</div>';
|
||||||
|
} else {
|
||||||
|
customerDropdown.innerHTML = data.map(function (c) {
|
||||||
|
return '<div class="customer-dropdown-item" data-id="' + c.id_customer + '">' +
|
||||||
|
'<div><span class="cdi-name">' + esc(c.name) + '</span>' +
|
||||||
|
(c.rfc ? ' <span class="cdi-rfc">' + esc(c.rfc) + '</span>' : '') + '</div>' +
|
||||||
|
'<span style="font-size:0.8rem;color:' + (c.balance > 0 ? 'var(--danger)' : 'var(--success)') + '">' +
|
||||||
|
fmt(c.balance) + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
customerDropdown.querySelectorAll('.customer-dropdown-item').forEach(function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
selectCustomer(parseInt(item.getAttribute('data-id')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customerDropdown.style.display = 'block';
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
customerSearchEl.addEventListener('blur', function () {
|
||||||
|
setTimeout(function () { customerDropdown.style.display = 'none'; }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectCustomer(id) {
|
||||||
|
api('/api/pos/customers/' + id).then(function (c) {
|
||||||
|
selectedCustomer = c;
|
||||||
|
document.getElementById('customer-select').style.display = 'none';
|
||||||
|
var info = document.getElementById('customer-info');
|
||||||
|
info.style.display = 'flex';
|
||||||
|
document.getElementById('sel-customer-name').textContent = c.name;
|
||||||
|
document.getElementById('sel-customer-rfc').textContent = c.rfc || 'Sin RFC';
|
||||||
|
var balEl = document.getElementById('sel-customer-balance');
|
||||||
|
balEl.textContent = 'Saldo: ' + fmt(c.balance);
|
||||||
|
balEl.className = 'cb-balance ' + (c.balance > 0 ? 'positive' : 'zero');
|
||||||
|
customerDropdown.style.display = 'none';
|
||||||
|
updateFacturarBtn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-change-customer').addEventListener('click', function () {
|
||||||
|
selectedCustomer = null;
|
||||||
|
document.getElementById('customer-info').style.display = 'none';
|
||||||
|
document.getElementById('customer-select').style.display = 'block';
|
||||||
|
customerSearchEl.value = '';
|
||||||
|
customerSearchEl.focus();
|
||||||
|
updateFacturarBtn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// New Customer Modal
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
document.getElementById('btn-new-customer').addEventListener('click', function () {
|
||||||
|
document.getElementById('modal-new-customer').style.display = 'flex';
|
||||||
|
document.getElementById('nc-name').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nc-cancel').addEventListener('click', function () {
|
||||||
|
document.getElementById('modal-new-customer').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nc-save').addEventListener('click', function () {
|
||||||
|
var name = document.getElementById('nc-name').value.trim();
|
||||||
|
if (!name) { toast('Ingresa el nombre del cliente', 'error'); return; }
|
||||||
|
|
||||||
|
api('/api/pos/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
rfc: document.getElementById('nc-rfc').value.trim() || null,
|
||||||
|
business_name: document.getElementById('nc-business').value.trim() || null,
|
||||||
|
phone: document.getElementById('nc-phone').value.trim() || null,
|
||||||
|
email: document.getElementById('nc-email').value.trim() || null,
|
||||||
|
address: document.getElementById('nc-address').value.trim() || null,
|
||||||
|
credit_limit: parseFloat(document.getElementById('nc-credit').value) || 0,
|
||||||
|
payment_terms: parseInt(document.getElementById('nc-terms').value) || 30
|
||||||
|
})
|
||||||
|
}).then(function (res) {
|
||||||
|
toast('Cliente creado: ' + name);
|
||||||
|
document.getElementById('modal-new-customer').style.display = 'none';
|
||||||
|
selectCustomer(res.id);
|
||||||
|
// Clear form
|
||||||
|
['nc-name','nc-rfc','nc-business','nc-phone','nc-email','nc-address'].forEach(function(id) {
|
||||||
|
document.getElementById(id).value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('nc-credit').value = '0';
|
||||||
|
document.getElementById('nc-terms').value = '30';
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Part Search — Autocomplete
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var partSearchTimer = null;
|
||||||
|
var partSearchEl = document.getElementById('part-search');
|
||||||
|
var partResults = document.getElementById('part-results');
|
||||||
|
var searchResults = [];
|
||||||
|
var highlightIdx = -1;
|
||||||
|
|
||||||
|
function doPartSearch() {
|
||||||
|
var q = partSearchEl.value.trim();
|
||||||
|
if (q.length < 1) { partResults.style.display = 'none'; searchResults = []; return; }
|
||||||
|
clearTimeout(partSearchTimer);
|
||||||
|
partSearchTimer = setTimeout(function () {
|
||||||
|
api('/api/pos/search-parts?q=' + encodeURIComponent(q)).then(function (results) {
|
||||||
|
searchResults = results;
|
||||||
|
highlightIdx = -1;
|
||||||
|
renderSearchResults();
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults() {
|
||||||
|
if (searchResults.length === 0 && partSearchEl.value.trim().length > 0) {
|
||||||
|
partResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron partes para "' + esc(partSearchEl.value) + '"</div>';
|
||||||
|
partResults.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchResults.length === 0) { partResults.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
partResults.innerHTML = searchResults.map(function (p, i) {
|
||||||
|
var active = i === highlightIdx ? ' part-result-active' : '';
|
||||||
|
return '<div class="part-result-item' + active + '" data-idx="' + i + '">' +
|
||||||
|
'<div><span class="pri-number">' + esc(p.oem_part_number) + '</span>' +
|
||||||
|
'<span class="pri-name">' + esc(p.name_part) + '</span></div>' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:0.4rem">' +
|
||||||
|
'<span class="pri-type ' + p.part_type + '">' + p.part_type + '</span>' +
|
||||||
|
(p.cost_usd ? '<span style="font-size:0.8rem;color:var(--text-secondary)">' + fmt(p.cost_usd) + '</span>' : '') +
|
||||||
|
'<span style="font-size:0.75rem;color:var(--text-secondary)">' + esc(p.group_name || '') + '</span>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
partResults.querySelectorAll('.part-result-item').forEach(function (item) {
|
||||||
|
item.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSearchResult(parseInt(item.getAttribute('data-idx')));
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseenter', function () {
|
||||||
|
highlightIdx = parseInt(item.getAttribute('data-idx'));
|
||||||
|
updateHighlight();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
partResults.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHighlight() {
|
||||||
|
partResults.querySelectorAll('.part-result-item').forEach(function (el, i) {
|
||||||
|
if (i === highlightIdx) {
|
||||||
|
el.classList.add('part-result-active');
|
||||||
|
el.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
el.classList.remove('part-result-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearchResult(idx) {
|
||||||
|
if (idx >= 0 && idx < searchResults.length) {
|
||||||
|
addToCart(searchResults[idx]);
|
||||||
|
partSearchEl.value = '';
|
||||||
|
partResults.style.display = 'none';
|
||||||
|
searchResults = [];
|
||||||
|
highlightIdx = -1;
|
||||||
|
partSearchEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partSearchEl.addEventListener('input', doPartSearch);
|
||||||
|
|
||||||
|
partSearchEl.addEventListener('keydown', function (e) {
|
||||||
|
if (partResults.style.display === 'none' || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, searchResults.length - 1);
|
||||||
|
updateHighlight();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
updateHighlight();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightIdx >= 0) {
|
||||||
|
selectSearchResult(highlightIdx);
|
||||||
|
} else if (searchResults.length === 1) {
|
||||||
|
selectSearchResult(0);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
partResults.style.display = 'none';
|
||||||
|
highlightIdx = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
partSearchEl.addEventListener('focus', function () {
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
partResults.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
partSearchEl.addEventListener('blur', function () {
|
||||||
|
setTimeout(function () { partResults.style.display = 'none'; }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Cart
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function addToCart(part) {
|
||||||
|
cart.push({
|
||||||
|
part_id: part.part_type === 'oem' ? part.id_part : null,
|
||||||
|
aftermarket_id: part.part_type === 'aftermarket' ? part.id_part : null,
|
||||||
|
description: (part.oem_part_number || '') + ' - ' + (part.name_part || ''),
|
||||||
|
part_type: part.part_type,
|
||||||
|
quantity: 1,
|
||||||
|
unit_cost: part.cost_usd || 0,
|
||||||
|
margin_pct: defaultMargin,
|
||||||
|
unit_price: (part.cost_usd || 0) * (1 + defaultMargin / 100)
|
||||||
|
});
|
||||||
|
renderCart();
|
||||||
|
partSearchEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
var tbody = document.getElementById('cart-body');
|
||||||
|
|
||||||
|
if (cart.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>';
|
||||||
|
updateTotals();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = cart.map(function (item, i) {
|
||||||
|
var lineTotal = item.quantity * item.unit_price;
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="cart-desc">' + esc(item.description) + '</td>' +
|
||||||
|
'<td><span class="pri-type ' + item.part_type + '">' + item.part_type + '</span></td>' +
|
||||||
|
'<td><input class="cart-qty" type="number" min="1" value="' + item.quantity + '" data-idx="' + i + '" data-field="quantity"></td>' +
|
||||||
|
'<td><input class="cart-cost" type="number" step="0.01" value="' + item.unit_cost.toFixed(2) + '" data-idx="' + i + '" data-field="unit_cost"></td>' +
|
||||||
|
'<td><input class="cart-margin" type="number" step="1" value="' + item.margin_pct.toFixed(0) + '" data-idx="' + i + '" data-field="margin_pct">%</td>' +
|
||||||
|
'<td>' + fmt(item.unit_price) + '</td>' +
|
||||||
|
'<td>' + fmt(lineTotal) + '</td>' +
|
||||||
|
'<td><button class="cart-remove" data-idx="' + i + '">×</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Input change handlers
|
||||||
|
tbody.querySelectorAll('input').forEach(function (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var idx = parseInt(input.getAttribute('data-idx'));
|
||||||
|
var field = input.getAttribute('data-field');
|
||||||
|
var val = parseFloat(input.value) || 0;
|
||||||
|
cart[idx][field] = val;
|
||||||
|
|
||||||
|
// Recalculate price from cost + margin
|
||||||
|
if (field === 'unit_cost' || field === 'margin_pct') {
|
||||||
|
cart[idx].unit_price = cart[idx].unit_cost * (1 + cart[idx].margin_pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove handlers
|
||||||
|
tbody.querySelectorAll('.cart-remove').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
cart.splice(parseInt(btn.getAttribute('data-idx')), 1);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotals() {
|
||||||
|
var itemCount = cart.reduce(function (sum, it) { return sum + it.quantity; }, 0);
|
||||||
|
var subtotal = cart.reduce(function (sum, it) { return sum + (it.quantity * it.unit_price); }, 0);
|
||||||
|
var tax = subtotal * 0.16;
|
||||||
|
var total = subtotal + tax;
|
||||||
|
|
||||||
|
document.getElementById('sum-items').textContent = itemCount;
|
||||||
|
document.getElementById('sum-subtotal').textContent = fmt(subtotal);
|
||||||
|
document.getElementById('sum-tax').textContent = fmt(tax);
|
||||||
|
document.getElementById('sum-total').textContent = fmt(total);
|
||||||
|
|
||||||
|
updateFacturarBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFacturarBtn() {
|
||||||
|
document.getElementById('btn-facturar').disabled = !(selectedCustomer && cart.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Facturar
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
document.getElementById('btn-facturar').addEventListener('click', function () {
|
||||||
|
if (!selectedCustomer || cart.length === 0) return;
|
||||||
|
|
||||||
|
var btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Generando...';
|
||||||
|
|
||||||
|
var items = cart.map(function (it) {
|
||||||
|
return {
|
||||||
|
part_id: it.part_id,
|
||||||
|
aftermarket_id: it.aftermarket_id,
|
||||||
|
description: it.description,
|
||||||
|
quantity: it.quantity,
|
||||||
|
unit_cost: it.unit_cost,
|
||||||
|
margin_pct: it.margin_pct,
|
||||||
|
unit_price: Math.round(it.unit_price * 100) / 100
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
api('/api/pos/invoices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: selectedCustomer.id_customer,
|
||||||
|
items: items,
|
||||||
|
notes: document.getElementById('invoice-notes').value.trim()
|
||||||
|
})
|
||||||
|
}).then(function (res) {
|
||||||
|
toast('Factura ' + res.folio + ' creada por ' + fmt(res.total));
|
||||||
|
|
||||||
|
// Reset cart
|
||||||
|
cart = [];
|
||||||
|
renderCart();
|
||||||
|
document.getElementById('invoice-notes').value = '';
|
||||||
|
|
||||||
|
// Refresh customer balance
|
||||||
|
selectCustomer(selectedCustomer.id_customer);
|
||||||
|
|
||||||
|
btn.textContent = 'Facturar';
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Facturar';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Init
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
})();
|
||||||
5897
dashboard/server.py
5897
dashboard/server.py
File diff suppressed because it is too large
Load Diff
262
dashboard/shared.css
Normal file
262
dashboard/shared.css
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/* ============================================================
|
||||||
|
shared.css -- Common styles for all Nexus Autoparts pages
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Reset --- */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CSS Variables (union of all pages) --- */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-hover: #252532;
|
||||||
|
--bg-tertiary: #1a1a25;
|
||||||
|
--accent: #ff6b35;
|
||||||
|
--accent-hover: #ff8555;
|
||||||
|
--accent-glow: rgba(255, 107, 53, 0.3);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--info: #3b82f6;
|
||||||
|
--danger: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Base body --- */
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Shared Button Styles --- */
|
||||||
|
.btn {
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 15px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 25px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Shared Animations --- */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading & Empty States --- */
|
||||||
|
.state-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-container i {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-container h4 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scrollbar Styling --- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Skip Link (accessibility) --- */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
z-index: 3000;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Screen Reader Only --- */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Alert / Toast Styles --- */
|
||||||
|
.alert {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(0, 214, 143, 0.1);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal Base Styles --- */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 2000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Form Styles --- */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Quality Badges --- */
|
||||||
|
.quality-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-economy { background: var(--warning); color: #000; }
|
||||||
|
.quality-standard { background: var(--info); color: white; }
|
||||||
|
.quality-premium { background: var(--success); color: white; }
|
||||||
|
.quality-oem { background: #9b59b6; color: white; }
|
||||||
678
dashboard/tienda.css
Normal file
678
dashboard/tienda.css
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
/* ============================================================
|
||||||
|
tienda.css -- Store / Tablet dashboard styles
|
||||||
|
Nexus Autoparts — tablet-first, touch-friendly
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* --- Base overrides for tienda page --- */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Header --- */
|
||||||
|
.t-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: rgba(18, 18, 26, 0.92);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-logo-mark {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
|
||||||
|
border-radius: 9px;
|
||||||
|
box-shadow: 0 3px 14px var(--accent-glow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-logo-mark::after {
|
||||||
|
content: '\2699\FE0F';
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-brand-name {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-brand-sub {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Header center: search --- */
|
||||||
|
.t-header-center {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-box {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.7rem;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.8rem 0.55rem 2.2rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-box input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-box input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-results {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0; right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||||
|
display: none;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-results.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-result-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-result-item:hover,
|
||||||
|
.t-search-result-item:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-result-item .sri-number {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-search-result-item .sri-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Header right: clock --- */
|
||||||
|
.t-header-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-clock {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Main --- */
|
||||||
|
.t-main {
|
||||||
|
padding: 4.2rem 1rem 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- KPI Row --- */
|
||||||
|
.t-kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colored left accent bar */
|
||||||
|
.t-kpi::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi[data-color="accent"]::before { background: var(--accent); }
|
||||||
|
.t-kpi[data-color="success"]::before { background: var(--success); }
|
||||||
|
.t-kpi[data-color="info"]::before { background: var(--info); }
|
||||||
|
.t-kpi[data-color="warning"]::before { background: var(--warning); }
|
||||||
|
|
||||||
|
.t-kpi-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-icon svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi[data-color="accent"] .t-kpi-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
|
||||||
|
.t-kpi[data-color="success"] .t-kpi-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
|
||||||
|
.t-kpi[data-color="info"] .t-kpi-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
|
||||||
|
.t-kpi[data-color="warning"] .t-kpi-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
|
||||||
|
|
||||||
|
.t-kpi-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-value {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-count {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Content Grid --- */
|
||||||
|
.t-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cards --- */
|
||||||
|
.t-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-card-full {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-card-title {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-card-header .t-card-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-see-all {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-see-all:hover,
|
||||||
|
.t-see-all:active {
|
||||||
|
background: rgba(255, 107, 53, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Quick Actions Grid --- */
|
||||||
|
.t-actions-card {
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.15s, background 0.2s, border-color 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-action[data-color="accent"] .t-action-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
|
||||||
|
.t-action[data-color="accent"]:hover { border-color: var(--accent); }
|
||||||
|
.t-action[data-color="info"] .t-action-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
|
||||||
|
.t-action[data-color="info"]:hover { border-color: var(--info); }
|
||||||
|
.t-action[data-color="success"] .t-action-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
|
||||||
|
.t-action[data-color="success"]:hover { border-color: var(--success); }
|
||||||
|
.t-action[data-color="warning"] .t-action-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
|
||||||
|
.t-action[data-color="warning"]:hover { border-color: var(--warning); }
|
||||||
|
|
||||||
|
/* --- Debtors List --- */
|
||||||
|
.t-debtors-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtor {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtor:hover,
|
||||||
|
.t-debtor:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtor-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtor-invoices {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtor-amount {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Invoice List --- */
|
||||||
|
.t-invoice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice:hover,
|
||||||
|
.t-invoice:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-folio {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-customer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-total {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-status {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-status.paid {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-status.pending {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-status.partial {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-invoice-status.cancelled {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Today's Payments card --- */
|
||||||
|
.t-today-payments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-today-amount {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--success);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-today-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty state --- */
|
||||||
|
.t-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scrollbar (minimal for touch) --- */
|
||||||
|
.t-debtors-list::-webkit-scrollbar,
|
||||||
|
.t-invoice-list::-webkit-scrollbar,
|
||||||
|
.t-search-results::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtors-list::-webkit-scrollbar-track,
|
||||||
|
.t-invoice-list::-webkit-scrollbar-track,
|
||||||
|
.t-search-results::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-debtors-list::-webkit-scrollbar-thumb,
|
||||||
|
.t-invoice-list::-webkit-scrollbar-thumb,
|
||||||
|
.t-search-results::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
|
||||||
|
/* Tablet landscape (default target) */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.t-main {
|
||||||
|
padding: 4rem 0.8rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet portrait / large phone */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.t-header-center {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-main {
|
||||||
|
padding: 3.8rem 0.6rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi {
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-actions-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small phone */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.t-kpi-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Fade-in animation for cards --- */
|
||||||
|
@keyframes t-fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi {
|
||||||
|
animation: t-fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-kpi:nth-child(1) { animation-delay: 0.05s; }
|
||||||
|
.t-kpi:nth-child(2) { animation-delay: 0.1s; }
|
||||||
|
.t-kpi:nth-child(3) { animation-delay: 0.15s; }
|
||||||
|
.t-kpi:nth-child(4) { animation-delay: 0.2s; }
|
||||||
|
|
||||||
|
.t-card {
|
||||||
|
animation: t-fadeIn 0.4s ease both;
|
||||||
|
animation-delay: 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-content .t-col:nth-child(2) .t-card {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-content .t-col:nth-child(2) .t-card:nth-child(2) {
|
||||||
|
animation-delay: 0.35s;
|
||||||
|
}
|
||||||
156
dashboard/tienda.html
Normal file
156
dashboard/tienda.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Tienda — NEXUS AUTOPARTS</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=Outfit:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/shared.css">
|
||||||
|
<link rel="stylesheet" href="/tienda.css">
|
||||||
|
<link rel="manifest" crossorigin="use-credentials">
|
||||||
|
<meta name="theme-color" content="#0a0a0f">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="t-header">
|
||||||
|
<div class="t-header-left">
|
||||||
|
<div class="t-logo-mark"></div>
|
||||||
|
<div class="t-brand">
|
||||||
|
<span class="t-brand-name">NEXUS</span>
|
||||||
|
<span class="t-brand-sub">AUTOPARTS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="t-header-center">
|
||||||
|
<div class="t-search-box">
|
||||||
|
<svg class="t-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input id="global-search" type="text" placeholder="Buscar parte, OEM, cliente..." autocomplete="off">
|
||||||
|
<div id="global-results" class="t-search-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="t-header-right">
|
||||||
|
<span class="t-clock" id="clock"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Grid -->
|
||||||
|
<main class="t-main">
|
||||||
|
<!-- KPI Row -->
|
||||||
|
<section class="t-kpi-row">
|
||||||
|
<div class="t-kpi" data-color="accent">
|
||||||
|
<div class="t-kpi-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi-data">
|
||||||
|
<span class="t-kpi-value" id="kpi-sales-today">$0</span>
|
||||||
|
<span class="t-kpi-label">Ventas hoy</span>
|
||||||
|
</div>
|
||||||
|
<span class="t-kpi-count" id="kpi-sales-count">0 facturas</span>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi" data-color="success">
|
||||||
|
<div class="t-kpi-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi-data">
|
||||||
|
<span class="t-kpi-value" id="kpi-month">$0</span>
|
||||||
|
<span class="t-kpi-label">Ventas del mes</span>
|
||||||
|
</div>
|
||||||
|
<span class="t-kpi-count" id="kpi-month-count">0 facturas</span>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi" data-color="info">
|
||||||
|
<div class="t-kpi-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi-data">
|
||||||
|
<span class="t-kpi-value" id="kpi-customers">0</span>
|
||||||
|
<span class="t-kpi-label">Clientes activos</span>
|
||||||
|
</div>
|
||||||
|
<span class="t-kpi-count" id="kpi-parts-count">0 partes</span>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi" data-color="warning">
|
||||||
|
<div class="t-kpi-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="4" width="22" height="16" rx="2"/><path d="M1 10h22"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="t-kpi-data">
|
||||||
|
<span class="t-kpi-value" id="kpi-pending">$0</span>
|
||||||
|
<span class="t-kpi-label">Por cobrar</span>
|
||||||
|
</div>
|
||||||
|
<span class="t-kpi-count" id="kpi-pending-count">0 facturas</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Content Grid: 2 columns -->
|
||||||
|
<section class="t-content">
|
||||||
|
<!-- Left column -->
|
||||||
|
<div class="t-col">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="t-card t-actions-card">
|
||||||
|
<h2 class="t-card-title">Acciones</h2>
|
||||||
|
<div class="t-actions-grid">
|
||||||
|
<a href="/pos" class="t-action" data-color="accent">
|
||||||
|
<div class="t-action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Nueva Venta</span>
|
||||||
|
</a>
|
||||||
|
<a href="/cuentas" class="t-action" data-color="info">
|
||||||
|
<div class="t-action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Cuentas</span>
|
||||||
|
</a>
|
||||||
|
<a href="/captura" class="t-action" data-color="success">
|
||||||
|
<div class="t-action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Captura</span>
|
||||||
|
</a>
|
||||||
|
<a href="/" class="t-action" data-color="warning">
|
||||||
|
<div class="t-action-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Catalogo</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Debtors -->
|
||||||
|
<div class="t-card">
|
||||||
|
<h2 class="t-card-title">Cuentas pendientes</h2>
|
||||||
|
<div id="debtors-list" class="t-debtors-list">
|
||||||
|
<div class="t-empty">Sin cuentas pendientes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
|
<div class="t-col">
|
||||||
|
<!-- Recent Invoices -->
|
||||||
|
<div class="t-card t-card-full">
|
||||||
|
<div class="t-card-header">
|
||||||
|
<h2 class="t-card-title">Ultimas facturas</h2>
|
||||||
|
<a href="/cuentas" class="t-see-all">Ver todas</a>
|
||||||
|
</div>
|
||||||
|
<div id="recent-invoices" class="t-invoice-list">
|
||||||
|
<div class="t-empty">Sin facturas recientes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cobros de hoy -->
|
||||||
|
<div class="t-card">
|
||||||
|
<div class="t-card-header">
|
||||||
|
<h2 class="t-card-title">Cobros de hoy</h2>
|
||||||
|
</div>
|
||||||
|
<div class="t-today-payments">
|
||||||
|
<div class="t-today-amount" id="payments-today-amount">$0.00</div>
|
||||||
|
<div class="t-today-count" id="payments-today-count">0 pagos registrados</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/tienda.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
187
dashboard/tienda.js
Normal file
187
dashboard/tienda.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* tienda.js — Store / Tablet dashboard logic for Nexus Autoparts
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '';
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Utility
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Clock
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function updateClock() {
|
||||||
|
var now = new Date();
|
||||||
|
var h = now.getHours();
|
||||||
|
var m = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
var ampm = h >= 12 ? 'PM' : 'AM';
|
||||||
|
h = h % 12 || 12;
|
||||||
|
document.getElementById('clock').textContent = h + ':' + m + ' ' + ampm;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 30000);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Load Dashboard Stats
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function loadStats() {
|
||||||
|
fetch(API + '/api/tienda/stats')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var st = d.sales_today || {};
|
||||||
|
var sm = d.sales_month || {};
|
||||||
|
var pt = d.payments_today || {};
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
document.getElementById('kpi-sales-today').textContent = fmt(st.total);
|
||||||
|
document.getElementById('kpi-sales-count').textContent = (st.count || 0) + ' facturas';
|
||||||
|
document.getElementById('kpi-month').textContent = fmt(sm.total);
|
||||||
|
document.getElementById('kpi-month-count').textContent = (sm.count || 0) + ' facturas';
|
||||||
|
document.getElementById('kpi-customers').textContent = d.total_customers || 0;
|
||||||
|
document.getElementById('kpi-parts-count').textContent = (d.total_parts || 0) + ' partes';
|
||||||
|
document.getElementById('kpi-pending').textContent = fmt(d.pending_balance || 0);
|
||||||
|
document.getElementById('kpi-pending-count').textContent = (d.pending_invoices || 0) + ' facturas';
|
||||||
|
|
||||||
|
// Today's payments
|
||||||
|
document.getElementById('payments-today-amount').textContent = fmt(pt.total);
|
||||||
|
document.getElementById('payments-today-count').textContent = (pt.count || 0) + ' pagos registrados';
|
||||||
|
|
||||||
|
// Top debtors
|
||||||
|
renderDebtors(d.top_debtors || []);
|
||||||
|
|
||||||
|
// Recent invoices
|
||||||
|
renderInvoices(d.recent_invoices || []);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error('Error loading stats:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Render Debtors
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function renderDebtors(debtors) {
|
||||||
|
var el = document.getElementById('debtors-list');
|
||||||
|
|
||||||
|
if (debtors.length === 0) {
|
||||||
|
el.innerHTML = '<div class="t-empty">Sin cuentas pendientes</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = debtors.map(function (d) {
|
||||||
|
var limitPct = d.credit_limit > 0 ? Math.round(d.balance / d.credit_limit * 100) : 0;
|
||||||
|
return '<a href="/cuentas" class="t-debtor">'
|
||||||
|
+ '<div>'
|
||||||
|
+ '<div class="t-debtor-name">' + esc(d.name) + '</div>'
|
||||||
|
+ (d.credit_limit > 0 ? '<div class="t-debtor-invoices">' + limitPct + '% de l\u00edmite</div>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<span class="t-debtor-amount">' + fmt(d.balance) + '</span>'
|
||||||
|
+ '</a>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Render Recent Invoices
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
function renderInvoices(invoices) {
|
||||||
|
var el = document.getElementById('recent-invoices');
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
el.innerHTML = '<div class="t-empty">Sin facturas recientes</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = invoices.map(function (inv) {
|
||||||
|
var statusClass = inv.status || 'pending';
|
||||||
|
var statusLabel = { pending: 'Pendiente', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada' };
|
||||||
|
return '<div class="t-invoice">'
|
||||||
|
+ '<div class="t-invoice-left">'
|
||||||
|
+ '<span class="t-invoice-folio">' + esc(inv.folio) + '</span>'
|
||||||
|
+ '<span class="t-invoice-customer">' + esc(inv.customer_name) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="t-invoice-right">'
|
||||||
|
+ '<span class="t-invoice-total">' + fmt(inv.total) + '</span>'
|
||||||
|
+ '<span class="t-invoice-status ' + statusClass + '">' + (statusLabel[statusClass] || statusClass) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Global Search
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
var searchTimer = null;
|
||||||
|
var searchInput = document.getElementById('global-search');
|
||||||
|
var searchResults = document.getElementById('global-results');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
var q = this.value.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
searchResults.classList.remove('active');
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimer = setTimeout(function () {
|
||||||
|
fetch(API + '/api/pos/search-parts?q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (results) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
searchResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary);font-size:0.85rem">Sin resultados para "' + esc(q) + '"</div>';
|
||||||
|
} else {
|
||||||
|
searchResults.innerHTML = results.slice(0, 8).map(function (p) {
|
||||||
|
return '<div class="t-search-result-item">'
|
||||||
|
+ '<div>'
|
||||||
|
+ '<span class="sri-number">' + esc(p.oem_part_number) + '</span>'
|
||||||
|
+ '<span class="sri-name">' + esc(p.name_part) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
searchResults.classList.add('active');
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('blur', function () {
|
||||||
|
setTimeout(function () { searchResults.classList.remove('active'); }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('focus', function () {
|
||||||
|
if (searchResults.innerHTML.trim()) {
|
||||||
|
searchResults.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Init
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
|
||||||
|
// Auto-refresh every 2 minutes
|
||||||
|
setInterval(loadStats, 120000);
|
||||||
|
})();
|
||||||
1581
docs/API.md
1581
docs/API.md
File diff suppressed because it is too large
Load Diff
449
docs/ARCHITECTURE.md
Normal file
449
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Arquitectura - Nexus Autoparts
|
||||||
|
|
||||||
|
## Vista General del Sistema
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------+ +-------------------+ +-------------------+
|
||||||
|
| | | | | |
|
||||||
|
| TecDoc (Apify) | | NHTSA VIN API | | CSV/Excel |
|
||||||
|
| Actor API | | vpic.nhtsa.gov | | (Bodega upload) |
|
||||||
|
| | | | | |
|
||||||
|
+--------+----------+ +--------+----------+ +--------+----------+
|
||||||
|
| | |
|
||||||
|
| download/import | decode | upload
|
||||||
|
| | |
|
||||||
|
v v v
|
||||||
|
+--------+---------------------------+---------------------------+----------+
|
||||||
|
| |
|
||||||
|
| Flask Server (server.py) |
|
||||||
|
| Puerto 5000 |
|
||||||
|
| |
|
||||||
|
| +-------------+ +-------------+ +-------------+ +-----------------+ |
|
||||||
|
| | Auth Module | | Catalog API | | Inventory | | Admin CRUD | |
|
||||||
|
| | (auth.py) | | (publico) | | (BODEGA) | | (import/export) | |
|
||||||
|
| | JWT + bcrypt | | | | CSV mapping | | | |
|
||||||
|
| +------+------+ +------+------+ +------+------+ +--------+--------+ |
|
||||||
|
| | | | | |
|
||||||
|
+---------+----------------+----------------+-------------------+-----------+
|
||||||
|
| | | |
|
||||||
|
v v v v
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| PostgreSQL (nexus_autoparts) |
|
||||||
|
| SQLAlchemy text() raw SQL |
|
||||||
|
| |
|
||||||
|
| +----------+ +----------+ +----------+ +----------+ +---------------+ |
|
||||||
|
| | brands | | models | | years | | engines | | fuel_type | |
|
||||||
|
| +----+-----+ +----+-----+ +----+-----+ +----+-----+ | drivetrain | |
|
||||||
|
| | | | | | transmission | |
|
||||||
|
| +------+------+------------+------------+ +---------------+ |
|
||||||
|
| | |
|
||||||
|
| v |
|
||||||
|
| +-----------------------+ |
|
||||||
|
| | model_year_engine | (MYE - tabla central de vehiculos) |
|
||||||
|
| | PK: id_mye | |
|
||||||
|
| | UNIQUE(model,year, | |
|
||||||
|
| | engine,trim_level) | |
|
||||||
|
| +----------+------------+ |
|
||||||
|
| | |
|
||||||
|
| | 1:N |
|
||||||
|
| v |
|
||||||
|
| +---------------------+ +---------------------+ |
|
||||||
|
| | vehicle_parts | | vehicle_diagrams | |
|
||||||
|
| | (12B+ rows) | | | |
|
||||||
|
| | mye_id + part_id | | mye_id + diagram_id | |
|
||||||
|
| +----------+----------+ +----------+----------+ |
|
||||||
|
| | | |
|
||||||
|
| v v |
|
||||||
|
| +---------------------+ +---------------------+ |
|
||||||
|
| | parts (1.4M+ OEM) | | diagrams | |
|
||||||
|
| | oem_part_number | | image_path | |
|
||||||
|
| | group_id -> groups | | group_id -> groups | |
|
||||||
|
| +---+------+----------+ +-----+---------------+ |
|
||||||
|
| | | | |
|
||||||
|
| | v v |
|
||||||
|
| | +--------------------+ +---------------------+ |
|
||||||
|
| | | part_groups | | diagram_hotspots | |
|
||||||
|
| | | category_id | | part_id, coords | |
|
||||||
|
| | +--------+-----------+ +---------------------+ |
|
||||||
|
| | | |
|
||||||
|
| | v |
|
||||||
|
| | +--------------------+ |
|
||||||
|
| | | part_categories | |
|
||||||
|
| | | (arbol jerarquico) | |
|
||||||
|
| | | parent_id -> self | |
|
||||||
|
| | +--------------------+ |
|
||||||
|
| | |
|
||||||
|
| +----------+-----------+ |
|
||||||
|
| | | |
|
||||||
|
| v v |
|
||||||
|
| +---------------------+ +------------------------+ |
|
||||||
|
| | aftermarket_parts | | part_cross_references | |
|
||||||
|
| | (300K+) | | (13M+) | |
|
||||||
|
| | oem_part_id -> parts| | part_id -> parts | |
|
||||||
|
| | manufacturer_id | | reference_type | |
|
||||||
|
| +----------+----------+ +------------------------+ |
|
||||||
|
| | |
|
||||||
|
| v |
|
||||||
|
| +---------------------+ |
|
||||||
|
| | manufacturers | |
|
||||||
|
| | type, quality_tier | |
|
||||||
|
| +---------------------+ |
|
||||||
|
| |
|
||||||
|
| === SaaS Tables === |
|
||||||
|
| |
|
||||||
|
| +----------+ +----------+ +---------------------+ +-------------+ |
|
||||||
|
| | users | | roles | | warehouse_inventory | | sessions | |
|
||||||
|
| | id_user | | ADMIN | | user_id + part_id | | refresh | |
|
||||||
|
| | id_rol | | OWNER | | price, stock | | token | |
|
||||||
|
| | email | | TALLER | | location | | expires_at | |
|
||||||
|
| | pass | | BODEGA | +---------------------+ +-------------+ |
|
||||||
|
| +----------+ +----------+ |
|
||||||
|
| +------------------------+ |
|
||||||
|
| | inventory_uploads | |
|
||||||
|
| | inventory_col_mappings | |
|
||||||
|
| +------------------------+ |
|
||||||
|
| |
|
||||||
|
| === Lookup Tables === |
|
||||||
|
| countries, materials, shapes, position_part, reference_type, |
|
||||||
|
| manufacture_type, quality_tier |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
v v v v
|
||||||
|
+-------------------+ +----------+ +-------------+ +----------------+
|
||||||
|
| login.html | | demo.html| | admin.html | | bodega.html |
|
||||||
|
| tienda.html | | (catalog)| | (CRUD + | | (inventory |
|
||||||
|
| pos.html | | | | users) | | upload/manage) |
|
||||||
|
| cuentas.html | | | | | | |
|
||||||
|
| captura.html | | | | | | |
|
||||||
|
+-------------------+ +----------+ +-------------+ +----------------+
|
||||||
|
Frontend: HTML/CSS/JS vanilla (sin framework)
|
||||||
|
Shared: nav.js, shared.css
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Overview
|
||||||
|
|
||||||
|
### Tablas principales y sus relaciones
|
||||||
|
|
||||||
|
**Vehiculos (jerarquia):**
|
||||||
|
```
|
||||||
|
brands (id_brand, name_brand, country, region)
|
||||||
|
|
|
||||||
|
+--< models (id_model, brand_id, name_model, body_type)
|
||||||
|
|
|
||||||
|
+--< model_year_engine (id_mye, model_id, year_id, engine_id, trim_level)
|
||||||
|
| | |
|
||||||
|
| | +-- engines (id_engine, name_engine, displacement_cc, cylinders, power_hp)
|
||||||
|
| +-------- years (id_year, year_car)
|
||||||
|
|
|
||||||
|
+--< vehicle_parts (mye_id, part_id, quantity, position)
|
||||||
|
+--< vehicle_diagrams (mye_id, diagram_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partes (jerarquia):**
|
||||||
|
```
|
||||||
|
part_categories (id, name, parent_id) <-- arbol recursivo
|
||||||
|
|
|
||||||
|
+--< part_groups (id, category_id, name)
|
||||||
|
|
|
||||||
|
+--< parts (id_part, oem_part_number, name, group_id)
|
||||||
|
|
|
||||||
|
+--< aftermarket_parts (oem_part_id -> parts, manufacturer_id, part_number)
|
||||||
|
+--< part_cross_references (part_id -> parts, cross_reference_number, type)
|
||||||
|
+--< vehicle_parts (part_id -> parts, mye_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**SaaS / Multi-tenant:**
|
||||||
|
```
|
||||||
|
roles (id_rol: 1=ADMIN, 2=OWNER, 3=TALLER, 4=BODEGA)
|
||||||
|
|
|
||||||
|
+--< users (id_user, email, pass, id_rol, business_name, is_active)
|
||||||
|
|
|
||||||
|
+--< sessions (refresh_token, expires_at)
|
||||||
|
+--< warehouse_inventory (user_id, part_id, price, stock, location)
|
||||||
|
+--< inventory_uploads (user_id, filename, status, rows_imported)
|
||||||
|
+--< inventory_column_mappings (user_id, mapping JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convenciones de nombres en PostgreSQL
|
||||||
|
|
||||||
|
| Tabla | PK | Naming pattern |
|
||||||
|
|-------|-----|---------------|
|
||||||
|
| brands | `id_brand` | `name_brand` |
|
||||||
|
| models | `id_model` | `name_model` |
|
||||||
|
| years | `id_year` | `year_car` |
|
||||||
|
| engines | `id_engine` | `name_engine` |
|
||||||
|
| model_year_engine | `id_mye` | Tabla pivote central |
|
||||||
|
| parts | `id_part` | `name_part`, `oem_part_number` |
|
||||||
|
| part_categories | `id_part_category` | `name_part_category` |
|
||||||
|
| part_groups | `id_part_group` | `name_part_group` |
|
||||||
|
| manufacturers | `id_manufacture` | `name_manufacture` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TecDoc Data Pipeline
|
||||||
|
|
||||||
|
Pipeline de 3 fases para importar datos de TecDoc (el catalogo europeo de autopartes).
|
||||||
|
|
||||||
|
```
|
||||||
|
Fase 1: DOWNLOAD Fase 2: IMPORT Fase 3: LINK
|
||||||
|
(import_tecdoc.py download) (import_tecdoc.py import) (link_vehicle_parts.py)
|
||||||
|
|
||||||
|
+------------------+ +------------------+ +------------------+
|
||||||
|
| Apify TecDoc | | JSON files | | parts + |
|
||||||
|
| Actor API | | data/tecdoc/ | | model_year_engine|
|
||||||
|
| $69/month | | | | |
|
||||||
|
| HTTP 201 = run | | manufacturers/ | | Genera links |
|
||||||
|
| | --------> | models/ | ---------> | masivos en |
|
||||||
|
| typeId=1 (cars) | JSON | vehicles/ | INSERT | vehicle_parts |
|
||||||
|
| langId=4 (EN) | files | | partes + | (12B+ filas) |
|
||||||
|
| countryId=153(MX)| | Resumable: | vehiculos | |
|
||||||
|
+------------------+ | skips existing | +------------------+
|
||||||
|
+------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scripts adicionales
|
||||||
|
|
||||||
|
- **`import_tecdoc_parts.py`**: Importa partes OEM y aftermarket desde TecDoc
|
||||||
|
- **`import_live.py`**: Importa datos en tiempo real (sin bajar a JSON primero)
|
||||||
|
- **`migrate_aftermarket.py`**: Normaliza datos aftermarket a la estructura con `quality_tier`, `manufacturers`
|
||||||
|
|
||||||
|
### Filtros de importacion
|
||||||
|
|
||||||
|
- Se descartan variantes regionales (e.g., "TOYOTA (GAC)")
|
||||||
|
- `countryFilterId=153` limita a vehiculos vendidos en Mexico
|
||||||
|
- El script es resumable: si un JSON ya existe en `data/tecdoc/`, se salta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
Flujo de autenticacion basado en JWT con refresh tokens.
|
||||||
|
|
||||||
|
```
|
||||||
|
Cliente Servidor PostgreSQL
|
||||||
|
| | |
|
||||||
|
| POST /api/auth/register | |
|
||||||
|
| {name, email, pass, role} | |
|
||||||
|
|------------------------------>| bcrypt.hash(pass) |
|
||||||
|
| |----------------------------->|
|
||||||
|
| | INSERT users (is_active=F) |
|
||||||
|
| 201 "pending activation" | |
|
||||||
|
|<------------------------------| |
|
||||||
|
| | |
|
||||||
|
| (Admin activa la cuenta) | |
|
||||||
|
| | |
|
||||||
|
| POST /api/auth/login | |
|
||||||
|
| {email, password} | |
|
||||||
|
|------------------------------>| SELECT users WHERE email |
|
||||||
|
| |<-----------------------------|
|
||||||
|
| | bcrypt.check(pass) |
|
||||||
|
| | Verify is_active=true |
|
||||||
|
| | |
|
||||||
|
| | jwt.encode(user_id, role, |
|
||||||
|
| | business_name, exp=15min) |
|
||||||
|
| | |
|
||||||
|
| | secrets.token_urlsafe(48) |
|
||||||
|
| | INSERT sessions |
|
||||||
|
| |----------------------------->|
|
||||||
|
| | |
|
||||||
|
| {access_token, refresh_token,| |
|
||||||
|
| user: {id, name, role}} | |
|
||||||
|
|<------------------------------| |
|
||||||
|
| | |
|
||||||
|
| GET /api/... (protected) | |
|
||||||
|
| Authorization: Bearer <AT> | |
|
||||||
|
|------------------------------>| jwt.decode(AT) |
|
||||||
|
| | Check role in allowed_roles |
|
||||||
|
| | Set g.user = {user_id, role}|
|
||||||
|
| 200 {data} | |
|
||||||
|
|<------------------------------| |
|
||||||
|
| | |
|
||||||
|
| (Access token expires) | |
|
||||||
|
| | |
|
||||||
|
| POST /api/auth/refresh | |
|
||||||
|
| {refresh_token} | |
|
||||||
|
|------------------------------>| SELECT sessions |
|
||||||
|
| |<-----------------------------|
|
||||||
|
| | Check expires_at > now() |
|
||||||
|
| | jwt.encode(new access_token)|
|
||||||
|
| {access_token} | |
|
||||||
|
|<------------------------------| |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Roles y permisos
|
||||||
|
|
||||||
|
| Rol | ID | Permisos |
|
||||||
|
|-----|----|----------|
|
||||||
|
| `ADMIN` | 1 | Todo: gestionar usuarios, catalogo, inventario |
|
||||||
|
| `OWNER` | 2 | Gestionar usuarios, ver estadisticas |
|
||||||
|
| `TALLER` | 3 | Consultar catalogo, ver disponibilidad en bodegas |
|
||||||
|
| `BODEGA` | 4 | Gestionar inventario propio, subir CSV/Excel |
|
||||||
|
|
||||||
|
### JWT Token payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"role": "TALLER",
|
||||||
|
"business_name": "Taller Perez",
|
||||||
|
"type": "access",
|
||||||
|
"exp": 1742300000,
|
||||||
|
"iat": 1742299100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory Flow (Bodega)
|
||||||
|
|
||||||
|
Flujo para que una bodega suba su inventario via CSV/Excel.
|
||||||
|
|
||||||
|
```
|
||||||
|
Bodega (Browser) Servidor PostgreSQL
|
||||||
|
| | |
|
||||||
|
| 1. PUT /api/inventory/mapping| |
|
||||||
|
| {part_number: "NUM_PARTE", | |
|
||||||
|
| price: "PRECIO", | |
|
||||||
|
| stock: "EXISTENCIA", | UPSERT inventory_col_map |
|
||||||
|
| location: "ALMACEN"} |----------------------------->|
|
||||||
|
|<------------------------------| |
|
||||||
|
| | |
|
||||||
|
| 2. POST /api/inventory/upload| |
|
||||||
|
| multipart/form-data: file | |
|
||||||
|
|------------------------------>| |
|
||||||
|
| | a) Read mapping |
|
||||||
|
| |<-----------------------------|
|
||||||
|
| | |
|
||||||
|
| | b) Parse CSV/Excel |
|
||||||
|
| | (openpyxl or csv module) |
|
||||||
|
| | |
|
||||||
|
| | c) For each row: |
|
||||||
|
| | - Extract part_number |
|
||||||
|
| | using mapping |
|
||||||
|
| | - Find part by OEM # |
|
||||||
|
| | - If not found, try |
|
||||||
|
| | aftermarket_parts |
|
||||||
|
| | - UPSERT warehouse_inv |
|
||||||
|
| |----------------------------->|
|
||||||
|
| | |
|
||||||
|
| {imported: 450, errors: 12} | d) Update inventory_uploads |
|
||||||
|
|<------------------------------|----------------------------->|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabla `warehouse_inventory`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
warehouse_inventory (
|
||||||
|
id_inventory BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users,
|
||||||
|
part_id INTEGER REFERENCES parts,
|
||||||
|
price NUMERIC(12,2),
|
||||||
|
stock_quantity INTEGER DEFAULT 0,
|
||||||
|
warehouse_location VARCHAR(100) DEFAULT 'Principal',
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
UNIQUE(user_id, part_id, warehouse_location)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
El UNIQUE constraint permite que una bodega tenga la misma parte en multiples ubicaciones (e.g., "Principal", "Sucursal Norte").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aftermarket Linking
|
||||||
|
|
||||||
|
Como se relacionan partes OEM con alternativas aftermarket y cross-references.
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------+
|
||||||
|
| parts |
|
||||||
|
| (OEM catalog) |
|
||||||
|
| id_part: 500 |
|
||||||
|
| oem_part_number: |
|
||||||
|
| "04152-YZZA1" |
|
||||||
|
+--------+-----------+
|
||||||
|
|
|
||||||
|
+--------------+--------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------------+ +----------------------------+
|
||||||
|
| aftermarket_parts | | part_cross_references |
|
||||||
|
| (alternativas) | | (numeros alternos) |
|
||||||
|
| | | |
|
||||||
|
| oem_part_id: 500 | | part_id: 500 |
|
||||||
|
| manufacturer_id: -> DENSO | | cross_reference_number: |
|
||||||
|
| part_number: "DXP-1234" | | "90915-YZZF1" |
|
||||||
|
| quality_tier: "premium" | | reference_type: |
|
||||||
|
| price_usd: 12.50 | | "oem_alternate" |
|
||||||
|
| warranty_months: 24 | | source: "TecDoc" |
|
||||||
|
+----------------------------+ +----------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tipos de cross-reference
|
||||||
|
|
||||||
|
| Tipo | Descripcion |
|
||||||
|
|------|-------------|
|
||||||
|
| `oem_alternate` | Otro numero OEM para la misma parte (mismo fabricante) |
|
||||||
|
| `supersession` | La parte fue reemplazada por un numero nuevo |
|
||||||
|
| `interchange` | Numero de un competidor que es equivalente |
|
||||||
|
| `competitor` | Referencia cruzada a otra marca |
|
||||||
|
|
||||||
|
### Flujo de busqueda por numero de parte
|
||||||
|
|
||||||
|
Cuando un taller busca un numero de parte, el sistema busca en 3 lugares:
|
||||||
|
|
||||||
|
1. **`parts.oem_part_number`** - Match directo OEM
|
||||||
|
2. **`aftermarket_parts.part_number`** - Match en parte aftermarket, retorna el OEM original
|
||||||
|
3. **`part_cross_references.cross_reference_number`** - Match en cross-ref, retorna el OEM original
|
||||||
|
|
||||||
|
Esto permite que el taller encuentre la parte sin importar que numero use (OEM, aftermarket, o numero alterno).
|
||||||
|
|
||||||
|
### Calidad (quality_tier)
|
||||||
|
|
||||||
|
| Tier | Descripcion | Ejemplo |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `economy` | Precio bajo, calidad basica | Marcas genericas |
|
||||||
|
| `standard` | Calidad media, buen balance | Monroe, Moog |
|
||||||
|
| `premium` | Calidad alta, cercana a OEM | Denso, Bosch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Frontend
|
||||||
|
|
||||||
|
El frontend es HTML/CSS/JS vanilla sin framework. Cada pagina es independiente.
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard/
|
||||||
|
+-- shared.css # Estilos compartidos (colores, layout, cards)
|
||||||
|
+-- nav.js # Navegacion compartida (inyecta sidebar/header)
|
||||||
|
+-- login.html + .css + .js
|
||||||
|
+-- demo.html # Catalogo publico
|
||||||
|
+-- admin.html + .js # Panel admin (CRUD, users, import/export)
|
||||||
|
+-- bodega.html + .css + .js # Inventory management
|
||||||
|
+-- tienda.html + .css + .js # Store view para talleres
|
||||||
|
+-- pos.html + .css + .js # Point of sale
|
||||||
|
+-- captura.html + .css + .js # Captura de partes
|
||||||
|
+-- cuentas.html + .css + .js # Gestion de cuentas
|
||||||
|
+-- dashboard.js # Logica del catalogo principal
|
||||||
|
+-- enhanced-search.js # Busqueda avanzada
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron de comunicacion con API
|
||||||
|
|
||||||
|
Todas las paginas usan `fetch()` con el patron:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const res = await fetch('/api/...', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nota sobre NUMERIC de PostgreSQL
|
||||||
|
|
||||||
|
PostgreSQL retorna valores `NUMERIC` como strings. Todas las funciones de formato en JS usan `parseFloat()` para convertir antes de mostrar.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Documentación de Base de Datos - Autoparts DB
|
# Documentación de Base de Datos - Nexus Autoparts
|
||||||
|
|
||||||
## Resumen
|
## Resumen
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Guía de Instalación - Autoparts DB
|
# Guía de Instalación - Nexus Autoparts
|
||||||
|
|
||||||
## Requisitos del Sistema
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ El proyecto es compatible con:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clonar el repositorio
|
# 1. Clonar el repositorio
|
||||||
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git
|
git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
|
||||||
|
|
||||||
# 2. Entrar al directorio
|
# 2. Entrar al directorio
|
||||||
cd Autoparts-DB
|
cd Nexus-Autoparts
|
||||||
|
|
||||||
# 3. Instalar dependencias
|
# 3. Instalar dependencias
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -48,8 +48,8 @@ python3 server.py
|
|||||||
### Paso 1: Clonar el Repositorio
|
### Paso 1: Clonar el Repositorio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git
|
git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
|
||||||
cd Autoparts-DB
|
cd Nexus-Autoparts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Paso 2: Crear Entorno Virtual (Recomendado)
|
### Paso 2: Crear Entorno Virtual (Recomendado)
|
||||||
@@ -254,16 +254,16 @@ gunicorn -w 4 -b 0.0.0.0:8080 server:app
|
|||||||
|
|
||||||
### Usando systemd
|
### Usando systemd
|
||||||
|
|
||||||
Crear archivo `/etc/systemd/system/autoparts-db.service`:
|
Crear archivo `/etc/systemd/system/nexus-autoparts.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Autoparts DB Dashboard
|
Description=Nexus Autoparts Dashboard
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/path/to/Autoparts-DB/dashboard
|
WorkingDirectory=/path/to/Nexus-Autoparts/dashboard
|
||||||
ExecStart=/usr/bin/python3 server.py
|
ExecStart=/usr/bin/python3 server.py
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
@@ -274,8 +274,8 @@ WantedBy=multi-user.target
|
|||||||
Habilitar e iniciar:
|
Habilitar e iniciar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl enable autoparts-db
|
sudo systemctl enable nexus-autoparts
|
||||||
sudo systemctl start autoparts-db
|
sudo systemctl start nexus-autoparts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usando Docker (Opcional)
|
### Usando Docker (Opcional)
|
||||||
@@ -294,8 +294,8 @@ CMD ["python3", "dashboard/server.py"]
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t autoparts-db .
|
docker build -t nexus-autoparts .
|
||||||
docker run -p 5000:5000 autoparts-db
|
docker run -p 5000:5000 nexus-autoparts
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -319,7 +319,7 @@ pip install --upgrade -r requirements.txt
|
|||||||
deactivate
|
deactivate
|
||||||
|
|
||||||
# Eliminar directorio del proyecto
|
# Eliminar directorio del proyecto
|
||||||
rm -rf Autoparts-DB
|
rm -rf Nexus-Autoparts
|
||||||
|
|
||||||
# Eliminar entorno virtual (si está separado)
|
# Eliminar entorno virtual (si está separado)
|
||||||
rm -rf venv
|
rm -rf venv
|
||||||
|
|||||||
473
docs/METABASE_ACTIONS.md
Normal file
473
docs/METABASE_ACTIONS.md
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
# Metabase Actions — Alta de Piezas e Intercambios
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
- Metabase v0.44+ (Open Source o Pro)
|
||||||
|
- Actions habilitadas en Admin → Settings
|
||||||
|
- Database con "Model actions" activado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configuración Inicial
|
||||||
|
|
||||||
|
### 1.1 Habilitar Actions
|
||||||
|
1. Ir a **Admin** → **Settings**
|
||||||
|
2. Buscar **"Enable Actions"** → Activar
|
||||||
|
3. Ir a **Admin** → **Databases** → Click en `nexus_autoparts`
|
||||||
|
4. Activar **"Model actions"**
|
||||||
|
|
||||||
|
### 1.2 Crear Modelos Base
|
||||||
|
|
||||||
|
En Metabase, un **Modelo** es una pregunta (query) guardada como tabla virtual.
|
||||||
|
Los Actions se vinculan a Modelos.
|
||||||
|
|
||||||
|
**Modelo 1: Piezas OEM** → New → SQL Query:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
p.id_part,
|
||||||
|
p.oem_part_number,
|
||||||
|
p.name_part,
|
||||||
|
p.name_es,
|
||||||
|
pg.name_part_group AS grupo,
|
||||||
|
pc.name_part_category AS categoria,
|
||||||
|
p.description,
|
||||||
|
p.description_es,
|
||||||
|
p.weight_kg,
|
||||||
|
mat.name_material AS material
|
||||||
|
FROM parts p
|
||||||
|
JOIN part_groups pg ON p.group_id = pg.id_part_group
|
||||||
|
JOIN part_categories pc ON pg.category_id = pc.id_part_category
|
||||||
|
LEFT JOIN materials mat ON p.id_material = mat.id_material
|
||||||
|
ORDER BY p.oem_part_number
|
||||||
|
```
|
||||||
|
Guardar → Click ⋯ → **Turn into a model** → Nombrar: `Piezas OEM`
|
||||||
|
|
||||||
|
**Modelo 2: Fitments** → New → SQL Query:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
vp.id_vehicle_part,
|
||||||
|
b.name_brand AS marca,
|
||||||
|
m.name_model AS modelo,
|
||||||
|
y.year_car AS año,
|
||||||
|
e.name_engine AS motor,
|
||||||
|
p.oem_part_number,
|
||||||
|
p.name_part AS pieza,
|
||||||
|
vp.quantity_required AS cantidad,
|
||||||
|
pp.name_position_part AS posicion,
|
||||||
|
vp.fitment_notes AS notas
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
JOIN parts p ON vp.part_id = p.id_part
|
||||||
|
LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part
|
||||||
|
ORDER BY b.name_brand, m.name_model, y.year_car
|
||||||
|
```
|
||||||
|
Guardar → **Turn into a model** → Nombrar: `Fitments`
|
||||||
|
|
||||||
|
**Modelo 3: Aftermarket** → New → SQL Query:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
ap.id_aftermarket_parts,
|
||||||
|
p.oem_part_number AS oem_ref,
|
||||||
|
p.name_part AS pieza_oem,
|
||||||
|
mfr.name_manufacture AS fabricante,
|
||||||
|
ap.part_number AS numero_aftermarket,
|
||||||
|
ap.name_aftermarket_parts AS nombre,
|
||||||
|
ap.name_es AS nombre_es,
|
||||||
|
qt.name_quality AS calidad,
|
||||||
|
ap.price_usd AS precio_usd,
|
||||||
|
ap.warranty_months AS garantia_meses
|
||||||
|
FROM aftermarket_parts ap
|
||||||
|
JOIN parts p ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers mfr ON ap.manufacturer_id = mfr.id_manufacture
|
||||||
|
LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier
|
||||||
|
ORDER BY p.oem_part_number, mfr.name_manufacture
|
||||||
|
```
|
||||||
|
Guardar → **Turn into a model** → Nombrar: `Aftermarket`
|
||||||
|
|
||||||
|
**Modelo 4: Cross-References** → New → SQL Query:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
pcr.id_part_cross_ref,
|
||||||
|
p.oem_part_number AS oem_ref,
|
||||||
|
p.name_part AS pieza,
|
||||||
|
pcr.cross_reference_number AS numero_cruzado,
|
||||||
|
rt.name_ref_type AS tipo_referencia,
|
||||||
|
pcr.source_ref AS fuente,
|
||||||
|
pcr.notes AS notas
|
||||||
|
FROM part_cross_references pcr
|
||||||
|
JOIN parts p ON pcr.part_id = p.id_part
|
||||||
|
LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type
|
||||||
|
ORDER BY p.oem_part_number, rt.name_ref_type
|
||||||
|
```
|
||||||
|
Guardar → **Turn into a model** → Nombrar: `Cross-References`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Crear Actions (Formularios de Alta)
|
||||||
|
|
||||||
|
Para cada Modelo, crear Actions que permiten insertar datos.
|
||||||
|
|
||||||
|
### Action 1: Nueva Pieza OEM
|
||||||
|
|
||||||
|
1. Abrir el modelo **Piezas OEM**
|
||||||
|
2. Click **⋯** → **Info** → **Actions** → **New action**
|
||||||
|
3. Nombrar: `Alta de Pieza OEM`
|
||||||
|
4. Pegar este SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO parts (
|
||||||
|
oem_part_number,
|
||||||
|
name_part,
|
||||||
|
name_es,
|
||||||
|
group_id,
|
||||||
|
description,
|
||||||
|
description_es,
|
||||||
|
weight_kg,
|
||||||
|
id_material
|
||||||
|
) VALUES (
|
||||||
|
{{oem_part_number}},
|
||||||
|
{{name_part}},
|
||||||
|
{{name_es}},
|
||||||
|
{{group_id}},
|
||||||
|
{{description}},
|
||||||
|
{{description_es}},
|
||||||
|
{{weight_kg}},
|
||||||
|
{{id_material}}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Configurar campos del formulario:
|
||||||
|
|
||||||
|
| Variable | Label | Tipo | Requerido |
|
||||||
|
|----------|-------|------|-----------|
|
||||||
|
| `oem_part_number` | Número OEM | string | Si |
|
||||||
|
| `name_part` | Nombre (EN) | string | Si |
|
||||||
|
| `name_es` | Nombre (ES) | string | No |
|
||||||
|
| `group_id` | ID Grupo | number | Si |
|
||||||
|
| `description` | Descripción (EN) | string (long) | No |
|
||||||
|
| `description_es` | Descripción (ES) | string (long) | No |
|
||||||
|
| `weight_kg` | Peso (kg) | number | No |
|
||||||
|
| `id_material` | ID Material | number | No |
|
||||||
|
|
||||||
|
6. Click **Save**
|
||||||
|
|
||||||
|
> **Tip:** Para que el usuario no tenga que memorizar `group_id`, crea una pregunta
|
||||||
|
> auxiliar con los grupos disponibles:
|
||||||
|
> ```sql
|
||||||
|
> SELECT pg.id_part_group AS id, pg.name_part_group AS grupo,
|
||||||
|
> pc.name_part_category AS categoria
|
||||||
|
> FROM part_groups pg
|
||||||
|
> JOIN part_categories pc ON pg.category_id = pc.id_part_category
|
||||||
|
> ORDER BY pc.display_order, pg.display_order
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Action 2: Nuevo Fitment (vincular pieza a vehículo)
|
||||||
|
|
||||||
|
1. Abrir el modelo **Fitments**
|
||||||
|
2. **New action** → Nombrar: `Alta de Fitment`
|
||||||
|
3. SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO vehicle_parts (
|
||||||
|
model_year_engine_id,
|
||||||
|
part_id,
|
||||||
|
quantity_required,
|
||||||
|
id_position_part,
|
||||||
|
fitment_notes
|
||||||
|
) VALUES (
|
||||||
|
{{model_year_engine_id}},
|
||||||
|
{{part_id}},
|
||||||
|
{{quantity_required}},
|
||||||
|
{{id_position_part}},
|
||||||
|
{{fitment_notes}}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Label | Tipo | Requerido | Default |
|
||||||
|
|----------|-------|------|-----------|---------|
|
||||||
|
| `model_year_engine_id` | ID Vehículo (MYE) | number | Si | — |
|
||||||
|
| `part_id` | ID Pieza | number | Si | — |
|
||||||
|
| `quantity_required` | Cantidad | number | No | 1 |
|
||||||
|
| `id_position_part` | Posición (1=front, 2=rear) | number | No | — |
|
||||||
|
| `fitment_notes` | Notas | string | No | — |
|
||||||
|
|
||||||
|
> **Tip:** Para encontrar el `model_year_engine_id`, crea esta pregunta auxiliar:
|
||||||
|
> ```sql
|
||||||
|
> SELECT mye.id_mye AS id, b.name_brand AS marca,
|
||||||
|
> m.name_model AS modelo, y.year_car AS año, e.name_engine AS motor
|
||||||
|
> FROM model_year_engine mye
|
||||||
|
> JOIN models m ON mye.model_id = m.id_model
|
||||||
|
> JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
> JOIN years y ON mye.year_id = y.id_year
|
||||||
|
> JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
> WHERE b.name_brand ILIKE {{'%' || marca || '%'}}
|
||||||
|
> AND m.name_model ILIKE {{'%' || modelo || '%'}}
|
||||||
|
> ORDER BY b.name_brand, m.name_model, y.year_car
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Action 3: Fitment Masivo (una pieza → varios vehículos)
|
||||||
|
|
||||||
|
1. En el modelo **Fitments** → **New action**
|
||||||
|
2. Nombrar: `Fitment Masivo por Marca/Modelo/Años`
|
||||||
|
3. SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part)
|
||||||
|
SELECT mye.id_mye, {{part_id}}, {{quantity_required}}, {{id_position_part}}
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
WHERE b.name_brand ILIKE {{marca}}
|
||||||
|
AND m.name_model ILIKE {{modelo}}
|
||||||
|
AND y.year_car BETWEEN {{year_from}} AND {{year_to}}
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Label | Tipo | Requerido |
|
||||||
|
|----------|-------|------|-----------|
|
||||||
|
| `part_id` | ID Pieza | number | Si |
|
||||||
|
| `marca` | Marca (ej: TOYOTA) | string | Si |
|
||||||
|
| `modelo` | Modelo (ej: Camry) | string | Si |
|
||||||
|
| `year_from` | Año desde | number | Si |
|
||||||
|
| `year_to` | Año hasta | number | Si |
|
||||||
|
| `quantity_required` | Cantidad | number | Si |
|
||||||
|
| `id_position_part` | Posición (1=front, 2=rear, vacío=N/A) | number | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Action 4: Nueva Pieza Aftermarket
|
||||||
|
|
||||||
|
1. En el modelo **Aftermarket** → **New action**
|
||||||
|
2. Nombrar: `Alta de Pieza Aftermarket`
|
||||||
|
3. SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO aftermarket_parts (
|
||||||
|
oem_part_id,
|
||||||
|
manufacturer_id,
|
||||||
|
part_number,
|
||||||
|
name_aftermarket_parts,
|
||||||
|
name_es,
|
||||||
|
id_quality_tier,
|
||||||
|
price_usd,
|
||||||
|
warranty_months
|
||||||
|
) VALUES (
|
||||||
|
{{oem_part_id}},
|
||||||
|
{{manufacturer_id}},
|
||||||
|
{{part_number}},
|
||||||
|
{{name_aftermarket_parts}},
|
||||||
|
{{name_es}},
|
||||||
|
{{id_quality_tier}},
|
||||||
|
{{price_usd}},
|
||||||
|
{{warranty_months}}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Label | Tipo | Requerido |
|
||||||
|
|----------|-------|------|-----------|
|
||||||
|
| `oem_part_id` | ID Pieza OEM | number | Si |
|
||||||
|
| `manufacturer_id` | ID Fabricante | number | Si |
|
||||||
|
| `part_number` | Número Aftermarket | string | Si |
|
||||||
|
| `name_aftermarket_parts` | Nombre (EN) | string | No |
|
||||||
|
| `name_es` | Nombre (ES) | string | No |
|
||||||
|
| `id_quality_tier` | Calidad (1=economy, 2=oem, 3=premium, 4=standard) | number | No |
|
||||||
|
| `price_usd` | Precio USD | number | No |
|
||||||
|
| `warranty_months` | Garantía (meses) | number | No |
|
||||||
|
|
||||||
|
> **Tip:** Pregunta auxiliar para fabricantes:
|
||||||
|
> ```sql
|
||||||
|
> SELECT id_manufacture AS id, name_manufacture AS fabricante
|
||||||
|
> FROM manufacturers ORDER BY name_manufacture
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Action 5: Nueva Cross-Reference
|
||||||
|
|
||||||
|
1. En el modelo **Cross-References** → **New action**
|
||||||
|
2. Nombrar: `Alta de Cross-Reference`
|
||||||
|
3. SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO part_cross_references (
|
||||||
|
part_id,
|
||||||
|
cross_reference_number,
|
||||||
|
id_ref_type,
|
||||||
|
source_ref,
|
||||||
|
notes
|
||||||
|
) VALUES (
|
||||||
|
{{part_id}},
|
||||||
|
{{cross_reference_number}},
|
||||||
|
{{id_ref_type}},
|
||||||
|
{{source_ref}},
|
||||||
|
{{notes}}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Label | Tipo | Requerido |
|
||||||
|
|----------|-------|------|-----------|
|
||||||
|
| `part_id` | ID Pieza OEM | number | Si |
|
||||||
|
| `cross_reference_number` | Número de Referencia | string | Si |
|
||||||
|
| `id_ref_type` | Tipo (1=competitor, 2=interchange, 3=oem_alternate, 4=supersession) | number | No |
|
||||||
|
| `source_ref` | Fuente | string | No |
|
||||||
|
| `notes` | Notas | string | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dashboard de Carga de Datos
|
||||||
|
|
||||||
|
Crear un **Dashboard** que agrupe todo el flujo de carga:
|
||||||
|
|
||||||
|
1. **New** → **Dashboard** → Nombrar: `Panel de Carga de Datos`
|
||||||
|
2. Agregar estas tarjetas:
|
||||||
|
|
||||||
|
### Fila 1: Consulta rápida
|
||||||
|
- **Buscar Pieza** (pregunta con filtro `{{oem_number}}`)
|
||||||
|
- **Buscar Vehículo** (pregunta con filtros `{{marca}}`, `{{modelo}}`)
|
||||||
|
|
||||||
|
### Fila 2: Botones de Actions
|
||||||
|
- **+ Nueva Pieza OEM** → Action 1
|
||||||
|
- **+ Nuevo Fitment** → Action 2
|
||||||
|
- **+ Fitment Masivo** → Action 3
|
||||||
|
- **+ Aftermarket** → Action 4
|
||||||
|
- **+ Cross-Reference** → Action 5
|
||||||
|
|
||||||
|
### Fila 3: Referencias
|
||||||
|
- **Tabla de Grupos** (grupos con IDs para referencia)
|
||||||
|
- **Tabla de Fabricantes** (fabricantes con IDs)
|
||||||
|
- **Estadísticas** (conteos actuales)
|
||||||
|
|
||||||
|
### Fila 4: Últimos registros
|
||||||
|
- **Últimas piezas cargadas**:
|
||||||
|
```sql
|
||||||
|
SELECT oem_part_number, name_part, name_es, created_at
|
||||||
|
FROM parts ORDER BY created_at DESC LIMIT 10
|
||||||
|
```
|
||||||
|
- **Últimos fitments**:
|
||||||
|
```sql
|
||||||
|
SELECT b.name_brand, m.name_model, y.year_car, p.oem_part_number, vp.created_at
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN parts p ON vp.part_id = p.id_part
|
||||||
|
ORDER BY vp.created_at DESC LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flujo de Trabajo Recomendado
|
||||||
|
|
||||||
|
### Para cargar una pieza nueva con todo su contexto:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Abrir "Panel de Carga de Datos"
|
||||||
|
|
||||||
|
2. Click [+ Nueva Pieza OEM]
|
||||||
|
→ Llenar: OEM#, Nombre, Grupo, Descripción
|
||||||
|
→ Submit → Anotar el ID generado
|
||||||
|
|
||||||
|
3. Click [+ Fitment Masivo]
|
||||||
|
→ Ingresar: ID pieza, Marca, Modelo, Rango de años
|
||||||
|
→ Submit → La pieza queda vinculada a todos los vehículos
|
||||||
|
|
||||||
|
4. Click [+ Aftermarket] (repetir por cada fabricante)
|
||||||
|
→ Ingresar: ID pieza OEM, ID fabricante, # aftermarket, precio
|
||||||
|
→ Submit
|
||||||
|
|
||||||
|
5. Click [+ Cross-Reference] (repetir por cada referencia)
|
||||||
|
→ Ingresar: ID pieza, # referencia, tipo
|
||||||
|
→ Submit
|
||||||
|
|
||||||
|
6. Verificar en "Últimos registros" que todo se cargó
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Preguntas Auxiliares Importantes
|
||||||
|
|
||||||
|
Guardar estas como preguntas para tener a mano durante la carga:
|
||||||
|
|
||||||
|
### Buscar pieza por número
|
||||||
|
```sql
|
||||||
|
SELECT id_part, oem_part_number, name_part, name_es
|
||||||
|
FROM parts
|
||||||
|
WHERE oem_part_number ILIKE {{'%' || numero || '%'}}
|
||||||
|
OR name_part ILIKE {{'%' || numero || '%'}}
|
||||||
|
ORDER BY oem_part_number
|
||||||
|
LIMIT 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Buscar vehículo por marca/modelo
|
||||||
|
```sql
|
||||||
|
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
WHERE b.name_brand ILIKE {{'%' || marca || '%'}}
|
||||||
|
AND m.name_model ILIKE {{'%' || modelo || '%'}}
|
||||||
|
ORDER BY y.year_car DESC
|
||||||
|
LIMIT 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catálogo de grupos (referencia para group_id)
|
||||||
|
```sql
|
||||||
|
SELECT pg.id_part_group AS id, pg.name_part_group AS grupo,
|
||||||
|
pc.name_part_category AS categoria
|
||||||
|
FROM part_groups pg
|
||||||
|
JOIN part_categories pc ON pg.category_id = pc.id_part_category
|
||||||
|
ORDER BY pc.display_order, pg.display_order
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catálogo de fabricantes (referencia para manufacturer_id)
|
||||||
|
```sql
|
||||||
|
SELECT m.id_manufacture AS id, m.name_manufacture AS fabricante,
|
||||||
|
mt.name_type_manu AS tipo, qt.name_quality AS calidad
|
||||||
|
FROM manufacturers m
|
||||||
|
LEFT JOIN manufacture_type mt ON m.id_type_manu = mt.id_type_manu
|
||||||
|
LEFT JOIN quality_tier qt ON m.id_quality_tier = qt.id_quality_tier
|
||||||
|
ORDER BY m.name_manufacture
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. IDs de Referencia Rápida
|
||||||
|
|
||||||
|
### Calidad (`id_quality_tier`)
|
||||||
|
| ID | Valor |
|
||||||
|
|----|-------|
|
||||||
|
| 1 | economy |
|
||||||
|
| 2 | oem |
|
||||||
|
| 3 | premium |
|
||||||
|
| 4 | standard |
|
||||||
|
|
||||||
|
### Tipo de referencia (`id_ref_type`)
|
||||||
|
| ID | Valor | Uso |
|
||||||
|
|----|-------|-----|
|
||||||
|
| 1 | competitor | Número de competidor equivalente |
|
||||||
|
| 2 | interchange | Intercambio directo compatible |
|
||||||
|
| 3 | oem_alternate | Número OEM alterno |
|
||||||
|
| 4 | supersession | Pieza que esta reemplaza |
|
||||||
|
|
||||||
|
### Posición (`id_position_part`)
|
||||||
|
| ID | Valor |
|
||||||
|
|----|-------|
|
||||||
|
| 1 | front |
|
||||||
|
| 2 | rear |
|
||||||
|
|
||||||
|
### Tipo de fabricante (`id_type_manu`)
|
||||||
|
| ID | Valor |
|
||||||
|
|----|-------|
|
||||||
|
| 1 | aftermarket |
|
||||||
|
| 2 | oem |
|
||||||
599
docs/METABASE_GUIDE.md
Normal file
599
docs/METABASE_GUIDE.md
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
# Guía Metabase — Nexus Autoparts
|
||||||
|
|
||||||
|
## 1. Conexión a la Base de Datos
|
||||||
|
|
||||||
|
### Datos de conexión PostgreSQL
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|-------|-------|
|
||||||
|
| **Host** | `localhost` (o la IP del servidor: `192.168.10.198`) |
|
||||||
|
| **Puerto** | `5432` |
|
||||||
|
| **Base de datos** | `nexus_autoparts` |
|
||||||
|
| **Usuario** | `nexus` |
|
||||||
|
| **Contraseña** | `nexus_autoparts_2026` |
|
||||||
|
| **SSL** | No requerido (conexión local) |
|
||||||
|
|
||||||
|
### Pasos en Metabase
|
||||||
|
|
||||||
|
1. Ir a **Admin** → **Databases** → **Add database**
|
||||||
|
2. Seleccionar **PostgreSQL**
|
||||||
|
3. Llenar los campos con los datos anteriores
|
||||||
|
4. Click **Save**
|
||||||
|
5. Metabase sincronizará las tablas automáticamente (~30 segundos)
|
||||||
|
|
||||||
|
> **Tip:** Si Metabase está en otro servidor, usar la IP `192.168.10.198` en lugar de `localhost`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Estructura de la Base de Datos
|
||||||
|
|
||||||
|
### Diagrama de relaciones simplificado
|
||||||
|
|
||||||
|
```
|
||||||
|
brands ──→ models ──→ model_year_engine (MYE) ←── years
|
||||||
|
↑ ↑ ↑
|
||||||
|
engines │ vehicle_parts ──→ parts
|
||||||
|
│ ↑
|
||||||
|
vehicle_diagrams part_cross_references
|
||||||
|
↓ aftermarket_parts
|
||||||
|
diagrams diagram_hotspots
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tablas principales (con datos)
|
||||||
|
|
||||||
|
| Tabla | Registros | Descripción |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `brands` | 102 | Marcas de vehículos |
|
||||||
|
| `models` | 4,031 | Modelos por marca |
|
||||||
|
| `years` | 80 | Años (1946-2026) |
|
||||||
|
| `engines` | 13,430 | Motores con specs |
|
||||||
|
| `model_year_engine` | 47,858 | Combinación marca-modelo-año-motor |
|
||||||
|
|
||||||
|
### Tablas de piezas (vacías — para llenar)
|
||||||
|
|
||||||
|
| Tabla | Descripción |
|
||||||
|
|-------|-------------|
|
||||||
|
| `parts` | Piezas OEM (número de parte, nombre, grupo) |
|
||||||
|
| `vehicle_parts` | Relación pieza ↔ vehículo (fitments) |
|
||||||
|
| `aftermarket_parts` | Piezas aftermarket por fabricante |
|
||||||
|
| `part_cross_references` | Intercambios y referencias cruzadas |
|
||||||
|
| `manufacturers` | 47 fabricantes ya cargados |
|
||||||
|
|
||||||
|
### Tablas lookup (catálogos de referencia)
|
||||||
|
|
||||||
|
| Tabla | Valores actuales |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `part_categories` | 12: Body, Brake, Cooling, Drivetrain, Electrical, Engine, Exhaust, Fuel, HVAC, Steering, Suspension, Transmission |
|
||||||
|
| `part_groups` | 63 grupos dentro de las categorías |
|
||||||
|
| `quality_tier` | economy, standard, oem, premium |
|
||||||
|
| `reference_type` | competitor, interchange, oem_alternate, supersession |
|
||||||
|
| `position_part` | front, rear |
|
||||||
|
| `manufacture_type` | aftermarket, oem |
|
||||||
|
| `materials` | (vacía — agregar según se necesite) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Alta de Piezas OEM
|
||||||
|
|
||||||
|
### Orden obligatorio de carga
|
||||||
|
|
||||||
|
```
|
||||||
|
1. materials (si aplica)
|
||||||
|
2. parts ← PRIMERO las piezas
|
||||||
|
3. vehicle_parts ← DESPUÉS los fitments
|
||||||
|
4. aftermarket_parts
|
||||||
|
5. part_cross_references
|
||||||
|
```
|
||||||
|
|
||||||
|
> **IMPORTANTE:** Respetar este orden. `vehicle_parts` y `aftermarket_parts` requieren que la pieza ya exista en `parts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.1 Agregar una pieza OEM (`parts`)
|
||||||
|
|
||||||
|
En Metabase: click **+ New** → **SQL query** → ejecutar:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, name_es, group_id, description, description_es, weight_kg, id_material)
|
||||||
|
VALUES (
|
||||||
|
'04465-06090', -- Número OEM (obligatorio, único por grupo)
|
||||||
|
'Front Brake Pad Set', -- Nombre en inglés (obligatorio)
|
||||||
|
'Juego de Balatas Delanteras', -- Nombre en español (opcional)
|
||||||
|
16, -- group_id: 16 = Brake Pads (ver tabla abajo)
|
||||||
|
'Ceramic brake pad set for front axle', -- Descripción EN (opcional)
|
||||||
|
'Juego de balatas cerámicas para eje delantero', -- Descripción ES (opcional)
|
||||||
|
1.2, -- Peso en kg (opcional)
|
||||||
|
NULL -- id_material (opcional, ver tabla materials)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carga masiva de piezas
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, name_es, group_id, description) VALUES
|
||||||
|
('04465-06090', 'Front Brake Pad Set', 'Juego Balatas Delanteras', 16, 'Ceramic front pads'),
|
||||||
|
('04465-33471', 'Front Brake Pad Set', 'Juego Balatas Delanteras', 16, 'Semi-metallic front pads'),
|
||||||
|
('43512-06150', 'Front Brake Rotor', 'Disco de Freno Delantero', 17, 'Vented front rotor 296mm'),
|
||||||
|
('19101-28491', 'Radiator', 'Radiador', 31, 'Aluminum core radiator'),
|
||||||
|
('16400-28531', 'Cooling Fan Assembly', 'Ensamble Ventilador', 35, 'Electric cooling fan')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Referencia de `group_id` (grupos de piezas)
|
||||||
|
|
||||||
|
| group_id | Grupo | Categoría |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| **Frenos y Ruedas** | | |
|
||||||
|
| 16 | Brake Pads | Brake & Wheel Hub |
|
||||||
|
| 17 | Brake Rotors | Brake & Wheel Hub |
|
||||||
|
| 20 | Brake Calipers | Brake & Wheel Hub |
|
||||||
|
| 27 | Wheel Bearings | Brake & Wheel Hub |
|
||||||
|
| 28 | Wheel Hubs | Brake & Wheel Hub |
|
||||||
|
| **Motor** | | |
|
||||||
|
| 70 | Oil Filters | Engine |
|
||||||
|
| 71 | Air Filters | Engine |
|
||||||
|
| 72 | Spark Plugs | Engine |
|
||||||
|
| 73 | Belts | Engine |
|
||||||
|
| 76 | Timing Components | Engine |
|
||||||
|
| 91 | Engine Mounts | Engine |
|
||||||
|
| **Enfriamiento** | | |
|
||||||
|
| 31 | Radiators | Cooling System |
|
||||||
|
| 33 | Water Pumps | Cooling System |
|
||||||
|
| 34 | Thermostats | Cooling System |
|
||||||
|
| 35 | Cooling Fans | Cooling System |
|
||||||
|
| **Eléctrico** | | |
|
||||||
|
| 55 | Alternators | Electrical & Lighting |
|
||||||
|
| 56 | Starters | Electrical & Lighting |
|
||||||
|
| 57 | Ignition Coils | Electrical & Lighting |
|
||||||
|
| 65 | Sensors | Electrical & Lighting |
|
||||||
|
| **Combustible** | | |
|
||||||
|
| 110 | Fuel Pumps | Fuel & Air |
|
||||||
|
| 111 | Fuel Filters | Fuel & Air |
|
||||||
|
| 112 | Fuel Injectors | Fuel & Air |
|
||||||
|
| **Escape** | | |
|
||||||
|
| 99 | Catalytic Converters | Exhaust |
|
||||||
|
| 100 | Mufflers | Exhaust |
|
||||||
|
| 108 | Headers | Exhaust |
|
||||||
|
| **Dirección** | | |
|
||||||
|
| 141 | Power Steering Pumps | Steering |
|
||||||
|
| 144 | Steering Racks | Steering |
|
||||||
|
| 145 | Steering Gearboxes | Steering |
|
||||||
|
| 146 | Tie Rods | Steering |
|
||||||
|
| 147 | Tie Rod Ends | Steering |
|
||||||
|
| 148 | Inner Tie Rods | Steering |
|
||||||
|
| 151 | Pitman Arms | Steering |
|
||||||
|
| 152 | Idler Arms | Steering |
|
||||||
|
| 153 | Center Links | Steering |
|
||||||
|
| 154 | Drag Links | Steering |
|
||||||
|
| 155 | Steering Knuckles | Steering |
|
||||||
|
| 191 | Steering Dampers | Steering |
|
||||||
|
| **Suspensión** | | |
|
||||||
|
| 156 | Shocks | Suspension |
|
||||||
|
| 157 | Struts | Suspension |
|
||||||
|
| 158 | Strut Mounts | Suspension |
|
||||||
|
| 159 | Coil Springs | Suspension |
|
||||||
|
| 160 | Leaf Springs | Suspension |
|
||||||
|
| 161 | Control Arms | Suspension |
|
||||||
|
| 164 | Ball Joints | Suspension |
|
||||||
|
| 165 | Bushings | Suspension |
|
||||||
|
| 167 | Sway Bar Links | Suspension |
|
||||||
|
| 168 | Sway Bar Bushings | Suspension |
|
||||||
|
| 169 | Torsion Bars | Suspension |
|
||||||
|
| 170 | Trailing Arms | Suspension |
|
||||||
|
| **Transmisión** | | |
|
||||||
|
| 175 | Transmission Filters | Transmission |
|
||||||
|
| 185 | Transmission Mounts | Transmission |
|
||||||
|
| **A/C y Calefacción** | | |
|
||||||
|
| 127 | AC Compressors | Heat & Air Conditioning |
|
||||||
|
| 135 | Blower Motors | Heat & Air Conditioning |
|
||||||
|
| 138 | Cabin Air Filters | Heat & Air Conditioning |
|
||||||
|
| **Tren Motriz** | | |
|
||||||
|
| 44 | CV Axles | Drivetrain |
|
||||||
|
| 45 | CV Joints | Drivetrain |
|
||||||
|
| 50 | Axle Shafts | Drivetrain |
|
||||||
|
| **Carrocería** | | |
|
||||||
|
| 15 | Moldings & Trim | Body & Lamp Assembly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Alta de Fitments (Pieza ↔ Vehículo)
|
||||||
|
|
||||||
|
Un fitment vincula una pieza con un vehículo específico (combinación modelo-año-motor).
|
||||||
|
|
||||||
|
### 4.1 Encontrar el `id_mye` del vehículo
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Buscar el id_mye para un vehículo específico
|
||||||
|
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
WHERE b.name_brand ILIKE 'Toyota'
|
||||||
|
AND m.name_model ILIKE 'Camry'
|
||||||
|
AND y.year_car = 2020
|
||||||
|
ORDER BY e.name_engine;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Encontrar el `id_part` de la pieza
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Buscar pieza por número OEM
|
||||||
|
SELECT id_part, oem_part_number, name_part FROM parts
|
||||||
|
WHERE oem_part_number = '04465-06090';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Crear el fitment (`vehicle_parts`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part, fitment_notes)
|
||||||
|
VALUES (
|
||||||
|
12345, -- id_mye del vehículo (del paso 4.1)
|
||||||
|
67890, -- id_part de la pieza (del paso 4.2)
|
||||||
|
1, -- Cantidad requerida (1 juego)
|
||||||
|
1, -- Posición: 1 = front, 2 = rear (ver position_part)
|
||||||
|
'Fits all trim levels' -- Notas de compatibilidad (opcional)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Fitment masivo (misma pieza en varios vehículos)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ejemplo: Balata 04465-06090 compatible con todos los Camry 2018-2023
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part)
|
||||||
|
SELECT mye.id_mye,
|
||||||
|
(SELECT id_part FROM parts WHERE oem_part_number = '04465-06090'),
|
||||||
|
1, -- cantidad
|
||||||
|
1 -- posición: front
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
WHERE b.name_brand = 'TOYOTA'
|
||||||
|
AND m.name_model = 'Camry'
|
||||||
|
AND y.year_car BETWEEN 2018 AND 2023
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valores de `position_part`
|
||||||
|
|
||||||
|
| id_position_part | Nombre |
|
||||||
|
|-----------------|--------|
|
||||||
|
| 1 | front |
|
||||||
|
| 2 | rear |
|
||||||
|
|
||||||
|
> Para agregar más posiciones (left, right, upper, lower):
|
||||||
|
> ```sql
|
||||||
|
> INSERT INTO position_part (name_position_part) VALUES ('left'), ('right'), ('upper'), ('lower');
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Alta de Piezas Aftermarket
|
||||||
|
|
||||||
|
Vincula una pieza aftermarket con su equivalente OEM y el fabricante.
|
||||||
|
|
||||||
|
### 5.1 Crear pieza aftermarket (`aftermarket_parts`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO aftermarket_parts (
|
||||||
|
oem_part_id, manufacturer_id, part_number,
|
||||||
|
name_aftermarket_parts, name_es,
|
||||||
|
id_quality_tier, price_usd, warranty_months
|
||||||
|
) VALUES (
|
||||||
|
67890, -- id_part de la pieza OEM equivalente (de tabla parts)
|
||||||
|
3, -- id_manufacture del fabricante (ver manufacturers)
|
||||||
|
'CXD1293', -- Número de parte aftermarket
|
||||||
|
'Ceramic Brake Pad Set', -- Nombre EN
|
||||||
|
'Juego Balatas Cerámicas', -- Nombre ES
|
||||||
|
3, -- Calidad: 1=economy, 2=oem, 3=premium, 4=standard
|
||||||
|
45.99, -- Precio USD
|
||||||
|
24 -- Garantía en meses
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Carga masiva aftermarket
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, id_quality_tier, price_usd, warranty_months) VALUES
|
||||||
|
(67890, 3, 'CXD1293', 'Premium Ceramic Brake Pad', 3, 45.99, 24),
|
||||||
|
(67890, 5, 'MKD1293', 'Economy Semi-Metallic Brake Pad', 1, 22.50, 12),
|
||||||
|
(67890, 8, 'PBR1293', 'OEM Replacement Brake Pad', 2, 38.00, 18)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Referencia de `quality_tier`
|
||||||
|
|
||||||
|
| id_quality_tier | Nombre | Descripción |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| 1 | economy | Económica, calidad básica |
|
||||||
|
| 2 | oem | Calidad equivalente al original |
|
||||||
|
| 3 | premium | Calidad superior al original |
|
||||||
|
| 4 | standard | Calidad estándar del mercado |
|
||||||
|
|
||||||
|
### Consultar fabricantes existentes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT m.id_manufacture, m.name_manufacture, mt.name_type_manu AS tipo,
|
||||||
|
qt.name_quality AS calidad, c.name_country AS pais
|
||||||
|
FROM manufacturers m
|
||||||
|
LEFT JOIN manufacture_type mt ON m.id_type_manu = mt.id_type_manu
|
||||||
|
LEFT JOIN quality_tier qt ON m.id_quality_tier = qt.id_quality_tier
|
||||||
|
LEFT JOIN countries c ON m.id_country = c.id_country
|
||||||
|
ORDER BY m.name_manufacture;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agregar un nuevo fabricante
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO manufacturers (name_manufacture, id_type_manu, id_quality_tier, id_country, website)
|
||||||
|
VALUES (
|
||||||
|
'BREMBO',
|
||||||
|
1, -- 1=aftermarket, 2=oem
|
||||||
|
3, -- 1=economy, 2=oem, 3=premium, 4=standard
|
||||||
|
NULL, -- id_country (buscar en tabla countries o agregar)
|
||||||
|
'https://www.brembo.com'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Alta de Referencias Cruzadas / Intercambios
|
||||||
|
|
||||||
|
Las cross-references vinculan una pieza OEM con números de parte alternativos.
|
||||||
|
|
||||||
|
### 6.1 Crear referencia cruzada (`part_cross_references`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, id_ref_type, source_ref, notes)
|
||||||
|
VALUES (
|
||||||
|
67890, -- id_part de la pieza OEM
|
||||||
|
'D1293', -- Número de referencia cruzada
|
||||||
|
2, -- Tipo: 2 = interchange (ver tabla abajo)
|
||||||
|
'Wagner Catalog', -- Fuente de la referencia (opcional)
|
||||||
|
'Direct replacement' -- Notas (opcional)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Carga masiva de cross-references
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, id_ref_type, source_ref) VALUES
|
||||||
|
(67890, 'D1293', 2, 'Wagner'), -- interchange
|
||||||
|
(67890, '04465-06100', 3, 'Toyota'), -- oem_alternate (número OEM alterno)
|
||||||
|
(67890, 'BC1293', 1, 'Akebono'), -- competitor
|
||||||
|
(67890, '04465-06080', 4, 'Toyota') -- supersession (reemplaza a esta)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valores de `reference_type`
|
||||||
|
|
||||||
|
| id_ref_type | Nombre | Uso |
|
||||||
|
|------------|--------|-----|
|
||||||
|
| 1 | competitor | Número equivalente de competidor |
|
||||||
|
| 2 | interchange | Intercambio directo compatible |
|
||||||
|
| 3 | oem_alternate | Número OEM alterno del mismo fabricante |
|
||||||
|
| 4 | supersession | El número OEM que esta pieza reemplaza |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Alta de Materiales
|
||||||
|
|
||||||
|
Si necesitas especificar el material de una pieza:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Agregar materiales
|
||||||
|
INSERT INTO materials (name_material) VALUES
|
||||||
|
('Steel'),
|
||||||
|
('Aluminum'),
|
||||||
|
('Ceramic'),
|
||||||
|
('Rubber'),
|
||||||
|
('Cast Iron'),
|
||||||
|
('Stainless Steel'),
|
||||||
|
('Copper'),
|
||||||
|
('Plastic')
|
||||||
|
ON CONFLICT (name_material) DO NOTHING;
|
||||||
|
|
||||||
|
-- Luego, al crear una pieza, usar el id_material correspondiente:
|
||||||
|
-- SELECT id_material FROM materials WHERE name_material = 'Ceramic';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Queries Útiles para Metabase (Dashboards)
|
||||||
|
|
||||||
|
### Vista completa de una pieza con todos sus datos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
p.oem_part_number AS "Número OEM",
|
||||||
|
p.name_part AS "Nombre EN",
|
||||||
|
p.name_es AS "Nombre ES",
|
||||||
|
pg.name_part_group AS "Grupo",
|
||||||
|
pc.name_part_category AS "Categoría",
|
||||||
|
p.weight_kg AS "Peso (kg)",
|
||||||
|
mat.name_material AS "Material",
|
||||||
|
COUNT(DISTINCT vp.model_year_engine_id) AS "Vehículos compatibles",
|
||||||
|
COUNT(DISTINCT ap.id_aftermarket_parts) AS "Opciones aftermarket",
|
||||||
|
COUNT(DISTINCT pcr.id_part_cross_ref) AS "Cross-references"
|
||||||
|
FROM parts p
|
||||||
|
JOIN part_groups pg ON p.group_id = pg.id_part_group
|
||||||
|
JOIN part_categories pc ON pg.category_id = pc.id_part_category
|
||||||
|
LEFT JOIN materials mat ON p.id_material = mat.id_material
|
||||||
|
LEFT JOIN vehicle_parts vp ON vp.part_id = p.id_part
|
||||||
|
LEFT JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
|
||||||
|
LEFT JOIN part_cross_references pcr ON pcr.part_id = p.id_part
|
||||||
|
GROUP BY p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
|
pg.name_part_group, pc.name_part_category, p.weight_kg, mat.name_material
|
||||||
|
ORDER BY pc.name_part_category, pg.name_part_group, p.name_part;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catálogo de piezas por vehículo
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
b.name_brand AS "Marca",
|
||||||
|
m.name_model AS "Modelo",
|
||||||
|
y.year_car AS "Año",
|
||||||
|
e.name_engine AS "Motor",
|
||||||
|
pc.name_part_category AS "Categoría",
|
||||||
|
pg.name_part_group AS "Grupo",
|
||||||
|
p.oem_part_number AS "# OEM",
|
||||||
|
p.name_part AS "Pieza",
|
||||||
|
vp.quantity_required AS "Cantidad",
|
||||||
|
pp.name_position_part AS "Posición"
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id_mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
JOIN parts p ON vp.part_id = p.id_part
|
||||||
|
JOIN part_groups pg ON p.group_id = pg.id_part_group
|
||||||
|
JOIN part_categories pc ON pg.category_id = pc.id_part_category
|
||||||
|
LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part
|
||||||
|
WHERE b.name_brand ILIKE '{{marca}}'
|
||||||
|
AND m.name_model ILIKE '{{modelo}}'
|
||||||
|
ORDER BY pc.display_order, pg.display_order, p.name_part;
|
||||||
|
```
|
||||||
|
|
||||||
|
> En Metabase, `{{marca}}` y `{{modelo}}` se convierten en filtros interactivos.
|
||||||
|
|
||||||
|
### Piezas aftermarket con precios
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
p.oem_part_number AS "OEM #",
|
||||||
|
p.name_part AS "Pieza OEM",
|
||||||
|
mfr.name_manufacture AS "Fabricante",
|
||||||
|
ap.part_number AS "# Aftermarket",
|
||||||
|
ap.name_aftermarket_parts AS "Nombre",
|
||||||
|
qt.name_quality AS "Calidad",
|
||||||
|
ap.price_usd AS "Precio USD",
|
||||||
|
ap.warranty_months AS "Garantía (meses)"
|
||||||
|
FROM aftermarket_parts ap
|
||||||
|
JOIN parts p ON ap.oem_part_id = p.id_part
|
||||||
|
JOIN manufacturers mfr ON ap.manufacturer_id = mfr.id_manufacture
|
||||||
|
LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier
|
||||||
|
ORDER BY p.oem_part_number, qt.name_quality DESC, ap.price_usd;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-references de una pieza
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
p.oem_part_number AS "OEM #",
|
||||||
|
p.name_part AS "Pieza",
|
||||||
|
pcr.cross_reference_number AS "# Referencia",
|
||||||
|
rt.name_ref_type AS "Tipo",
|
||||||
|
pcr.source_ref AS "Fuente",
|
||||||
|
pcr.notes AS "Notas"
|
||||||
|
FROM part_cross_references pcr
|
||||||
|
JOIN parts p ON pcr.part_id = p.id_part
|
||||||
|
LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type
|
||||||
|
WHERE p.oem_part_number = '{{numero_oem}}'
|
||||||
|
ORDER BY rt.name_ref_type, pcr.cross_reference_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estadísticas generales
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM parts) AS "Total piezas",
|
||||||
|
(SELECT COUNT(*) FROM vehicle_parts) AS "Total fitments",
|
||||||
|
(SELECT COUNT(*) FROM aftermarket_parts) AS "Total aftermarket",
|
||||||
|
(SELECT COUNT(*) FROM part_cross_references) AS "Total cross-refs",
|
||||||
|
(SELECT COUNT(*) FROM manufacturers) AS "Total fabricantes",
|
||||||
|
(SELECT COUNT(DISTINCT model_year_engine_id) FROM vehicle_parts) AS "Vehículos con piezas";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Flujo Completo: Ejemplo Paso a Paso
|
||||||
|
|
||||||
|
### Dar de alta "Balata delantera Toyota Camry 2020"
|
||||||
|
|
||||||
|
**Paso 1:** Agregar la pieza OEM
|
||||||
|
```sql
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, name_es, group_id)
|
||||||
|
VALUES ('04465-06090', 'Front Brake Pad Set', 'Juego Balatas Delanteras', 16)
|
||||||
|
RETURNING id_part;
|
||||||
|
-- Resultado: id_part = 1 (anotar este ID)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 2:** Buscar los vehículos compatibles
|
||||||
|
```sql
|
||||||
|
SELECT mye.id_mye, y.year_car, e.name_engine
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
WHERE b.name_brand = 'TOYOTA' AND m.name_model = 'Camry'
|
||||||
|
AND y.year_car BETWEEN 2018 AND 2023;
|
||||||
|
-- Resultado: lista de id_mye para cada configuración
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 3:** Crear los fitments
|
||||||
|
```sql
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, id_position_part)
|
||||||
|
SELECT mye.id_mye, 1, 1, 1 -- id_part=1, qty=1, position=front
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
WHERE b.name_brand = 'TOYOTA' AND m.name_model = 'Camry'
|
||||||
|
AND y.year_car BETWEEN 2018 AND 2023
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 4:** Agregar opciones aftermarket
|
||||||
|
```sql
|
||||||
|
INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, id_quality_tier, price_usd, warranty_months) VALUES
|
||||||
|
(1, 3, 'CXD1293', 'Premium Ceramic Pad', 3, 45.99, 24),
|
||||||
|
(1, 5, 'MKD1293', 'Economy Brake Pad', 1, 22.50, 12);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 5:** Agregar cross-references
|
||||||
|
```sql
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, id_ref_type, source_ref) VALUES
|
||||||
|
(1, 'D1293', 2, 'Wagner'),
|
||||||
|
(1, 'BC1293', 1, 'Akebono'),
|
||||||
|
(1, '04465-06100', 3, 'Toyota OEM Alternate');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Notas Importantes
|
||||||
|
|
||||||
|
### Restricciones únicas (evitan duplicados)
|
||||||
|
- `parts`: No tiene constraint único en `oem_part_number` (puede haber mismo OEM en diferentes grupos)
|
||||||
|
- `vehicle_parts`: Único por `(model_year_engine_id, part_id, id_position_part)`
|
||||||
|
- `vehicle_diagrams`: Único por `(diagram_id, model_year_engine_id)`
|
||||||
|
- `model_year_engine`: Único por `(model_id, year_id, engine_id, trim_level)`
|
||||||
|
|
||||||
|
### Búsqueda full-text
|
||||||
|
La tabla `parts` tiene un trigger que auto-genera `search_vector` al insertar/actualizar. Esto permite búsquedas rápidas en español:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM parts
|
||||||
|
WHERE search_vector @@ plainto_tsquery('spanish', 'balata delantera');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Para agregar nuevas categorías o grupos
|
||||||
|
```sql
|
||||||
|
-- Nueva categoría
|
||||||
|
INSERT INTO part_categories (name_part_category, name_es, display_order)
|
||||||
|
VALUES ('Interior', 'Interior', 13);
|
||||||
|
|
||||||
|
-- Nuevo grupo dentro de una categoría
|
||||||
|
INSERT INTO part_groups (category_id, name_part_group, name_es, display_order)
|
||||||
|
VALUES (1, 'Headlights', 'Faros Delanteros', 10);
|
||||||
|
-- (category_id 1 = Body & Lamp Assembly)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Para agregar un país (para fabricantes)
|
||||||
|
```sql
|
||||||
|
INSERT INTO countries (name_country) VALUES ('Italy')
|
||||||
|
ON CONFLICT (name_country) DO NOTHING;
|
||||||
|
```
|
||||||
84
docs/plans/2026-03-01-captura-partes-design.md
Normal file
84
docs/plans/2026-03-01-captura-partes-design.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Captura de Partes OEM — Diseño
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
App web de captura de datos para 3 capturistas que trabajan en pipeline:
|
||||||
|
1. **Capturista OEM** — registra partes OEM por vehículo
|
||||||
|
2. **Capturista Intercambios** — agrega aftermarket por pieza OEM
|
||||||
|
3. **Capturista Imágenes** — sube fotos por pieza OEM
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
- Frontend: HTML/CSS/JS vanilla (una sola página con 3 tabs)
|
||||||
|
- Backend: API Flask existente en `server.py` (endpoints `/api/admin/*`)
|
||||||
|
- Base de datos: PostgreSQL `nexus_autoparts`
|
||||||
|
- Almacenamiento imágenes: `/home/Autopartes/dashboard/static/parts/`
|
||||||
|
|
||||||
|
## Sección 1: Captura OEM
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Capturista ve lista de vehículos pendientes (sin partes OEM)
|
||||||
|
2. Filtra por marca/modelo, elige un vehículo
|
||||||
|
3. Ve tabla con 12 categorías / 63 grupos
|
||||||
|
4. Por cada grupo, puede [+ Agregar pieza]: # OEM, nombre, cantidad
|
||||||
|
5. Guarda fila por fila (POST /api/admin/parts + POST /api/admin/fitment)
|
||||||
|
6. Marca vehículo como "Terminado" → desaparece de pendientes
|
||||||
|
|
||||||
|
### Estado de vehículo
|
||||||
|
- **Pendiente**: 0 partes registradas
|
||||||
|
- **En progreso**: tiene partes pero no marcado terminado
|
||||||
|
- **Terminado**: marcado explícitamente por capturista
|
||||||
|
|
||||||
|
### Lógica de guardado
|
||||||
|
1. POST /api/admin/parts → crea pieza OEM → obtiene part_id
|
||||||
|
2. POST /api/admin/fitment → vincula pieza a vehículo (mye_id + part_id)
|
||||||
|
3. Si OEM ya existe en DB, reutilizar part_id existente
|
||||||
|
|
||||||
|
## Sección 2: Captura Intercambios
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Ve lista de piezas OEM sin aftermarket
|
||||||
|
2. Selecciona una pieza → ve su info OEM
|
||||||
|
3. Agrega intercambios: fabricante, # aftermarket, calidad, precio, garantía
|
||||||
|
4. POST /api/admin/aftermarket
|
||||||
|
5. Siguiente pieza
|
||||||
|
|
||||||
|
### Campos por intercambio
|
||||||
|
- manufacturer_id (dropdown de fabricantes)
|
||||||
|
- part_number (texto)
|
||||||
|
- name (texto)
|
||||||
|
- quality_tier (economy/standard/oem/premium)
|
||||||
|
- price_usd (número)
|
||||||
|
- warranty_months (número)
|
||||||
|
|
||||||
|
## Sección 3: Captura Imágenes
|
||||||
|
|
||||||
|
### Flujo
|
||||||
|
1. Ve lista de piezas OEM sin imagen
|
||||||
|
2. Selecciona una pieza → ve su info
|
||||||
|
3. Sube archivo de imagen (jpg/png/webp)
|
||||||
|
4. Se guarda en /static/parts/{oem_number}.{ext}
|
||||||
|
5. Se actualiza campo image_url en tabla parts
|
||||||
|
|
||||||
|
### Restricciones
|
||||||
|
- Máximo 2MB por imagen
|
||||||
|
- Formatos: jpg, png, webp
|
||||||
|
- Se redimensiona a 800x800 max en el servidor (si es necesario)
|
||||||
|
|
||||||
|
## Nuevos endpoints necesarios
|
||||||
|
|
||||||
|
### Estado de vehículos
|
||||||
|
- GET /api/captura/vehicles/pending — vehículos sin partes
|
||||||
|
- GET /api/captura/vehicles/in-progress — con partes pero no terminados
|
||||||
|
- POST /api/captura/vehicles/{mye_id}/complete — marcar terminado
|
||||||
|
|
||||||
|
### Piezas para intercambios
|
||||||
|
- GET /api/captura/parts/without-aftermarket — piezas sin intercambio
|
||||||
|
|
||||||
|
### Piezas para imágenes
|
||||||
|
- GET /api/captura/parts/without-image — piezas sin foto
|
||||||
|
- POST /api/captura/parts/{part_id}/image — subir imagen
|
||||||
|
|
||||||
|
## Archivos
|
||||||
|
- `/home/Autopartes/dashboard/captura.html` — página principal
|
||||||
|
- `/home/Autopartes/dashboard/captura.js` — lógica de las 3 secciones
|
||||||
|
- `/home/Autopartes/dashboard/captura.css` — estilos específicos
|
||||||
|
- Ruta en server.py: `/captura` → sirve captura.html
|
||||||
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal file
130
docs/plans/2026-03-02-pos-cuentas-design.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Punto de Venta + Cuentas por Cobrar — Diseño
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
Sistema de punto de venta integrado al catálogo Nexus Autoparts con:
|
||||||
|
- Ventas de partes OEM y aftermarket
|
||||||
|
- Facturación con datos fiscales (RFC, IVA, folio consecutivo)
|
||||||
|
- Cuentas a crédito para clientes frecuentes
|
||||||
|
- Pagos: abonos parciales y pago al corte
|
||||||
|
- Precios calculados por costo + margen configurable
|
||||||
|
|
||||||
|
## Tablas nuevas
|
||||||
|
|
||||||
|
### customers
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_customer | SERIAL PK | |
|
||||||
|
| name | VARCHAR(200) | Nombre del cliente |
|
||||||
|
| rfc | VARCHAR(13) | RFC fiscal |
|
||||||
|
| business_name | VARCHAR(300) | Razón social |
|
||||||
|
| email | VARCHAR(200) | |
|
||||||
|
| phone | VARCHAR(20) | |
|
||||||
|
| address | TEXT | Dirección fiscal |
|
||||||
|
| credit_limit | DECIMAL(12,2) | Límite de crédito |
|
||||||
|
| balance | DECIMAL(12,2) DEFAULT 0 | Saldo actual (lo que debe) |
|
||||||
|
| payment_terms | INTEGER DEFAULT 30 | Días de crédito |
|
||||||
|
| active | BOOLEAN DEFAULT TRUE | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
### invoices
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_invoice | SERIAL PK | |
|
||||||
|
| customer_id | INTEGER FK customers | |
|
||||||
|
| folio | VARCHAR(20) UNIQUE | Folio consecutivo (ej: NX-000001) |
|
||||||
|
| date_issued | TIMESTAMP DEFAULT NOW() | Fecha emisión |
|
||||||
|
| subtotal | DECIMAL(12,2) | Sin IVA |
|
||||||
|
| tax_rate | DECIMAL(5,4) DEFAULT 0.16 | Tasa IVA |
|
||||||
|
| tax_amount | DECIMAL(12,2) | Monto IVA |
|
||||||
|
| total | DECIMAL(12,2) | Total con IVA |
|
||||||
|
| amount_paid | DECIMAL(12,2) DEFAULT 0 | Total abonado |
|
||||||
|
| status | VARCHAR(20) DEFAULT 'pending' | pending/partial/paid/cancelled |
|
||||||
|
| notes | TEXT | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
### invoice_items
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_invoice_item | SERIAL PK | |
|
||||||
|
| invoice_id | INTEGER FK invoices | |
|
||||||
|
| part_id | INTEGER FK parts (nullable) | Pieza OEM |
|
||||||
|
| aftermarket_id | INTEGER FK aftermarket_parts (nullable) | Pieza aftermarket |
|
||||||
|
| description | VARCHAR(500) | Descripción de la línea |
|
||||||
|
| quantity | INTEGER DEFAULT 1 | |
|
||||||
|
| unit_cost | DECIMAL(12,2) | Costo unitario |
|
||||||
|
| margin_pct | DECIMAL(5,2) | Margen % aplicado |
|
||||||
|
| unit_price | DECIMAL(12,2) | Precio de venta unitario |
|
||||||
|
| line_total | DECIMAL(12,2) | quantity * unit_price |
|
||||||
|
|
||||||
|
### payments
|
||||||
|
| Columna | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id_payment | SERIAL PK | |
|
||||||
|
| customer_id | INTEGER FK customers | |
|
||||||
|
| invoice_id | INTEGER FK invoices (nullable) | Si aplica a factura específica |
|
||||||
|
| amount | DECIMAL(12,2) | Monto del pago |
|
||||||
|
| payment_method | VARCHAR(20) | efectivo/transferencia/cheque/tarjeta |
|
||||||
|
| reference | VARCHAR(100) | # referencia del pago |
|
||||||
|
| date_payment | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
| notes | TEXT | |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
## Columnas nuevas en tablas existentes
|
||||||
|
|
||||||
|
### parts
|
||||||
|
- `cost_usd DECIMAL(12,2)` — costo de la pieza
|
||||||
|
|
||||||
|
### aftermarket_parts
|
||||||
|
- `cost_usd DECIMAL(12,2)` — costo de la pieza aftermarket
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
- Margen default: 30% (configurable)
|
||||||
|
- IVA: 16%
|
||||||
|
- Folio format: NX-XXXXXX (consecutivo)
|
||||||
|
|
||||||
|
## Páginas web nuevas
|
||||||
|
|
||||||
|
### /pos — Punto de Venta
|
||||||
|
- Selector de cliente (buscador + crear nuevo)
|
||||||
|
- Buscador de partes (OEM y aftermarket)
|
||||||
|
- Carrito con líneas editables (costo, margen, precio)
|
||||||
|
- Botón facturar → genera factura con folio
|
||||||
|
|
||||||
|
### /cuentas — Cuentas por Cobrar
|
||||||
|
- Lista de clientes con saldos
|
||||||
|
- Detalle de cliente: facturas pendientes, historial pagos
|
||||||
|
- Registrar pago/abono
|
||||||
|
- Estado de cuenta imprimible
|
||||||
|
|
||||||
|
## Endpoints API nuevos
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
- GET /api/pos/customers — listar clientes
|
||||||
|
- GET /api/pos/customers/:id — detalle cliente con saldo
|
||||||
|
- POST /api/pos/customers — crear cliente
|
||||||
|
- PUT /api/pos/customers/:id — editar cliente
|
||||||
|
|
||||||
|
### Facturas
|
||||||
|
- GET /api/pos/invoices — listar facturas (filtros: cliente, status, fecha)
|
||||||
|
- GET /api/pos/invoices/:id — detalle factura con líneas
|
||||||
|
- POST /api/pos/invoices — crear factura (con líneas)
|
||||||
|
- PUT /api/pos/invoices/:id/cancel — cancelar factura
|
||||||
|
|
||||||
|
### Pagos
|
||||||
|
- GET /api/pos/payments — listar pagos
|
||||||
|
- POST /api/pos/payments — registrar pago/abono
|
||||||
|
- GET /api/pos/customers/:id/statement — estado de cuenta
|
||||||
|
|
||||||
|
## Flujos
|
||||||
|
|
||||||
|
### Venta
|
||||||
|
1. Seleccionar/crear cliente
|
||||||
|
2. Buscar partes → agregar al carrito
|
||||||
|
3. Ajustar margen si necesario
|
||||||
|
4. Facturar → se crea invoice + items, se suma al balance del cliente
|
||||||
|
|
||||||
|
### Pago
|
||||||
|
1. Buscar cliente → ver saldo y facturas pendientes
|
||||||
|
2. Registrar pago (monto, método, referencia)
|
||||||
|
3. Se aplica a factura o como abono general
|
||||||
|
4. Se actualiza balance del cliente
|
||||||
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal file
722
docs/plans/2026-03-02-pos-cuentas-plan.md
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
# POS + Cuentas por Cobrar — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a Point of Sale with credit accounts, invoicing with tax data, and payment tracking to the Nexus Autoparts system.
|
||||||
|
|
||||||
|
**Architecture:** New PostgreSQL tables (customers, invoices, invoice_items, payments) + API endpoints in server.py + two new pages (pos.html, cuentas.html). Prices are cost + configurable margin. Customer balances are maintained via triggers on invoice/payment inserts.
|
||||||
|
|
||||||
|
**Tech Stack:** Flask, PostgreSQL, SQLAlchemy raw SQL via `text()`, vanilla HTML/CSS/JS (same stack as existing app).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Database schema — Create new tables and columns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py` (no changes yet, just DB)
|
||||||
|
|
||||||
|
**Step 1: Add cost_usd columns and create POS tables**
|
||||||
|
|
||||||
|
Run this SQL via Python script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# /home/Autopartes/setup_pos_tables.py
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
from config import DB_URL
|
||||||
|
|
||||||
|
engine = create_engine(DB_URL)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("""
|
||||||
|
-- Add cost columns
|
||||||
|
ALTER TABLE parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
|
||||||
|
ALTER TABLE aftermarket_parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2);
|
||||||
|
|
||||||
|
-- Customers
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id_customer SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
rfc VARCHAR(13),
|
||||||
|
business_name VARCHAR(300),
|
||||||
|
email VARCHAR(200),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
address TEXT,
|
||||||
|
credit_limit DECIMAL(12,2) DEFAULT 0,
|
||||||
|
balance DECIMAL(12,2) DEFAULT 0,
|
||||||
|
payment_terms INTEGER DEFAULT 30,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invoices
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id_invoice SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
|
||||||
|
folio VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
date_issued TIMESTAMP DEFAULT NOW(),
|
||||||
|
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,4) DEFAULT 0.16,
|
||||||
|
tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
amount_paid DECIMAL(12,2) DEFAULT 0,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invoice items
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
|
id_invoice_item SERIAL PRIMARY KEY,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id_invoice) ON DELETE CASCADE,
|
||||||
|
part_id INTEGER REFERENCES parts(id_part),
|
||||||
|
aftermarket_id INTEGER REFERENCES aftermarket_parts(id_aftermarket_parts),
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
unit_cost DECIMAL(12,2) DEFAULT 0,
|
||||||
|
margin_pct DECIMAL(5,2) DEFAULT 30,
|
||||||
|
unit_price DECIMAL(12,2) NOT NULL,
|
||||||
|
line_total DECIMAL(12,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payments
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id_payment SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id_customer),
|
||||||
|
invoice_id INTEGER REFERENCES invoices(id_invoice),
|
||||||
|
amount DECIMAL(12,2) NOT NULL,
|
||||||
|
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
||||||
|
reference VARCHAR(100),
|
||||||
|
date_payment TIMESTAMP DEFAULT NOW(),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_folio ON invoices(folio);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id);
|
||||||
|
|
||||||
|
-- Folio sequence
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS invoice_folio_seq START 1;
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
print("POS tables created successfully")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run the script**
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes && python3 setup_pos_tables.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify**
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from config import DB_URL
|
||||||
|
engine = create_engine(DB_URL)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for t in ['customers','invoices','invoice_items','payments']:
|
||||||
|
cols = conn.execute(text(f\"SELECT column_name FROM information_schema.columns WHERE table_name='{t}' ORDER BY ordinal_position\")).fetchall()
|
||||||
|
print(f'{t}: {[c[0] for c in cols]}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add setup_pos_tables.py && git commit -m "feat(pos): add POS database schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: API endpoints — Customers CRUD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py` — insert before Main Block (line ~2672)
|
||||||
|
|
||||||
|
**Step 1: Add customer endpoints**
|
||||||
|
|
||||||
|
Insert these endpoints before the `# Main Block` comment in server.py:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ============================================================================
|
||||||
|
# POS (Point of Sale) Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/pos')
|
||||||
|
def pos_page():
|
||||||
|
return send_from_directory('.', 'pos.html')
|
||||||
|
|
||||||
|
@app.route('/pos.js')
|
||||||
|
def pos_js():
|
||||||
|
return send_from_directory('.', 'pos.js')
|
||||||
|
|
||||||
|
@app.route('/pos.css')
|
||||||
|
def pos_css():
|
||||||
|
return send_from_directory('.', 'pos.css')
|
||||||
|
|
||||||
|
@app.route('/cuentas')
|
||||||
|
def cuentas_page():
|
||||||
|
return send_from_directory('.', 'cuentas.html')
|
||||||
|
|
||||||
|
@app.route('/cuentas.js')
|
||||||
|
def cuentas_js():
|
||||||
|
return send_from_directory('.', 'cuentas.js')
|
||||||
|
|
||||||
|
@app.route('/cuentas.css')
|
||||||
|
def cuentas_css():
|
||||||
|
return send_from_directory('.', 'cuentas.css')
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Customers ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers')
|
||||||
|
def api_pos_customers():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
filters = ["active = TRUE"]
|
||||||
|
params = {'limit': per_page, 'offset': offset}
|
||||||
|
if search:
|
||||||
|
filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)")
|
||||||
|
params['search'] = f'%{search}%'
|
||||||
|
|
||||||
|
where = ' AND '.join(filters)
|
||||||
|
total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar()
|
||||||
|
|
||||||
|
rows = session.execute(text(f"""
|
||||||
|
SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms
|
||||||
|
FROM customers WHERE {where}
|
||||||
|
ORDER BY name LIMIT :limit OFFSET :offset
|
||||||
|
"""), params).mappings().all()
|
||||||
|
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
|
||||||
|
'page': page, 'per_page': per_page, 'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page
|
||||||
|
}})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>')
|
||||||
|
def api_pos_customer_detail(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
row = session.execute(text(
|
||||||
|
"SELECT * FROM customers WHERE id_customer = :id"
|
||||||
|
), {'id': customer_id}).mappings().first()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
return jsonify(dict(row))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers', methods=['POST'])
|
||||||
|
def api_pos_create_customer():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms)
|
||||||
|
VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms)
|
||||||
|
RETURNING id_customer
|
||||||
|
"""), {
|
||||||
|
'name': data['name'], 'rfc': data.get('rfc'),
|
||||||
|
'business_name': data.get('business_name'),
|
||||||
|
'email': data.get('email'), 'phone': data.get('phone'),
|
||||||
|
'address': data.get('address'),
|
||||||
|
'credit_limit': data.get('credit_limit', 0),
|
||||||
|
'payment_terms': data.get('payment_terms', 30)
|
||||||
|
})
|
||||||
|
new_id = result.scalar()
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': new_id, 'message': 'Cliente creado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>', methods=['PUT'])
|
||||||
|
def api_pos_update_customer(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
session.execute(text("""
|
||||||
|
UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name,
|
||||||
|
email = :email, phone = :phone, address = :address,
|
||||||
|
credit_limit = :credit_limit, payment_terms = :payment_terms
|
||||||
|
WHERE id_customer = :id
|
||||||
|
"""), {
|
||||||
|
'name': data['name'], 'rfc': data.get('rfc'),
|
||||||
|
'business_name': data.get('business_name'),
|
||||||
|
'email': data.get('email'), 'phone': data.get('phone'),
|
||||||
|
'address': data.get('address'),
|
||||||
|
'credit_limit': data.get('credit_limit', 0),
|
||||||
|
'payment_terms': data.get('payment_terms', 30),
|
||||||
|
'id': customer_id
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'message': 'Cliente actualizado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify routes load**
|
||||||
|
```bash
|
||||||
|
cd /home/Autopartes/dashboard && python3 -c "import server; [print(r.rule) for r in server.app.url_map.iter_rules() if 'pos' in r.rule]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add customer CRUD endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API endpoints — Invoices and invoice items
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py`
|
||||||
|
|
||||||
|
**Step 1: Add invoice endpoints** (insert after customer endpoints, before Main Block)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Invoices ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices')
|
||||||
|
def api_pos_invoices():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
customer_id = request.args.get('customer_id', '')
|
||||||
|
status = request.args.get('status', '')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 100)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
filters = ["1=1"]
|
||||||
|
params = {'limit': per_page, 'offset': offset}
|
||||||
|
if customer_id:
|
||||||
|
filters.append("i.customer_id = :customer_id")
|
||||||
|
params['customer_id'] = int(customer_id)
|
||||||
|
if status:
|
||||||
|
filters.append("i.status = :status")
|
||||||
|
params['status'] = status
|
||||||
|
|
||||||
|
where = ' AND '.join(filters)
|
||||||
|
total = session.execute(text(f"""
|
||||||
|
SELECT COUNT(*) FROM invoices i WHERE {where}
|
||||||
|
"""), params).scalar()
|
||||||
|
|
||||||
|
rows = session.execute(text(f"""
|
||||||
|
SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount,
|
||||||
|
i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc
|
||||||
|
FROM invoices i
|
||||||
|
JOIN customers c ON i.customer_id = c.id_customer
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY i.date_issued DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""), params).mappings().all()
|
||||||
|
return jsonify({'data': [dict(r) for r in rows], 'pagination': {
|
||||||
|
'page': page, 'per_page': per_page, 'total': total,
|
||||||
|
'total_pages': (total + per_page - 1) // per_page
|
||||||
|
}})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices/<int:invoice_id>')
|
||||||
|
def api_pos_invoice_detail(invoice_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
inv = session.execute(text("""
|
||||||
|
SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address
|
||||||
|
FROM invoices i JOIN customers c ON i.customer_id = c.id_customer
|
||||||
|
WHERE i.id_invoice = :id
|
||||||
|
"""), {'id': invoice_id}).mappings().first()
|
||||||
|
if not inv:
|
||||||
|
return jsonify({'error': 'Factura no encontrada'}), 404
|
||||||
|
|
||||||
|
items = session.execute(text("""
|
||||||
|
SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number
|
||||||
|
FROM invoice_items ii
|
||||||
|
LEFT JOIN parts p ON ii.part_id = p.id_part
|
||||||
|
LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts
|
||||||
|
WHERE ii.invoice_id = :id
|
||||||
|
ORDER BY ii.id_invoice_item
|
||||||
|
"""), {'id': invoice_id}).mappings().all()
|
||||||
|
|
||||||
|
return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices', methods=['POST'])
|
||||||
|
def api_pos_create_invoice():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
customer_id = data['customer_id']
|
||||||
|
items = data['items'] # [{part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price}]
|
||||||
|
tax_rate = data.get('tax_rate', 0.16)
|
||||||
|
notes = data.get('notes', '')
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return jsonify({'error': 'La factura debe tener al menos una línea'}), 400
|
||||||
|
|
||||||
|
# Generate folio
|
||||||
|
folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar()
|
||||||
|
folio = f"NX-{folio_num:06d}"
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
subtotal = sum(it['quantity'] * it['unit_price'] for it in items)
|
||||||
|
tax_amount = round(subtotal * tax_rate, 2)
|
||||||
|
total = round(subtotal + tax_amount, 2)
|
||||||
|
|
||||||
|
# Create invoice
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes)
|
||||||
|
VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes)
|
||||||
|
RETURNING id_invoice
|
||||||
|
"""), {
|
||||||
|
'customer_id': customer_id, 'folio': folio,
|
||||||
|
'subtotal': subtotal, 'tax_rate': tax_rate,
|
||||||
|
'tax_amount': tax_amount, 'total': total, 'notes': notes
|
||||||
|
})
|
||||||
|
invoice_id = result.scalar()
|
||||||
|
|
||||||
|
# Create items
|
||||||
|
for it in items:
|
||||||
|
line_total = it['quantity'] * it['unit_price']
|
||||||
|
session.execute(text("""
|
||||||
|
INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description,
|
||||||
|
quantity, unit_cost, margin_pct, unit_price, line_total)
|
||||||
|
VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total)
|
||||||
|
"""), {
|
||||||
|
'inv_id': invoice_id,
|
||||||
|
'part_id': it.get('part_id'),
|
||||||
|
'af_id': it.get('aftermarket_id'),
|
||||||
|
'desc': it['description'],
|
||||||
|
'qty': it['quantity'],
|
||||||
|
'cost': it.get('unit_cost', 0),
|
||||||
|
'margin': it.get('margin_pct', 30),
|
||||||
|
'price': it['unit_price'],
|
||||||
|
'total': line_total
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update customer balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance + :total WHERE id_customer = :id"
|
||||||
|
), {'total': total, 'id': customer_id})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/invoices/<int:invoice_id>/cancel', methods=['PUT'])
|
||||||
|
def api_pos_cancel_invoice(invoice_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
inv = session.execute(text(
|
||||||
|
"SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id"
|
||||||
|
), {'id': invoice_id}).mappings().first()
|
||||||
|
if not inv:
|
||||||
|
return jsonify({'error': 'Factura no encontrada'}), 404
|
||||||
|
if inv['status'] == 'cancelled':
|
||||||
|
return jsonify({'error': 'La factura ya está cancelada'}), 400
|
||||||
|
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id"
|
||||||
|
), {'id': invoice_id})
|
||||||
|
|
||||||
|
# Reverse the balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance - :total WHERE id_customer = :cid"
|
||||||
|
), {'total': inv['total'], 'cid': inv['customer_id']})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'message': 'Factura cancelada'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add invoice endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: API endpoints — Payments and statements
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/server.py`
|
||||||
|
|
||||||
|
**Step 1: Add payment endpoints** (insert after invoice endpoints)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Payments ----
|
||||||
|
|
||||||
|
@app.route('/api/pos/payments', methods=['POST'])
|
||||||
|
def api_pos_create_payment():
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
customer_id = data['customer_id']
|
||||||
|
amount = float(data['amount'])
|
||||||
|
payment_method = data.get('payment_method', 'efectivo')
|
||||||
|
reference = data.get('reference')
|
||||||
|
invoice_id = data.get('invoice_id')
|
||||||
|
notes = data.get('notes')
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
return jsonify({'error': 'El monto debe ser mayor a 0'}), 400
|
||||||
|
|
||||||
|
result = session.execute(text("""
|
||||||
|
INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes)
|
||||||
|
VALUES (:cid, :inv_id, :amount, :method, :ref, :notes)
|
||||||
|
RETURNING id_payment
|
||||||
|
"""), {
|
||||||
|
'cid': customer_id, 'inv_id': invoice_id,
|
||||||
|
'amount': amount, 'method': payment_method,
|
||||||
|
'ref': reference, 'notes': notes
|
||||||
|
})
|
||||||
|
payment_id = result.scalar()
|
||||||
|
|
||||||
|
# Update customer balance
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE customers SET balance = balance - :amount WHERE id_customer = :id"
|
||||||
|
), {'amount': amount, 'id': customer_id})
|
||||||
|
|
||||||
|
# If applied to specific invoice, update its amount_paid and status
|
||||||
|
if invoice_id:
|
||||||
|
session.execute(text(
|
||||||
|
"UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id"
|
||||||
|
), {'amount': amount, 'id': invoice_id})
|
||||||
|
# Update invoice status
|
||||||
|
session.execute(text("""
|
||||||
|
UPDATE invoices SET status = CASE
|
||||||
|
WHEN amount_paid >= total THEN 'paid'
|
||||||
|
WHEN amount_paid > 0 THEN 'partial'
|
||||||
|
ELSE 'pending'
|
||||||
|
END WHERE id_invoice = :id
|
||||||
|
"""), {'id': invoice_id})
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return jsonify({'id': payment_id, 'message': 'Pago registrado'})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/customers/<int:customer_id>/statement')
|
||||||
|
def api_pos_customer_statement(customer_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
customer = session.execute(text(
|
||||||
|
"SELECT * FROM customers WHERE id_customer = :id"
|
||||||
|
), {'id': customer_id}).mappings().first()
|
||||||
|
if not customer:
|
||||||
|
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||||
|
|
||||||
|
invoices = session.execute(text("""
|
||||||
|
SELECT id_invoice, folio, date_issued, total, amount_paid, status
|
||||||
|
FROM invoices WHERE customer_id = :id AND status != 'cancelled'
|
||||||
|
ORDER BY date_issued DESC LIMIT 100
|
||||||
|
"""), {'id': customer_id}).mappings().all()
|
||||||
|
|
||||||
|
payments = session.execute(text("""
|
||||||
|
SELECT p.id_payment, p.amount, p.payment_method, p.reference,
|
||||||
|
p.date_payment, p.notes, i.folio AS invoice_folio
|
||||||
|
FROM payments p
|
||||||
|
LEFT JOIN invoices i ON p.invoice_id = i.id_invoice
|
||||||
|
WHERE p.customer_id = :id
|
||||||
|
ORDER BY p.date_payment DESC LIMIT 100
|
||||||
|
"""), {'id': customer_id}).mappings().all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'customer': dict(customer),
|
||||||
|
'invoices': [dict(i) for i in invoices],
|
||||||
|
'payments': [dict(p) for p in payments]
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/pos/search-parts')
|
||||||
|
def api_pos_search_parts():
|
||||||
|
"""Search parts for the POS cart — returns OEM and aftermarket with prices."""
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
q = request.args.get('q', '')
|
||||||
|
if len(q) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Search OEM parts
|
||||||
|
oem = session.execute(text("""
|
||||||
|
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
|
||||||
|
p.cost_usd, pg.name_part_group AS group_name,
|
||||||
|
'oem' AS part_type
|
||||||
|
FROM parts p
|
||||||
|
JOIN part_groups pg ON p.group_id = pg.id_part_group
|
||||||
|
WHERE p.oem_part_number ILIKE :q OR p.name_part ILIKE :q
|
||||||
|
ORDER BY p.oem_part_number LIMIT 20
|
||||||
|
"""), {'q': f'%{q}%'}).mappings().all()
|
||||||
|
results.extend([dict(r) for r in oem])
|
||||||
|
|
||||||
|
# Search aftermarket parts
|
||||||
|
af = session.execute(text("""
|
||||||
|
SELECT ap.id_aftermarket_parts AS id_part, ap.part_number AS oem_part_number,
|
||||||
|
ap.name_aftermarket_parts AS name_part, ap.name_es,
|
||||||
|
COALESCE(ap.cost_usd, ap.price_usd) AS cost_usd,
|
||||||
|
m.name_manufacture AS group_name,
|
||||||
|
'aftermarket' AS part_type
|
||||||
|
FROM aftermarket_parts ap
|
||||||
|
JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture
|
||||||
|
WHERE ap.part_number ILIKE :q OR ap.name_aftermarket_parts ILIKE :q
|
||||||
|
ORDER BY ap.part_number LIMIT 20
|
||||||
|
"""), {'q': f'%{q}%'}).mappings().all()
|
||||||
|
results.extend([dict(r) for r in af])
|
||||||
|
|
||||||
|
return jsonify(results)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/server.py && git commit -m "feat(pos): add payment and search endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Frontend — POS page (pos.html + pos.css + pos.js)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.html`
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.css`
|
||||||
|
- Create: `/home/Autopartes/dashboard/pos.js`
|
||||||
|
|
||||||
|
**Step 1: Create pos.html**
|
||||||
|
|
||||||
|
HTML page with:
|
||||||
|
- Customer selector (search + create new)
|
||||||
|
- Part search bar (searches OEM + aftermarket)
|
||||||
|
- Cart table (description, qty, cost, margin%, price, total)
|
||||||
|
- Totals section (subtotal, IVA 16%, total)
|
||||||
|
- "Facturar" button
|
||||||
|
|
||||||
|
**Step 2: Create pos.css**
|
||||||
|
|
||||||
|
Styles for the POS layout: 2-column (left=search+cart, right=customer info + totals).
|
||||||
|
|
||||||
|
**Step 3: Create pos.js**
|
||||||
|
|
||||||
|
JavaScript logic:
|
||||||
|
- Customer search and selection
|
||||||
|
- Part search → add to cart
|
||||||
|
- Editable margin per line
|
||||||
|
- Auto-calculate prices: `unit_price = cost * (1 + margin/100)`
|
||||||
|
- Totals: subtotal, IVA, total
|
||||||
|
- Facturar → POST /api/pos/invoices
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/pos.html dashboard/pos.css dashboard/pos.js
|
||||||
|
git commit -m "feat(pos): add point of sale frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Frontend — Cuentas page (cuentas.html + cuentas.css + cuentas.js)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.html`
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.css`
|
||||||
|
- Create: `/home/Autopartes/dashboard/cuentas.js`
|
||||||
|
|
||||||
|
**Step 1: Create cuentas.html**
|
||||||
|
|
||||||
|
HTML page with:
|
||||||
|
- Customer list with balances
|
||||||
|
- Customer detail: info card, pending invoices, payment history
|
||||||
|
- Payment form: amount, method, reference, apply to invoice
|
||||||
|
- Create/edit customer modal
|
||||||
|
|
||||||
|
**Step 2: Create cuentas.js**
|
||||||
|
|
||||||
|
JavaScript logic:
|
||||||
|
- Load customers with balances
|
||||||
|
- Customer detail view with statement
|
||||||
|
- Register payment → POST /api/pos/payments
|
||||||
|
- Create/edit customer form
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add dashboard/cuentas.html dashboard/cuentas.css dashboard/cuentas.js
|
||||||
|
git commit -m "feat(pos): add accounts receivable frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Navigation + final integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/home/Autopartes/dashboard/nav.js`
|
||||||
|
|
||||||
|
**Step 1: Add POS and Cuentas links to nav**
|
||||||
|
|
||||||
|
Add to the `navLinks` array and `isActive` function:
|
||||||
|
```javascript
|
||||||
|
// isActive:
|
||||||
|
if ((h === '/pos') && (p === '/pos')) return true;
|
||||||
|
if ((h === '/cuentas') && (p === '/cuentas')) return true;
|
||||||
|
|
||||||
|
// navLinks:
|
||||||
|
{ label: 'POS', href: '/pos' },
|
||||||
|
{ label: 'Cuentas', href: '/cuentas' },
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Test full flow**
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
nohup python3 /home/Autopartes/dashboard/server.py > /tmp/nexus-server.log 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Test customer creation
|
||||||
|
curl -s -X POST http://localhost:5000/api/pos/customers \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Taller Prueba","rfc":"TAL123456XX0","credit_limit":50000,"payment_terms":30}'
|
||||||
|
|
||||||
|
# Test page loads
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/pos
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/cuentas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Final commit**
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "feat(pos): complete POS and accounts system"
|
||||||
|
```
|
||||||
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal file
242
docs/plans/2026-03-15-saas-aftermarket-design.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Nexus Autoparts — SaaS + Aftermarket Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-15
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two features for Nexus Autoparts:
|
||||||
|
1. **SaaS user system** — auth, roles (ADMIN, OWNER, TALLER, BODEGA), warehouse inventory uploads with flexible column mapping, availability/pricing visible to authenticated talleres.
|
||||||
|
2. **Aftermarket parts cleanup** — migrate 357K AFT- prefixed parts from `parts` table into `aftermarket_parts`, link to OEM parts, fix import pipeline.
|
||||||
|
|
||||||
|
Architecture: **Monolith approach** — everything in the existing PostgreSQL DB and Flask backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Authentication & Users
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
**`roles`** (existing, update names):
|
||||||
|
|
||||||
|
| id_rol | name_rol |
|
||||||
|
|--------|----------|
|
||||||
|
| 1 | ADMIN |
|
||||||
|
| 2 | OWNER |
|
||||||
|
| 3 | TALLER |
|
||||||
|
| 4 | BODEGA |
|
||||||
|
|
||||||
|
**`users`** (existing, extend):
|
||||||
|
```
|
||||||
|
id_user (PK)
|
||||||
|
name_user (VARCHAR 200)
|
||||||
|
email (VARCHAR 200, UNIQUE)
|
||||||
|
pass (VARCHAR 200, bcrypt hash)
|
||||||
|
id_rol (FK → roles)
|
||||||
|
business_name (VARCHAR 200)
|
||||||
|
phone (VARCHAR 50)
|
||||||
|
address (TEXT)
|
||||||
|
is_active (BOOLEAN DEFAULT false)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
last_login (TIMESTAMP)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`sessions`** (new):
|
||||||
|
```
|
||||||
|
id_session (PK)
|
||||||
|
user_id (FK → users)
|
||||||
|
refresh_token (VARCHAR 500, UNIQUE)
|
||||||
|
expires_at (TIMESTAMP)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
- Login: `POST /api/auth/login` → JWT access token (15 min) + refresh token (30 days)
|
||||||
|
- Refresh: `POST /api/auth/refresh` → new access token
|
||||||
|
- JWT payload: `user_id`, `role`, `business_name`
|
||||||
|
- Public endpoints: catalog, landing, search, aftermarket list
|
||||||
|
- Protected endpoints: pricing, inventory, admin, POS
|
||||||
|
- Flask middleware validates JWT on protected routes
|
||||||
|
|
||||||
|
### Registration
|
||||||
|
- Bodegas and talleres register via form
|
||||||
|
- ADMIN approves accounts (is_active = false by default)
|
||||||
|
- ADMIN can create accounts directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Warehouse Inventory
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
**`warehouse_inventory`** (new):
|
||||||
|
```
|
||||||
|
id_inventory (PK, BIGINT)
|
||||||
|
user_id (FK → users)
|
||||||
|
part_id (FK → parts)
|
||||||
|
price (NUMERIC 12,2)
|
||||||
|
stock_quantity (INTEGER)
|
||||||
|
min_order_quantity (INTEGER DEFAULT 1)
|
||||||
|
warehouse_location (VARCHAR 100)
|
||||||
|
updated_at (TIMESTAMP DEFAULT now())
|
||||||
|
UNIQUE (user_id, part_id, warehouse_location)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`inventory_uploads`** (new):
|
||||||
|
```
|
||||||
|
id_upload (PK)
|
||||||
|
user_id (FK → users)
|
||||||
|
filename (VARCHAR 200)
|
||||||
|
status (VARCHAR 20: pending, processing, completed, failed)
|
||||||
|
rows_total (INTEGER)
|
||||||
|
rows_imported (INTEGER)
|
||||||
|
rows_errors (INTEGER)
|
||||||
|
error_log (TEXT)
|
||||||
|
created_at (TIMESTAMP DEFAULT now())
|
||||||
|
completed_at (TIMESTAMP)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`inventory_column_mappings`** (new):
|
||||||
|
```
|
||||||
|
id_mapping (PK)
|
||||||
|
user_id (FK → users, UNIQUE)
|
||||||
|
mapping (JSONB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example JSONB mapping:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"part_number": "COLUMNA_A",
|
||||||
|
"price": "PRECIO_VENTA",
|
||||||
|
"stock": "EXISTENCIAS",
|
||||||
|
"location": "SUCURSAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload Flow
|
||||||
|
1. Bodega uploads CSV/Excel → `POST /api/inventory/upload`
|
||||||
|
2. Backend reads file, applies JSONB mapping for that bodega
|
||||||
|
3. Matches part numbers against `parts.oem_part_number`
|
||||||
|
4. UPSERT into `warehouse_inventory`
|
||||||
|
5. Records result in `inventory_uploads`
|
||||||
|
|
||||||
|
### Catalog Display (authenticated TALLER)
|
||||||
|
```
|
||||||
|
Disponibilidad en bodegas:
|
||||||
|
BODEGA CENTRAL MX | $450.00 | 12 en stock | Guadalajara
|
||||||
|
REFACCIONES DEL NORTE | $485.00 | 3 en stock | Monterrey
|
||||||
|
```
|
||||||
|
- Only authenticated talleres see prices and stock
|
||||||
|
- Public users see catalog without prices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Aftermarket Parts Migration
|
||||||
|
|
||||||
|
### Current Problem
|
||||||
|
- 357,360 parts with `AFT-` prefix in `parts` table treated as OEM
|
||||||
|
- Format: `AFT-{articleNo}-{supplierName}` (e.g., `AFT-AC191-PARTQUIP`)
|
||||||
|
- Description: `"Aftermarket PARTQUIP"`
|
||||||
|
- Have vehicle_parts linked
|
||||||
|
- `aftermarket_parts` table exists but has only 1 record
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
**Step 1: Parse AFT- prefix**
|
||||||
|
```
|
||||||
|
AFT-AC191-PARTQUIP → part_number: AC191, manufacturer: PARTQUIP
|
||||||
|
AFT-10-0058-Airstal → part_number: 10-0058, manufacturer: Airstal
|
||||||
|
```
|
||||||
|
Logic: last segment after last `-` that matches a `manufacturers.name_manufacture` is the manufacturer. The rest (without `AFT-`) is the part number.
|
||||||
|
|
||||||
|
**Step 2: Find corresponding OEM part**
|
||||||
|
- Search `part_cross_references` where `cross_reference_number` = articleNo and `source_ref` = supplierName
|
||||||
|
- That gives us the `part_id` of the real OEM part
|
||||||
|
- If no cross-reference, search via `vehicle_parts` — OEM parts linked to same vehicles in same category
|
||||||
|
|
||||||
|
**Step 3: Populate `aftermarket_parts`**
|
||||||
|
```
|
||||||
|
oem_part_id → the OEM part found
|
||||||
|
manufacturer_id → FK to manufacturer (PARTQUIP, Airstal, etc.)
|
||||||
|
part_number → AC191 (clean, no prefix)
|
||||||
|
name_aftermarket_parts → original name_part
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Migrate vehicle_parts**
|
||||||
|
- vehicle_parts pointing to AFT- part get re-linked to the real OEM part
|
||||||
|
- Or deleted if OEM already has that link (ON CONFLICT DO NOTHING)
|
||||||
|
|
||||||
|
**Step 5: Delete AFT- parts from `parts`**
|
||||||
|
- Once migrated to `aftermarket_parts` and re-linked, remove from `parts`
|
||||||
|
|
||||||
|
### Import Pipeline Changes
|
||||||
|
- `import_live.py` and `import_tecdoc_parts.py` stop creating `AFT-` parts in `parts`
|
||||||
|
- Instead insert directly into `aftermarket_parts` with clean manufacturer and part number
|
||||||
|
- `vehicle_parts` only link to real OEM parts
|
||||||
|
|
||||||
|
### Catalog Display
|
||||||
|
```
|
||||||
|
Alternativas aftermarket:
|
||||||
|
PARTQUIP AC191 | Ver disponibilidad →
|
||||||
|
BOSCH 0 986 AB2 854 | Ver disponibilidad →
|
||||||
|
KAWE 6497 10 | Ver disponibilidad →
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: API Endpoints & Pages
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
**Auth:**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| POST | `/api/auth/register` | Public | Register taller/bodega |
|
||||||
|
| POST | `/api/auth/login` | Public | Login → JWT + refresh |
|
||||||
|
| POST | `/api/auth/refresh` | Authenticated | Renew access token |
|
||||||
|
| GET | `/api/auth/me` | Authenticated | User profile |
|
||||||
|
|
||||||
|
**Inventory (BODEGA):**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| POST | `/api/inventory/upload` | BODEGA | Upload CSV/Excel |
|
||||||
|
| GET | `/api/inventory/uploads` | BODEGA | Upload history |
|
||||||
|
| GET | `/api/inventory/mapping` | BODEGA | View column mapping |
|
||||||
|
| PUT | `/api/inventory/mapping` | BODEGA | Configure mapping |
|
||||||
|
| GET | `/api/inventory/items` | BODEGA | View own inventory |
|
||||||
|
| DELETE | `/api/inventory/items` | BODEGA | Clear inventory |
|
||||||
|
|
||||||
|
**Availability (TALLER):**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| GET | `/api/parts/{id}/availability` | TALLER | Prices/stock from all bodegas |
|
||||||
|
| GET | `/api/parts/{id}/aftermarket` | Public | Aftermarket alternatives list |
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
| Method | Route | Access | Description |
|
||||||
|
|--------|-------|--------|-------------|
|
||||||
|
| GET | `/api/admin/users` | ADMIN | List users |
|
||||||
|
| PUT | `/api/admin/users/{id}/activate` | ADMIN | Approve/deactivate account |
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
|
||||||
|
**`login.html`** — Login/registration form. Redirects by role after login.
|
||||||
|
|
||||||
|
**`bodega.html`** — Warehouse panel:
|
||||||
|
- Configure column mapping
|
||||||
|
- Upload CSV/Excel
|
||||||
|
- View upload history with status
|
||||||
|
- View current inventory with search
|
||||||
|
|
||||||
|
### Modified Pages
|
||||||
|
- **`index.html` / `demo.html`** — Add "Disponibilidad en bodegas" section in part detail (TALLER only). Add "Alternativas aftermarket" section (public).
|
||||||
|
- **`admin.html`** — Add "Usuarios" tab for account management.
|
||||||
|
- **`nav.js`** — Add login/logout button, show username.
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
```
|
||||||
|
Public: catalog, search, landing, login, aftermarket list
|
||||||
|
TALLER: prices, availability, history
|
||||||
|
BODEGA: upload, mapping, own inventory
|
||||||
|
ADMIN/OWNER: all above + user management + admin panel
|
||||||
|
```
|
||||||
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
1306
docs/plans/2026-03-15-saas-aftermarket-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
470
migrate_to_postgres.py
Normal file
470
migrate_to_postgres.py
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate data from SQLite (vehicle_database.db) to PostgreSQL (nexus_autoparts).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 migrate_to_postgres.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from config import DB_URL, SQLITE_PATH
|
||||||
|
from models import (
|
||||||
|
Base, SEARCH_VECTOR_TRIGGER_SQL,
|
||||||
|
FuelType, BodyType, Drivetrain, Transmission, Material,
|
||||||
|
PositionPart, ManufactureType, QualityTier, Country,
|
||||||
|
ReferenceType, Shape,
|
||||||
|
Brand, Year, Engine, Model, ModelYearEngine,
|
||||||
|
PartCategory, PartGroup, Part, VehiclePart,
|
||||||
|
Manufacturer, AftermarketPart, PartCrossReference,
|
||||||
|
Diagram, VehicleDiagram, DiagramHotspot, VinCache,
|
||||||
|
)
|
||||||
|
|
||||||
|
BATCH_SIZE = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f" {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def connect_sqlite():
|
||||||
|
conn = sqlite3.connect(SQLITE_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def populate_lookup(pg, sqlite_conn, LookupClass, pk_col, name_col,
|
||||||
|
sql_query):
|
||||||
|
"""Extract distinct values from SQLite and insert into a lookup table.
|
||||||
|
Returns a dict mapping text value → new PK id.
|
||||||
|
"""
|
||||||
|
rows = sqlite_conn.execute(sql_query).fetchall()
|
||||||
|
values = sorted(set(r[0] for r in rows if r[0]))
|
||||||
|
mapping = {}
|
||||||
|
for i, val in enumerate(values, start=1):
|
||||||
|
obj = LookupClass(**{pk_col: i, name_col: val})
|
||||||
|
pg.add(obj)
|
||||||
|
mapping[val] = i
|
||||||
|
pg.flush()
|
||||||
|
log(f" {LookupClass.__tablename__}: {len(mapping)} values")
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_table(pg, sqlite_conn, query, build_obj_fn, label, batch=BATCH_SIZE):
|
||||||
|
"""Generic batch-migrate helper."""
|
||||||
|
rows = sqlite_conn.execute(query).fetchall()
|
||||||
|
total = len(rows)
|
||||||
|
count = 0
|
||||||
|
for i in range(0, total, batch):
|
||||||
|
chunk = rows[i:i + batch]
|
||||||
|
for row in chunk:
|
||||||
|
obj = build_obj_fn(row)
|
||||||
|
if obj is not None:
|
||||||
|
pg.add(obj)
|
||||||
|
pg.flush()
|
||||||
|
count += len(chunk)
|
||||||
|
if count % 50000 == 0 or count == total:
|
||||||
|
log(f" {label}: {count}/{total}")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print(" NEXUS AUTOPARTS — SQLite → PostgreSQL Migration")
|
||||||
|
print("=" * 60)
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# Connect
|
||||||
|
print("\n[1] Connecting...")
|
||||||
|
sqlite_conn = connect_sqlite()
|
||||||
|
engine = create_engine(DB_URL, echo=False)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
# Drop & recreate all tables
|
||||||
|
print("\n[2] Creating schema...")
|
||||||
|
Base.metadata.drop_all(engine)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
log("All tables created")
|
||||||
|
|
||||||
|
pg = Session()
|
||||||
|
|
||||||
|
# ── Lookup tables ──────────────────────────────────────
|
||||||
|
print("\n[3] Populating lookup tables...")
|
||||||
|
|
||||||
|
fuel_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, FuelType, "id_fuel", "name_fuel",
|
||||||
|
"SELECT DISTINCT fuel_type FROM engines WHERE fuel_type IS NOT NULL")
|
||||||
|
|
||||||
|
body_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, BodyType, "id_body", "name_body",
|
||||||
|
"SELECT DISTINCT body_type FROM models WHERE body_type IS NOT NULL")
|
||||||
|
|
||||||
|
drive_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, Drivetrain, "id_drivetrain", "name_drivetrain",
|
||||||
|
"SELECT DISTINCT drivetrain FROM model_year_engine WHERE drivetrain IS NOT NULL")
|
||||||
|
|
||||||
|
trans_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, Transmission, "id_transmission", "name_transmission",
|
||||||
|
"SELECT DISTINCT transmission FROM model_year_engine WHERE transmission IS NOT NULL")
|
||||||
|
|
||||||
|
mat_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, Material, "id_material", "name_material",
|
||||||
|
"SELECT DISTINCT material FROM parts WHERE material IS NOT NULL")
|
||||||
|
|
||||||
|
pos_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, PositionPart, "id_position_part", "name_position_part",
|
||||||
|
"SELECT DISTINCT position FROM vehicle_parts WHERE position IS NOT NULL")
|
||||||
|
|
||||||
|
mtype_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, ManufactureType, "id_type_manu", "name_type_manu",
|
||||||
|
"SELECT DISTINCT type FROM manufacturers WHERE type IS NOT NULL")
|
||||||
|
|
||||||
|
qtier_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, QualityTier, "id_quality_tier", "name_quality",
|
||||||
|
"""SELECT DISTINCT quality_tier FROM (
|
||||||
|
SELECT quality_tier FROM manufacturers WHERE quality_tier IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT quality_tier FROM aftermarket_parts WHERE quality_tier IS NOT NULL
|
||||||
|
)""")
|
||||||
|
|
||||||
|
country_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, Country, "id_country", "name_country",
|
||||||
|
"SELECT DISTINCT country FROM manufacturers WHERE country IS NOT NULL")
|
||||||
|
|
||||||
|
reftype_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, ReferenceType, "id_ref_type", "name_ref_type",
|
||||||
|
"SELECT DISTINCT reference_type FROM part_cross_references WHERE reference_type IS NOT NULL")
|
||||||
|
|
||||||
|
shape_map = populate_lookup(
|
||||||
|
pg, sqlite_conn, Shape, "id_shape", "name_shape",
|
||||||
|
"SELECT DISTINCT shape FROM diagram_hotspots WHERE shape IS NOT NULL")
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# ── Core tables ────────────────────────────────────────
|
||||||
|
print("\n[4] Migrating core tables...")
|
||||||
|
|
||||||
|
# brands
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM brands ORDER BY id",
|
||||||
|
lambda r: Brand(
|
||||||
|
id_brand=r["id"], name_brand=r["name"],
|
||||||
|
country=r["country"], founded_year=r["founded_year"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"brands")
|
||||||
|
pg.commit()
|
||||||
|
log(f"brands: {n} rows")
|
||||||
|
|
||||||
|
# years
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM years ORDER BY id",
|
||||||
|
lambda r: Year(
|
||||||
|
id_year=r["id"], year_car=r["year"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"years")
|
||||||
|
pg.commit()
|
||||||
|
log(f"years: {n} rows")
|
||||||
|
|
||||||
|
# engines
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM engines ORDER BY id",
|
||||||
|
lambda r: Engine(
|
||||||
|
id_engine=r["id"], name_engine=r["name"],
|
||||||
|
displacement_cc=r["displacement_cc"], cylinders=r["cylinders"],
|
||||||
|
id_fuel=fuel_map.get(r["fuel_type"]),
|
||||||
|
power_hp=r["power_hp"], torque_nm=r["torque_nm"],
|
||||||
|
engine_code=r["engine_code"], created_at=r["created_at"]),
|
||||||
|
"engines")
|
||||||
|
pg.commit()
|
||||||
|
log(f"engines: {n} rows")
|
||||||
|
|
||||||
|
# models
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM models ORDER BY id",
|
||||||
|
lambda r: Model(
|
||||||
|
id_model=r["id"], brand_id=r["brand_id"],
|
||||||
|
name_model=r["name"],
|
||||||
|
id_body=body_map.get(r["body_type"]),
|
||||||
|
generation=r["generation"],
|
||||||
|
production_start_year=r["production_start_year"],
|
||||||
|
production_end_year=r["production_end_year"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"models")
|
||||||
|
pg.commit()
|
||||||
|
log(f"models: {n} rows")
|
||||||
|
|
||||||
|
# model_year_engine
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM model_year_engine ORDER BY id",
|
||||||
|
lambda r: ModelYearEngine(
|
||||||
|
id_mye=r["id"], model_id=r["model_id"],
|
||||||
|
year_id=r["year_id"], engine_id=r["engine_id"],
|
||||||
|
trim_level=r["trim_level"],
|
||||||
|
id_drivetrain=drive_map.get(r["drivetrain"]),
|
||||||
|
id_transmission=trans_map.get(r["transmission"]),
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"model_year_engine")
|
||||||
|
pg.commit()
|
||||||
|
log(f"model_year_engine: {n} rows")
|
||||||
|
|
||||||
|
# part_categories
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM part_categories ORDER BY id",
|
||||||
|
lambda r: PartCategory(
|
||||||
|
id_part_category=r["id"],
|
||||||
|
name_part_category=r["name"], name_es=r["name_es"],
|
||||||
|
parent_id=r["parent_id"], slug=r["slug"],
|
||||||
|
icon_name=r["icon_name"], display_order=r["display_order"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"part_categories")
|
||||||
|
pg.commit()
|
||||||
|
log(f"part_categories: {n} rows")
|
||||||
|
|
||||||
|
# part_groups
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM part_groups ORDER BY id",
|
||||||
|
lambda r: PartGroup(
|
||||||
|
id_part_group=r["id"], category_id=r["category_id"],
|
||||||
|
name_part_group=r["name"], name_es=r["name_es"],
|
||||||
|
slug=r["slug"], display_order=r["display_order"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"part_groups")
|
||||||
|
pg.commit()
|
||||||
|
log(f"part_groups: {n} rows")
|
||||||
|
|
||||||
|
# parts (without search_vector — trigger will fill it)
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM parts ORDER BY id",
|
||||||
|
lambda r: Part(
|
||||||
|
id_part=r["id"], oem_part_number=r["oem_part_number"],
|
||||||
|
name_part=r["name"], name_es=r["name_es"],
|
||||||
|
group_id=r["group_id"],
|
||||||
|
description=r["description"], description_es=r["description_es"],
|
||||||
|
weight_kg=r["weight_kg"],
|
||||||
|
id_material=mat_map.get(r["material"]),
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"parts")
|
||||||
|
pg.commit()
|
||||||
|
log(f"parts: {n} rows")
|
||||||
|
|
||||||
|
# vehicle_parts
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM vehicle_parts ORDER BY id",
|
||||||
|
lambda r: VehiclePart(
|
||||||
|
id_vehicle_part=r["id"],
|
||||||
|
model_year_engine_id=r["model_year_engine_id"],
|
||||||
|
part_id=r["part_id"],
|
||||||
|
quantity_required=r["quantity_required"],
|
||||||
|
id_position_part=pos_map.get(r["position"]),
|
||||||
|
fitment_notes=r["fitment_notes"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"vehicle_parts", batch=10000)
|
||||||
|
pg.commit()
|
||||||
|
log(f"vehicle_parts: {n} rows")
|
||||||
|
|
||||||
|
# manufacturers
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM manufacturers ORDER BY id",
|
||||||
|
lambda r: Manufacturer(
|
||||||
|
id_manufacture=r["id"], name_manufacture=r["name"],
|
||||||
|
id_type_manu=mtype_map.get(r["type"]),
|
||||||
|
id_quality_tier=qtier_map.get(r["quality_tier"]),
|
||||||
|
id_country=country_map.get(r["country"]),
|
||||||
|
logo_url=r["logo_url"], website=r["website"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"manufacturers")
|
||||||
|
pg.commit()
|
||||||
|
log(f"manufacturers: {n} rows")
|
||||||
|
|
||||||
|
# aftermarket_parts (skip orphans with missing oem_part_id)
|
||||||
|
valid_part_ids = set(r[0] for r in sqlite_conn.execute("SELECT id FROM parts").fetchall())
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM aftermarket_parts ORDER BY id",
|
||||||
|
lambda r: AftermarketPart(
|
||||||
|
id_aftermarket_parts=r["id"],
|
||||||
|
oem_part_id=r["oem_part_id"],
|
||||||
|
manufacturer_id=r["manufacturer_id"],
|
||||||
|
part_number=r["part_number"],
|
||||||
|
name_aftermarket_parts=r["name"], name_es=r["name_es"],
|
||||||
|
id_quality_tier=qtier_map.get(r["quality_tier"]),
|
||||||
|
price_usd=r["price_usd"],
|
||||||
|
warranty_months=r["warranty_months"],
|
||||||
|
created_at=r["created_at"]) if r["oem_part_id"] in valid_part_ids else None,
|
||||||
|
"aftermarket_parts")
|
||||||
|
pg.commit()
|
||||||
|
log(f"aftermarket_parts: {n} rows")
|
||||||
|
|
||||||
|
# part_cross_references
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM part_cross_references ORDER BY id",
|
||||||
|
lambda r: PartCrossReference(
|
||||||
|
id_part_cross_ref=r["id"], part_id=r["part_id"],
|
||||||
|
cross_reference_number=r["cross_reference_number"],
|
||||||
|
id_ref_type=reftype_map.get(r["reference_type"]),
|
||||||
|
source_ref=r["source"], notes=r["notes"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"part_cross_references")
|
||||||
|
pg.commit()
|
||||||
|
log(f"part_cross_references: {n} rows")
|
||||||
|
|
||||||
|
# diagrams
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM diagrams ORDER BY id",
|
||||||
|
lambda r: Diagram(
|
||||||
|
id_diagram=r["id"], name_diagram=r["name"],
|
||||||
|
name_es=r["name_es"], group_id=r["group_id"],
|
||||||
|
image_path=r["image_path"],
|
||||||
|
thumbnail_path=r["thumbnail_path"],
|
||||||
|
display_order=r["display_order"],
|
||||||
|
source_diagram=r["source"],
|
||||||
|
created_at=r["created_at"]),
|
||||||
|
"diagrams")
|
||||||
|
pg.commit()
|
||||||
|
log(f"diagrams: {n} rows")
|
||||||
|
|
||||||
|
# vehicle_diagrams (skip orphans with missing diagram_id)
|
||||||
|
valid_diagram_ids = set(r[0] for r in sqlite_conn.execute("SELECT id FROM diagrams").fetchall())
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM vehicle_diagrams ORDER BY id",
|
||||||
|
lambda r: VehicleDiagram(
|
||||||
|
id_vehicle_dgr=r["id"], diagram_id=r["diagram_id"],
|
||||||
|
model_year_engine_id=r["model_year_engine_id"],
|
||||||
|
notes=r["notes"], created_at=r["created_at"])
|
||||||
|
if r["diagram_id"] in valid_diagram_ids else None,
|
||||||
|
"vehicle_diagrams")
|
||||||
|
pg.commit()
|
||||||
|
log(f"vehicle_diagrams: {n} rows")
|
||||||
|
|
||||||
|
# diagram_hotspots
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM diagram_hotspots ORDER BY id",
|
||||||
|
lambda r: DiagramHotspot(
|
||||||
|
id_dgr_hotspot=r["id"], diagram_id=r["diagram_id"],
|
||||||
|
part_id=r["part_id"], callout_number=r["callout_number"],
|
||||||
|
id_shape=shape_map.get(r["shape"]),
|
||||||
|
coords=r["coords"], created_at=r["created_at"]),
|
||||||
|
"diagram_hotspots")
|
||||||
|
pg.commit()
|
||||||
|
log(f"diagram_hotspots: {n} rows")
|
||||||
|
|
||||||
|
# vin_cache
|
||||||
|
import json
|
||||||
|
n = migrate_table(pg, sqlite_conn,
|
||||||
|
"SELECT * FROM vin_cache ORDER BY id",
|
||||||
|
lambda r: VinCache(
|
||||||
|
id=r["id"], vin=r["vin"],
|
||||||
|
decoded_data=json.loads(r["decoded_data"]) if r["decoded_data"] else {},
|
||||||
|
make=r["make"], model=r["model"], year=r["year"],
|
||||||
|
engine_info=r["engine_info"], body_class=r["body_class"],
|
||||||
|
drive_type=r["drive_type"],
|
||||||
|
model_year_engine_id=r["model_year_engine_id"],
|
||||||
|
created_at=r["created_at"], expires_at=r["expires_at"]),
|
||||||
|
"vin_cache")
|
||||||
|
pg.commit()
|
||||||
|
log(f"vin_cache: {n} rows")
|
||||||
|
|
||||||
|
# ── Reset sequences ───────────────────────────────────
|
||||||
|
print("\n[5] Resetting sequences...")
|
||||||
|
seq_tables = [
|
||||||
|
("brands", "id_brand"),
|
||||||
|
("years", "id_year"),
|
||||||
|
("engines", "id_engine"),
|
||||||
|
("models", "id_model"),
|
||||||
|
("model_year_engine", "id_mye"),
|
||||||
|
("part_categories", "id_part_category"),
|
||||||
|
("part_groups", "id_part_group"),
|
||||||
|
("parts", "id_part"),
|
||||||
|
("vehicle_parts", "id_vehicle_part"),
|
||||||
|
("manufacturers", "id_manufacture"),
|
||||||
|
("aftermarket_parts", "id_aftermarket_parts"),
|
||||||
|
("part_cross_references", "id_part_cross_ref"),
|
||||||
|
("diagrams", "id_diagram"),
|
||||||
|
("vehicle_diagrams", "id_vehicle_dgr"),
|
||||||
|
("diagram_hotspots", "id_dgr_hotspot"),
|
||||||
|
("vin_cache", "id"),
|
||||||
|
("fuel_type", "id_fuel"),
|
||||||
|
("body_type", "id_body"),
|
||||||
|
("drivetrain", "id_drivetrain"),
|
||||||
|
("transmission", "id_transmission"),
|
||||||
|
("materials", "id_material"),
|
||||||
|
("position_part", "id_position_part"),
|
||||||
|
("manufacture_type", "id_type_manu"),
|
||||||
|
("quality_tier", "id_quality_tier"),
|
||||||
|
("countries", "id_country"),
|
||||||
|
("reference_type", "id_ref_type"),
|
||||||
|
("shapes", "id_shape"),
|
||||||
|
]
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for table, pk in seq_tables:
|
||||||
|
conn.execute(text(
|
||||||
|
f"SELECT setval(pg_get_serial_sequence('{table}', '{pk}'), "
|
||||||
|
f"COALESCE((SELECT MAX({pk}) FROM {table}), 0) + 1, false)"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
log("All sequences reset")
|
||||||
|
|
||||||
|
# ── Full-text search trigger ──────────────────────────
|
||||||
|
print("\n[6] Creating search trigger & updating vectors...")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(SEARCH_VECTOR_TRIGGER_SQL))
|
||||||
|
conn.commit()
|
||||||
|
# Backfill search_vector for existing rows
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE parts SET search_vector = to_tsvector('spanish',
|
||||||
|
coalesce(oem_part_number, '') || ' ' ||
|
||||||
|
coalesce(name_part, '') || ' ' ||
|
||||||
|
coalesce(name_es, '') || ' ' ||
|
||||||
|
coalesce(description, ''))
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
log("Search vectors populated")
|
||||||
|
|
||||||
|
# ── Verify counts ─────────────────────────────────────
|
||||||
|
print("\n[7] Verifying row counts...")
|
||||||
|
sqlite_tables = [
|
||||||
|
"brands", "models", "years", "engines", "model_year_engine",
|
||||||
|
"part_categories", "part_groups", "parts", "vehicle_parts",
|
||||||
|
"manufacturers", "aftermarket_parts", "part_cross_references",
|
||||||
|
"diagrams", "vehicle_diagrams", "diagram_hotspots", "vin_cache"
|
||||||
|
]
|
||||||
|
pg_tables = [
|
||||||
|
("brands", "id_brand"), ("models", "id_model"),
|
||||||
|
("years", "id_year"), ("engines", "id_engine"),
|
||||||
|
("model_year_engine", "id_mye"),
|
||||||
|
("part_categories", "id_part_category"),
|
||||||
|
("part_groups", "id_part_group"), ("parts", "id_part"),
|
||||||
|
("vehicle_parts", "id_vehicle_part"),
|
||||||
|
("manufacturers", "id_manufacture"),
|
||||||
|
("aftermarket_parts", "id_aftermarket_parts"),
|
||||||
|
("part_cross_references", "id_part_cross_ref"),
|
||||||
|
("diagrams", "id_diagram"),
|
||||||
|
("vehicle_diagrams", "id_vehicle_dgr"),
|
||||||
|
("diagram_hotspots", "id_dgr_hotspot"),
|
||||||
|
("vin_cache", "id"),
|
||||||
|
]
|
||||||
|
ok = True
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for st, (pt, pk) in zip(sqlite_tables, pg_tables):
|
||||||
|
s_count = sqlite_conn.execute(f"SELECT COUNT(*) FROM {st}").fetchone()[0]
|
||||||
|
p_count = conn.execute(text(f"SELECT COUNT(*) FROM {pt}")).scalar()
|
||||||
|
status = "OK" if s_count == p_count else "MISMATCH"
|
||||||
|
if status == "MISMATCH":
|
||||||
|
ok = False
|
||||||
|
log(f" {st}: SQLite={s_count} PG={p_count} [{status}]")
|
||||||
|
|
||||||
|
sqlite_conn.close()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
if ok:
|
||||||
|
print(f" Migration completed successfully in {elapsed:.1f}s")
|
||||||
|
else:
|
||||||
|
print(f" Migration completed with MISMATCHES in {elapsed:.1f}s")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
410
models.py
Normal file
410
models.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy ORM models for Nexus Autoparts.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Float, Boolean, Text, DateTime, ForeignKey,
|
||||||
|
UniqueConstraint, Index, func, text
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||||
|
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Lookup tables
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FuelType(Base):
|
||||||
|
__tablename__ = "fuel_type"
|
||||||
|
id_fuel = Column(Integer, primary_key=True)
|
||||||
|
name_fuel = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyType(Base):
|
||||||
|
__tablename__ = "body_type"
|
||||||
|
id_body = Column(Integer, primary_key=True)
|
||||||
|
name_body = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Drivetrain(Base):
|
||||||
|
__tablename__ = "drivetrain"
|
||||||
|
id_drivetrain = Column(Integer, primary_key=True)
|
||||||
|
name_drivetrain = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Transmission(Base):
|
||||||
|
__tablename__ = "transmission"
|
||||||
|
id_transmission = Column(Integer, primary_key=True)
|
||||||
|
name_transmission = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Material(Base):
|
||||||
|
__tablename__ = "materials"
|
||||||
|
id_material = Column(Integer, primary_key=True)
|
||||||
|
name_material = Column(String(100), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PositionPart(Base):
|
||||||
|
__tablename__ = "position_part"
|
||||||
|
id_position_part = Column(Integer, primary_key=True)
|
||||||
|
name_position_part = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ManufactureType(Base):
|
||||||
|
__tablename__ = "manufacture_type"
|
||||||
|
id_type_manu = Column(Integer, primary_key=True)
|
||||||
|
name_type_manu = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class QualityTier(Base):
|
||||||
|
__tablename__ = "quality_tier"
|
||||||
|
id_quality_tier = Column(Integer, primary_key=True)
|
||||||
|
name_quality = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Country(Base):
|
||||||
|
__tablename__ = "countries"
|
||||||
|
id_country = Column(Integer, primary_key=True)
|
||||||
|
name_country = Column(String(100), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceType(Base):
|
||||||
|
__tablename__ = "reference_type"
|
||||||
|
id_ref_type = Column(Integer, primary_key=True)
|
||||||
|
name_ref_type = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Shape(Base):
|
||||||
|
__tablename__ = "shapes"
|
||||||
|
id_shape = Column(Integer, primary_key=True)
|
||||||
|
name_shape = Column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Core tables
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Brand(Base):
|
||||||
|
__tablename__ = "brands"
|
||||||
|
id_brand = Column(Integer, primary_key=True)
|
||||||
|
name_brand = Column(String(200), nullable=False, unique=True)
|
||||||
|
country = Column(String(100))
|
||||||
|
founded_year = Column(Integer)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
models = relationship("Model", back_populates="brand")
|
||||||
|
|
||||||
|
|
||||||
|
class Year(Base):
|
||||||
|
__tablename__ = "years"
|
||||||
|
id_year = Column(Integer, primary_key=True)
|
||||||
|
year_car = Column(Integer, nullable=False, unique=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Engine(Base):
|
||||||
|
__tablename__ = "engines"
|
||||||
|
id_engine = Column(Integer, primary_key=True)
|
||||||
|
name_engine = Column(String(300), nullable=False)
|
||||||
|
displacement_cc = Column(Float)
|
||||||
|
cylinders = Column(Integer)
|
||||||
|
id_fuel = Column(Integer, ForeignKey("fuel_type.id_fuel"))
|
||||||
|
power_hp = Column(Integer)
|
||||||
|
torque_nm = Column(Integer)
|
||||||
|
engine_code = Column(String(100))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
fuel_type = relationship("FuelType")
|
||||||
|
|
||||||
|
|
||||||
|
class Model(Base):
|
||||||
|
__tablename__ = "models"
|
||||||
|
id_model = Column(Integer, primary_key=True)
|
||||||
|
brand_id = Column(Integer, ForeignKey("brands.id_brand"), nullable=False)
|
||||||
|
name_model = Column(String(300), nullable=False)
|
||||||
|
id_body = Column(Integer, ForeignKey("body_type.id_body"))
|
||||||
|
generation = Column(String(100))
|
||||||
|
production_start_year = Column(Integer)
|
||||||
|
production_end_year = Column(Integer)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
brand = relationship("Brand", back_populates="models")
|
||||||
|
body_type = relationship("BodyType")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_models_brand", "brand_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelYearEngine(Base):
|
||||||
|
__tablename__ = "model_year_engine"
|
||||||
|
id_mye = Column(Integer, primary_key=True)
|
||||||
|
model_id = Column(Integer, ForeignKey("models.id_model"), nullable=False)
|
||||||
|
year_id = Column(Integer, ForeignKey("years.id_year"), nullable=False)
|
||||||
|
engine_id = Column(Integer, ForeignKey("engines.id_engine"), nullable=False)
|
||||||
|
trim_level = Column(String(100))
|
||||||
|
id_drivetrain = Column(Integer, ForeignKey("drivetrain.id_drivetrain"))
|
||||||
|
id_transmission = Column(Integer, ForeignKey("transmission.id_transmission"))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
model = relationship("Model")
|
||||||
|
year = relationship("Year")
|
||||||
|
engine = relationship("Engine")
|
||||||
|
drivetrain = relationship("Drivetrain")
|
||||||
|
transmission = relationship("Transmission")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("model_id", "year_id", "engine_id", "trim_level",
|
||||||
|
name="uq_mye_combo"),
|
||||||
|
Index("idx_mye_model", "model_id"),
|
||||||
|
Index("idx_mye_year", "year_id"),
|
||||||
|
Index("idx_mye_engine", "engine_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PartCategory(Base):
|
||||||
|
__tablename__ = "part_categories"
|
||||||
|
id_part_category = Column(Integer, primary_key=True)
|
||||||
|
name_part_category = Column(String(200), nullable=False)
|
||||||
|
name_es = Column(String(200))
|
||||||
|
parent_id = Column(Integer, ForeignKey("part_categories.id_part_category"))
|
||||||
|
slug = Column(String(200), unique=True)
|
||||||
|
icon_name = Column(String(100))
|
||||||
|
display_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
parent = relationship("PartCategory", remote_side="PartCategory.id_part_category")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_part_categories_parent", "parent_id"),
|
||||||
|
Index("idx_part_categories_slug", "slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PartGroup(Base):
|
||||||
|
__tablename__ = "part_groups"
|
||||||
|
id_part_group = Column(Integer, primary_key=True)
|
||||||
|
category_id = Column(Integer, ForeignKey("part_categories.id_part_category"), nullable=False)
|
||||||
|
name_part_group = Column(String(200), nullable=False)
|
||||||
|
name_es = Column(String(200))
|
||||||
|
slug = Column(String(200))
|
||||||
|
display_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
category = relationship("PartCategory")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_part_groups_category", "category_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Part(Base):
|
||||||
|
__tablename__ = "parts"
|
||||||
|
id_part = Column(Integer, primary_key=True)
|
||||||
|
oem_part_number = Column(String(100), nullable=False)
|
||||||
|
name_part = Column(String(300), nullable=False)
|
||||||
|
name_es = Column(String(300))
|
||||||
|
group_id = Column(Integer, ForeignKey("part_groups.id_part_group"))
|
||||||
|
description = Column(Text)
|
||||||
|
description_es = Column(Text)
|
||||||
|
weight_kg = Column(Float)
|
||||||
|
id_material = Column(Integer, ForeignKey("materials.id_material"))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
search_vector = Column(TSVECTOR)
|
||||||
|
|
||||||
|
group = relationship("PartGroup")
|
||||||
|
material = relationship("Material")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_parts_oem", "oem_part_number"),
|
||||||
|
Index("idx_parts_group", "group_id"),
|
||||||
|
Index("idx_parts_search", "search_vector", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VehiclePart(Base):
|
||||||
|
__tablename__ = "vehicle_parts"
|
||||||
|
id_vehicle_part = Column(Integer, primary_key=True)
|
||||||
|
model_year_engine_id = Column(Integer, ForeignKey("model_year_engine.id_mye"), nullable=False)
|
||||||
|
part_id = Column(Integer, ForeignKey("parts.id_part"), nullable=False)
|
||||||
|
quantity_required = Column(Integer, default=1)
|
||||||
|
id_position_part = Column(Integer, ForeignKey("position_part.id_position_part"))
|
||||||
|
fitment_notes = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
model_year_engine = relationship("ModelYearEngine")
|
||||||
|
part = relationship("Part")
|
||||||
|
position = relationship("PositionPart")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("model_year_engine_id", "part_id", "id_position_part",
|
||||||
|
name="uq_vehicle_part"),
|
||||||
|
Index("idx_vehicle_parts_mye", "model_year_engine_id"),
|
||||||
|
Index("idx_vehicle_parts_part", "part_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Manufacturer(Base):
|
||||||
|
__tablename__ = "manufacturers"
|
||||||
|
id_manufacture = Column(Integer, primary_key=True)
|
||||||
|
name_manufacture = Column(String(200), nullable=False, unique=True)
|
||||||
|
id_type_manu = Column(Integer, ForeignKey("manufacture_type.id_type_manu"))
|
||||||
|
id_quality_tier = Column(Integer, ForeignKey("quality_tier.id_quality_tier"))
|
||||||
|
id_country = Column(Integer, ForeignKey("countries.id_country"))
|
||||||
|
logo_url = Column(String(500))
|
||||||
|
website = Column(String(500))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
manufacture_type = relationship("ManufactureType")
|
||||||
|
quality_tier = relationship("QualityTier")
|
||||||
|
country = relationship("Country")
|
||||||
|
|
||||||
|
|
||||||
|
class AftermarketPart(Base):
|
||||||
|
__tablename__ = "aftermarket_parts"
|
||||||
|
id_aftermarket_parts = Column(Integer, primary_key=True)
|
||||||
|
oem_part_id = Column(Integer, ForeignKey("parts.id_part"), nullable=False)
|
||||||
|
manufacturer_id = Column(Integer, ForeignKey("manufacturers.id_manufacture"), nullable=False)
|
||||||
|
part_number = Column(String(100), nullable=False)
|
||||||
|
name_aftermarket_parts = Column(String(300))
|
||||||
|
name_es = Column(String(300))
|
||||||
|
id_quality_tier = Column(Integer, ForeignKey("quality_tier.id_quality_tier"))
|
||||||
|
price_usd = Column(Float)
|
||||||
|
warranty_months = Column(Integer)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
oem_part = relationship("Part")
|
||||||
|
manufacturer = relationship("Manufacturer")
|
||||||
|
quality_tier = relationship("QualityTier")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_aftermarket_oem", "oem_part_id"),
|
||||||
|
Index("idx_aftermarket_manufacturer", "manufacturer_id"),
|
||||||
|
Index("idx_aftermarket_part_number", "part_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PartCrossReference(Base):
|
||||||
|
__tablename__ = "part_cross_references"
|
||||||
|
id_part_cross_ref = Column(Integer, primary_key=True)
|
||||||
|
part_id = Column(Integer, ForeignKey("parts.id_part"), nullable=False)
|
||||||
|
cross_reference_number = Column(String(100), nullable=False)
|
||||||
|
id_ref_type = Column(Integer, ForeignKey("reference_type.id_ref_type"))
|
||||||
|
source_ref = Column(String(200))
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
part = relationship("Part")
|
||||||
|
reference_type = relationship("ReferenceType")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_cross_ref_part", "part_id"),
|
||||||
|
Index("idx_cross_ref_number", "cross_reference_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Diagram(Base):
|
||||||
|
__tablename__ = "diagrams"
|
||||||
|
id_diagram = Column(Integer, primary_key=True)
|
||||||
|
name_diagram = Column(String(300), nullable=False)
|
||||||
|
name_es = Column(String(300))
|
||||||
|
group_id = Column(Integer, ForeignKey("part_groups.id_part_group"), nullable=False)
|
||||||
|
image_path = Column(String(500), nullable=False)
|
||||||
|
thumbnail_path = Column(String(500))
|
||||||
|
display_order = Column(Integer, default=0)
|
||||||
|
source_diagram = Column(String(200))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
group = relationship("PartGroup")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_diagrams_group", "group_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDiagram(Base):
|
||||||
|
__tablename__ = "vehicle_diagrams"
|
||||||
|
id_vehicle_dgr = Column(Integer, primary_key=True)
|
||||||
|
diagram_id = Column(Integer, ForeignKey("diagrams.id_diagram"), nullable=False)
|
||||||
|
model_year_engine_id = Column(Integer, ForeignKey("model_year_engine.id_mye"), nullable=False)
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
diagram = relationship("Diagram")
|
||||||
|
model_year_engine = relationship("ModelYearEngine")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("diagram_id", "model_year_engine_id", name="uq_vehicle_diagram"),
|
||||||
|
Index("idx_vehicle_diagrams_diagram", "diagram_id"),
|
||||||
|
Index("idx_vehicle_diagrams_mye", "model_year_engine_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagramHotspot(Base):
|
||||||
|
__tablename__ = "diagram_hotspots"
|
||||||
|
id_dgr_hotspot = Column(Integer, primary_key=True)
|
||||||
|
diagram_id = Column(Integer, ForeignKey("diagrams.id_diagram"), nullable=False)
|
||||||
|
part_id = Column(Integer, ForeignKey("parts.id_part"))
|
||||||
|
callout_number = Column(Integer)
|
||||||
|
id_shape = Column(Integer, ForeignKey("shapes.id_shape"))
|
||||||
|
coords = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
diagram = relationship("Diagram")
|
||||||
|
part = relationship("Part")
|
||||||
|
shape = relationship("Shape")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_hotspots_diagram", "diagram_id"),
|
||||||
|
Index("idx_hotspots_part", "part_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VinCache(Base):
|
||||||
|
__tablename__ = "vin_cache"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
vin = Column(String(17), nullable=False, unique=True)
|
||||||
|
decoded_data = Column(JSONB, nullable=False)
|
||||||
|
make = Column(String(100))
|
||||||
|
model = Column(String(100))
|
||||||
|
year = Column(Integer)
|
||||||
|
engine_info = Column(String(200))
|
||||||
|
body_class = Column(String(100))
|
||||||
|
drive_type = Column(String(100))
|
||||||
|
model_year_engine_id = Column(Integer, ForeignKey("model_year_engine.id_mye"))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = Column(DateTime)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_vin_cache_vin", "vin"),
|
||||||
|
Index("idx_vin_cache_make_model", "make", "model", "year"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Full-text search trigger SQL (run after table creation)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
SEARCH_VECTOR_TRIGGER_SQL = """
|
||||||
|
CREATE OR REPLACE FUNCTION parts_search_vector_update() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_vector := to_tsvector('spanish',
|
||||||
|
coalesce(NEW.oem_part_number, '') || ' ' ||
|
||||||
|
coalesce(NEW.name_part, '') || ' ' ||
|
||||||
|
coalesce(NEW.name_es, '') || ' ' ||
|
||||||
|
coalesce(NEW.description, '')
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_parts_search_vector ON parts;
|
||||||
|
CREATE TRIGGER trg_parts_search_vector
|
||||||
|
BEFORE INSERT OR UPDATE ON parts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION parts_search_vector_update();
|
||||||
|
"""
|
||||||
@@ -2,3 +2,9 @@ flask==2.3.3
|
|||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
beautifulsoup4>=4.11.0
|
beautifulsoup4>=4.11.0
|
||||||
lxml>=4.9.0
|
lxml>=4.9.0
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
psycopg2-binary>=2.9
|
||||||
|
flask-sqlalchemy>=3.1
|
||||||
|
PyJWT>=2.8
|
||||||
|
bcrypt>=4.0
|
||||||
|
openpyxl>=3.1
|
||||||
|
|||||||
144
scripts/import_live.py
Normal file
144
scripts/import_live.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Live importer: watches detail files and imports OEM data as it arrives.
|
||||||
|
Runs in a loop, importing new detail files every 30 seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import psycopg2
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
DETAILS_DIR = Path("/home/Autopartes/data/tecdoc/parts/details")
|
||||||
|
ARTICLES_DIR = Path("/home/Autopartes/data/tecdoc/parts/articles")
|
||||||
|
TRACK_FILE = Path("/home/Autopartes/data/tecdoc/parts/.imported_ids")
|
||||||
|
|
||||||
|
INTERVAL = 30 # seconds between import runs
|
||||||
|
|
||||||
|
|
||||||
|
def load_imported():
|
||||||
|
"""Load set of already-imported articleIds."""
|
||||||
|
if TRACK_FILE.exists():
|
||||||
|
return set(TRACK_FILE.read_text().split())
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def save_imported(ids):
|
||||||
|
TRACK_FILE.write_text("\n".join(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
imported = load_imported()
|
||||||
|
print(f"Already imported: {len(imported)} articles", flush=True)
|
||||||
|
|
||||||
|
# Build article→category mapping once
|
||||||
|
article_cats = {}
|
||||||
|
for f in ARTICLES_DIR.glob("*.json"):
|
||||||
|
parts = f.stem.split("_")
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
cat_id = int(parts[1])
|
||||||
|
try:
|
||||||
|
for a in json.loads(f.read_text()):
|
||||||
|
aid = a.get('articleId')
|
||||||
|
if aid and aid not in article_cats:
|
||||||
|
article_cats[aid] = cat_id
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
print(f"Article→category mappings: {len(article_cats):,}", flush=True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
detail_files = list(DETAILS_DIR.glob("*.json"))
|
||||||
|
new_files = [f for f in detail_files if f.stem not in imported]
|
||||||
|
|
||||||
|
if not new_files:
|
||||||
|
print(f" [{time.strftime('%H:%M:%S')}] No new files. Total imported: {len(imported):,}. Waiting...", flush=True)
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" [{time.strftime('%H:%M:%S')}] Found {len(new_files)} new detail files to import", flush=True)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Load caches
|
||||||
|
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
|
||||||
|
part_cache = {r[0]: r[1] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
|
||||||
|
mfr_cache = {r[1]: r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
stats = {'parts': 0, 'xrefs': 0, 'mfrs': 0, 'updated': 0}
|
||||||
|
|
||||||
|
for f in new_files:
|
||||||
|
article_id = f.stem
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
except:
|
||||||
|
imported.add(article_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
oem_list = data.get('articleOemNo', [])
|
||||||
|
article = data.get('article', {}) or {}
|
||||||
|
article_no = article.get('articleNo', '')
|
||||||
|
supplier = article.get('supplierName', '')
|
||||||
|
product_name = article.get('articleProductName', '')
|
||||||
|
|
||||||
|
if not oem_list:
|
||||||
|
imported.add(article_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ensure manufacturer
|
||||||
|
if supplier and supplier not in mfr_cache:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
|
||||||
|
(supplier,))
|
||||||
|
mfr_cache[supplier] = cur.fetchone()[0]
|
||||||
|
stats['mfrs'] += 1
|
||||||
|
|
||||||
|
for oem in oem_list:
|
||||||
|
oem_no = oem.get('oemDisplayNo', '')
|
||||||
|
oem_brand = oem.get('oemBrand', '')
|
||||||
|
if not oem_no:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if oem_no not in part_cache:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
|
||||||
|
RETURNING id_part
|
||||||
|
""", (oem_no, product_name, f"OEM {oem_brand}"))
|
||||||
|
part_cache[oem_no] = cur.fetchone()[0]
|
||||||
|
stats['parts'] += 1
|
||||||
|
else:
|
||||||
|
# Update the existing AFT- placeholder with real OEM number
|
||||||
|
stats['updated'] += 1
|
||||||
|
|
||||||
|
part_id = part_cache[oem_no]
|
||||||
|
|
||||||
|
# Cross-reference
|
||||||
|
if article_no and supplier:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, source_ref)
|
||||||
|
VALUES (%s, %s, %s) ON CONFLICT DO NOTHING
|
||||||
|
""", (part_id, article_no, supplier))
|
||||||
|
stats['xrefs'] += 1
|
||||||
|
|
||||||
|
imported.add(article_id)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
save_imported(imported)
|
||||||
|
total_details = len(list(DETAILS_DIR.glob("*.json")))
|
||||||
|
print(f" Imported batch: +{stats['parts']} parts, +{stats['xrefs']} xrefs, +{stats['mfrs']} mfrs | "
|
||||||
|
f"Total imported: {len(imported):,} | Details on disk: {total_details:,}", flush=True)
|
||||||
|
|
||||||
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
148
scripts/import_phase1.py
Normal file
148
scripts/import_phase1.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quick import of Phase 1 TecDoc article data into PostgreSQL.
|
||||||
|
Imports aftermarket parts and their vehicle mappings from article list files,
|
||||||
|
without waiting for OEM detail downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import psycopg2
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
ARTICLES_DIR = Path("/home/Autopartes/data/tecdoc/parts/articles")
|
||||||
|
DETAILS_DIR = Path("/home/Autopartes/data/tecdoc/parts/details")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Load category mapping: tecdoc_id → id_part_category
|
||||||
|
cur.execute("SELECT id_part_category, tecdoc_id FROM part_categories WHERE tecdoc_id IS NOT NULL")
|
||||||
|
cat_map = {r[1]: r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Load existing manufacturers
|
||||||
|
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
|
||||||
|
mfr_cache = {r[1]: r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Load existing parts by OEM
|
||||||
|
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
|
||||||
|
part_cache = {r[0]: r[1] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Load existing cross-refs to avoid duplicates
|
||||||
|
cur.execute("SELECT part_id, cross_reference_number, source_ref FROM part_cross_references")
|
||||||
|
xref_set = {(r[0], r[1], r[2]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Also check detail files for OEM numbers
|
||||||
|
detail_oem = {} # articleId → list of {oemBrand, oemDisplayNo}
|
||||||
|
detail_files = list(DETAILS_DIR.glob("*.json"))
|
||||||
|
print(f"Loading {len(detail_files)} detail files for OEM data...", flush=True)
|
||||||
|
for f in detail_files:
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
article = data.get('article', {})
|
||||||
|
if article and article.get('oemNo'):
|
||||||
|
detail_oem[int(f.stem)] = article['oemNo']
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stats = {'parts': 0, 'xrefs': 0, 'mfrs': 0, 'skipped': 0}
|
||||||
|
|
||||||
|
article_files = sorted(ARTICLES_DIR.glob("*.json"))
|
||||||
|
print(f"Processing {len(article_files)} article files...", flush=True)
|
||||||
|
|
||||||
|
# Collect all unique articles across all files
|
||||||
|
all_articles = {} # articleId → article data + category
|
||||||
|
for f in article_files:
|
||||||
|
parts = f.stem.split("_")
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
cat_id = int(parts[1])
|
||||||
|
cat_db_id = cat_map.get(cat_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
articles = json.loads(f.read_text())
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
aid = a.get('articleId')
|
||||||
|
if aid and aid not in all_articles:
|
||||||
|
a['_cat_db_id'] = cat_db_id
|
||||||
|
a['_cat_td_id'] = cat_id
|
||||||
|
all_articles[aid] = a
|
||||||
|
|
||||||
|
print(f"Unique articles to process: {len(all_articles):,}", flush=True)
|
||||||
|
|
||||||
|
batch = 0
|
||||||
|
for aid, a in all_articles.items():
|
||||||
|
article_no = a.get('articleNo', '')
|
||||||
|
supplier = a.get('supplierName', '')
|
||||||
|
product_name = a.get('articleProductName', '')
|
||||||
|
cat_db_id = a.get('_cat_db_id')
|
||||||
|
|
||||||
|
if not article_no or not supplier:
|
||||||
|
stats['skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ensure manufacturer exists
|
||||||
|
if supplier not in mfr_cache:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
|
||||||
|
(supplier,))
|
||||||
|
mfr_cache[supplier] = cur.fetchone()[0]
|
||||||
|
stats['mfrs'] += 1
|
||||||
|
|
||||||
|
# If we have OEM details for this article, create OEM parts
|
||||||
|
oem_numbers = detail_oem.get(aid, [])
|
||||||
|
if oem_numbers:
|
||||||
|
for oem in oem_numbers:
|
||||||
|
oem_no = oem.get('oemDisplayNo', '')
|
||||||
|
oem_brand = oem.get('oemBrand', '')
|
||||||
|
if not oem_no:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if oem_no not in part_cache:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
|
||||||
|
RETURNING id_part
|
||||||
|
""", (oem_no, product_name, f"OEM {oem_brand}"))
|
||||||
|
part_cache[oem_no] = cur.fetchone()[0]
|
||||||
|
stats['parts'] += 1
|
||||||
|
|
||||||
|
part_id = part_cache[oem_no]
|
||||||
|
|
||||||
|
# Add cross-reference (aftermarket → OEM)
|
||||||
|
xref_key = (part_id, article_no, supplier)
|
||||||
|
if xref_key not in xref_set:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_reference_number, source_ref)
|
||||||
|
VALUES (%s, %s, %s) ON CONFLICT DO NOTHING
|
||||||
|
""", (part_id, article_no, supplier))
|
||||||
|
xref_set.add(xref_key)
|
||||||
|
stats['xrefs'] += 1
|
||||||
|
else:
|
||||||
|
# No OEM data yet - skip, will be imported when detail arrives
|
||||||
|
pass
|
||||||
|
|
||||||
|
batch += 1
|
||||||
|
if batch % 5000 == 0:
|
||||||
|
conn.commit()
|
||||||
|
print(f" {batch:,}/{len(all_articles):,} — {stats['parts']:,} parts, {stats['xrefs']:,} xrefs", flush=True)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n{'='*50}", flush=True)
|
||||||
|
print(f"IMPORT COMPLETE", flush=True)
|
||||||
|
print(f" Parts: {stats['parts']:,}", flush=True)
|
||||||
|
print(f" Cross-refs: {stats['xrefs']:,}", flush=True)
|
||||||
|
print(f" Manufacturers: {stats['mfrs']:,}", flush=True)
|
||||||
|
print(f" Skipped: {stats['skipped']:,}", flush=True)
|
||||||
|
print(f"{'='*50}", flush=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
414
scripts/import_tecdoc.py
Normal file
414
scripts/import_tecdoc.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import vehicle data from TecDoc (Apify) into Nexus Autoparts PostgreSQL.
|
||||||
|
|
||||||
|
Two-phase approach:
|
||||||
|
Phase 1: Download all data from TecDoc API to local JSON files
|
||||||
|
Phase 2: Import JSON files into PostgreSQL
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/import_tecdoc.py download # Phase 1: fetch from API
|
||||||
|
python3 scripts/import_tecdoc.py download --brand TOYOTA # Single brand
|
||||||
|
python3 scripts/import_tecdoc.py import # Phase 2: load into DB
|
||||||
|
python3 scripts/import_tecdoc.py status # Check progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
APIFY_TOKEN = os.environ.get("APIFY_TOKEN", "apify_api_l5SrcwYyanAO45AFxrEpviUcuVRIFK2yPdc5")
|
||||||
|
APIFY_ACTOR = "making-data-meaningful~tecdoc"
|
||||||
|
APIFY_URL = f"https://api.apify.com/v2/acts/{APIFY_ACTOR}/run-sync-get-dataset-items"
|
||||||
|
DB_URL = os.environ.get("DATABASE_URL", "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts")
|
||||||
|
|
||||||
|
TYPE_ID = 1 # Passenger cars
|
||||||
|
LANG_ID = 4 # English
|
||||||
|
COUNTRY_ID = 153 # Mexico
|
||||||
|
|
||||||
|
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
|
||||||
|
APIFY_DELAY = 1.0 # seconds between API calls
|
||||||
|
|
||||||
|
|
||||||
|
def apify_call(input_data, retries=3):
|
||||||
|
"""Call Apify actor and return result."""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
APIFY_URL, params={"token": APIFY_TOKEN},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json=input_data, timeout=120
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
return data[0] if isinstance(data, list) and data else data
|
||||||
|
elif resp.status_code == 429:
|
||||||
|
wait = 15 * (attempt + 1)
|
||||||
|
print(f" Rate limited, waiting {wait}s...", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f" HTTP {resp.status_code}: {resp.text[:100]}", flush=True)
|
||||||
|
time.sleep(5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}", flush=True)
|
||||||
|
time.sleep(5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Phase 1: Download ────────────────
|
||||||
|
|
||||||
|
def download(brand_filter=None):
|
||||||
|
"""Download all TecDoc data to local JSON files."""
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Step 1: Manufacturers
|
||||||
|
mfr_file = DATA_DIR / "manufacturers.json"
|
||||||
|
if mfr_file.exists():
|
||||||
|
manufacturers = json.loads(mfr_file.read_text())
|
||||||
|
print(f"Loaded {len(manufacturers)} cached manufacturers", flush=True)
|
||||||
|
else:
|
||||||
|
print("Fetching manufacturers...", flush=True)
|
||||||
|
result = apify_call({"endpoint_manufacturerIdsByTypeId": True, "manufacturer_typeId_2": TYPE_ID})
|
||||||
|
manufacturers = result["manufacturers"]
|
||||||
|
mfr_file.write_text(json.dumps(manufacturers, indent=1))
|
||||||
|
print(f" Saved {len(manufacturers)} manufacturers", flush=True)
|
||||||
|
|
||||||
|
if brand_filter:
|
||||||
|
manufacturers = [m for m in manufacturers if brand_filter.upper() in m["manufacturerName"].upper()]
|
||||||
|
print(f"Filtered to {len(manufacturers)} matching '{brand_filter}'", flush=True)
|
||||||
|
|
||||||
|
# Step 2: Models for each manufacturer
|
||||||
|
models_dir = DATA_DIR / "models"
|
||||||
|
models_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for i, mfr in enumerate(manufacturers):
|
||||||
|
mfr_id = mfr["manufacturerId"]
|
||||||
|
mfr_name = mfr["manufacturerName"]
|
||||||
|
model_file = models_dir / f"{mfr_id}.json"
|
||||||
|
|
||||||
|
if model_file.exists():
|
||||||
|
continue # Skip already downloaded
|
||||||
|
|
||||||
|
print(f"[{i+1}/{len(manufacturers)}] {mfr_name} (id={mfr_id})", flush=True)
|
||||||
|
time.sleep(APIFY_DELAY)
|
||||||
|
|
||||||
|
result = apify_call({
|
||||||
|
"endpoint_modelsByTypeManufacturer": True,
|
||||||
|
"models_typeId_1": TYPE_ID,
|
||||||
|
"models_manufacturerId_1": mfr_id,
|
||||||
|
"models_langId_1": LANG_ID,
|
||||||
|
"models_countryFilterId_1": COUNTRY_ID
|
||||||
|
})
|
||||||
|
|
||||||
|
models = result.get("models", []) if result else []
|
||||||
|
model_file.write_text(json.dumps(models, indent=1))
|
||||||
|
print(f" {len(models)} models", flush=True)
|
||||||
|
|
||||||
|
# Step 3: Vehicle types for each model
|
||||||
|
vehicles_dir = DATA_DIR / "vehicles"
|
||||||
|
vehicles_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Iterate all model files
|
||||||
|
total_models = 0
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for model_file in sorted(models_dir.glob("*.json")):
|
||||||
|
mfr_id = model_file.stem
|
||||||
|
models = json.loads(model_file.read_text())
|
||||||
|
total_models += len(models)
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
td_model_id = model["modelId"]
|
||||||
|
vehicle_file = vehicles_dir / f"{td_model_id}.json"
|
||||||
|
|
||||||
|
if vehicle_file.exists():
|
||||||
|
processed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" [{processed+1}/{total_models}] Model {model['modelName']} (id={td_model_id})", flush=True)
|
||||||
|
time.sleep(APIFY_DELAY)
|
||||||
|
|
||||||
|
result = apify_call({
|
||||||
|
"endpoint_vehicleEngineTypesByModel": True,
|
||||||
|
"vehicle_typeId_3": TYPE_ID,
|
||||||
|
"vehicle_modelId_3": td_model_id,
|
||||||
|
"vehicle_langId_3": LANG_ID,
|
||||||
|
"vehicle_countryFilterId_3": COUNTRY_ID
|
||||||
|
})
|
||||||
|
|
||||||
|
vehicles = result.get("modelTypes", []) if result else []
|
||||||
|
vehicle_file.write_text(json.dumps(vehicles, indent=1))
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
print(f"\nDownload complete! {processed} model vehicle files.", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Phase 2: Import ────────────────
|
||||||
|
|
||||||
|
def parse_fuel_id(fuel_str):
|
||||||
|
if not fuel_str:
|
||||||
|
return None
|
||||||
|
f = fuel_str.lower()
|
||||||
|
if "diesel" in f:
|
||||||
|
return 1
|
||||||
|
if "electric" in f and "petrol" not in f and "gas" not in f:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def parse_body_id(model_name):
|
||||||
|
if not model_name:
|
||||||
|
return None
|
||||||
|
mapping = {
|
||||||
|
"Saloon": 1, "Sedan": 1, "Coupe": 2, "Coupé": 2,
|
||||||
|
"Hatchback": 3, "SUV": 4, "Off-Road": 4, "Crossover": 5,
|
||||||
|
"Truck": 6, "Van": 7, "Box Body": 7, "MPV": 8,
|
||||||
|
"Estate": 9, "Wagon": 9, "Kombi": 9,
|
||||||
|
"Convertible": 10, "Cabrio": 10, "Cabriolet": 10,
|
||||||
|
"Pick-up": 11, "Pickup": 11,
|
||||||
|
"Platform": 12, "Chassis": 12, "Bus": 13, "Roadster": 15,
|
||||||
|
}
|
||||||
|
for key, val in mapping.items():
|
||||||
|
if key in model_name:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def do_import():
|
||||||
|
"""Import downloaded JSON data into PostgreSQL."""
|
||||||
|
if not DATA_DIR.exists():
|
||||||
|
print("No data directory found. Run 'download' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mfr_file = DATA_DIR / "manufacturers.json"
|
||||||
|
if not mfr_file.exists():
|
||||||
|
print("No manufacturers.json found. Run 'download' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manufacturers = json.loads(mfr_file.read_text())
|
||||||
|
models_dir = DATA_DIR / "models"
|
||||||
|
vehicles_dir = DATA_DIR / "vehicles"
|
||||||
|
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Ensure years exist (1950–2027)
|
||||||
|
cur.execute("SELECT id_year, year_car FROM years")
|
||||||
|
year_cache = {r[1]: r[0] for r in cur.fetchall()}
|
||||||
|
for y in range(1950, 2028):
|
||||||
|
if y not in year_cache:
|
||||||
|
cur.execute("INSERT INTO years (year_car) VALUES (%s) RETURNING id_year", (y,))
|
||||||
|
year_cache[y] = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
brand_cache = {}
|
||||||
|
model_cache = {}
|
||||||
|
engine_cache = {}
|
||||||
|
mye_set = set()
|
||||||
|
|
||||||
|
stats = {"brands": 0, "models": 0, "engines": 0, "mye": 0, "skipped": 0}
|
||||||
|
current_year = datetime.now().year
|
||||||
|
|
||||||
|
for mfr in manufacturers:
|
||||||
|
mfr_id = mfr["manufacturerId"]
|
||||||
|
brand_name = mfr["manufacturerName"]
|
||||||
|
|
||||||
|
# Skip regional duplicates
|
||||||
|
if "(" in brand_name:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_file = models_dir / f"{mfr_id}.json"
|
||||||
|
if not model_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
models = json.loads(model_file.read_text())
|
||||||
|
if not models:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert brand
|
||||||
|
if brand_name not in brand_cache:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO brands (name_brand) VALUES (%s) ON CONFLICT (name_brand) DO UPDATE SET name_brand=EXCLUDED.name_brand RETURNING id_brand",
|
||||||
|
(brand_name,))
|
||||||
|
brand_cache[brand_name] = cur.fetchone()[0]
|
||||||
|
stats["brands"] += 1
|
||||||
|
|
||||||
|
brand_id = brand_cache[brand_name]
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
model_name = model.get("modelName")
|
||||||
|
if not model_name:
|
||||||
|
continue
|
||||||
|
td_model_id = model["modelId"]
|
||||||
|
year_from = model.get("modelYearFrom", "")[:4] if model.get("modelYearFrom") else None
|
||||||
|
year_to = model.get("modelYearTo", "")[:4] if model.get("modelYearTo") else None
|
||||||
|
body_id = parse_body_id(model_name)
|
||||||
|
|
||||||
|
# Insert model
|
||||||
|
model_key = (brand_id, model_name)
|
||||||
|
if model_key not in model_cache:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO models (brand_id, name_model, id_body, production_start_year, production_end_year)
|
||||||
|
VALUES (%s, %s, %s, %s, %s) RETURNING id_model""",
|
||||||
|
(brand_id, model_name, body_id,
|
||||||
|
int(year_from) if year_from else None,
|
||||||
|
int(year_to) if year_to else None))
|
||||||
|
model_cache[model_key] = cur.fetchone()[0]
|
||||||
|
stats["models"] += 1
|
||||||
|
|
||||||
|
model_db_id = model_cache[model_key]
|
||||||
|
|
||||||
|
# Load vehicles
|
||||||
|
vehicle_file = vehicles_dir / f"{td_model_id}.json"
|
||||||
|
if not vehicle_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
vehicles = json.loads(vehicle_file.read_text())
|
||||||
|
if not vehicles:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Dedup by vehicleId
|
||||||
|
seen_v = {}
|
||||||
|
for v in vehicles:
|
||||||
|
vid = v["vehicleId"]
|
||||||
|
if vid not in seen_v:
|
||||||
|
seen_v[vid] = v
|
||||||
|
seen_v[vid]["_codes"] = [v.get("engineCodes", "")]
|
||||||
|
else:
|
||||||
|
c = v.get("engineCodes", "")
|
||||||
|
if c and c not in seen_v[vid]["_codes"]:
|
||||||
|
seen_v[vid]["_codes"].append(c)
|
||||||
|
|
||||||
|
for v in seen_v.values():
|
||||||
|
cap_lt = float(v["capacityLt"]) if v.get("capacityLt") else 0
|
||||||
|
cylinders = v.get("numberOfCylinders")
|
||||||
|
fuel = v.get("fuelType", "")
|
||||||
|
power_ps = float(v["powerPs"]) if v.get("powerPs") else 0
|
||||||
|
power_hp = int(power_ps * 0.9863) if power_ps else None
|
||||||
|
displacement = float(v["capacityTech"]) if v.get("capacityTech") else None
|
||||||
|
codes = ", ".join(v["_codes"])
|
||||||
|
fuel_id = parse_fuel_id(fuel)
|
||||||
|
|
||||||
|
# Build engine name
|
||||||
|
fl = fuel.lower() if fuel else ""
|
||||||
|
if "electric" in fl and "petrol" not in fl and cap_lt == 0:
|
||||||
|
eng_name = f"Electric {power_hp}hp" if power_hp else "Electric"
|
||||||
|
else:
|
||||||
|
eng_name = f"{cap_lt:.1f}L"
|
||||||
|
if cylinders:
|
||||||
|
eng_name += f" {cylinders}cyl"
|
||||||
|
if "diesel" in fl:
|
||||||
|
eng_name += " Diesel"
|
||||||
|
elif "electric" in fl:
|
||||||
|
eng_name += " Hybrid"
|
||||||
|
if power_hp:
|
||||||
|
eng_name += f" {power_hp}hp"
|
||||||
|
|
||||||
|
engine_key = (eng_name, displacement, cylinders, fuel_id, power_hp, codes)
|
||||||
|
if engine_key not in engine_cache:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO engines (name_engine, displacement_cc, cylinders, id_fuel, power_hp, engine_code)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id_engine""",
|
||||||
|
(eng_name, displacement, cylinders, fuel_id, power_hp, codes))
|
||||||
|
engine_cache[engine_key] = cur.fetchone()[0]
|
||||||
|
stats["engines"] += 1
|
||||||
|
|
||||||
|
engine_db_id = engine_cache[engine_key]
|
||||||
|
|
||||||
|
start_str = v.get("constructionIntervalStart")
|
||||||
|
end_str = v.get("constructionIntervalEnd")
|
||||||
|
if not start_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_year = max(int(start_str[:4]), 1950)
|
||||||
|
end_year = min(int(end_str[:4]) if end_str else current_year, current_year + 1)
|
||||||
|
trim = v.get("typeEngineName", "")
|
||||||
|
|
||||||
|
for year in range(start_year, end_year + 1):
|
||||||
|
yid = year_cache.get(year)
|
||||||
|
if not yid:
|
||||||
|
continue
|
||||||
|
mye_key = (model_db_id, yid, engine_db_id, trim)
|
||||||
|
if mye_key in mye_set:
|
||||||
|
continue
|
||||||
|
mye_set.add(mye_key)
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO model_year_engine (model_id, year_id, engine_id, trim_level)
|
||||||
|
VALUES (%s, %s, %s, %s) ON CONFLICT DO NOTHING""",
|
||||||
|
(model_db_id, yid, engine_db_id, trim))
|
||||||
|
stats["mye"] += 1
|
||||||
|
|
||||||
|
# Commit per brand
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}", flush=True)
|
||||||
|
print(f"IMPORT COMPLETE", flush=True)
|
||||||
|
print(f" Brands: {stats['brands']} ({stats['skipped']} regional skipped)", flush=True)
|
||||||
|
print(f" Models: {stats['models']}", flush=True)
|
||||||
|
print(f" Engines: {stats['engines']}", flush=True)
|
||||||
|
print(f" MYE: {stats['mye']}", flush=True)
|
||||||
|
print(f"{'='*60}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Status ────────────────
|
||||||
|
|
||||||
|
def status():
|
||||||
|
"""Show download progress."""
|
||||||
|
if not DATA_DIR.exists():
|
||||||
|
print("No data directory yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
mfr_file = DATA_DIR / "manufacturers.json"
|
||||||
|
if not mfr_file.exists():
|
||||||
|
print("Manufacturers not downloaded yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manufacturers = json.loads(mfr_file.read_text())
|
||||||
|
models_dir = DATA_DIR / "models"
|
||||||
|
vehicles_dir = DATA_DIR / "vehicles"
|
||||||
|
|
||||||
|
model_files = list(models_dir.glob("*.json")) if models_dir.exists() else []
|
||||||
|
vehicle_files = list(vehicles_dir.glob("*.json")) if vehicles_dir.exists() else []
|
||||||
|
|
||||||
|
total_models = 0
|
||||||
|
for f in model_files:
|
||||||
|
total_models += len(json.loads(f.read_text()))
|
||||||
|
|
||||||
|
print(f"Manufacturers: {len(manufacturers)} total")
|
||||||
|
print(f"Model files: {len(model_files)} / {len(manufacturers)} brands downloaded")
|
||||||
|
print(f"Total models: {total_models}")
|
||||||
|
print(f"Vehicle files: {len(vehicle_files)} / {total_models} models downloaded")
|
||||||
|
|
||||||
|
if total_models > 0:
|
||||||
|
pct = len(vehicle_files) / total_models * 100
|
||||||
|
print(f"Progress: {pct:.1f}%")
|
||||||
|
remaining = total_models - len(vehicle_files)
|
||||||
|
est_minutes = remaining * APIFY_DELAY / 60 + remaining * 3 / 60 # delay + avg API time
|
||||||
|
print(f"Est. remaining: ~{est_minutes:.0f} minutes ({remaining} API calls)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="TecDoc vehicle data import")
|
||||||
|
parser.add_argument("command", choices=["download", "import", "status"])
|
||||||
|
parser.add_argument("--brand", help="Filter by brand name")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "download":
|
||||||
|
download(brand_filter=args.brand)
|
||||||
|
elif args.command == "import":
|
||||||
|
do_import()
|
||||||
|
elif args.command == "status":
|
||||||
|
status()
|
||||||
531
scripts/import_tecdoc_parts.py
Normal file
531
scripts/import_tecdoc_parts.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import OEM parts data from TecDoc (Apify) into Nexus Autoparts PostgreSQL.
|
||||||
|
|
||||||
|
Three-phase approach:
|
||||||
|
Phase 1: Download categories per vehicle → JSON files
|
||||||
|
Phase 2: Download article lists per vehicle+category → JSON files
|
||||||
|
Phase 3: Download article details (OEM numbers) → JSON files
|
||||||
|
Phase 4: Import all JSON data into PostgreSQL
|
||||||
|
|
||||||
|
Uses one representative vehicleId per TecDoc model to minimize API calls.
|
||||||
|
Supports concurrent API calls for speed.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/import_tecdoc_parts.py download # Phases 1-3
|
||||||
|
python3 scripts/import_tecdoc_parts.py import # Phase 4
|
||||||
|
python3 scripts/import_tecdoc_parts.py status # Check progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
APIFY_TOKEN = os.environ.get("APIFY_TOKEN", "apify_api_l5SrcwYyanAO45AFxrEpviUcuVRIFK2yPdc5")
|
||||||
|
APIFY_ACTOR = "making-data-meaningful~tecdoc"
|
||||||
|
APIFY_URL = f"https://api.apify.com/v2/acts/{APIFY_ACTOR}/run-sync-get-dataset-items"
|
||||||
|
DB_URL = os.environ.get("DATABASE_URL", "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts")
|
||||||
|
|
||||||
|
TYPE_ID = 1 # Passenger cars
|
||||||
|
LANG_ID = 4 # English
|
||||||
|
COUNTRY_ID = 153 # Mexico
|
||||||
|
|
||||||
|
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
|
||||||
|
PARTS_DIR = DATA_DIR / "parts"
|
||||||
|
ARTICLES_DIR = PARTS_DIR / "articles" # vehicle articles by category
|
||||||
|
DETAILS_DIR = PARTS_DIR / "details" # article OEM details
|
||||||
|
|
||||||
|
MAX_WORKERS = 30 # Concurrent API calls
|
||||||
|
APIFY_DELAY = 0.1 # Seconds between API calls per thread
|
||||||
|
|
||||||
|
# Top brands for Mexico & USA
|
||||||
|
TOP_BRANDS = [
|
||||||
|
'TOYOTA', 'NISSAN', 'CHEVROLET', 'VOLKSWAGEN', 'VW', 'HONDA', 'FORD',
|
||||||
|
'HYUNDAI', 'KIA', 'MAZDA', 'BMW', 'MERCEDES-BENZ', 'AUDI',
|
||||||
|
'JEEP', 'DODGE', 'CHRYSLER', 'RAM', 'GMC', 'BUICK', 'CADILLAC',
|
||||||
|
'SUBARU', 'MITSUBISHI', 'SUZUKI', 'ACURA', 'LEXUS', 'INFINITI',
|
||||||
|
'LINCOLN', 'FIAT', 'PEUGEOT', 'RENAULT', 'SEAT'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top-level TecDoc category IDs (from our DB)
|
||||||
|
TOP_CATEGORIES = None # Loaded dynamically
|
||||||
|
|
||||||
|
|
||||||
|
def apify_call(input_data, retries=3):
|
||||||
|
"""Call Apify actor and return result."""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
APIFY_URL, params={"token": APIFY_TOKEN},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json=input_data, timeout=180
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
return data[0] if isinstance(data, list) and data else data
|
||||||
|
elif resp.status_code == 429:
|
||||||
|
wait = 30 * (attempt + 1)
|
||||||
|
print(f" Rate limited, waiting {wait}s...", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f" HTTP {resp.status_code}: {resp.text[:200]}", flush=True)
|
||||||
|
time.sleep(5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}", flush=True)
|
||||||
|
time.sleep(5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_top_categories():
|
||||||
|
"""Load top-level TecDoc category IDs from database."""
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT tecdoc_id, name_part_category FROM part_categories WHERE tecdoc_id IS NOT NULL ORDER BY display_order")
|
||||||
|
cats = [(r[0], r[1]) for r in cur.fetchall()]
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return cats
|
||||||
|
|
||||||
|
|
||||||
|
def get_representative_vehicles():
|
||||||
|
"""Get one representative vehicleId per TecDoc model for top brands."""
|
||||||
|
mfrs = json.loads((DATA_DIR / "manufacturers.json").read_text())
|
||||||
|
models_dir = DATA_DIR / "models"
|
||||||
|
vehicles_dir = DATA_DIR / "vehicles"
|
||||||
|
|
||||||
|
representatives = [] # (vehicleId, brand_name, model_name, td_model_id)
|
||||||
|
|
||||||
|
for mfr in mfrs:
|
||||||
|
name = mfr['manufacturerName']
|
||||||
|
if '(' in name:
|
||||||
|
continue
|
||||||
|
if name.upper() not in [b.upper() for b in TOP_BRANDS]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mfr_id = mfr['manufacturerId']
|
||||||
|
model_file = models_dir / f"{mfr_id}.json"
|
||||||
|
if not model_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
models = json.loads(model_file.read_text())
|
||||||
|
for model in models:
|
||||||
|
td_model_id = model['modelId']
|
||||||
|
model_name = model.get('modelName', '')
|
||||||
|
vehicle_file = vehicles_dir / f"{td_model_id}.json"
|
||||||
|
if not vehicle_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
vehicles = json.loads(vehicle_file.read_text())
|
||||||
|
if not vehicles:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pick the first vehicle with a valid vehicleId as representative
|
||||||
|
vid = vehicles[0].get('vehicleId')
|
||||||
|
if vid:
|
||||||
|
# Also collect ALL vehicleIds for this model
|
||||||
|
all_vids = [v['vehicleId'] for v in vehicles if v.get('vehicleId')]
|
||||||
|
representatives.append({
|
||||||
|
'vehicleId': vid,
|
||||||
|
'allVehicleIds': all_vids,
|
||||||
|
'brand': name,
|
||||||
|
'model': model_name,
|
||||||
|
'tdModelId': td_model_id
|
||||||
|
})
|
||||||
|
|
||||||
|
return representatives
|
||||||
|
|
||||||
|
|
||||||
|
def download_articles_for_vehicle(vid, category_id, category_name):
|
||||||
|
"""Download article list for a vehicle+category. Returns article count."""
|
||||||
|
outfile = ARTICLES_DIR / f"{vid}_{category_id}.json"
|
||||||
|
if outfile.exists():
|
||||||
|
return 0 # Already downloaded
|
||||||
|
|
||||||
|
time.sleep(APIFY_DELAY)
|
||||||
|
result = apify_call({
|
||||||
|
'endpoint_partsArticleListByVehicleIdCategoryId': True,
|
||||||
|
'parts_vehicleId_18': vid,
|
||||||
|
'parts_categoryId_18': category_id,
|
||||||
|
'parts_typeId_18': TYPE_ID,
|
||||||
|
'parts_langId_18': LANG_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result and isinstance(result, dict) and 'articles' in result:
|
||||||
|
articles = result.get('articles') or []
|
||||||
|
outfile.write_text(json.dumps(articles, indent=1))
|
||||||
|
return len(articles)
|
||||||
|
else:
|
||||||
|
# Save empty to avoid re-querying
|
||||||
|
outfile.write_text("[]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def download_article_detail(article_id):
|
||||||
|
"""Download OEM details for a single article."""
|
||||||
|
outfile = DETAILS_DIR / f"{article_id}.json"
|
||||||
|
if outfile.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
time.sleep(APIFY_DELAY)
|
||||||
|
result = apify_call({
|
||||||
|
'endpoint_partsArticleDetailsByArticleId': True,
|
||||||
|
'parts_articleId_13': article_id,
|
||||||
|
'parts_langId_13': LANG_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result and result.get('articleOemNo'):
|
||||||
|
outfile.write_text(json.dumps(result, indent=1))
|
||||||
|
return True
|
||||||
|
elif result and isinstance(result.get('article'), dict):
|
||||||
|
outfile.write_text(json.dumps(result, indent=1))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Download ────────────────
|
||||||
|
|
||||||
|
def download(brand_filter=None):
|
||||||
|
"""Download all parts data from TecDoc."""
|
||||||
|
PARTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ARTICLES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DETAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
categories = load_top_categories()
|
||||||
|
print(f"Loaded {len(categories)} top-level categories", flush=True)
|
||||||
|
|
||||||
|
representatives = get_representative_vehicles()
|
||||||
|
if brand_filter:
|
||||||
|
representatives = [r for r in representatives if brand_filter.upper() in r['brand'].upper()]
|
||||||
|
print(f"Found {len(representatives)} representative vehicles for top brands", flush=True)
|
||||||
|
|
||||||
|
# Phase 1: Download articles per vehicle+category
|
||||||
|
total_tasks = len(representatives) * len(categories)
|
||||||
|
completed = 0
|
||||||
|
total_articles = 0
|
||||||
|
|
||||||
|
print(f"\n{'='*60}", flush=True)
|
||||||
|
print(f"PHASE 1: Download articles ({total_tasks:,} tasks)", flush=True)
|
||||||
|
print(f"{'='*60}", flush=True)
|
||||||
|
|
||||||
|
for i, rep in enumerate(representatives):
|
||||||
|
vid = rep['vehicleId']
|
||||||
|
brand = rep['brand']
|
||||||
|
model = rep['model']
|
||||||
|
|
||||||
|
# Check if all categories already downloaded for this vehicle
|
||||||
|
existing = sum(1 for cat_id, _ in categories
|
||||||
|
if (ARTICLES_DIR / f"{vid}_{cat_id}.json").exists())
|
||||||
|
if existing == len(categories):
|
||||||
|
completed += len(categories)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{i+1}/{len(representatives)}] {brand} {model} (vid={vid})", flush=True)
|
||||||
|
|
||||||
|
def download_task(args):
|
||||||
|
vid, cat_id, cat_name = args
|
||||||
|
return download_articles_for_vehicle(vid, cat_id, cat_name)
|
||||||
|
|
||||||
|
tasks = [(vid, cat_id, cat_name) for cat_id, cat_name in categories
|
||||||
|
if not (ARTICLES_DIR / f"{vid}_{cat_id}.json").exists()]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
|
futures = {executor.submit(download_task, t): t for t in tasks}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
try:
|
||||||
|
count = future.result()
|
||||||
|
total_articles += count
|
||||||
|
completed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Task error: {e}", flush=True)
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
completed += existing # Count pre-existing
|
||||||
|
|
||||||
|
print(f"\nPhase 1 complete: {total_articles:,} articles found", flush=True)
|
||||||
|
|
||||||
|
# Phase 2: Collect unique articleIds and download OEM details
|
||||||
|
print(f"\n{'='*60}", flush=True)
|
||||||
|
print(f"PHASE 2: Collect unique articles & download OEM details", flush=True)
|
||||||
|
print(f"{'='*60}", flush=True)
|
||||||
|
|
||||||
|
unique_articles = set()
|
||||||
|
for f in ARTICLES_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
articles = json.loads(f.read_text())
|
||||||
|
for a in articles:
|
||||||
|
if 'articleId' in a:
|
||||||
|
unique_articles.add(a['articleId'])
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter out already downloaded
|
||||||
|
to_download = [aid for aid in unique_articles
|
||||||
|
if not (DETAILS_DIR / f"{aid}.json").exists()]
|
||||||
|
|
||||||
|
print(f"Unique articles: {len(unique_articles):,}", flush=True)
|
||||||
|
print(f"Already have details: {len(unique_articles) - len(to_download):,}", flush=True)
|
||||||
|
print(f"Need to download: {len(to_download):,}", flush=True)
|
||||||
|
|
||||||
|
if to_download:
|
||||||
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
|
futures = {executor.submit(download_article_detail, aid): aid
|
||||||
|
for aid in to_download}
|
||||||
|
done = 0
|
||||||
|
for future in as_completed(futures):
|
||||||
|
done += 1
|
||||||
|
if done % 100 == 0:
|
||||||
|
print(f" Details: {done}/{len(to_download)}", flush=True)
|
||||||
|
|
||||||
|
print(f"\nDownload complete!", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Import ────────────────
|
||||||
|
|
||||||
|
def do_import():
|
||||||
|
"""Import downloaded parts data into PostgreSQL."""
|
||||||
|
if not ARTICLES_DIR.exists():
|
||||||
|
print("No articles directory. Run 'download' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Load category mapping: tecdoc_id → (id_part_category, name)
|
||||||
|
cur.execute("SELECT id_part_category, tecdoc_id, name_part_category FROM part_categories WHERE tecdoc_id IS NOT NULL")
|
||||||
|
cat_map = {r[1]: (r[0], r[2]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Load group mapping: tecdoc_id → id_part_group
|
||||||
|
cur.execute("SELECT id_part_group, tecdoc_id, category_id FROM part_groups WHERE tecdoc_id IS NOT NULL")
|
||||||
|
group_map = {r[1]: (r[0], r[2]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Load brand mapping from DB
|
||||||
|
cur.execute("SELECT id_brand, name_brand FROM brands")
|
||||||
|
brand_db = {r[1].upper(): r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Build vehicle mapping: vehicleId → list of MYE ids
|
||||||
|
representatives = get_representative_vehicles()
|
||||||
|
|
||||||
|
# Build vehicleId → model mapping from our DB
|
||||||
|
# We need to map TecDoc modelId → our model_id
|
||||||
|
cur.execute("""
|
||||||
|
SELECT m.id_model, b.name_brand, m.name_model, m.id_brand
|
||||||
|
FROM models m JOIN brands b ON m.id_brand = b.id_brand
|
||||||
|
""")
|
||||||
|
db_models = cur.fetchall()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'parts_inserted': 0, 'parts_existing': 0,
|
||||||
|
'vehicle_parts': 0, 'aftermarket': 0,
|
||||||
|
'cross_refs': 0, 'manufacturers': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process article detail files
|
||||||
|
detail_files = list(DETAILS_DIR.glob("*.json"))
|
||||||
|
print(f"Processing {len(detail_files)} article details...", flush=True)
|
||||||
|
|
||||||
|
# Cache for parts by OEM number
|
||||||
|
oem_cache = {} # oem_no → id_part
|
||||||
|
|
||||||
|
# Cache for manufacturers
|
||||||
|
mfr_cache = {} # supplier_name → id_manufacture
|
||||||
|
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
mfr_cache[r[1]] = r[0]
|
||||||
|
|
||||||
|
# Cache existing parts
|
||||||
|
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
oem_cache[r[0]] = r[1]
|
||||||
|
|
||||||
|
# Build article→vehicles mapping from article files
|
||||||
|
article_vehicles = {} # articleId → set of vehicleIds
|
||||||
|
article_category = {} # articleId → categoryId (TecDoc)
|
||||||
|
|
||||||
|
for f in ARTICLES_DIR.glob("*.json"):
|
||||||
|
parts = f.stem.split("_")
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
vid, cat_id = int(parts[0]), int(parts[1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
articles = json.loads(f.read_text())
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
aid = a.get('articleId')
|
||||||
|
if aid:
|
||||||
|
if aid not in article_vehicles:
|
||||||
|
article_vehicles[aid] = set()
|
||||||
|
article_vehicles[aid].add(vid)
|
||||||
|
article_category[aid] = cat_id
|
||||||
|
|
||||||
|
print(f"Article→vehicle mappings: {len(article_vehicles)}", flush=True)
|
||||||
|
|
||||||
|
batch_count = 0
|
||||||
|
|
||||||
|
for detail_file in detail_files:
|
||||||
|
article_id = int(detail_file.stem)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(detail_file.read_text())
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
article = data.get('article', {})
|
||||||
|
if not article:
|
||||||
|
continue
|
||||||
|
|
||||||
|
article_no = article.get('articleNo', '')
|
||||||
|
supplier_name = article.get('supplierName', '')
|
||||||
|
product_name = article.get('articleProductName', '')
|
||||||
|
supplier_id = article.get('supplierId')
|
||||||
|
|
||||||
|
# Get OEM numbers
|
||||||
|
oem_numbers = article.get('oemNo', [])
|
||||||
|
if not oem_numbers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get category for this article
|
||||||
|
td_cat_id = article_category.get(article_id)
|
||||||
|
cat_info = cat_map.get(td_cat_id)
|
||||||
|
cat_db_id = cat_info[0] if cat_info else None
|
||||||
|
|
||||||
|
# Ensure manufacturer exists
|
||||||
|
if supplier_name and supplier_name not in mfr_cache:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
|
||||||
|
(supplier_name,))
|
||||||
|
mfr_cache[supplier_name] = cur.fetchone()[0]
|
||||||
|
stats['manufacturers'] += 1
|
||||||
|
|
||||||
|
mfr_id = mfr_cache.get(supplier_name)
|
||||||
|
|
||||||
|
# Insert each OEM part
|
||||||
|
for oem_entry in oem_numbers:
|
||||||
|
oem_no = oem_entry.get('oemDisplayNo', '')
|
||||||
|
oem_brand = oem_entry.get('oemBrand', '')
|
||||||
|
if not oem_no:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert OEM part if not exists
|
||||||
|
if oem_no not in oem_cache:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO parts (oem_part_number, name_part, name_es, category_id, description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (oem_part_number) DO UPDATE SET name_part = EXCLUDED.name_part
|
||||||
|
RETURNING id_part
|
||||||
|
""", (oem_no, product_name, None, cat_db_id, f"OEM {oem_brand}"))
|
||||||
|
oem_cache[oem_no] = cur.fetchone()[0]
|
||||||
|
stats['parts_inserted'] += 1
|
||||||
|
else:
|
||||||
|
stats['parts_existing'] += 1
|
||||||
|
|
||||||
|
part_id = oem_cache[oem_no]
|
||||||
|
|
||||||
|
# Insert aftermarket cross-reference
|
||||||
|
if article_no and supplier_name:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO part_cross_references (part_id, cross_ref_number, id_ref_type, source_ref)
|
||||||
|
VALUES (%s, %s, NULL, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (part_id, article_no, supplier_name))
|
||||||
|
stats['cross_refs'] += 1
|
||||||
|
|
||||||
|
batch_count += 1
|
||||||
|
if batch_count % 500 == 0:
|
||||||
|
conn.commit()
|
||||||
|
print(f" Processed {batch_count}/{len(detail_files)} articles, "
|
||||||
|
f"{stats['parts_inserted']} parts inserted", flush=True)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}", flush=True)
|
||||||
|
print(f"IMPORT COMPLETE", flush=True)
|
||||||
|
print(f" Parts inserted: {stats['parts_inserted']:,}", flush=True)
|
||||||
|
print(f" Parts existing: {stats['parts_existing']:,}", flush=True)
|
||||||
|
print(f" Cross-references: {stats['cross_refs']:,}", flush=True)
|
||||||
|
print(f" Manufacturers: {stats['manufacturers']:,}", flush=True)
|
||||||
|
print(f"{'='*60}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────── Status ────────────────
|
||||||
|
|
||||||
|
def status():
|
||||||
|
"""Show download progress."""
|
||||||
|
categories = load_top_categories()
|
||||||
|
representatives = get_representative_vehicles()
|
||||||
|
|
||||||
|
print(f"Representative vehicles: {len(representatives)}")
|
||||||
|
print(f"Categories: {len(categories)}")
|
||||||
|
print(f"Expected article files: {len(representatives) * len(categories):,}")
|
||||||
|
|
||||||
|
article_files = list(ARTICLES_DIR.glob("*.json")) if ARTICLES_DIR.exists() else []
|
||||||
|
detail_files = list(DETAILS_DIR.glob("*.json")) if DETAILS_DIR.exists() else []
|
||||||
|
|
||||||
|
# Count unique articleIds
|
||||||
|
unique_articles = set()
|
||||||
|
total_article_count = 0
|
||||||
|
for f in article_files:
|
||||||
|
try:
|
||||||
|
articles = json.loads(f.read_text())
|
||||||
|
for a in articles:
|
||||||
|
if 'articleId' in a:
|
||||||
|
unique_articles.add(a['articleId'])
|
||||||
|
total_article_count += len(articles)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
expected = len(representatives) * len(categories)
|
||||||
|
pct_articles = len(article_files) / expected * 100 if expected > 0 else 0
|
||||||
|
|
||||||
|
print(f"\nArticle files: {len(article_files):,} / {expected:,} ({pct_articles:.1f}%)")
|
||||||
|
print(f"Total articles: {total_article_count:,}")
|
||||||
|
print(f"Unique articleIds: {len(unique_articles):,}")
|
||||||
|
print(f"Detail files: {len(detail_files):,} / {len(unique_articles):,}")
|
||||||
|
|
||||||
|
if expected > 0:
|
||||||
|
remaining = expected - len(article_files)
|
||||||
|
est_minutes = remaining * (APIFY_DELAY + 3) / MAX_WORKERS / 60
|
||||||
|
print(f"\nEst. remaining (articles): ~{est_minutes:.0f} min ({remaining:,} calls)")
|
||||||
|
|
||||||
|
remaining_details = len(unique_articles) - len(detail_files)
|
||||||
|
if remaining_details > 0:
|
||||||
|
est_detail_min = remaining_details * (APIFY_DELAY + 3) / MAX_WORKERS / 60
|
||||||
|
print(f"Est. remaining (details): ~{est_detail_min:.0f} min ({remaining_details:,} calls)")
|
||||||
|
|
||||||
|
# Per-brand breakdown
|
||||||
|
print(f"\n{'Brand':20s} {'Models':>7} {'Done':>7} {'%':>6}")
|
||||||
|
print("-" * 44)
|
||||||
|
for brand in sorted(TOP_BRANDS):
|
||||||
|
brand_reps = [r for r in representatives if r['brand'].upper() == brand]
|
||||||
|
brand_done = sum(1 for r in brand_reps
|
||||||
|
for cat_id, _ in categories
|
||||||
|
if (ARTICLES_DIR / f"{r['vehicleId']}_{cat_id}.json").exists())
|
||||||
|
brand_total = len(brand_reps) * len(categories)
|
||||||
|
pct = brand_done / brand_total * 100 if brand_total > 0 else 0
|
||||||
|
print(f" {brand:18s} {len(brand_reps):>7} {brand_done:>7} {pct:>5.1f}%")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="TecDoc parts import")
|
||||||
|
parser.add_argument("command", choices=["download", "import", "status"])
|
||||||
|
parser.add_argument("--brand", help="Filter by brand name")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "download":
|
||||||
|
download(brand_filter=args.brand)
|
||||||
|
elif args.command == "import":
|
||||||
|
do_import()
|
||||||
|
elif args.command == "status":
|
||||||
|
status()
|
||||||
251
scripts/link_vehicle_parts.py
Normal file
251
scripts/link_vehicle_parts.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Link parts to vehicles using TecDoc article files.
|
||||||
|
Maps: article file (vehicleId_categoryId.json) → parts → vehicle_parts (MYE ids)
|
||||||
|
Optimized v3: year+engine filtering + batch inserts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
DATA_DIR = Path("/home/Autopartes/data/tecdoc")
|
||||||
|
ARTICLES_DIR = DATA_DIR / "parts" / "articles"
|
||||||
|
DETAILS_DIR = DATA_DIR / "parts" / "details"
|
||||||
|
|
||||||
|
BATCH_SIZE = 50000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_capacity_liters(cap):
|
||||||
|
"""Convert TecDoc capacityLt (e.g. '1998.0000' cc) to liters float (1.998)."""
|
||||||
|
try:
|
||||||
|
cc = float(cap)
|
||||||
|
return round(cc / 1000, 1)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_engine_liters(engine_name):
|
||||||
|
"""Extract liters from engine name like '2.0L 4cyl 127hp'."""
|
||||||
|
m = re.match(r'(\d+\.\d+)L', engine_name)
|
||||||
|
if m:
|
||||||
|
return round(float(m.group(1)), 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Step 1: Build vehicleId → vehicle info from TecDoc files
|
||||||
|
print("Building vehicleId → vehicle info mapping...", flush=True)
|
||||||
|
mfrs = json.loads((DATA_DIR / "manufacturers.json").read_text())
|
||||||
|
vid_info = {} # vehicleId → {brand, model, year_start, year_end, liters}
|
||||||
|
for mfr in mfrs:
|
||||||
|
brand = mfr['manufacturerName']
|
||||||
|
if '(' in brand:
|
||||||
|
continue
|
||||||
|
mfr_id = mfr['manufacturerId']
|
||||||
|
model_file = DATA_DIR / "models" / f"{mfr_id}.json"
|
||||||
|
if not model_file.exists():
|
||||||
|
continue
|
||||||
|
models = json.loads(model_file.read_text())
|
||||||
|
for model in models:
|
||||||
|
model_name = model.get('modelName', '')
|
||||||
|
if not model_name:
|
||||||
|
continue
|
||||||
|
vehicle_file = DATA_DIR / "vehicles" / f"{model['modelId']}.json"
|
||||||
|
if not vehicle_file.exists():
|
||||||
|
continue
|
||||||
|
vehicles = json.loads(vehicle_file.read_text())
|
||||||
|
if not vehicles:
|
||||||
|
continue
|
||||||
|
for v in vehicles:
|
||||||
|
vid = v.get('vehicleId')
|
||||||
|
if not vid:
|
||||||
|
continue
|
||||||
|
# Parse year range
|
||||||
|
year_start = None
|
||||||
|
year_end = None
|
||||||
|
try:
|
||||||
|
cs = v.get('constructionIntervalStart', '')
|
||||||
|
if cs:
|
||||||
|
year_start = int(cs[:4])
|
||||||
|
ce = v.get('constructionIntervalEnd', '')
|
||||||
|
if ce:
|
||||||
|
year_end = int(ce[:4])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Parse engine capacity
|
||||||
|
liters = parse_capacity_liters(v.get('capacityLt') or v.get('capacityTax'))
|
||||||
|
vid_info[vid] = {
|
||||||
|
'brand': brand,
|
||||||
|
'model': model_name,
|
||||||
|
'year_start': year_start,
|
||||||
|
'year_end': year_end,
|
||||||
|
'liters': liters,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" {len(vid_info):,} vehicleIds mapped", flush=True)
|
||||||
|
|
||||||
|
# Step 2: Build (brand, modelName) → list of (mye_id, year, liters) from our DB
|
||||||
|
print("Building brand/model → MYE details mapping...", flush=True)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT b.name_brand, m.name_model, mye.id_mye, y.year_car, e.name_engine
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id_model
|
||||||
|
JOIN brands b ON m.brand_id = b.id_brand
|
||||||
|
JOIN years y ON mye.year_id = y.id_year
|
||||||
|
JOIN engines e ON mye.engine_id = e.id_engine
|
||||||
|
""")
|
||||||
|
brand_model_to_myes = {}
|
||||||
|
for brand, model, mye_id, year, engine_name in cur.fetchall():
|
||||||
|
key = (brand, model)
|
||||||
|
liters = extract_engine_liters(engine_name)
|
||||||
|
if key not in brand_model_to_myes:
|
||||||
|
brand_model_to_myes[key] = []
|
||||||
|
brand_model_to_myes[key].append((mye_id, year, liters))
|
||||||
|
|
||||||
|
print(f" {len(brand_model_to_myes):,} brand/model combos with {sum(len(v) for v in brand_model_to_myes.values()):,} MYEs", flush=True)
|
||||||
|
|
||||||
|
# Step 3: Build OEM number → part_id from DB
|
||||||
|
print("Loading parts cache...", flush=True)
|
||||||
|
cur.execute("SELECT oem_part_number, id_part FROM parts WHERE oem_part_number IS NOT NULL")
|
||||||
|
part_cache = {r[0]: r[1] for r in cur.fetchall()}
|
||||||
|
print(f" {len(part_cache):,} parts cached", flush=True)
|
||||||
|
|
||||||
|
# Step 4: Load detail files to get articleId → OEM numbers
|
||||||
|
print("Loading article detail OEM mappings...", flush=True)
|
||||||
|
article_to_oems = {}
|
||||||
|
for f in DETAILS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
oem_list = data.get('articleOemNo', [])
|
||||||
|
if oem_list:
|
||||||
|
oem_nos = [o.get('oemDisplayNo') for o in oem_list if o.get('oemDisplayNo')]
|
||||||
|
if oem_nos:
|
||||||
|
article_to_oems[int(f.stem)] = oem_nos
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
print(f" {len(article_to_oems):,} articles with OEM data", flush=True)
|
||||||
|
|
||||||
|
# Step 5: Process article files and create vehicle_parts
|
||||||
|
print("\nCreating vehicle_parts links (filtered + batch mode)...", flush=True)
|
||||||
|
|
||||||
|
stats = {'links': 0, 'skipped_no_mye': 0, 'skipped_no_part': 0, 'files': 0, 'filtered_out': 0}
|
||||||
|
pending = []
|
||||||
|
|
||||||
|
def flush_batch():
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
execute_values(cur, """
|
||||||
|
INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required)
|
||||||
|
VALUES %s ON CONFLICT DO NOTHING
|
||||||
|
""", pending, page_size=10000)
|
||||||
|
conn.commit()
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
article_files = sorted(ARTICLES_DIR.glob("*.json"))
|
||||||
|
for f in article_files:
|
||||||
|
parts_split = f.stem.split("_")
|
||||||
|
if len(parts_split) != 2:
|
||||||
|
continue
|
||||||
|
vid = int(parts_split[0])
|
||||||
|
|
||||||
|
info = vid_info.get(vid)
|
||||||
|
if not info:
|
||||||
|
stats['skipped_no_mye'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
bm = (info['brand'], info['model'])
|
||||||
|
all_myes = brand_model_to_myes.get(bm, [])
|
||||||
|
if not all_myes:
|
||||||
|
stats['skipped_no_mye'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter MYEs by year range and engine capacity
|
||||||
|
td_ys = info['year_start']
|
||||||
|
td_ye = info['year_end']
|
||||||
|
td_lit = info['liters']
|
||||||
|
|
||||||
|
filtered_myes = []
|
||||||
|
for mye_id, mye_year, mye_liters in all_myes:
|
||||||
|
# Year filter: MYE year must fall within TecDoc construction interval
|
||||||
|
if td_ys and td_ye:
|
||||||
|
if mye_year < td_ys or mye_year > td_ye:
|
||||||
|
stats['filtered_out'] += 1
|
||||||
|
continue
|
||||||
|
elif td_ys:
|
||||||
|
if mye_year < td_ys:
|
||||||
|
stats['filtered_out'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Engine capacity filter: must match within 0.2L tolerance
|
||||||
|
if td_lit and mye_liters:
|
||||||
|
if abs(td_lit - mye_liters) > 0.2:
|
||||||
|
stats['filtered_out'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered_myes.append(mye_id)
|
||||||
|
|
||||||
|
if not filtered_myes:
|
||||||
|
# Fallback: if filtering removed everything, skip
|
||||||
|
stats['skipped_no_mye'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
articles = json.loads(f.read_text())
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
aid = a.get('articleId')
|
||||||
|
article_no = a.get('articleNo', '')
|
||||||
|
supplier = a.get('supplierName', '')
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
part_ids = set()
|
||||||
|
oem_nos = article_to_oems.get(aid, [])
|
||||||
|
for oem_no in oem_nos:
|
||||||
|
pid = part_cache.get(oem_no)
|
||||||
|
if pid:
|
||||||
|
part_ids.add(pid)
|
||||||
|
|
||||||
|
if not part_ids:
|
||||||
|
stats['skipped_no_part'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
for mye_id in filtered_myes:
|
||||||
|
for part_id in part_ids:
|
||||||
|
pending.append((mye_id, part_id, 1))
|
||||||
|
stats['links'] += 1
|
||||||
|
|
||||||
|
if len(pending) >= BATCH_SIZE:
|
||||||
|
flush_batch()
|
||||||
|
|
||||||
|
stats['files'] += 1
|
||||||
|
if stats['files'] % 500 == 0:
|
||||||
|
flush_batch()
|
||||||
|
print(f" {stats['files']:,}/{len(article_files):,} files | "
|
||||||
|
f"{stats['links']:,} links | {stats['filtered_out']:,} filtered out", flush=True)
|
||||||
|
|
||||||
|
flush_batch()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n{'='*50}", flush=True)
|
||||||
|
print(f"LINKING COMPLETE", flush=True)
|
||||||
|
print(f" Files processed: {stats['files']:,}", flush=True)
|
||||||
|
print(f" Links created: {stats['links']:,}", flush=True)
|
||||||
|
print(f" Filtered out: {stats['filtered_out']:,}", flush=True)
|
||||||
|
print(f" Skipped (no MYE): {stats['skipped_no_mye']:,}", flush=True)
|
||||||
|
print(f" Skipped (no part):{stats['skipped_no_part']:,}", flush=True)
|
||||||
|
print(f"{'='*50}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
220
scripts/migrate_aftermarket.py
Executable file
220
scripts/migrate_aftermarket.py
Executable file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate AFT- prefixed parts from `parts` table to `aftermarket_parts` table.
|
||||||
|
|
||||||
|
Parts with oem_part_number like 'AFT-{partNumber}-{manufacturerName}' are
|
||||||
|
aftermarket parts stored incorrectly in the parts table. This script parses
|
||||||
|
them, links to OEM parts via cross-references, inserts into aftermarket_parts,
|
||||||
|
and deletes the originals from parts (CASCADE cleans vehicle_parts & cross_refs).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/migrate_aftermarket.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
DB_DSN = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
BATCH_SIZE = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def load_manufacturers(cur):
|
||||||
|
"""Load all manufacturer names and ids into a dict {lowercase_name: id}."""
|
||||||
|
cur.execute("SELECT id_manufacture, name_manufacture FROM manufacturers")
|
||||||
|
mfrs = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
mfrs[row[1].lower()] = row[0]
|
||||||
|
print(f"Loaded {len(mfrs)} known manufacturers", flush=True)
|
||||||
|
return mfrs
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aft_part(oem_part_number, known_manufacturers):
|
||||||
|
"""
|
||||||
|
Parse 'AFT-{partNumber}-{manufacturerName}' into (part_number, manufacturer_name).
|
||||||
|
|
||||||
|
The manufacturer is the longest right-side suffix (after a '-') that matches
|
||||||
|
a known manufacturer. Fallback: last segment is the manufacturer.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
AFT-AC191-PARTQUIP -> ('AC191', 'PARTQUIP')
|
||||||
|
AFT-10-0058-Airstal -> ('10-0058', 'Airstal')
|
||||||
|
AFT-A-123-Some-Brand -> ('A-123', 'Some-Brand') if 'some-brand' is known
|
||||||
|
"""
|
||||||
|
without_prefix = oem_part_number[4:] # remove 'AFT-'
|
||||||
|
segments = without_prefix.split('-')
|
||||||
|
|
||||||
|
if len(segments) < 2:
|
||||||
|
# Can't split — treat entire thing as part number, no manufacturer
|
||||||
|
return without_prefix, None
|
||||||
|
|
||||||
|
# Try increasingly longer suffixes from the right to find a known manufacturer
|
||||||
|
# e.g. for segments [A, B, C, D], try "D", "C-D", "B-C-D"
|
||||||
|
# (don't try the full string — need at least one segment for part_number)
|
||||||
|
for i in range(len(segments) - 1, 0, -1):
|
||||||
|
candidate = '-'.join(segments[i:])
|
||||||
|
if candidate.lower() in known_manufacturers:
|
||||||
|
part_number = '-'.join(segments[:i])
|
||||||
|
return part_number, candidate
|
||||||
|
|
||||||
|
# Fallback: last segment is manufacturer
|
||||||
|
manufacturer_name = segments[-1]
|
||||||
|
part_number = '-'.join(segments[:-1])
|
||||||
|
return part_number, manufacturer_name
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = psycopg2.connect(DB_DSN)
|
||||||
|
conn.autocommit = False
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Step 1: Load known manufacturers
|
||||||
|
known_manufacturers = load_manufacturers(cur)
|
||||||
|
|
||||||
|
# Step 2: Count AFT- parts
|
||||||
|
cur.execute("SELECT COUNT(*) FROM parts WHERE oem_part_number LIKE 'AFT-%%'")
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
print(f"Found {total:,} AFT- parts to migrate", flush=True)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
print("Nothing to do.")
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all AFT- parts into memory (357K rows is manageable)
|
||||||
|
print("Loading all AFT- parts into memory...", flush=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_part, oem_part_number, name_part, description, group_id "
|
||||||
|
"FROM parts WHERE oem_part_number LIKE 'AFT-%%' "
|
||||||
|
"ORDER BY id_part"
|
||||||
|
)
|
||||||
|
all_aft_parts = cur.fetchall()
|
||||||
|
print(f" Loaded {len(all_aft_parts):,} rows", flush=True)
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
skipped_no_mfr = 0
|
||||||
|
skipped_no_oem = 0
|
||||||
|
inserted = 0
|
||||||
|
batch_num = 0
|
||||||
|
new_manufacturers = 0
|
||||||
|
|
||||||
|
# Process in batches
|
||||||
|
for i in range(0, len(all_aft_parts), BATCH_SIZE):
|
||||||
|
batch = all_aft_parts[i:i + BATCH_SIZE]
|
||||||
|
stats = process_batch(conn, cur, batch, known_manufacturers)
|
||||||
|
inserted += stats['inserted']
|
||||||
|
skipped_no_mfr += stats['skipped_no_mfr']
|
||||||
|
skipped_no_oem += stats['skipped_no_oem']
|
||||||
|
new_manufacturers += stats['new_manufacturers']
|
||||||
|
migrated += len(batch)
|
||||||
|
batch_num += 1
|
||||||
|
print(f" Batch {batch_num}: processed {migrated:,}/{total:,} "
|
||||||
|
f"(inserted={inserted:,}, no_oem={skipped_no_oem:,}, "
|
||||||
|
f"no_mfr={skipped_no_mfr:,})", flush=True)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n=== Migration Complete ===")
|
||||||
|
print(f" Total AFT- parts processed: {migrated:,}")
|
||||||
|
print(f" Inserted into aftermarket_parts: {inserted:,}")
|
||||||
|
print(f" Skipped (no manufacturer parsed): {skipped_no_mfr:,}")
|
||||||
|
print(f" Skipped (no OEM part found): {skipped_no_oem:,}")
|
||||||
|
print(f" New manufacturers created: {new_manufacturers:,}")
|
||||||
|
|
||||||
|
|
||||||
|
def process_batch(conn, cur, batch, known_manufacturers):
|
||||||
|
"""Process a batch of AFT- parts. Returns stats dict."""
|
||||||
|
stats = {'inserted': 0, 'skipped_no_mfr': 0, 'skipped_no_oem': 0, 'new_manufacturers': 0}
|
||||||
|
|
||||||
|
parts_to_delete = []
|
||||||
|
aftermarket_rows = [] # (oem_part_id, manufacturer_id, part_number, name_aftermarket_parts)
|
||||||
|
|
||||||
|
for id_part, oem_part_number, name_part, description, group_id in batch:
|
||||||
|
part_number, manufacturer_name = parse_aft_part(oem_part_number, known_manufacturers)
|
||||||
|
|
||||||
|
if not manufacturer_name:
|
||||||
|
stats['skipped_no_mfr'] += 1
|
||||||
|
# Still delete the malformed AFT- part from parts table
|
||||||
|
parts_to_delete.append(id_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ensure manufacturer exists
|
||||||
|
mfr_key = manufacturer_name.lower()
|
||||||
|
if mfr_key not in known_manufacturers:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) "
|
||||||
|
"ON CONFLICT (name_manufacture) DO NOTHING "
|
||||||
|
"RETURNING id_manufacture",
|
||||||
|
(manufacturer_name,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
known_manufacturers[mfr_key] = row[0]
|
||||||
|
stats['new_manufacturers'] += 1
|
||||||
|
else:
|
||||||
|
# Was inserted by a concurrent process; fetch it
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_manufacture FROM manufacturers WHERE name_manufacture = %s",
|
||||||
|
(manufacturer_name,)
|
||||||
|
)
|
||||||
|
known_manufacturers[mfr_key] = cur.fetchone()[0]
|
||||||
|
|
||||||
|
manufacturer_id = known_manufacturers[mfr_key]
|
||||||
|
|
||||||
|
# Find the OEM part via cross-references
|
||||||
|
cur.execute(
|
||||||
|
"SELECT part_id FROM part_cross_references "
|
||||||
|
"WHERE cross_reference_number = %s AND source_ref = %s "
|
||||||
|
"LIMIT 1",
|
||||||
|
(part_number, manufacturer_name)
|
||||||
|
)
|
||||||
|
xref_row = cur.fetchone()
|
||||||
|
|
||||||
|
if not xref_row:
|
||||||
|
stats['skipped_no_oem'] += 1
|
||||||
|
# Still delete — it doesn't belong in parts table
|
||||||
|
parts_to_delete.append(id_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
oem_part_id = xref_row[0]
|
||||||
|
aftermarket_rows.append((oem_part_id, manufacturer_id, part_number, name_part))
|
||||||
|
parts_to_delete.append(id_part)
|
||||||
|
|
||||||
|
# Batch insert into aftermarket_parts
|
||||||
|
if aftermarket_rows:
|
||||||
|
execute_values(
|
||||||
|
cur,
|
||||||
|
"INSERT INTO aftermarket_parts "
|
||||||
|
"(oem_part_id, manufacturer_id, part_number, name_aftermarket_parts) "
|
||||||
|
"VALUES %s ON CONFLICT DO NOTHING",
|
||||||
|
aftermarket_rows,
|
||||||
|
page_size=1000
|
||||||
|
)
|
||||||
|
stats['inserted'] = len(aftermarket_rows)
|
||||||
|
|
||||||
|
# Delete dependent rows first (FK without CASCADE)
|
||||||
|
if parts_to_delete:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM vehicle_parts WHERE part_id = ANY(%s)",
|
||||||
|
(parts_to_delete,)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM part_cross_references WHERE part_id = ANY(%s)",
|
||||||
|
(parts_to_delete,)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM parts WHERE id_part = ANY(%s)",
|
||||||
|
(parts_to_delete,)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
t0 = time.time()
|
||||||
|
main()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"Elapsed: {elapsed:.1f}s")
|
||||||
127
scripts/migrate_saas_schema.py
Normal file
127
scripts/migrate_saas_schema.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: SaaS schema changes
|
||||||
|
- Update roles (ADMIN, OWNER, TALLER, BODEGA)
|
||||||
|
- Extend users table with business_name, is_active, created_at, last_login
|
||||||
|
- Create sessions, warehouse_inventory, inventory_uploads, inventory_column_mappings tables
|
||||||
|
- Add indexes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DB_URL = "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
|
||||||
|
|
||||||
|
STATEMENTS = [
|
||||||
|
# ── Roles: UPSERT to desired values ──
|
||||||
|
"""
|
||||||
|
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (1, 'ADMIN')
|
||||||
|
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (2, 'OWNER')
|
||||||
|
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (3, 'TALLER')
|
||||||
|
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO roles (id_rol, name_rol) OVERRIDING SYSTEM VALUE VALUES (4, 'BODEGA')
|
||||||
|
ON CONFLICT (id_rol) DO UPDATE SET name_rol = EXCLUDED.name_rol;
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Extend users table ──
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS business_name VARCHAR(200);",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT false;",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT now();",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login TIMESTAMP;",
|
||||||
|
|
||||||
|
# ── Unique index on users(email) ──
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Sessions table ──
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id_session SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id_user) ON DELETE CASCADE,
|
||||||
|
refresh_token VARCHAR(500) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Warehouse inventory table ──
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS warehouse_inventory (
|
||||||
|
id_inventory BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id_user),
|
||||||
|
part_id INTEGER NOT NULL REFERENCES parts(id_part),
|
||||||
|
price NUMERIC(12,2),
|
||||||
|
stock_quantity INTEGER DEFAULT 0,
|
||||||
|
min_order_quantity INTEGER DEFAULT 1,
|
||||||
|
warehouse_location VARCHAR(100) DEFAULT 'Principal',
|
||||||
|
updated_at TIMESTAMP DEFAULT now(),
|
||||||
|
UNIQUE(user_id, part_id, warehouse_location)
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Inventory uploads table ──
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_uploads (
|
||||||
|
id_upload SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id_user),
|
||||||
|
filename VARCHAR(200),
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
rows_total INTEGER,
|
||||||
|
rows_imported INTEGER,
|
||||||
|
rows_errors INTEGER,
|
||||||
|
error_log TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Inventory column mappings table ──
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_column_mappings (
|
||||||
|
id_mapping SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id_user),
|
||||||
|
mapping JSONB DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
|
||||||
|
# ── Indexes ──
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(refresh_token);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_wi_part ON warehouse_inventory(part_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_wi_user ON warehouse_inventory(user_id);",
|
||||||
|
|
||||||
|
# ── Activate existing admin users ──
|
||||||
|
"UPDATE users SET is_active = true WHERE id_rol = 1;",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = psycopg2.connect(DB_URL)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
for i, sql in enumerate(STATEMENTS, 1):
|
||||||
|
label = sql.strip().split('\n')[0].strip()[:80]
|
||||||
|
try:
|
||||||
|
cur.execute(sql)
|
||||||
|
print(f" [{i:2d}/{len(STATEMENTS)}] OK {label}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i:2d}/{len(STATEMENTS)}] ERR {label}\n {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
print(f"\nMigration complete — {len(STATEMENTS)} statements executed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
43
scripts/run_all_brands.sh
Executable file
43
scripts/run_all_brands.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sequential download + import for all target brands
|
||||||
|
|
||||||
|
LOG="/tmp/tecdoc_all_brands.log"
|
||||||
|
SCRIPTS="/home/Autopartes/scripts"
|
||||||
|
|
||||||
|
BRANDS=("RENAULT" "NISSAN")
|
||||||
|
|
||||||
|
for BRAND in "${BRANDS[@]}"; do
|
||||||
|
echo "" | tee -a "$LOG"
|
||||||
|
echo "$(date): ========== Starting $BRAND ==========" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Start download
|
||||||
|
BRAND_LOG="/tmp/tecdoc_parts_$(echo $BRAND | tr ' ' '_').log"
|
||||||
|
python3 "$SCRIPTS/import_tecdoc_parts.py" download --brand "$BRAND" >> "$BRAND_LOG" 2>&1 &
|
||||||
|
DL_PID=$!
|
||||||
|
echo "$(date): Download started (PID $DL_PID)" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Start live importer
|
||||||
|
python3 "$SCRIPTS/import_live.py" >> /tmp/tecdoc_import_live.log 2>&1 &
|
||||||
|
LI_PID=$!
|
||||||
|
echo "$(date): Live importer started (PID $LI_PID)" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Wait for download to finish
|
||||||
|
wait $DL_PID
|
||||||
|
echo "$(date): Download for $BRAND complete!" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Give live importer time to catch up, then stop it
|
||||||
|
sleep 60
|
||||||
|
kill $LI_PID 2>/dev/null
|
||||||
|
wait $LI_PID 2>/dev/null
|
||||||
|
echo "$(date): Live importer stopped" | tee -a "$LOG"
|
||||||
|
|
||||||
|
# Run vehicle linker
|
||||||
|
echo "$(date): Starting vehicle linker for $BRAND..." | tee -a "$LOG"
|
||||||
|
python3 "$SCRIPTS/link_vehicle_parts.py" >> /tmp/tecdoc_linker.log 2>&1
|
||||||
|
echo "$(date): Linker for $BRAND complete!" | tee -a "$LOG"
|
||||||
|
|
||||||
|
echo "$(date): ========== $BRAND DONE ==========" | tee -a "$LOG"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" | tee -a "$LOG"
|
||||||
|
echo "$(date): ALL BRANDS COMPLETE!" | tee -a "$LOG"
|
||||||
125
vehicle_database/scripts/create_cross_references.py
Normal file
125
vehicle_database/scripts/create_cross_references.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GENERADOR DE REFERENCIAS CRUZADAS ENTRE MARCAS
|
||||||
|
Encuentra partes de diferentes fabricantes que cubren los mismos vehículos
|
||||||
|
y crea referencias cruzadas bidireccionales entre ellas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("GENERADOR DE REFERENCIAS CRUZADAS ENTRE MARCAS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get existing cross-ref count
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM part_cross_references")
|
||||||
|
existing_xrefs = cursor.fetchone()[0]
|
||||||
|
print(f"\nCross-refs existentes: {existing_xrefs:,}")
|
||||||
|
|
||||||
|
# Step 1: For each part_group, find parts from different brands
|
||||||
|
# that fit the same vehicle (model_year_engine)
|
||||||
|
print("\n[1/3] Buscando partes que cubren los mismos vehículos...")
|
||||||
|
|
||||||
|
# Build a map: (group_id, mye_id) -> list of (part_id, part_number)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT vp.model_year_engine_id, vp.part_id, p.oem_part_number, p.group_id
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN parts p ON vp.part_id = p.id
|
||||||
|
WHERE p.group_id IS NOT NULL
|
||||||
|
ORDER BY p.group_id, vp.model_year_engine_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
group_mye_parts = defaultdict(set)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
key = (row['group_id'], row['model_year_engine_id'])
|
||||||
|
group_mye_parts[key].add((row['part_id'], row['oem_part_number']))
|
||||||
|
|
||||||
|
print(f" Combinaciones grupo+vehículo: {len(group_mye_parts):,}")
|
||||||
|
|
||||||
|
# Step 2: For each (group, vehicle) with multiple parts from different brands,
|
||||||
|
# create cross-references
|
||||||
|
print("\n[2/3] Generando pares de cross-reference...")
|
||||||
|
|
||||||
|
# Build existing cross-ref set for fast lookup
|
||||||
|
cursor.execute("SELECT part_id, cross_reference_number FROM part_cross_references")
|
||||||
|
existing = set()
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
existing.add((row['part_id'], row['cross_reference_number']))
|
||||||
|
|
||||||
|
print(f" Cross-refs existentes en set: {len(existing):,}")
|
||||||
|
|
||||||
|
# Collect new cross-reference pairs
|
||||||
|
new_xrefs = []
|
||||||
|
for key, parts_set in group_mye_parts.items():
|
||||||
|
if len(parts_set) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts_list = list(parts_set)
|
||||||
|
for i in range(len(parts_list)):
|
||||||
|
pid_a, pn_a = parts_list[i]
|
||||||
|
for j in range(i + 1, len(parts_list)):
|
||||||
|
pid_b, pn_b = parts_list[j]
|
||||||
|
|
||||||
|
# Skip if same part number prefix (same brand)
|
||||||
|
if pn_a[:3] == pn_b[:3]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add A->B
|
||||||
|
if (pid_a, pn_b) not in existing:
|
||||||
|
new_xrefs.append((pid_a, pn_b))
|
||||||
|
existing.add((pid_a, pn_b))
|
||||||
|
|
||||||
|
# Add B->A
|
||||||
|
if (pid_b, pn_a) not in existing:
|
||||||
|
new_xrefs.append((pid_b, pn_a))
|
||||||
|
existing.add((pid_b, pn_a))
|
||||||
|
|
||||||
|
print(f" Nuevas cross-refs a crear: {len(new_xrefs):,}")
|
||||||
|
|
||||||
|
# Step 3: Insert
|
||||||
|
print("\n[3/3] Insertando cross-references...")
|
||||||
|
inserted = 0
|
||||||
|
for i, (part_id, xref_number) in enumerate(new_xrefs):
|
||||||
|
if i % 5000 == 0 and i > 0:
|
||||||
|
print(f" Insertando {i}/{len(new_xrefs)}...")
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Vehicle Fitment Match')",
|
||||||
|
(part_id, xref_number))
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Final stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM part_cross_references")
|
||||||
|
total_xrefs = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("CROSS-REFERENCES COMPLETADAS")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Cross-refs antes: {existing_xrefs:,}
|
||||||
|
- Nuevas cross-refs: {inserted:,}
|
||||||
|
- Total cross-refs: {total_xrefs:,}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
179
vehicle_database/scripts/extract_moog_diagrams.py
Normal file
179
vehicle_database/scripts/extract_moog_diagrams.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
EXTRACTOR DE IMÁGENES DE DIAGRAMAS MOOG
|
||||||
|
Extrae las ilustraciones de suspensión/dirección de los PDFs MOOG
|
||||||
|
y las guarda como archivos de imagen mapeados a sus figure codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pypdf
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path(__file__).parent.parent.parent / 'dashboard' / 'static' / 'diagrams' / 'moog'
|
||||||
|
|
||||||
|
VOLUMES = {
|
||||||
|
'1': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol1_1989back.pdf',
|
||||||
|
'start_page': 3,
|
||||||
|
'end_page': 1037,
|
||||||
|
'label': 'Vol 1 (≤1989)',
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol2_1990_2005.pdf',
|
||||||
|
'start_page': 6,
|
||||||
|
'end_page': 1641,
|
||||||
|
'label': 'Vol 2 (1990-2005)',
|
||||||
|
},
|
||||||
|
'3': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol3_2006up.pdf',
|
||||||
|
'start_page': 7,
|
||||||
|
'end_page': 1089,
|
||||||
|
'label': 'Vol 3 (2006+)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
FIGURE_RE = re.compile(r'\b([FSR]\d{3})\b')
|
||||||
|
|
||||||
|
|
||||||
|
def extract_figure_codes(text):
|
||||||
|
"""Extract ordered unique figure codes from page text."""
|
||||||
|
codes = []
|
||||||
|
seen = set()
|
||||||
|
for m in FIGURE_RE.finditer(text):
|
||||||
|
code = m.group(1)
|
||||||
|
if code not in seen:
|
||||||
|
codes.append(code)
|
||||||
|
seen.add(code)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def extract_volume(vol_key, already_extracted):
|
||||||
|
"""Extract diagram images from one MOOG volume."""
|
||||||
|
vol = VOLUMES[vol_key]
|
||||||
|
print(f"\n--- Procesando {vol['label']} ---")
|
||||||
|
print(f" PDF: {vol['path']}")
|
||||||
|
|
||||||
|
pdf = pypdf.PdfReader(vol['path'])
|
||||||
|
total_pages = len(pdf.pages)
|
||||||
|
end_page = min(vol['end_page'], total_pages - 1)
|
||||||
|
|
||||||
|
extracted = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for page_idx in range(vol['start_page'], end_page + 1):
|
||||||
|
if page_idx % 100 == 0:
|
||||||
|
print(f" Página {page_idx}/{end_page}... (extraídas: {extracted})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = pdf.pages[page_idx]
|
||||||
|
text = page.extract_text() or ''
|
||||||
|
|
||||||
|
# Get figure codes from this page
|
||||||
|
fig_codes = extract_figure_codes(text)
|
||||||
|
if not fig_codes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter out already-extracted codes
|
||||||
|
needed_codes = [c for c in fig_codes if c not in already_extracted]
|
||||||
|
if not needed_codes:
|
||||||
|
skipped += len(fig_codes)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract images from page
|
||||||
|
images = []
|
||||||
|
try:
|
||||||
|
for img_key in page.images:
|
||||||
|
img_data = img_key.data
|
||||||
|
# Filter by size - diagram images are >10KB typically
|
||||||
|
if len(img_data) > 5000:
|
||||||
|
images.append(img_data)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: try to extract from xobjects directly
|
||||||
|
try:
|
||||||
|
if '/XObject' in page['/Resources']:
|
||||||
|
xobjects = page['/Resources']['/XObject'].get_object()
|
||||||
|
for obj_name in sorted(xobjects.keys()):
|
||||||
|
xobj = xobjects[obj_name].get_object()
|
||||||
|
if xobj.get('/Subtype') == '/Image':
|
||||||
|
w = int(xobj.get('/Width', 0))
|
||||||
|
h = int(xobj.get('/Height', 0))
|
||||||
|
if w > 200 and h > 100:
|
||||||
|
try:
|
||||||
|
img_data = xobj.get_data()
|
||||||
|
if len(img_data) > 5000:
|
||||||
|
images.append(img_data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Match figure codes to images
|
||||||
|
# Strategy: if same number of large images and figure codes, match 1:1 in order
|
||||||
|
# If fewer images than codes, some codes share images (use first available)
|
||||||
|
# If more images than codes, filter further by size
|
||||||
|
for i, code in enumerate(needed_codes):
|
||||||
|
if i < len(images):
|
||||||
|
img_data = images[i]
|
||||||
|
# Determine file extension from magic bytes
|
||||||
|
ext = 'jpg'
|
||||||
|
if img_data[:4] == b'\x89PNG':
|
||||||
|
ext = 'png'
|
||||||
|
elif img_data[:4] == b'\x00\x00\x00\x0c':
|
||||||
|
ext = 'jp2'
|
||||||
|
|
||||||
|
out_path = OUTPUT_DIR / f"{code}.{ext}"
|
||||||
|
out_path.write_bytes(img_data)
|
||||||
|
already_extracted.add(code)
|
||||||
|
extracted += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
print(f" Error en página {page_idx}: {e}")
|
||||||
|
|
||||||
|
print(f" Resultado: {extracted} extraídas, {skipped} ya existentes, {errors} errores")
|
||||||
|
return extracted
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
volumes = sys.argv[1:] if len(sys.argv) > 1 else ['3', '2', '1']
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("EXTRACTOR DE DIAGRAMAS MOOG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Directorio de salida: {OUTPUT_DIR}")
|
||||||
|
|
||||||
|
# Check what's already extracted
|
||||||
|
already_extracted = set()
|
||||||
|
for f in OUTPUT_DIR.iterdir():
|
||||||
|
if f.suffix in ('.jpg', '.png', '.jp2'):
|
||||||
|
already_extracted.add(f.stem)
|
||||||
|
print(f"Ya extraídas: {len(already_extracted)}")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for vol_key in volumes:
|
||||||
|
if vol_key not in VOLUMES:
|
||||||
|
print(f"Volumen {vol_key} no reconocido, saltando...")
|
||||||
|
continue
|
||||||
|
count = extract_volume(vol_key, already_extracted)
|
||||||
|
total += count
|
||||||
|
|
||||||
|
print(f"\n{'=' * 70}")
|
||||||
|
print(f"EXTRACCIÓN COMPLETADA: {total} nuevas imágenes")
|
||||||
|
print(f"Total en directorio: {len(list(OUTPUT_DIR.iterdir()))}")
|
||||||
|
print(f"{'=' * 70}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
362
vehicle_database/scripts/import_cartek_catalog.py
Normal file
362
vehicle_database/scripts/import_cartek_catalog.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR DEL CATÁLOGO CARTEK - FILTROS DE ACEITE
|
||||||
|
Formato: Brand → Model | YearFrom | YearTo | CTK#### | Observations
|
||||||
|
Solo aceite. PDF: /tmp/catalogs/cartek_aceite.pdf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/catalogs/cartek_aceite.pdf'
|
||||||
|
|
||||||
|
# Known brand headers in the Cartek catalog
|
||||||
|
BRAND_HEADERS = {
|
||||||
|
'ACURA', 'ALFA ROMEO', 'AM GENERAL', 'AMERICAN MOTORS', 'ASTON MARTIN',
|
||||||
|
'ASUNA', 'AUDI', 'AUSTIN', 'AUSTIN HEALEY', 'AVANTI', 'BAIC', 'BENTLEY',
|
||||||
|
'BERTONE', 'BMW', 'BRICKLIN', 'BUICK', 'CADILLAC', 'CHECKER', 'CHEVROLET',
|
||||||
|
'CHRYSLER', 'DAEWOO', 'DAIHATSU', 'DATSUN', 'DELOREAN', 'DESOTO',
|
||||||
|
'DETOMASO', 'DODGE', 'EAGLE', 'EDSEL', 'EXCALIBUR', 'FAW', 'FIAT', 'FORD',
|
||||||
|
'FREIGHTLINER', 'GEO', 'GMC', 'HILLMAN', 'HONDA', 'HUMMER', 'HYUNDAI',
|
||||||
|
'IC CORPORATION', 'INFINITI', 'INTERNATIONAL', 'ISUZU', 'JAC', 'JAGUAR',
|
||||||
|
'JEEP', 'JENSEN', 'KARMA', 'KIA', 'KUBOTA', 'LAFORZA', 'LAND ROVER',
|
||||||
|
'LEXUS', 'LINCOLN', 'LOTUS', 'MACK', 'MAZDA', 'MERCEDES-BENZ', 'MERCURY',
|
||||||
|
'MERKUR', 'MINI', 'MITSUBISHI', 'MORGAN', 'NISSAN', 'NSU', 'OLDSMOBILE',
|
||||||
|
'OPEL', 'OSHKOSH MOTOR TRUCK CO.', 'PETERBILT', 'PEUGEOT', 'PLYMOUTH',
|
||||||
|
'POLARIS', 'PONTIAC', 'PORSCHE', 'QVALE', 'RAM', 'RENAULT', 'ROLLS ROYCE',
|
||||||
|
'SAAB', 'SATURN', 'SCION', 'SEAT', 'SHELBY', 'SMART', 'SRT',
|
||||||
|
'STERLING TRUCK', 'STUDEBAKER', 'SUBARU', 'SUNBEAM', 'SUZUKI', 'TOYOTA',
|
||||||
|
'TRIUMPH', 'VAM', 'VOLKSWAGEN', 'VOLVO', 'VPG', 'WORKHORSE',
|
||||||
|
'WORKHORSE CUSTOM CHASSIS', 'YAMAHA', 'YUGO',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None):
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, type_, quality, country))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_brand(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model(cursor, brand_id, name):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)",
|
||||||
|
(brand_id, name))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_year(cursor, year):
|
||||||
|
cursor.execute("SELECT id FROM years WHERE year = ?", (year,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO years (year) VALUES (?)", (year,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_generic_engine(cursor):
|
||||||
|
"""Get or create a generic engine for catalogs without engine data."""
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = 'Generic'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')")
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id=None):
|
||||||
|
if engine_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?",
|
||||||
|
(model_id, year_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
if not engine_id:
|
||||||
|
engine_id = get_generic_engine(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_part(cursor, part_number, group_id, name, name_es, description):
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id'], False
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_number, name, name_es, group_id, description))
|
||||||
|
return cursor.lastrowid, True
|
||||||
|
|
||||||
|
|
||||||
|
def get_oil_filter_group(cursor):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_groups WHERE name = 'Oil Filters' LIMIT 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("SELECT id FROM part_categories WHERE name = 'Engine' LIMIT 1")
|
||||||
|
cat = cursor.fetchone()
|
||||||
|
if not cat:
|
||||||
|
return None
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_groups (category_id, name, name_es) VALUES (?, 'Oil Filters', 'Filtros de Aceite')",
|
||||||
|
(cat['id'],))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cartek_pdf(pdf_path):
|
||||||
|
"""Parse the Cartek oil filter catalog PDF."""
|
||||||
|
pdf = pypdf.PdfReader(pdf_path)
|
||||||
|
entries = []
|
||||||
|
current_brand = None
|
||||||
|
|
||||||
|
for page_num in range(4, len(pdf.pages)): # Skip cover/index pages
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
pending_model = None
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip header/footer lines
|
||||||
|
if 'Marca/Modelo' in line or 'Observaciones' in line:
|
||||||
|
continue
|
||||||
|
# Skip page numbers
|
||||||
|
if re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for brand header
|
||||||
|
if line in BRAND_HEADERS:
|
||||||
|
current_brand = line
|
||||||
|
pending_model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current_brand:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to parse data line: Model YearFrom YearTo CTK#### Observations
|
||||||
|
match = re.match(
|
||||||
|
r'^(.+?)\s+(\d{4})\s+(\d{4})\s+(CTK\w+)\s+(.*)$', line)
|
||||||
|
if match:
|
||||||
|
model = match.group(1).strip()
|
||||||
|
if pending_model:
|
||||||
|
model = f"{pending_model} {model}"
|
||||||
|
pending_model = None
|
||||||
|
|
||||||
|
year_from = int(match.group(2))
|
||||||
|
year_to = int(match.group(3))
|
||||||
|
part_number = match.group(4).strip()
|
||||||
|
observations = match.group(5).strip()
|
||||||
|
|
||||||
|
for year in range(year_from, year_to + 1):
|
||||||
|
entries.append({
|
||||||
|
'brand': current_brand,
|
||||||
|
'model': model,
|
||||||
|
'year': year,
|
||||||
|
'part_number': part_number,
|
||||||
|
'observations': observations,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Check if this is a continuation model name (e.g., "Avalanche")
|
||||||
|
# followed by a sub-model on the next line
|
||||||
|
if not re.match(r'^\d', line) and not line.startswith('CTK'):
|
||||||
|
# Could be a model name prefix (like "Avalanche" before "1500")
|
||||||
|
# or a sub-brand header we don't recognize
|
||||||
|
pending_model = line
|
||||||
|
else:
|
||||||
|
pending_model = None
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("IMPORTADOR - CATÁLOGO CARTEK FILTROS DE ACEITE")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n[1/5] Leyendo PDF: {PDF_PATH}")
|
||||||
|
entries = parse_cartek_pdf(PDF_PATH)
|
||||||
|
print(f" Entradas parseadas: {len(entries)}")
|
||||||
|
|
||||||
|
# Get unique parts and brands
|
||||||
|
unique_parts = set(e['part_number'] for e in entries)
|
||||||
|
unique_brands = set(e['brand'] for e in entries)
|
||||||
|
print(f" Partes únicas: {len(unique_parts)}")
|
||||||
|
print(f" Marcas de vehículos: {len(unique_brands)}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create Cartek manufacturer
|
||||||
|
print("\n[2/5] Creando fabricante Cartek...")
|
||||||
|
cartek_mfr_id = ensure_manufacturer(cursor, 'Cartek', 'aftermarket', 'standard', 'Mexico')
|
||||||
|
print(f" Cartek manufacturer_id: {cartek_mfr_id}")
|
||||||
|
|
||||||
|
# Get oil filter group
|
||||||
|
oil_group_id = get_oil_filter_group(cursor)
|
||||||
|
print(f" Oil Filters group_id: {oil_group_id}")
|
||||||
|
|
||||||
|
# Create parts
|
||||||
|
print("\n[3/5] Creando partes de filtros...")
|
||||||
|
part_ids = {}
|
||||||
|
parts_created = 0
|
||||||
|
for pn in sorted(unique_parts):
|
||||||
|
name = f"Oil Filter {pn}"
|
||||||
|
name_es = f"Filtro de Aceite {pn}"
|
||||||
|
part_id, created = get_or_create_part(
|
||||||
|
cursor, pn, oil_group_id, name, name_es, "Cartek Oil Filter")
|
||||||
|
part_ids[pn] = part_id
|
||||||
|
if created:
|
||||||
|
parts_created += 1
|
||||||
|
print(f" Partes creadas: {parts_created}")
|
||||||
|
print(f" Partes existentes: {len(unique_parts) - parts_created}")
|
||||||
|
|
||||||
|
# Create vehicles and fitments
|
||||||
|
print("\n[4/5] Creando vehículos y fitments...")
|
||||||
|
vehicles_created = 0
|
||||||
|
fitments_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
cache_key = (entry['brand'], entry['model'], entry['year'])
|
||||||
|
if cache_key not in mye_cache:
|
||||||
|
brand_id = ensure_brand(cursor, entry['brand'])
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
|
||||||
|
# Try to find existing MYE (any engine)
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id
|
||||||
|
JOIN brands b ON m.brand_id = b.id
|
||||||
|
JOIN years y ON mye.year_id = y.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(entry['brand'], entry['model'], entry['year']))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
mye_id = mye_cache[cache_key]
|
||||||
|
part_id = part_ids.get(entry['part_number'])
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if fitment exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?",
|
||||||
|
(mye_id, part_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
notes = f"Catálogo Cartek - ACEITE"
|
||||||
|
if entry['observations'] and entry['observations'] != '-':
|
||||||
|
notes += f" ({entry['observations']})"
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)",
|
||||||
|
(mye_id, part_id, notes))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created}")
|
||||||
|
print(f" Fitments creados: {fitments_created}")
|
||||||
|
|
||||||
|
# Create cross-references by matching Cartek parts to existing parts (Gonher, etc.)
|
||||||
|
# that fit the same vehicle
|
||||||
|
print("\n[5/5] Creando referencias cruzadas...")
|
||||||
|
xrefs_created = 0
|
||||||
|
|
||||||
|
for pn, part_id in part_ids.items():
|
||||||
|
# Find other parts in the same group that fit the same vehicles
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT p2.id, p2.oem_part_number
|
||||||
|
FROM vehicle_parts vp1
|
||||||
|
JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id
|
||||||
|
JOIN parts p2 ON vp2.part_id = p2.id
|
||||||
|
WHERE vp1.part_id = ?
|
||||||
|
AND p2.id != ?
|
||||||
|
AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?)
|
||||||
|
AND p2.oem_part_number NOT LIKE 'CTK%'
|
||||||
|
LIMIT 20
|
||||||
|
""", (part_id, part_id, part_id))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
# Add cross-ref from Cartek to other brand
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Cartek Catalog')",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
# Add reverse cross-ref
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(row['id'], pn))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'Cartek Catalog')",
|
||||||
|
(row['id'], pn))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
print(f" Cross-refs creadas: {xrefs_created}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IMPORTACIÓN CARTEK COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Cross-refs creadas: {xrefs_created:,}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
680
vehicle_database/scripts/import_dar_catalog.py
Normal file
680
vehicle_database/scripts/import_dar_catalog.py
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR DEL CATÁLOGO DAR "LÍNEA AZUL" 2020
|
||||||
|
Formato: Brand → Model → AÑO DESCRIPCIÓN SKU #PÁG
|
||||||
|
Pages 27-571 contain vehicle application data.
|
||||||
|
PDF: /tmp/catalogs/suspension/catalogo_azul_2020.pdf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/catalogs/suspension/catalogo_azul_2020.pdf'
|
||||||
|
|
||||||
|
# Page range (0-indexed) for vehicle application data
|
||||||
|
START_PAGE = 27
|
||||||
|
END_PAGE = 571
|
||||||
|
|
||||||
|
# Known brand headers in the DAR catalog
|
||||||
|
DAR_BRANDS = {
|
||||||
|
'ACURA', 'ALFA ROMEO', 'AUDI', 'BMW', 'BUICK', 'CADILLAC',
|
||||||
|
'CHEVROLET, GMC', 'CHRYSLER', 'DATSUN', 'DODGE', 'EAGLE',
|
||||||
|
'FIAT', 'FORD, MERCURY', 'GEO', 'HONDA', 'HUMMER', 'HYUNDAI',
|
||||||
|
'INFINITI', 'ISUZU', 'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA', 'MERCEDES-BENZ',
|
||||||
|
'MERKUR', 'MINI', 'MITSUBISHI', 'NISSAN', 'OLDSMOBILE',
|
||||||
|
'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC', 'PORSCHE',
|
||||||
|
'RAM', 'RENAULT', 'SAAB', 'SATURN', 'SCION', 'SEAT', 'SMART',
|
||||||
|
'SUBARU', 'SUZUKI', 'TOYOTA', 'TRIUMPH', 'VOLKSWAGEN',
|
||||||
|
'VOLVO', 'VOLVO/MASA',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Year range regex: 2-digit or 4-digit years, or TODOS
|
||||||
|
YEAR_RE = re.compile(r'^(\d{2,4})\s*-\s*(\d{2,4})\b')
|
||||||
|
YEAR_SINGLE_RE = re.compile(r'^(\d{2,4})\b')
|
||||||
|
TODOS_RE = re.compile(r'^TODOS\b', re.IGNORECASE)
|
||||||
|
|
||||||
|
# Line ending with SKU + page ref: ...SKU_TOKEN 3-4_DIGIT_PAGEREF
|
||||||
|
ENTRY_END_RE = re.compile(r'^(.+?)\s+(\S+)\s+(\d{3,4})\s*$')
|
||||||
|
|
||||||
|
# Skip patterns
|
||||||
|
SKIP_PATTERNS = [
|
||||||
|
'Línea Azul',
|
||||||
|
'CATALOGO AZUL',
|
||||||
|
'AÑO DESCRIPCIÓN SKU #PÁG',
|
||||||
|
'AÑO DESCRIPCIÓN SKU',
|
||||||
|
'.indb',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None):
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, type_, quality, country))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_brand(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model(cursor, brand_id, name):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)",
|
||||||
|
(brand_id, name))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_year(cursor, year):
|
||||||
|
cursor.execute("SELECT id FROM years WHERE year = ?", (year,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO years (year) VALUES (?)", (year,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_generic_engine(cursor):
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = 'Generic'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')")
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id=None):
|
||||||
|
if engine_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?",
|
||||||
|
(model_id, year_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
if not engine_id:
|
||||||
|
engine_id = get_generic_engine(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_part(cursor, part_number, group_id, name, name_es, description):
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id'], False
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_number, name, name_es, group_id, description))
|
||||||
|
return cursor.lastrowid, True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Group ID lookup cache ---
|
||||||
|
_group_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_id(cursor, name_en):
|
||||||
|
if name_en not in _group_cache:
|
||||||
|
cursor.execute("SELECT id FROM part_groups WHERE name = ?", (name_en,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
_group_cache[name_en] = row['id'] if row else None
|
||||||
|
return _group_cache[name_en]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_description(cursor, desc):
|
||||||
|
"""Map DAR description text to a DB group_id."""
|
||||||
|
d = desc.upper()
|
||||||
|
|
||||||
|
# Amortiguadores (Shocks)
|
||||||
|
if 'AMORTIGUADOR' in d and 'BASE' not in d:
|
||||||
|
if 'CAJUELA' in d or 'COFRE' in d or 'VIDRIO' in d:
|
||||||
|
return get_group_id(cursor, 'Struts') # trunk/hood/glass struts
|
||||||
|
if 'DIRECCIÓN' in d or 'DIRECCION' in d:
|
||||||
|
return get_group_id(cursor, 'Steering Dampers')
|
||||||
|
return get_group_id(cursor, 'Shocks')
|
||||||
|
|
||||||
|
# Base amortiguador (Strut Mounts)
|
||||||
|
if 'BASE AMORTIGUADOR' in d:
|
||||||
|
return get_group_id(cursor, 'Strut Mounts')
|
||||||
|
|
||||||
|
# Balero (Bearings)
|
||||||
|
if 'BALERO' in d:
|
||||||
|
return get_group_id(cursor, 'Wheel Bearings')
|
||||||
|
|
||||||
|
# Maza (Wheel Hubs)
|
||||||
|
if 'MAZA' in d:
|
||||||
|
return get_group_id(cursor, 'Wheel Hubs')
|
||||||
|
|
||||||
|
# Soporte de Motor / Transmisión (Mounts)
|
||||||
|
if 'SOPORTE DE MOTOR' in d or 'SOPORTE MOTOR' in d:
|
||||||
|
return get_group_id(cursor, 'Engine Mounts')
|
||||||
|
if 'SOPORTE DE TRANSMIS' in d or 'SOPORTE TRANSMIS' in d:
|
||||||
|
return get_group_id(cursor, 'Transmission Mounts')
|
||||||
|
if 'SOPORTE' in d and 'AMORTIGUADOR' in d:
|
||||||
|
return get_group_id(cursor, 'Strut Mounts')
|
||||||
|
if 'SOPORTE BRAZO' in d:
|
||||||
|
return get_group_id(cursor, 'Idler Arms')
|
||||||
|
|
||||||
|
# Rotula (Ball Joint)
|
||||||
|
if 'RÓTULA' in d or 'ROTULA' in d:
|
||||||
|
return get_group_id(cursor, 'Ball Joints')
|
||||||
|
|
||||||
|
# Terminal exterior / dirección (Tie Rod Ends)
|
||||||
|
if 'TERMINAL EXTERIOR' in d or 'TERMINAL DIREC' in d:
|
||||||
|
return get_group_id(cursor, 'Tie Rod Ends')
|
||||||
|
|
||||||
|
# Terminal interior (Inner Tie Rods)
|
||||||
|
if 'TERMINAL INTERIOR' in d:
|
||||||
|
return get_group_id(cursor, 'Inner Tie Rods')
|
||||||
|
|
||||||
|
# Horquilla (Control Arms)
|
||||||
|
if 'HORQUILLA' in d:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
|
||||||
|
# Buje de varilla estabilizadora
|
||||||
|
if 'GOMA' in d and 'ESTABILIZADORA' in d:
|
||||||
|
return get_group_id(cursor, 'Sway Bar Bushings')
|
||||||
|
if 'BUJE' in d and 'ESTABILIZADORA' in d:
|
||||||
|
return get_group_id(cursor, 'Sway Bar Bushings')
|
||||||
|
|
||||||
|
# Tornillo estabilizador (Sway Bar Links)
|
||||||
|
if 'TORNILLO ESTABILIZADOR' in d:
|
||||||
|
return get_group_id(cursor, 'Sway Bar Links')
|
||||||
|
|
||||||
|
# Buje (Bushings)
|
||||||
|
if 'BUJE' in d:
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
|
||||||
|
# Resorte (Springs)
|
||||||
|
if 'RESORTE' in d:
|
||||||
|
return get_group_id(cursor, 'Coil Springs')
|
||||||
|
|
||||||
|
# Brazo auxiliar (Idler Arm)
|
||||||
|
if 'BRAZO AUXILIAR' in d:
|
||||||
|
return get_group_id(cursor, 'Idler Arms')
|
||||||
|
|
||||||
|
# Brazo Pitman
|
||||||
|
if 'BRAZO PITMAN' in d or 'PITMAN' in d:
|
||||||
|
return get_group_id(cursor, 'Pitman Arms')
|
||||||
|
|
||||||
|
# Varilla / Barra central (Center Links)
|
||||||
|
if 'BARRA CENTRAL' in d or 'VARILLA CENTRAL' in d:
|
||||||
|
return get_group_id(cursor, 'Center Links')
|
||||||
|
|
||||||
|
# Varilla lateral / Barra de arrastre (Drag Links)
|
||||||
|
if 'VARILLA' in d:
|
||||||
|
return get_group_id(cursor, 'Drag Links')
|
||||||
|
|
||||||
|
# Cremallera (Steering Rack)
|
||||||
|
if 'CREMALLERA' in d:
|
||||||
|
return get_group_id(cursor, 'Steering Racks')
|
||||||
|
|
||||||
|
# Bomba dirección (Power Steering Pump)
|
||||||
|
if 'BOMBA DIREC' in d:
|
||||||
|
return get_group_id(cursor, 'Power Steering Pumps')
|
||||||
|
|
||||||
|
# Cople dirección (Steering Gearbox / Coupling)
|
||||||
|
if 'COPLE DIREC' in d:
|
||||||
|
return get_group_id(cursor, 'Steering Gearboxes')
|
||||||
|
|
||||||
|
# Flector dirección
|
||||||
|
if 'FLECTOR' in d:
|
||||||
|
return get_group_id(cursor, 'Steering Gearboxes')
|
||||||
|
|
||||||
|
# Nudo dirección (Steering Knuckle)
|
||||||
|
if 'NUDO DIREC' in d:
|
||||||
|
return get_group_id(cursor, 'Steering Knuckles')
|
||||||
|
|
||||||
|
# Excéntrico (Camber/Caster)
|
||||||
|
if 'EXCÉNTRICO' in d or 'EXCENTRICO' in d or 'CAMBER' in d:
|
||||||
|
return get_group_id(cursor, 'Camber/Caster Kits')
|
||||||
|
|
||||||
|
# Junta CV
|
||||||
|
if 'JUNTA' in d and ('RUEDA' in d or 'CAJA' in d):
|
||||||
|
return get_group_id(cursor, 'CV Joints')
|
||||||
|
|
||||||
|
# Macheta / Flecha
|
||||||
|
if 'MACHETA' in d or 'FLECHA' in d:
|
||||||
|
return get_group_id(cursor, 'CV Axles')
|
||||||
|
|
||||||
|
# Tirante (Trailing Arm)
|
||||||
|
if 'TIRANTE' in d:
|
||||||
|
return get_group_id(cursor, 'Trailing Arms')
|
||||||
|
|
||||||
|
# Barra horquilla / Barra torsión
|
||||||
|
if 'BARRA' in d and 'TORSIÓN' in d:
|
||||||
|
return get_group_id(cursor, 'Torsion Bars')
|
||||||
|
if 'BARRA' in d and 'HORQUILLA' in d:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
|
||||||
|
# Default: Ball Joints
|
||||||
|
return get_group_id(cursor, 'Ball Joints')
|
||||||
|
|
||||||
|
|
||||||
|
# --- Part type name from description ---
|
||||||
|
def part_names_from_desc(desc, sku):
|
||||||
|
"""Generate English and Spanish names from DAR description."""
|
||||||
|
name_es = f"{desc} {sku}"
|
||||||
|
# Simplified English name
|
||||||
|
name_en = desc
|
||||||
|
for es, en in [
|
||||||
|
('AMORTIGUADOR DELANTERO', 'Front Shock'),
|
||||||
|
('AMORTIGUADOR TRASERO', 'Rear Shock'),
|
||||||
|
('AMORTIGUADOR', 'Shock Absorber'),
|
||||||
|
('BASE AMORTIGUADOR', 'Strut Mount'),
|
||||||
|
('BALERO DOBLE', 'Double Bearing'),
|
||||||
|
('BALERO CONICO', 'Tapered Bearing'),
|
||||||
|
('BALERO', 'Wheel Bearing'),
|
||||||
|
('BOMBA DIREC', 'Power Steering Pump'),
|
||||||
|
('BRAZO AUXILIAR', 'Idler Arm'),
|
||||||
|
('BRAZO PITMAN', 'Pitman Arm'),
|
||||||
|
('BUJE', 'Bushing'),
|
||||||
|
('CREMALLERA', 'Steering Rack'),
|
||||||
|
('COPLE DIREC', 'Steering Coupler'),
|
||||||
|
('FLECTOR', 'Steering Flex Disc'),
|
||||||
|
('GOMA VARILLA ESTABILIZADORA', 'Sway Bar Bushing'),
|
||||||
|
('HORQUILLA INFERIOR', 'Lower Control Arm'),
|
||||||
|
('HORQUILLA SUPERIOR', 'Upper Control Arm'),
|
||||||
|
('HORQUILLA', 'Control Arm'),
|
||||||
|
('MAZA DELANTERA', 'Front Wheel Hub'),
|
||||||
|
('MAZA TRASERA', 'Rear Wheel Hub'),
|
||||||
|
('MAZA', 'Wheel Hub'),
|
||||||
|
('RESORTE DELANTERO', 'Front Coil Spring'),
|
||||||
|
('RESORTE TRASERO', 'Rear Coil Spring'),
|
||||||
|
('RESORTE', 'Coil Spring'),
|
||||||
|
('RÓTULA INFERIOR', 'Lower Ball Joint'),
|
||||||
|
('RÓTULA SUPERIOR', 'Upper Ball Joint'),
|
||||||
|
('ROTULA INFERIOR', 'Lower Ball Joint'),
|
||||||
|
('ROTULA SUPERIOR', 'Upper Ball Joint'),
|
||||||
|
('RÓTULA', 'Ball Joint'),
|
||||||
|
('ROTULA', 'Ball Joint'),
|
||||||
|
('SOPORTE DE MOTOR', 'Engine Mount'),
|
||||||
|
('SOPORTE DE TRANSMIS', 'Transmission Mount'),
|
||||||
|
('TERMINAL EXTERIOR', 'Outer Tie Rod End'),
|
||||||
|
('TERMINAL INTERIOR', 'Inner Tie Rod'),
|
||||||
|
('TERMINAL DIREC', 'Tie Rod End'),
|
||||||
|
('TIRANTE', 'Trailing Arm'),
|
||||||
|
('TORNILLO ESTABILIZADOR', 'Sway Bar Link'),
|
||||||
|
('VARILLA', 'Drag Link'),
|
||||||
|
('EXCÉNTRICO', 'Camber Kit'),
|
||||||
|
]:
|
||||||
|
if es in desc.upper():
|
||||||
|
name_en = f"{en} {sku}"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
name_en = f"{desc} {sku}"
|
||||||
|
return name_en, name_es
|
||||||
|
|
||||||
|
|
||||||
|
def convert_year(yy):
|
||||||
|
"""Convert 2-digit year to 4-digit. 00-30 → 2000-2030, 31-99 → 1931-1999."""
|
||||||
|
y = int(yy)
|
||||||
|
if y >= 100:
|
||||||
|
return y # already 4-digit
|
||||||
|
if y <= 30:
|
||||||
|
return 2000 + y
|
||||||
|
return 1900 + y
|
||||||
|
|
||||||
|
|
||||||
|
def is_skip_line(line):
|
||||||
|
for pat in SKIP_PATTERNS:
|
||||||
|
if pat in line:
|
||||||
|
return True
|
||||||
|
# Pure page numbers
|
||||||
|
if re.match(r'^\d{1,3}$', line.strip()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_brand_line(line):
|
||||||
|
"""Check if line is a brand header."""
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped in DAR_BRANDS:
|
||||||
|
return True
|
||||||
|
# Some brands have extra whitespace or minor variations
|
||||||
|
for b in DAR_BRANDS:
|
||||||
|
if stripped.upper() == b:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dar_pdf(pdf_path):
|
||||||
|
"""Parse the DAR Catalogo Azul vehicle application pages."""
|
||||||
|
pdf = pypdf.PdfReader(pdf_path)
|
||||||
|
entries = []
|
||||||
|
current_brands = [] # List because some pages have "CHEVROLET, GMC"
|
||||||
|
current_model = None
|
||||||
|
|
||||||
|
# Accumulator for multi-line entries
|
||||||
|
entry_year_from = None
|
||||||
|
entry_year_to = None
|
||||||
|
entry_lines = []
|
||||||
|
|
||||||
|
def flush_entry():
|
||||||
|
nonlocal entry_year_from, entry_year_to, entry_lines
|
||||||
|
if not entry_lines or entry_year_from is None:
|
||||||
|
entry_lines = []
|
||||||
|
entry_year_from = None
|
||||||
|
entry_year_to = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Join accumulated lines
|
||||||
|
full_text = ' '.join(entry_lines)
|
||||||
|
|
||||||
|
# Try to extract SKU and page ref from the end
|
||||||
|
m = ENTRY_END_RE.match(full_text)
|
||||||
|
if m:
|
||||||
|
desc_text = m.group(1).strip()
|
||||||
|
sku = m.group(2).strip()
|
||||||
|
# page_ref = m.group(3) # not used for import
|
||||||
|
|
||||||
|
if sku and desc_text and current_model:
|
||||||
|
for brand_name in current_brands:
|
||||||
|
for year in range(entry_year_from, entry_year_to + 1):
|
||||||
|
entries.append({
|
||||||
|
'brand': brand_name,
|
||||||
|
'model': current_model,
|
||||||
|
'year': year,
|
||||||
|
'description': desc_text,
|
||||||
|
'sku': sku,
|
||||||
|
})
|
||||||
|
|
||||||
|
entry_lines = []
|
||||||
|
entry_year_from = None
|
||||||
|
entry_year_to = None
|
||||||
|
|
||||||
|
for page_num in range(START_PAGE, min(END_PAGE + 1, len(pdf.pages))):
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if is_skip_line(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for brand header
|
||||||
|
if is_brand_line(line):
|
||||||
|
flush_entry()
|
||||||
|
# Split combined brands like "CHEVROLET, GMC"
|
||||||
|
current_brands = [b.strip() for b in line.split(',')]
|
||||||
|
current_model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for model line
|
||||||
|
# A model line is: not starting with a digit, not a data entry,
|
||||||
|
# not a brand, and we already have a brand
|
||||||
|
if not current_brands:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this line starts with a year range
|
||||||
|
m_year = YEAR_RE.match(line)
|
||||||
|
m_single = YEAR_SINGLE_RE.match(line) if not m_year else None
|
||||||
|
m_todos = TODOS_RE.match(line)
|
||||||
|
|
||||||
|
if m_year or m_todos:
|
||||||
|
# Flush previous entry
|
||||||
|
flush_entry()
|
||||||
|
|
||||||
|
if m_todos:
|
||||||
|
# "TODOS" = all years, use a reasonable range
|
||||||
|
entry_year_from = 1960
|
||||||
|
entry_year_to = 2020
|
||||||
|
rest = line[m_todos.end():].strip()
|
||||||
|
else:
|
||||||
|
y1 = convert_year(m_year.group(1))
|
||||||
|
y2 = convert_year(m_year.group(2))
|
||||||
|
entry_year_from = min(y1, y2)
|
||||||
|
entry_year_to = max(y1, y2)
|
||||||
|
rest = line[m_year.end():].strip()
|
||||||
|
|
||||||
|
if rest:
|
||||||
|
entry_lines.append(rest)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we're accumulating an entry, add continuation line
|
||||||
|
if entry_year_from is not None:
|
||||||
|
entry_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if it's a single year + data (rare)
|
||||||
|
if m_single and len(line) > 4:
|
||||||
|
y_val = int(m_single.group(1))
|
||||||
|
# Only treat as year if it's a plausible 2-digit year (not a 4+ digit number)
|
||||||
|
if y_val < 100 and len(m_single.group(1)) == 2:
|
||||||
|
flush_entry()
|
||||||
|
entry_year_from = convert_year(m_single.group(1))
|
||||||
|
entry_year_to = entry_year_from
|
||||||
|
rest = line[m_single.end():].strip()
|
||||||
|
if rest:
|
||||||
|
entry_lines.append(rest)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we get here, it's likely a model name
|
||||||
|
# Strip "(cont)" suffix
|
||||||
|
model_name = re.sub(r'\s*\(cont\)\s*$', '', line, flags=re.IGNORECASE).strip()
|
||||||
|
if model_name and not model_name.startswith('AÑO') and len(model_name) > 1:
|
||||||
|
flush_entry()
|
||||||
|
current_model = model_name
|
||||||
|
|
||||||
|
# Flush last entry
|
||||||
|
flush_entry()
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("IMPORTADOR - CATÁLOGO DAR 'LÍNEA AZUL' 2020")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n[1/5] Leyendo PDF: {PDF_PATH}")
|
||||||
|
entries = parse_dar_pdf(PDF_PATH)
|
||||||
|
print(f" Entradas parseadas: {len(entries):,}")
|
||||||
|
|
||||||
|
unique_skus = set(e['sku'] for e in entries)
|
||||||
|
unique_brands = set(e['brand'] for e in entries)
|
||||||
|
unique_models = set((e['brand'], e['model']) for e in entries)
|
||||||
|
print(f" SKUs únicos: {len(unique_skus):,}")
|
||||||
|
print(f" Marcas de vehículos: {len(unique_brands):,}")
|
||||||
|
print(f" Modelos únicos: {len(unique_models):,}")
|
||||||
|
|
||||||
|
# Show sample entries
|
||||||
|
print("\n Primeras 5 entradas:")
|
||||||
|
for e in entries[:5]:
|
||||||
|
print(f" {e['brand']} {e['model']} {e['year']} | {e['description']} | {e['sku']}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create DAR manufacturer
|
||||||
|
print("\n[2/5] Creando fabricante DAR...")
|
||||||
|
dar_mfr_id = ensure_manufacturer(cursor, 'DAR', 'aftermarket', 'standard', 'Mexico')
|
||||||
|
print(f" DAR manufacturer_id: {dar_mfr_id}")
|
||||||
|
|
||||||
|
# Create parts
|
||||||
|
print("\n[3/5] Creando partes...")
|
||||||
|
part_ids = {}
|
||||||
|
parts_created = 0
|
||||||
|
for sku in sorted(unique_skus):
|
||||||
|
# Find one entry with this SKU to get description
|
||||||
|
sample = next(e for e in entries if e['sku'] == sku)
|
||||||
|
group_id = classify_description(cursor, sample['description'])
|
||||||
|
name_en, name_es = part_names_from_desc(sample['description'], sku)
|
||||||
|
part_id, created = get_or_create_part(
|
||||||
|
cursor, sku, group_id, name_en, name_es, 'DAR Línea Azul')
|
||||||
|
part_ids[sku] = part_id
|
||||||
|
if created:
|
||||||
|
parts_created += 1
|
||||||
|
|
||||||
|
print(f" Partes creadas: {parts_created:,}")
|
||||||
|
print(f" Partes existentes: {len(unique_skus) - parts_created:,}")
|
||||||
|
|
||||||
|
# Create aftermarket entries for DAR-specific parts
|
||||||
|
print(" Creando aftermarket entries...")
|
||||||
|
am_created = 0
|
||||||
|
for sku in sorted(unique_skus):
|
||||||
|
part_id = part_ids.get(sku)
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM aftermarket_parts WHERE manufacturer_id = ? AND part_number = ?",
|
||||||
|
(dar_mfr_id, sku))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
sample = next(e for e in entries if e['sku'] == sku)
|
||||||
|
name_en, name_es = part_names_from_desc(sample['description'], sku)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO aftermarket_parts (oem_part_id, manufacturer_id, part_number, name, name_es) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_id, dar_mfr_id, sku, name_en, name_es))
|
||||||
|
am_created += 1
|
||||||
|
print(f" Aftermarket entries creadas: {am_created:,}")
|
||||||
|
|
||||||
|
# Create vehicles and fitments
|
||||||
|
print("\n[4/5] Creando vehículos y fitments...")
|
||||||
|
vehicles_created = 0
|
||||||
|
fitments_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if i % 10000 == 0 and i > 0:
|
||||||
|
print(f" Procesando {i:,}/{len(entries):,}...")
|
||||||
|
|
||||||
|
cache_key = (entry['brand'], entry['model'], entry['year'])
|
||||||
|
if cache_key not in mye_cache:
|
||||||
|
brand_id = ensure_brand(cursor, entry['brand'])
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
|
||||||
|
# Try to find existing MYE
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id
|
||||||
|
JOIN brands b ON m.brand_id = b.id
|
||||||
|
JOIN years y ON mye.year_id = y.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(entry['brand'], entry['model'], entry['year']))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
mye_id = mye_cache[cache_key]
|
||||||
|
part_id = part_ids.get(entry['sku'])
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if fitment exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?",
|
||||||
|
(mye_id, part_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
notes = f"Catálogo DAR Línea Azul 2020"
|
||||||
|
if entry.get('description'):
|
||||||
|
notes += f" - {entry['description']}"
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)",
|
||||||
|
(mye_id, part_id, notes))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created:,}")
|
||||||
|
print(f" Fitments creados: {fitments_created:,}")
|
||||||
|
|
||||||
|
# Cross-references: match DAR parts to MOOG parts on same vehicles
|
||||||
|
print("\n[5/5] Creando referencias cruzadas...")
|
||||||
|
xrefs_created = 0
|
||||||
|
|
||||||
|
for sku, part_id in part_ids.items():
|
||||||
|
# Find other parts (different brand) in same group fitting same vehicles
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT p2.id, p2.oem_part_number
|
||||||
|
FROM vehicle_parts vp1
|
||||||
|
JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id
|
||||||
|
JOIN parts p2 ON vp2.part_id = p2.id
|
||||||
|
WHERE vp1.part_id = ?
|
||||||
|
AND p2.id != ?
|
||||||
|
AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?)
|
||||||
|
AND p2.oem_part_number != ?
|
||||||
|
LIMIT 30
|
||||||
|
""", (part_id, part_id, part_id, sku))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
other_pn = row['oem_part_number']
|
||||||
|
# Skip if same part number prefix pattern (same brand)
|
||||||
|
if other_pn[:3] == sku[:3]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A -> B
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(part_id, other_pn))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'DAR Catalog')",
|
||||||
|
(part_id, other_pn))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
# B -> A
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(row['id'], sku))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'DAR Catalog')",
|
||||||
|
(row['id'], sku))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
print(f" Cross-refs creadas: {xrefs_created:,}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IMPORTACIÓN DAR COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Aftermarket entries: {am_created:,}
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Cross-refs creadas: {xrefs_created:,}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
548
vehicle_database/scripts/import_fram_catalog.py
Normal file
548
vehicle_database/scripts/import_fram_catalog.py
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR DEL CATÁLOGO FRAM 2017
|
||||||
|
- Sección de vehículos livianos (páginas 3-87): Brand → Model + Motor + Dates + Filters
|
||||||
|
- Sección de equivalencias (páginas 149-199): Competitor → FRAM mappings
|
||||||
|
- Filtros: PH/CH = Aceite, CA/PA = Aire, G/P/PS = Combustible, CF/CFA = Cabina
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/catalogs/fram_2017.pdf'
|
||||||
|
|
||||||
|
# Filter type classification by part number prefix
|
||||||
|
FILTER_PREFIXES = {
|
||||||
|
'PH': ('Oil Filters', 'Oil Filter', 'Filtro de Aceite'),
|
||||||
|
'CH': ('Oil Filters', 'Oil Filter Cartridge', 'Filtro de Aceite Cartucho'),
|
||||||
|
'CA': ('Air Filters', 'Air Filter', 'Filtro de Aire'),
|
||||||
|
'PA': ('Air Filters', 'Air Filter', 'Filtro de Aire'),
|
||||||
|
'G': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'),
|
||||||
|
'P': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'),
|
||||||
|
'PS': ('Fuel Filters', 'Fuel Filter', 'Filtro de Combustible'),
|
||||||
|
'CF': ('Cabin Air Filters', 'Cabin Air Filter', 'Filtro de Cabina'),
|
||||||
|
'CFA': ('Cabin Air Filters', 'Cabin Air Filter', 'Filtro de Cabina'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# FRAM part number pattern
|
||||||
|
FRAM_PART_RE = re.compile(r'\b(CFA?\d[\w-]*|PH\d[\w-]*|CH\d[\w-]*|CA\d[\w-]*|PA\d[\w-]*|PS\d[\w-]*|G\d[\w-]*|P\d[\w-]*)\b')
|
||||||
|
|
||||||
|
# Known brands that appear as headers in the FRAM catalog
|
||||||
|
KNOWN_BRANDS = {
|
||||||
|
'ACURA', 'ALEKO', 'ALFA ROMEO', 'ASIA MOTORS', 'ASTON MARTIN', 'AUDI',
|
||||||
|
'BEDFORD', 'BENTLEY', 'BMW', 'BUICK', 'CADILLAC', 'CHANA', 'CHERY',
|
||||||
|
'CHEVROLET', 'CHRYSLER', 'CITROEN', 'DAEWOO', 'DACIA', 'DAIHATSU',
|
||||||
|
'DODGE', 'EAGLE', 'FAW', 'FIAT', 'FORD', 'GALLOPER', 'GEO', 'GEELY',
|
||||||
|
'GREAT WALL', 'HONDA', 'HUMMER', 'HYUNDAI', 'INFINITI', 'ISUZU',
|
||||||
|
'IVECO', 'JAC', 'JAGUAR', 'JEEP', 'KIA', 'LADA', 'LANCIA', 'LAND ROVER',
|
||||||
|
'LEXUS', 'LIFAN', 'LINCOLN', 'LOTUS', 'MAHINDRA', 'MASERATI', 'MAZDA',
|
||||||
|
'MERCEDES BENZ', 'MERCURY', 'MG', 'MINI', 'MITSUBISHI', 'NISSAN',
|
||||||
|
'OLDSMOBILE', 'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC', 'PORSCHE',
|
||||||
|
'RAM', 'RENAULT', 'ROVER', 'SAAB', 'SAMSUNG', 'SATURN', 'SCION',
|
||||||
|
'SEAT', 'SKODA', 'SMART', 'SSANGYONG', 'SUBARU', 'SUZUKI', 'TATA',
|
||||||
|
'TOYOTA', 'TRIUMPH', 'VAUXHALL', 'VOLKSWAGEN', 'VOLVO',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None):
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, type_, quality, country))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_brand(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model(cursor, brand_id, name):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)",
|
||||||
|
(brand_id, name))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_year(cursor, year):
|
||||||
|
cursor.execute("SELECT id FROM years WHERE year = ?", (year,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO years (year) VALUES (?)", (year,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_engine(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = ?", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
displacement = None
|
||||||
|
cylinders = None
|
||||||
|
fuel_type = 'gasoline'
|
||||||
|
m = re.search(r'(\d+)cc', name)
|
||||||
|
if m:
|
||||||
|
displacement = int(m.group(1))
|
||||||
|
if 'diesel' in name.lower() or 'td' in name.lower() or 'tdi' in name.lower() or 'jtd' in name.lower():
|
||||||
|
fuel_type = 'diesel'
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO engines (name, displacement_cc, cylinders, fuel_type) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, displacement, cylinders, fuel_type))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_generic_engine(cursor):
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = 'Generic'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')")
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id=None):
|
||||||
|
if engine_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?",
|
||||||
|
(model_id, year_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
if not engine_id:
|
||||||
|
engine_id = get_generic_engine(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def classify_filter(part_number):
|
||||||
|
"""Classify FRAM filter by part number prefix and return (group_name, name_en, name_es)."""
|
||||||
|
pn_upper = part_number.upper()
|
||||||
|
# Check longer prefixes first
|
||||||
|
for prefix in ['CFA', 'CF', 'PS', 'PH', 'CH', 'CA', 'PA']:
|
||||||
|
if pn_upper.startswith(prefix):
|
||||||
|
return FILTER_PREFIXES[prefix]
|
||||||
|
# Single letter prefixes
|
||||||
|
if pn_upper.startswith('G') and re.match(r'^G\d', pn_upper):
|
||||||
|
return FILTER_PREFIXES['G']
|
||||||
|
if pn_upper.startswith('P') and re.match(r'^P\d', pn_upper):
|
||||||
|
return FILTER_PREFIXES['P']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_group(cursor, group_name):
|
||||||
|
"""Get group ID by name."""
|
||||||
|
cursor.execute("SELECT id FROM part_groups WHERE name = ?", (group_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
# Find category
|
||||||
|
cat_map = {
|
||||||
|
'Oil Filters': 'Engine', 'Air Filters': 'Engine',
|
||||||
|
'Fuel Filters': 'Fuel & Air', 'Cabin Air Filters': 'Heat & Air Conditioning',
|
||||||
|
}
|
||||||
|
cat_name = cat_map.get(group_name, 'Engine')
|
||||||
|
cursor.execute("SELECT id FROM part_categories WHERE name = ?", (cat_name,))
|
||||||
|
cat = cursor.fetchone()
|
||||||
|
if not cat:
|
||||||
|
return None
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_groups (category_id, name) VALUES (?, ?)",
|
||||||
|
(cat['id'], group_name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_part(cursor, part_number, group_id, name, name_es, description):
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id'], False
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_number, name, name_es, group_id, description))
|
||||||
|
return cursor.lastrowid, True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date_range(date_str):
|
||||||
|
"""Parse FRAM date range like (03/88 - 09/97) into year range."""
|
||||||
|
m = re.match(r'\(?\s*(\d{2})/(\d{2,4})\s*-\s*(\d{2})/(\d{2,4})\s*\)?', date_str)
|
||||||
|
if m:
|
||||||
|
y1 = int(m.group(2))
|
||||||
|
y2 = int(m.group(4))
|
||||||
|
if y1 < 100:
|
||||||
|
y1 += 2000 if y1 < 50 else 1900
|
||||||
|
if y2 < 100:
|
||||||
|
y2 += 2000 if y2 < 50 else 1900
|
||||||
|
return list(range(y1, y2 + 1))
|
||||||
|
# Try single year
|
||||||
|
m = re.match(r'\(?\s*(\d{2})/(\d{2,4})\s*-?\s*\)?', date_str)
|
||||||
|
if m:
|
||||||
|
y = int(m.group(2))
|
||||||
|
if y < 100:
|
||||||
|
y += 2000 if y < 50 else 1900
|
||||||
|
return [y]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fram_parts(text):
|
||||||
|
"""Extract FRAM part numbers from a text string."""
|
||||||
|
return FRAM_PART_RE.findall(text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vehicle_entries(pdf):
|
||||||
|
"""Parse vehicle entries from FRAM catalog (light vehicles section)."""
|
||||||
|
entries = []
|
||||||
|
current_brand = None
|
||||||
|
current_model_group = None
|
||||||
|
|
||||||
|
for page_num in range(2, 87): # Pages 3-87 (0-indexed)
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
prev_line = ""
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip headers/footers
|
||||||
|
if line.startswith('LIVIANOS') or line.startswith('PESADOS'):
|
||||||
|
continue
|
||||||
|
if re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
if 'MARCA/CATEGORÍA' in line:
|
||||||
|
continue
|
||||||
|
# Skip dimension notes
|
||||||
|
if re.match(r'^H1=', line) or line.startswith('Parcial') or line.startswith('Panel') or line.startswith('Redondo'):
|
||||||
|
continue
|
||||||
|
if line.startswith('C/C.') or line.startswith('Unidad Sellada'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Brand detection
|
||||||
|
if line in KNOWN_BRANDS:
|
||||||
|
current_brand = line
|
||||||
|
current_model_group = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if line is a brand listed with other brands (e.g., "Acura - Aleko - Alfa Romeo")
|
||||||
|
if ' - ' in line and all(b.strip() in KNOWN_BRANDS for b in line.split(' - ') if b.strip()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current_brand:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to extract data from line
|
||||||
|
# Format: [MODEL_GROUP] description - Mot.CODE-DISPcc-Powerkw/hp (date_from - date_to) FILTER_CODES
|
||||||
|
|
||||||
|
# Check if this is a continuation of previous line
|
||||||
|
if prev_line and not re.match(r'^[A-Z]', line) and not FRAM_PART_RE.search(line):
|
||||||
|
prev_line = ""
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract date range and parts
|
||||||
|
date_match = re.search(r'\((\d{2}/\d{2,4}\s*-\s*(?:\d{2}/\d{2,4}\s*)?)\)', line)
|
||||||
|
parts = extract_fram_parts(line)
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
years = []
|
||||||
|
if date_match:
|
||||||
|
years = parse_date_range(date_match.group(1))
|
||||||
|
|
||||||
|
# Extract model name
|
||||||
|
model_name = None
|
||||||
|
# Check if line starts with an uppercase model group
|
||||||
|
model_match = re.match(r'^([A-Z][A-Z0-9\s/\-]+?)\s+\S', line)
|
||||||
|
if model_match:
|
||||||
|
potential_model = model_match.group(1).strip()
|
||||||
|
# If it looks like a model group (all caps, short)
|
||||||
|
if potential_model.isupper() and len(potential_model) < 30:
|
||||||
|
current_model_group = potential_model
|
||||||
|
model_name = current_model_group
|
||||||
|
else:
|
||||||
|
model_name = current_model_group or "Unknown"
|
||||||
|
else:
|
||||||
|
model_name = current_model_group or "Unknown"
|
||||||
|
|
||||||
|
if not years:
|
||||||
|
years = [2017] # Default to catalog year
|
||||||
|
|
||||||
|
for year in years:
|
||||||
|
for part in parts:
|
||||||
|
info = classify_filter(part)
|
||||||
|
if info:
|
||||||
|
entries.append({
|
||||||
|
'brand': current_brand,
|
||||||
|
'model': model_name,
|
||||||
|
'year': year,
|
||||||
|
'part_number': part,
|
||||||
|
'filter_type': info[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
prev_line = line
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cross_references(pdf):
|
||||||
|
"""Parse the equivalencias/cross-reference section."""
|
||||||
|
xrefs = []
|
||||||
|
|
||||||
|
for page_num in range(148, min(200, len(pdf.pages))):
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if 'EQUIVALENCIAS' not in text and 'Código' not in text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or 'EQUIVALENCIAS' in line or 'Código' in line:
|
||||||
|
continue
|
||||||
|
if re.match(r'^\d{1,3}$', line):
|
||||||
|
continue
|
||||||
|
# Skip brand header lines
|
||||||
|
if re.match(r'^[A-Z][a-z]', line) and ' - ' in line:
|
||||||
|
continue
|
||||||
|
if line.istitle() or (line[0].isupper() and line[1:2].islower() and len(line.split()) <= 3):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse: CompetitorNumber FRAMNumber
|
||||||
|
# FRAM numbers start with PH, CH, CA, PA, G, P, PS, CF, CFA
|
||||||
|
match = re.match(r'^(\S+)\s+((?:PH|CH|CA|PA|PS|CF|CFA|G|P)\w+)', line)
|
||||||
|
if match:
|
||||||
|
competitor_pn = match.group(1).strip()
|
||||||
|
fram_pn = match.group(2).strip()
|
||||||
|
# Skip if competitor number looks like a FRAM number
|
||||||
|
if re.match(r'^(PH|CH|CA|PA|PS|CF|CFA)', competitor_pn):
|
||||||
|
continue
|
||||||
|
xrefs.append({
|
||||||
|
'competitor': competitor_pn,
|
||||||
|
'fram': fram_pn,
|
||||||
|
})
|
||||||
|
|
||||||
|
return xrefs
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("IMPORTADOR - CATÁLOGO FRAM 2017")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n[1/6] Leyendo PDF: {PDF_PATH}")
|
||||||
|
pdf = pypdf.PdfReader(PDF_PATH)
|
||||||
|
print(f" Total páginas: {len(pdf.pages)}")
|
||||||
|
|
||||||
|
print("\n[2/6] Extrayendo datos del catálogo...")
|
||||||
|
vehicle_entries = parse_vehicle_entries(pdf)
|
||||||
|
cross_refs = parse_cross_references(pdf)
|
||||||
|
print(f" Entradas de vehículos: {len(vehicle_entries)}")
|
||||||
|
print(f" Equivalencias (cross-refs): {len(cross_refs)}")
|
||||||
|
|
||||||
|
# Get unique parts
|
||||||
|
unique_parts = {}
|
||||||
|
for e in vehicle_entries:
|
||||||
|
if e['part_number'] not in unique_parts:
|
||||||
|
info = classify_filter(e['part_number'])
|
||||||
|
if info:
|
||||||
|
unique_parts[e['part_number']] = info
|
||||||
|
print(f" Partes únicas: {len(unique_parts)}")
|
||||||
|
|
||||||
|
# Also get parts from cross-refs
|
||||||
|
for xref in cross_refs:
|
||||||
|
if xref['fram'] not in unique_parts:
|
||||||
|
info = classify_filter(xref['fram'])
|
||||||
|
if info:
|
||||||
|
unique_parts[xref['fram']] = info
|
||||||
|
|
||||||
|
print(f" Partes únicas (incl. cross-refs): {len(unique_parts)}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create FRAM manufacturer
|
||||||
|
print("\n[3/6] Creando fabricante FRAM...")
|
||||||
|
# Check if Fram already exists (from Gonher import)
|
||||||
|
fram_mfr_id = ensure_manufacturer(cursor, 'FRAM', 'aftermarket', 'standard', 'USA')
|
||||||
|
print(f" FRAM manufacturer_id: {fram_mfr_id}")
|
||||||
|
|
||||||
|
# Create parts
|
||||||
|
print("\n[4/6] Creando partes de filtros...")
|
||||||
|
part_ids = {}
|
||||||
|
parts_created = 0
|
||||||
|
group_cache = {}
|
||||||
|
|
||||||
|
for pn, (group_name, name_en, name_es) in unique_parts.items():
|
||||||
|
if group_name not in group_cache:
|
||||||
|
group_cache[group_name] = get_or_create_group(cursor, group_name)
|
||||||
|
group_id = group_cache[group_name]
|
||||||
|
if not group_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_name = f"{name_en} {pn}"
|
||||||
|
full_name_es = f"{name_es} {pn}"
|
||||||
|
part_id, created = get_or_create_part(
|
||||||
|
cursor, pn, group_id, full_name, full_name_es, "FRAM Filter")
|
||||||
|
part_ids[pn] = part_id
|
||||||
|
if created:
|
||||||
|
parts_created += 1
|
||||||
|
|
||||||
|
print(f" Partes creadas: {parts_created}")
|
||||||
|
|
||||||
|
# Create vehicles and fitments
|
||||||
|
print("\n[5/6] Creando vehículos y fitments...")
|
||||||
|
vehicles_created = 0
|
||||||
|
fitments_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for entry in vehicle_entries:
|
||||||
|
part_id = part_ids.get(entry['part_number'])
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache_key = (entry['brand'], entry['model'], entry['year'])
|
||||||
|
if cache_key not in mye_cache:
|
||||||
|
brand_id = ensure_brand(cursor, entry['brand'])
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id
|
||||||
|
JOIN brands b ON m.brand_id = b.id
|
||||||
|
JOIN years y ON mye.year_id = y.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(entry['brand'], entry['model'], entry['year']))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
mye_id = mye_cache[cache_key]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?",
|
||||||
|
(mye_id, part_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)",
|
||||||
|
(mye_id, part_id, f"Catálogo FRAM 2017 - {entry['filter_type']}"))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created}")
|
||||||
|
print(f" Fitments creados: {fitments_created}")
|
||||||
|
|
||||||
|
# Create cross-references
|
||||||
|
print("\n[6/6] Creando referencias cruzadas...")
|
||||||
|
xrefs_created = 0
|
||||||
|
|
||||||
|
# A) From equivalencias section
|
||||||
|
for xref in cross_refs:
|
||||||
|
fram_part_id = part_ids.get(xref['fram'])
|
||||||
|
if not fram_part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(fram_part_id, xref['competitor']))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Equivalencias 2017')",
|
||||||
|
(fram_part_id, xref['competitor']))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
# B) Match FRAM parts to other brands' parts by vehicle fitment
|
||||||
|
for pn, part_id in part_ids.items():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT p2.id, p2.oem_part_number
|
||||||
|
FROM vehicle_parts vp1
|
||||||
|
JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id
|
||||||
|
JOIN parts p2 ON vp2.part_id = p2.id
|
||||||
|
WHERE vp1.part_id = ?
|
||||||
|
AND p2.id != ?
|
||||||
|
AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?)
|
||||||
|
AND p2.oem_part_number NOT LIKE 'PH%'
|
||||||
|
AND p2.oem_part_number NOT LIKE 'CH%'
|
||||||
|
AND p2.oem_part_number NOT LIKE 'CA%'
|
||||||
|
AND p2.oem_part_number NOT LIKE 'PA%'
|
||||||
|
AND p2.oem_part_number NOT LIKE 'CF%'
|
||||||
|
AND p2.oem_part_number NOT LIKE 'CFA%'
|
||||||
|
LIMIT 20
|
||||||
|
""", (part_id, part_id, part_id))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
# Cross-ref FRAM → other
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Catalog 2017')",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
# Reverse cross-ref
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(row['id'], pn))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'FRAM Catalog 2017')",
|
||||||
|
(row['id'], pn))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
print(f" Cross-refs creadas: {xrefs_created}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IMPORTACIÓN FRAM COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Cross-refs creadas: {xrefs_created:,}
|
||||||
|
- Equivalencias leídas: {len(cross_refs):,}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
705
vehicle_database/scripts/import_moog_catalog.py
Normal file
705
vehicle_database/scripts/import_moog_catalog.py
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR DEL CATÁLOGO MOOG - SUSPENSIÓN Y DIRECCIÓN
|
||||||
|
Funciona para los 3 volúmenes:
|
||||||
|
Vol 1: ≤1989 /tmp/catalogs/suspension/moog_vol1_1989back.pdf pages 4-1037
|
||||||
|
Vol 2: 1990-2005 /tmp/catalogs/suspension/moog_vol2_1990_2005.pdf pages 7-1641
|
||||||
|
Vol 3: 2006+ /tmp/catalogs/suspension/moog_vol3_2006up.pdf pages 8-1089
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
|
||||||
|
VOLUMES = {
|
||||||
|
'1': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol1_1989back.pdf',
|
||||||
|
'start_page': 3, # 0-indexed
|
||||||
|
'end_page': 1037,
|
||||||
|
'label': 'Vol 1 (≤1989)',
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol2_1990_2005.pdf',
|
||||||
|
'start_page': 6,
|
||||||
|
'end_page': 1641,
|
||||||
|
'label': 'Vol 2 (1990-2005)',
|
||||||
|
},
|
||||||
|
'3': {
|
||||||
|
'path': '/tmp/catalogs/suspension/moog_vol3_2006up.pdf',
|
||||||
|
'start_page': 7,
|
||||||
|
'end_page': 1089,
|
||||||
|
'label': 'Vol 3 (2006+)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
MOOG_BRANDS = {
|
||||||
|
'ACURA', 'ALFA ROMEO', 'AMERICAN MOTORS', 'AMERICAN MOTORS CORP.',
|
||||||
|
'ASTON MARTIN', 'AUDI', 'BMW', 'BUICK', 'CADILLAC',
|
||||||
|
'CHEVROLET', 'CHEVROLET TRUCK', 'CHRYSLER',
|
||||||
|
'DATSUN', 'DODGE', 'DODGE TRUCK',
|
||||||
|
'EAGLE', 'FIAT', 'FORD', 'FORD TRUCK', 'FREIGHTLINER',
|
||||||
|
'GEO', 'GEO TRUCK', 'GENERAL MOTORS TRUCK',
|
||||||
|
'HONDA', 'HUMMER', 'HYUNDAI',
|
||||||
|
'INFINITI', 'INTERNATIONAL', 'ISUZU', 'ISUZU TRUCK',
|
||||||
|
'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LAFORZA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'LOTUS',
|
||||||
|
'MAZDA', 'MAZDA TRUCK', 'MERCEDES BENZ', 'MERCEDES-BENZ',
|
||||||
|
'MERCURY', 'MERKUR', 'MINI', 'MITSUBISHI', 'MITSUBISHI TRUCK',
|
||||||
|
'NISSAN', 'NISSAN TRUCK',
|
||||||
|
'OLDSMOBILE', 'OPEL',
|
||||||
|
'PEUGEOT', 'PLYMOUTH', 'PLYMOUTH TRUCK', 'PONTIAC', 'PORSCHE',
|
||||||
|
'RAM TRUCK', 'RENAULT', 'ROLLS ROYCE',
|
||||||
|
'SAAB', 'SATURN', 'SCION', 'SEAT', 'SHELBY', 'SMART', 'STERLING',
|
||||||
|
'SUBARU', 'SUBARU TRUCK', 'SUZUKI', 'SUZUKI TRUCK',
|
||||||
|
'TOYOTA', 'TOYOTA TRUCK', 'TRIUMPH',
|
||||||
|
'VOLKSWAGEN', 'VOLKSWAGEN TRUCK', 'VOLVO', 'VOLVO TRUCK',
|
||||||
|
'WILLYS MOTORS INC.',
|
||||||
|
}
|
||||||
|
|
||||||
|
# MOOG part number regex
|
||||||
|
MOOG_PART_RE = re.compile(
|
||||||
|
r'\b(K\d{3,7}T?|ES\d{3,7}[A-Z]{0,3}T?|EV\d{3,7}[A-Z]?|DS\d{3,7}'
|
||||||
|
r'|CC\d{3,6}|CK\d{3,7}|SSD\d{2,4}|BK\d{3,4}[A-Z]?'
|
||||||
|
r'|SB\d{3,4}|NIBJ\d+|VO[A-Z]{2}\d+|HY[A-Z]{2}\d+|AU[A-Z]{2}\d+|BM[A-Z]{2}\d+)\b'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Numeric-only springs (only used within spring category context)
|
||||||
|
SPRING_NUM_RE = re.compile(r'\b(\d{4,6})\b')
|
||||||
|
|
||||||
|
# Figure code
|
||||||
|
FIGURE_RE = re.compile(r'\b([FSR]\d{3})\b')
|
||||||
|
|
||||||
|
# Year range at start of line
|
||||||
|
YEAR_RE = re.compile(r'^(\d{4})(?:\s*-\s*(\d{4}))?')
|
||||||
|
|
||||||
|
# System sections
|
||||||
|
SYSTEM_PATTERNS = {
|
||||||
|
'SUSPENSION DELANTERA': 'front_suspension',
|
||||||
|
'SUSPENSIÓN DELANTERA': 'front_suspension',
|
||||||
|
'DIRECCIÓN': 'steering',
|
||||||
|
'DIRECCION': 'steering',
|
||||||
|
'SUSPENSION TRASERA': 'rear_suspension',
|
||||||
|
'SUSPENSIÓN TRASERA': 'rear_suspension',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Header/footer markers to skip
|
||||||
|
SKIP_MARKERS = [
|
||||||
|
'www.moogproblemsolver.com',
|
||||||
|
'CATÁLOGO MASTER',
|
||||||
|
'CATALOGO MASTER',
|
||||||
|
'Solucionador de problemas',
|
||||||
|
'búsqueda de piezas electrónicas',
|
||||||
|
'FMe-cat.mx',
|
||||||
|
'Año Observaciones',
|
||||||
|
'Total Solución',
|
||||||
|
'P/C\nCTD',
|
||||||
|
'Imagenes de piezas',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name, type_='aftermarket', quality='premium', country=None):
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, type_, quality, country))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_brand(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model(cursor, brand_id, name):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)",
|
||||||
|
(brand_id, name))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_year(cursor, year):
|
||||||
|
cursor.execute("SELECT id FROM years WHERE year = ?", (year,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO years (year) VALUES (?)", (year,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_generic_engine(cursor):
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = 'Generic'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')")
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id=None):
|
||||||
|
if engine_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?",
|
||||||
|
(model_id, year_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
if not engine_id:
|
||||||
|
engine_id = get_generic_engine(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_part(cursor, part_number, group_id, name, name_es, description):
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id'], False
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_number, name, name_es, group_id, description))
|
||||||
|
return cursor.lastrowid, True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Group ID lookup cache ---
|
||||||
|
_group_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_id(cursor, name_en):
|
||||||
|
"""Get group ID by English name."""
|
||||||
|
if name_en not in _group_cache:
|
||||||
|
cursor.execute("SELECT id FROM part_groups WHERE name = ?", (name_en,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
_group_cache[name_en] = row['id'] if row else None
|
||||||
|
return _group_cache[name_en]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_part(cursor, category_text, part_number):
|
||||||
|
"""Map MOOG category text + part number to a DB group_id."""
|
||||||
|
cat = category_text.lower() if category_text else ''
|
||||||
|
|
||||||
|
# By category text (Spanish)
|
||||||
|
if 'rótula' in cat and 'suspensión' in cat:
|
||||||
|
return get_group_id(cursor, 'Ball Joints')
|
||||||
|
if 'rótula' in cat and 'prensad' in cat:
|
||||||
|
return get_group_id(cursor, 'Ball Joints')
|
||||||
|
if 'brazo de control' in cat and 'rótula' in cat:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
if 'ensamble de brazo' in cat:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
if 'brazo de control' in cat:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
if 'horquilla' in cat:
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
if 'buje' in cat and 'estabilizadora' in cat:
|
||||||
|
return get_group_id(cursor, 'Sway Bar Bushings')
|
||||||
|
if 'buje' in cat and 'brazo' in cat:
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
if 'buje' in cat and 'amortiguador' in cat:
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
if 'buje' in cat and 'tracción' in cat:
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
if 'buje' in cat and 'camber' in cat:
|
||||||
|
return get_group_id(cursor, 'Camber/Caster Kits')
|
||||||
|
if 'buje' in cat:
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
if 'cople' in cat and 'estabilizadora' in cat:
|
||||||
|
return get_group_id(cursor, 'Sway Bar Links')
|
||||||
|
if 'soporte' in cat and ('strut' in cat.lower() or 'amortiguador' in cat):
|
||||||
|
return get_group_id(cursor, 'Strut Mounts')
|
||||||
|
if 'montaje' in cat and 'amortiguador' in cat:
|
||||||
|
return get_group_id(cursor, 'Strut Mounts')
|
||||||
|
if 'fuelle' in cat or 'cubrepolvo' in cat:
|
||||||
|
return get_group_id(cursor, 'Struts')
|
||||||
|
if 'asiento' in cat and 'resorte' in cat:
|
||||||
|
return get_group_id(cursor, 'Spring Seats')
|
||||||
|
if 'ensamble de terminal' in cat:
|
||||||
|
return get_group_id(cursor, 'Tie Rod Ends')
|
||||||
|
if 'terminal' in cat and 'dirección' in cat:
|
||||||
|
if part_number and part_number.startswith('EV'):
|
||||||
|
return get_group_id(cursor, 'Inner Tie Rods')
|
||||||
|
return get_group_id(cursor, 'Tie Rod Ends')
|
||||||
|
if 'barra central' in cat:
|
||||||
|
return get_group_id(cursor, 'Center Links')
|
||||||
|
if 'barra de arrastre' in cat or 'barra de acoplamiento' in cat:
|
||||||
|
return get_group_id(cursor, 'Drag Links')
|
||||||
|
if 'varilla de dirección' in cat:
|
||||||
|
return get_group_id(cursor, 'Drag Links')
|
||||||
|
if 'resorte' in cat and 'suspensión' in cat:
|
||||||
|
return get_group_id(cursor, 'Coil Springs')
|
||||||
|
if 'camber' in cat or 'caster' in cat:
|
||||||
|
return get_group_id(cursor, 'Camber/Caster Kits')
|
||||||
|
if 'brazo auxiliar' in cat or 'brazo loco' in cat:
|
||||||
|
return get_group_id(cursor, 'Idler Arms')
|
||||||
|
if 'brazo pitman' in cat:
|
||||||
|
return get_group_id(cursor, 'Pitman Arms')
|
||||||
|
if 'amortiguador de dirección' in cat:
|
||||||
|
return get_group_id(cursor, 'Steering Dampers')
|
||||||
|
if 'pasador' in cat and 'dirección' in cat:
|
||||||
|
return get_group_id(cursor, 'King Pin Sets')
|
||||||
|
if 'muelle' in cat:
|
||||||
|
return get_group_id(cursor, 'Leaf Springs')
|
||||||
|
if 'barra de torsión' in cat:
|
||||||
|
return get_group_id(cursor, 'Torsion Bars')
|
||||||
|
|
||||||
|
# Fallback by part prefix
|
||||||
|
if part_number:
|
||||||
|
if part_number.startswith('ES'):
|
||||||
|
return get_group_id(cursor, 'Tie Rod Ends')
|
||||||
|
if part_number.startswith('EV'):
|
||||||
|
return get_group_id(cursor, 'Inner Tie Rods')
|
||||||
|
if part_number.startswith('DS'):
|
||||||
|
return get_group_id(cursor, 'Center Links')
|
||||||
|
if part_number.startswith('CC') or (part_number.isdigit() and len(part_number) >= 4):
|
||||||
|
return get_group_id(cursor, 'Coil Springs')
|
||||||
|
if part_number.startswith('SSD'):
|
||||||
|
return get_group_id(cursor, 'Steering Dampers')
|
||||||
|
if part_number.startswith('CK'):
|
||||||
|
return get_group_id(cursor, 'Control Arms')
|
||||||
|
if part_number.startswith('BK'):
|
||||||
|
return get_group_id(cursor, 'King Pin Sets')
|
||||||
|
if part_number.startswith('SB'):
|
||||||
|
return get_group_id(cursor, 'Bushings')
|
||||||
|
|
||||||
|
return get_group_id(cursor, 'Ball Joints') # Default
|
||||||
|
|
||||||
|
|
||||||
|
# --- Part type names for DB ---
|
||||||
|
|
||||||
|
PART_TYPE_NAMES = {
|
||||||
|
'Ball Joints': ('Ball Joint', 'Rótula de Suspensión'),
|
||||||
|
'Bushings': ('Bushing', 'Buje'),
|
||||||
|
'Sway Bar Bushings': ('Sway Bar Bushing', 'Buje de Barra Estabilizadora'),
|
||||||
|
'Control Arms': ('Control Arm', 'Brazo de Control'),
|
||||||
|
'Sway Bar Links': ('Sway Bar Link', 'Cople de Barra Estabilizadora'),
|
||||||
|
'Strut Mounts': ('Strut Mount', 'Soporte de Strut'),
|
||||||
|
'Struts': ('Strut Boot', 'Fuelle de Strut'),
|
||||||
|
'Spring Seats': ('Spring Seat', 'Asiento de Resorte'),
|
||||||
|
'Tie Rod Ends': ('Tie Rod End', 'Terminal de Dirección'),
|
||||||
|
'Inner Tie Rods': ('Inner Tie Rod', 'Terminal Interior de Dirección'),
|
||||||
|
'Center Links': ('Center Link', 'Barra Central'),
|
||||||
|
'Drag Links': ('Drag Link', 'Barra de Arrastre'),
|
||||||
|
'Coil Springs': ('Coil Spring', 'Resorte Helicoidal'),
|
||||||
|
'Camber/Caster Kits': ('Camber/Caster Kit', 'Kit de Camber/Caster'),
|
||||||
|
'Idler Arms': ('Idler Arm', 'Brazo Auxiliar'),
|
||||||
|
'Pitman Arms': ('Pitman Arm', 'Brazo Pitman'),
|
||||||
|
'Steering Dampers': ('Steering Damper', 'Amortiguador de Dirección'),
|
||||||
|
'King Pin Sets': ('King Pin Set', 'Juego de Pivote'),
|
||||||
|
'Leaf Springs': ('Leaf Spring', 'Muelle'),
|
||||||
|
'Torsion Bars': ('Torsion Bar', 'Barra de Torsión'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Parsing ---
|
||||||
|
|
||||||
|
def is_skip_line(line):
|
||||||
|
"""Check if line is header/footer to skip."""
|
||||||
|
return any(m in line for m in SKIP_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_brand_model(line):
|
||||||
|
"""Try to parse a brand-model line. Returns (brand, model) or (None, None)."""
|
||||||
|
for dash in ['−', '–', '—', '-']:
|
||||||
|
if dash not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split(dash, 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
left = re.sub(r'\s*\(Cont\.?\)\.?\s*', '', parts[0]).strip()
|
||||||
|
right = re.sub(r'\s*\(Cont\.?\)\.?\s*', '', parts[1]).strip()
|
||||||
|
if not left or not right:
|
||||||
|
continue
|
||||||
|
|
||||||
|
left_up = left.upper()
|
||||||
|
right_up = right.upper()
|
||||||
|
|
||||||
|
# Check which side matches a known brand
|
||||||
|
for brand in MOOG_BRANDS:
|
||||||
|
if left_up == brand or left_up.startswith(brand + ' '):
|
||||||
|
return left, right
|
||||||
|
if right_up == brand or right_up.startswith(brand + ' '):
|
||||||
|
return right, left
|
||||||
|
|
||||||
|
# Heuristic: if left is all uppercase words and right has mixed case
|
||||||
|
if left.isupper() and len(left) > 2:
|
||||||
|
return left, right
|
||||||
|
if right.isupper() and len(right) > 2:
|
||||||
|
return right, left
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_system(line):
|
||||||
|
"""Check if line is a system section header."""
|
||||||
|
clean = line.strip().upper()
|
||||||
|
for pattern, system in SYSTEM_PATTERNS.items():
|
||||||
|
if clean.startswith(pattern.upper()):
|
||||||
|
return system
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORY_KEYWORDS = [
|
||||||
|
'Rótula', 'Rotula', 'Buje', 'Brazo de control', 'Brazo auxiliar',
|
||||||
|
'Brazo pitman', 'Brazo loco', 'Cople', 'Soporte', 'Fuelle',
|
||||||
|
'Asiento del resorte', 'Terminal de dirección', 'Terminal de direccion',
|
||||||
|
'Ensamble de terminal', 'Ensamble de brazo', 'Barra central',
|
||||||
|
'Barra de arrastre', 'Barra de dirección', 'Varilla',
|
||||||
|
'Juego de resortes', 'Resorte de suspensión', 'Juego para ajuste',
|
||||||
|
'Placa para ajuste', 'Seguro guia', 'Amortiguador de dirección',
|
||||||
|
'Pasador de dirección', 'Horquilla', 'Muelle',
|
||||||
|
'Juego de coples', 'Juego de soporte', 'Juego de montaje',
|
||||||
|
'Montaje del amortiguador',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_category_line(line):
|
||||||
|
"""Check if line is a part category header."""
|
||||||
|
for kw in CATEGORY_KEYWORDS:
|
||||||
|
if kw.lower() in line.lower():
|
||||||
|
# Make sure it doesn't also contain a part number (data line)
|
||||||
|
if not MOOG_PART_RE.search(line):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_moog_pdf(pdf_path, start_page, end_page):
|
||||||
|
"""Parse a MOOG catalog PDF and return entries."""
|
||||||
|
pdf = pypdf.PdfReader(pdf_path)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
current_brand = None
|
||||||
|
current_model = None
|
||||||
|
current_submodel = None
|
||||||
|
current_system = None
|
||||||
|
current_figure = None
|
||||||
|
current_category = None
|
||||||
|
current_year_from = None
|
||||||
|
current_year_to = None
|
||||||
|
|
||||||
|
total = min(len(pdf.pages), end_page)
|
||||||
|
|
||||||
|
for page_num in range(start_page, total):
|
||||||
|
if (page_num - start_page) % 100 == 0:
|
||||||
|
print(f" Página {page_num + 1}/{total}...")
|
||||||
|
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if is_skip_line(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip standalone page numbers
|
||||||
|
if re.match(r'^\d{1,4}$', line) and not current_category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Brand-model line
|
||||||
|
brand, model = parse_brand_model(line)
|
||||||
|
if brand and model:
|
||||||
|
current_brand = brand
|
||||||
|
current_model = model
|
||||||
|
current_submodel = None
|
||||||
|
current_system = None
|
||||||
|
current_figure = None
|
||||||
|
current_category = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# System section
|
||||||
|
system = detect_system(line)
|
||||||
|
if system:
|
||||||
|
current_system = system
|
||||||
|
current_category = None
|
||||||
|
current_submodel = None
|
||||||
|
# Check for figure code on same line or next
|
||||||
|
fig = FIGURE_RE.search(line)
|
||||||
|
if fig:
|
||||||
|
current_figure = fig.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Standalone figure code line
|
||||||
|
fig_match = re.match(r'^([FSR]\d{3})$', line.strip())
|
||||||
|
if fig_match:
|
||||||
|
current_figure = fig_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Figure code with comma (e.g., "F530,\nF531")
|
||||||
|
fig_multi = re.match(r'^([FSR]\d{3}),?$', line.strip())
|
||||||
|
if fig_multi and not YEAR_RE.match(line):
|
||||||
|
current_figure = fig_multi.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current_brand or not current_model:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Part category header
|
||||||
|
if is_category_line(line):
|
||||||
|
current_category = line.strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Data line with year
|
||||||
|
year_match = YEAR_RE.match(line)
|
||||||
|
if year_match:
|
||||||
|
y1 = int(year_match.group(1))
|
||||||
|
y2 = int(year_match.group(2)) if year_match.group(2) else y1
|
||||||
|
if 1930 <= y1 <= 2025 and 1930 <= y2 <= 2025:
|
||||||
|
current_year_from = min(y1, y2)
|
||||||
|
current_year_to = max(y1, y2)
|
||||||
|
|
||||||
|
# Extract MOOG part numbers from line
|
||||||
|
parts_found = MOOG_PART_RE.findall(line)
|
||||||
|
|
||||||
|
# Also check for numeric springs in spring context
|
||||||
|
if current_category and 'resorte' in current_category.lower():
|
||||||
|
for m in SPRING_NUM_RE.finditer(line):
|
||||||
|
num = m.group(1)
|
||||||
|
if len(num) >= 4 and not any(num == p for p in parts_found):
|
||||||
|
# Avoid matching years
|
||||||
|
n = int(num)
|
||||||
|
if not (1930 <= n <= 2025):
|
||||||
|
parts_found.append(num)
|
||||||
|
|
||||||
|
if not parts_found or not current_year_from:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build entries for each part found
|
||||||
|
model_name = current_model
|
||||||
|
if current_submodel:
|
||||||
|
model_name = f"{current_model} {current_submodel}"
|
||||||
|
|
||||||
|
for pn in parts_found:
|
||||||
|
# Clean part number (remove trailing T for Problem Solver)
|
||||||
|
clean_pn = pn.rstrip('T') if pn.endswith('T') and len(pn) > 4 else pn
|
||||||
|
|
||||||
|
for year in range(current_year_from, current_year_to + 1):
|
||||||
|
entries.append({
|
||||||
|
'brand': current_brand,
|
||||||
|
'model': model_name,
|
||||||
|
'year': year,
|
||||||
|
'system': current_system or 'front_suspension',
|
||||||
|
'figure': current_figure,
|
||||||
|
'category': current_category or '',
|
||||||
|
'part_number': clean_pn,
|
||||||
|
'notes': line.strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_brand(brand):
|
||||||
|
"""Normalize MOOG brand names to standard form."""
|
||||||
|
mappings = {
|
||||||
|
'CHEVROLET TRUCK': 'CHEVROLET',
|
||||||
|
'DODGE TRUCK': 'DODGE',
|
||||||
|
'FORD TRUCK': 'FORD',
|
||||||
|
'GENERAL MOTORS TRUCK': 'GMC',
|
||||||
|
'GEO TRUCK': 'GEO',
|
||||||
|
'ISUZU TRUCK': 'ISUZU',
|
||||||
|
'MAZDA TRUCK': 'MAZDA',
|
||||||
|
'MITSUBISHI TRUCK': 'MITSUBISHI',
|
||||||
|
'NISSAN TRUCK': 'NISSAN',
|
||||||
|
'PLYMOUTH TRUCK': 'PLYMOUTH',
|
||||||
|
'SUBARU TRUCK': 'SUBARU',
|
||||||
|
'SUZUKI TRUCK': 'SUZUKI',
|
||||||
|
'TOYOTA TRUCK': 'TOYOTA',
|
||||||
|
'VOLKSWAGEN TRUCK': 'VOLKSWAGEN',
|
||||||
|
'VOLVO TRUCK': 'VOLVO',
|
||||||
|
'AMERICAN MOTORS CORP.': 'AMERICAN MOTORS',
|
||||||
|
'AMERICAN MOTORS': 'AMERICAN MOTORS',
|
||||||
|
'MERCEDES BENZ': 'MERCEDES-BENZ',
|
||||||
|
'WILLYS MOTORS INC.': 'WILLYS',
|
||||||
|
'RAM TRUCK': 'RAM',
|
||||||
|
}
|
||||||
|
up = brand.upper().strip()
|
||||||
|
return mappings.get(up, brand.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] not in VOLUMES:
|
||||||
|
print("Uso: python3 import_moog_catalog.py <1|2|3>")
|
||||||
|
print(" 1 = Vol 1 (≤1989)")
|
||||||
|
print(" 2 = Vol 2 (1990-2005)")
|
||||||
|
print(" 3 = Vol 3 (2006+)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
vol = sys.argv[1]
|
||||||
|
config = VOLUMES[vol]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"IMPORTADOR - CATÁLOGO MOOG {config['label']}")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n[1/5] Leyendo PDF: {config['path']}")
|
||||||
|
entries = parse_moog_pdf(config['path'], config['start_page'], config['end_page'])
|
||||||
|
print(f" Entradas parseadas: {len(entries):,}")
|
||||||
|
|
||||||
|
unique_parts = {}
|
||||||
|
for e in entries:
|
||||||
|
if e['part_number'] not in unique_parts:
|
||||||
|
unique_parts[e['part_number']] = e['category']
|
||||||
|
|
||||||
|
unique_brands = set(normalize_brand(e['brand']) for e in entries)
|
||||||
|
print(f" Partes únicas: {len(unique_parts):,}")
|
||||||
|
print(f" Marcas de vehículos: {len(unique_brands)}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
print("\n[2/5] Creando fabricante MOOG...")
|
||||||
|
moog_mfr_id = ensure_manufacturer(cursor, 'MOOG', 'aftermarket', 'premium', 'USA')
|
||||||
|
print(f" MOOG manufacturer_id: {moog_mfr_id}")
|
||||||
|
|
||||||
|
print("\n[3/5] Creando partes...")
|
||||||
|
part_ids = {}
|
||||||
|
parts_created = 0
|
||||||
|
|
||||||
|
for pn, cat_text in sorted(unique_parts.items()):
|
||||||
|
group_id = classify_part(cursor, cat_text, pn)
|
||||||
|
if not group_id:
|
||||||
|
group_id = get_group_id(cursor, 'Ball Joints')
|
||||||
|
|
||||||
|
# Get group name for part description
|
||||||
|
cursor.execute("SELECT name FROM part_groups WHERE id = ?", (group_id,))
|
||||||
|
group_row = cursor.fetchone()
|
||||||
|
group_name = group_row['name'] if group_row else 'Suspension Part'
|
||||||
|
|
||||||
|
names = PART_TYPE_NAMES.get(group_name, (group_name, group_name))
|
||||||
|
name_en = f"{names[0]} {pn}"
|
||||||
|
name_es = f"{names[1]} {pn}"
|
||||||
|
|
||||||
|
part_id, created = get_or_create_part(
|
||||||
|
cursor, pn, group_id, name_en, name_es, f"MOOG {names[0]}")
|
||||||
|
part_ids[pn] = part_id
|
||||||
|
if created:
|
||||||
|
parts_created += 1
|
||||||
|
|
||||||
|
print(f" Partes creadas: {parts_created:,}")
|
||||||
|
print(f" Partes existentes: {len(unique_parts) - parts_created:,}")
|
||||||
|
|
||||||
|
print("\n[4/5] Creando vehículos y fitments...")
|
||||||
|
vehicles_created = 0
|
||||||
|
fitments_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if i % 10000 == 0 and i > 0:
|
||||||
|
print(f" Procesando {i:,}/{len(entries):,}...")
|
||||||
|
|
||||||
|
brand_name = normalize_brand(entry['brand'])
|
||||||
|
cache_key = (brand_name.upper(), entry['model'].upper(), entry['year'])
|
||||||
|
|
||||||
|
if cache_key not in mye_cache:
|
||||||
|
brand_id = ensure_brand(cursor, brand_name)
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id
|
||||||
|
JOIN brands b ON m.brand_id = b.id
|
||||||
|
JOIN years y ON mye.year_id = y.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", (brand_name, entry['model'], entry['year']))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
mye_id = mye_cache[cache_key]
|
||||||
|
part_id = part_ids.get(entry['part_number'])
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?",
|
||||||
|
(mye_id, part_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
notes = f"MOOG Catalog {config['label']}"
|
||||||
|
if entry['figure']:
|
||||||
|
notes += f" - Fig {entry['figure']}"
|
||||||
|
if entry['system']:
|
||||||
|
notes += f" - {entry['system']}"
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)",
|
||||||
|
(mye_id, part_id, notes))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created:,}")
|
||||||
|
print(f" Fitments creados: {fitments_created:,}")
|
||||||
|
|
||||||
|
# Store diagram references
|
||||||
|
print("\n[5/5] Guardando referencias de diagramas...")
|
||||||
|
figures_seen = set()
|
||||||
|
# Get a default group_id for diagrams
|
||||||
|
susp_group = get_group_id(cursor, 'Ball Joints') or 164
|
||||||
|
for entry in entries:
|
||||||
|
if entry['figure'] and entry['figure'] not in figures_seen:
|
||||||
|
figures_seen.add(entry['figure'])
|
||||||
|
cursor.execute("SELECT id FROM diagrams WHERE name = ?", (entry['figure'],))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
sys_label = {
|
||||||
|
'front_suspension': 'Suspensión Delantera',
|
||||||
|
'steering': 'Dirección',
|
||||||
|
'rear_suspension': 'Suspensión Trasera',
|
||||||
|
}.get(entry.get('system'), 'Suspensión')
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO diagrams (name, name_es, group_id, image_path, source) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(entry['figure'], f"MOOG {sys_label} - {entry['figure']}",
|
||||||
|
susp_group, f"moog/{entry['figure']}.png", 'MOOG Catalog'))
|
||||||
|
|
||||||
|
print(f" Diagramas registrados: {len(figures_seen)}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f"IMPORTACIÓN MOOG {config['label']} COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Diagramas: {len(figures_seen)}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
554
vehicle_database/scripts/import_wix_catalog.py
Normal file
554
vehicle_database/scripts/import_wix_catalog.py
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
IMPORTADOR DEL CATÁLOGO WIX 2021 - FILTROS
|
||||||
|
Formato: Brand → Year → Model → Engine + filter columns
|
||||||
|
Páginas 77-687: Autos de pasajeros / camionetas ligeras
|
||||||
|
PDF: /tmp/catalogs/wix_2021.pdf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
import pypdf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent / 'vehicle_database.db'
|
||||||
|
PDF_PATH = '/tmp/catalogs/wix_2021.pdf'
|
||||||
|
|
||||||
|
BRAND_HEADERS = {
|
||||||
|
'ACURA', 'ALFA ROMEO', 'AM GENERAL', 'AMERICAN MOTORS', 'ASTON MARTIN',
|
||||||
|
'ASUNA', 'AUDI', 'AUSTIN', 'AUSTIN HEALEY', 'AVANTI', 'BENTLEY', 'BMW',
|
||||||
|
'BUICK', 'CADILLAC', 'CHECKER', 'CHEVROLET', 'CHRYSLER', 'DAEWOO',
|
||||||
|
'DAIHATSU', 'DATSUN', 'DELOREAN', 'DODGE', 'EAGLE', 'FIAT', 'FORD',
|
||||||
|
'FREIGHTLINER', 'GEO', 'GMC', 'HILLMAN', 'HONDA', 'HUMMER', 'HYUNDAI',
|
||||||
|
'INFINITI', 'INTERNATIONAL', 'ISUZU', 'JAGUAR', 'JEEP', 'KIA',
|
||||||
|
'LAFORZA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'LOTUS', 'MACK', 'MAZDA',
|
||||||
|
'MERCEDES-BENZ', 'MERCURY', 'MERKUR', 'MINI', 'MITSUBISHI', 'MORGAN',
|
||||||
|
'NISSAN', 'OLDSMOBILE', 'OPEL', 'PEUGEOT', 'PLYMOUTH', 'PONTIAC',
|
||||||
|
'PORSCHE', 'RAM', 'RENAULT', 'ROLLS ROYCE', 'SAAB', 'SATURN', 'SCION',
|
||||||
|
'SEAT', 'SHELBY', 'SMART', 'SRT', 'STUDEBAKER', 'SUBARU', 'SUNBEAM',
|
||||||
|
'SUZUKI', 'TOYOTA', 'TRIUMPH', 'VOLKSWAGEN', 'VOLVO', 'WORKHORSE',
|
||||||
|
'WORKHORSE CUSTOM CHASSIS',
|
||||||
|
}
|
||||||
|
|
||||||
|
ENGINE_RE = re.compile(r'^[VLH]\s*\d+\s+\d+\.\d+L', re.IGNORECASE)
|
||||||
|
|
||||||
|
FOOTER_MARKERS = [
|
||||||
|
'Pass Car/Light Truck',
|
||||||
|
'Year/Año/Année',
|
||||||
|
'Model/Modelo/Modèle',
|
||||||
|
'N/A = Not Available',
|
||||||
|
'N/A = Non disponible',
|
||||||
|
'N/A = No disponible',
|
||||||
|
'Italicized Part Numbers',
|
||||||
|
'Las piezas con números',
|
||||||
|
'Les numéros de pièc',
|
||||||
|
'Engine/Motor/Moteur',
|
||||||
|
'Eng. Code',
|
||||||
|
'Código de',
|
||||||
|
'Code moteur',
|
||||||
|
'Oil XP',
|
||||||
|
'Aceite XP',
|
||||||
|
'Cabina Aire',
|
||||||
|
'Cabin Air XP',
|
||||||
|
'Combustible',
|
||||||
|
'Transmisión',
|
||||||
|
'Carburant',
|
||||||
|
]
|
||||||
|
|
||||||
|
FILTER_GROUPS = {
|
||||||
|
'oil': ('Oil Filters', 'Filtros de Aceite', 'Engine'),
|
||||||
|
'air': ('Air Filters', 'Filtros de Aire', 'Engine'),
|
||||||
|
'cabin_air': ('Cabin Air Filters', 'Filtros de Aire de Cabina', 'HVAC'),
|
||||||
|
'fuel': ('Fuel Filters', 'Filtros de Combustible', 'Fuel System'),
|
||||||
|
'transmission': ('Transmission Filters', 'Filtros de Transmisión', 'Transmission'),
|
||||||
|
}
|
||||||
|
|
||||||
|
TYPE_NAMES = {
|
||||||
|
'oil': ('Oil Filter', 'Filtro de Aceite'),
|
||||||
|
'oil_xp': ('Oil Filter XP', 'Filtro de Aceite XP'),
|
||||||
|
'air': ('Air Filter', 'Filtro de Aire'),
|
||||||
|
'air_xp': ('Air Filter XP', 'Filtro de Aire XP'),
|
||||||
|
'cabin_air': ('Cabin Air Filter', 'Filtro de Aire de Cabina'),
|
||||||
|
'cabin_air_xp': ('Cabin Air Filter XP', 'Filtro de Aire de Cabina XP'),
|
||||||
|
'fuel': ('Fuel Filter', 'Filtro de Combustible'),
|
||||||
|
'fuel_xp': ('Fuel Filter XP', 'Filtro de Combustible XP'),
|
||||||
|
'transmission': ('Transmission Filter', 'Filtro de Transmisión'),
|
||||||
|
'transmission_xp': ('Transmission Filter XP', 'Filtro de Transmisión XP'),
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_VALUES = {'N/A', 'N/R', 'N/S', 'MT72', '-'}
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_manufacturer(cursor, name, type_='aftermarket', quality='standard', country=None):
|
||||||
|
cursor.execute("SELECT id FROM manufacturers WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO manufacturers (name, type, quality_tier, country) VALUES (?, ?, ?, ?)",
|
||||||
|
(name, type_, quality, country))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_brand(cursor, name):
|
||||||
|
cursor.execute("SELECT id FROM brands WHERE UPPER(name) = UPPER(?)", (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO brands (name) VALUES (?)", (name,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_model(cursor, brand_id, name):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM models WHERE brand_id = ? AND UPPER(name) = UPPER(?)",
|
||||||
|
(brand_id, name))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO models (brand_id, name) VALUES (?, ?)", (brand_id, name))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_year(cursor, year):
|
||||||
|
cursor.execute("SELECT id FROM years WHERE year = ?", (year,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO years (year) VALUES (?)", (year,))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_generic_engine(cursor):
|
||||||
|
cursor.execute("SELECT id FROM engines WHERE name = 'Generic'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("INSERT INTO engines (name, fuel_type) VALUES ('Generic', 'gasoline')")
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mye(cursor, model_id, year_id, engine_id=None):
|
||||||
|
if engine_id:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ? AND engine_id = ?",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM model_year_engine WHERE model_id = ? AND year_id = ?",
|
||||||
|
(model_id, year_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
if not engine_id:
|
||||||
|
engine_id = get_generic_engine(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO model_year_engine (model_id, year_id, engine_id) VALUES (?, ?, ?)",
|
||||||
|
(model_id, year_id, engine_id))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_part(cursor, part_number, group_id, name, name_es, description):
|
||||||
|
cursor.execute("SELECT id FROM parts WHERE oem_part_number = ?", (part_number,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id'], False
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO parts (oem_part_number, name, name_es, group_id, description) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(part_number, name, name_es, group_id, description))
|
||||||
|
return cursor.lastrowid, True
|
||||||
|
|
||||||
|
|
||||||
|
def get_filter_group(cursor, filter_type):
|
||||||
|
name_en, name_es, category_name = FILTER_GROUPS[filter_type]
|
||||||
|
cursor.execute("SELECT id FROM part_groups WHERE name = ? LIMIT 1", (name_en,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return row['id']
|
||||||
|
cursor.execute("SELECT id FROM part_categories WHERE name = ? LIMIT 1", (category_name,))
|
||||||
|
cat = cursor.fetchone()
|
||||||
|
if not cat:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_categories (name, name_es) VALUES (?, ?)",
|
||||||
|
(category_name, category_name))
|
||||||
|
cat_id = cursor.lastrowid
|
||||||
|
else:
|
||||||
|
cat_id = cat['id']
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_groups (category_id, name, name_es) VALUES (?, ?, ?)",
|
||||||
|
(cat_id, name_en, name_es))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
# --- Part number extraction ---
|
||||||
|
|
||||||
|
def extract_wix_part(token):
|
||||||
|
"""Extract WIX part number from token, stripping footnote suffixes."""
|
||||||
|
token = token.strip().rstrip('.')
|
||||||
|
if not token or token in SKIP_VALUES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# XP variants: 5digits+XP
|
||||||
|
xp_match = re.match(r'^(\d{5}XP)', token)
|
||||||
|
if xp_match:
|
||||||
|
return xp_match.group(1)
|
||||||
|
|
||||||
|
# Alpha-prefixed parts
|
||||||
|
wl = re.match(r'^(WL\d{4,6})', token)
|
||||||
|
if wl:
|
||||||
|
return wl.group(1)
|
||||||
|
wa = re.match(r'^(WA\d{4,5})', token)
|
||||||
|
if wa:
|
||||||
|
return wa.group(1)
|
||||||
|
wp = re.match(r'^(WP\d{4,5})', token)
|
||||||
|
if wp:
|
||||||
|
return wp.group(1)
|
||||||
|
wf = re.match(r'^(WF\d{4})', token)
|
||||||
|
if wf:
|
||||||
|
return wf.group(1)
|
||||||
|
|
||||||
|
# Numeric 5-digit WIX parts
|
||||||
|
num = re.match(r'^(\d{5})', token)
|
||||||
|
if num:
|
||||||
|
pn = num.group(1)
|
||||||
|
p2 = pn[:2]
|
||||||
|
if p2 in ('51', '57', '42', '43', '44', '45', '46', '47', '48', '49',
|
||||||
|
'24', '33', '58'):
|
||||||
|
return pn
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_filter(pn):
|
||||||
|
"""Classify a WIX part number by filter type."""
|
||||||
|
if not pn:
|
||||||
|
return None
|
||||||
|
if pn.endswith('XP'):
|
||||||
|
base_type = classify_filter(pn[:-2])
|
||||||
|
return f"{base_type}_xp" if base_type else None
|
||||||
|
if pn.startswith('WL'):
|
||||||
|
return 'oil'
|
||||||
|
if pn.startswith('WA'):
|
||||||
|
return 'air'
|
||||||
|
if pn.startswith('WP'):
|
||||||
|
return 'cabin_air'
|
||||||
|
if pn.startswith('WF'):
|
||||||
|
return 'fuel'
|
||||||
|
if re.match(r'^5[17]\d{3}$', pn):
|
||||||
|
return 'oil'
|
||||||
|
if re.match(r'^4[2-9]\d{3}$', pn):
|
||||||
|
return 'air'
|
||||||
|
if re.match(r'^24\d{3}$', pn):
|
||||||
|
return 'cabin_air'
|
||||||
|
if re.match(r'^33\d{3}$', pn):
|
||||||
|
return 'fuel'
|
||||||
|
if re.match(r'^58\d{3}$', pn):
|
||||||
|
return 'transmission'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_parts_from_tokens(tokens):
|
||||||
|
"""Extract all unique WIX part numbers from tokens."""
|
||||||
|
parts = []
|
||||||
|
seen = set()
|
||||||
|
for token in tokens:
|
||||||
|
pn = extract_wix_part(token)
|
||||||
|
if pn and pn not in seen:
|
||||||
|
ftype = classify_filter(pn)
|
||||||
|
if ftype:
|
||||||
|
parts.append((pn, ftype))
|
||||||
|
seen.add(pn)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
# --- Line classification ---
|
||||||
|
|
||||||
|
def is_footer_line(line):
|
||||||
|
return any(m in line for m in FOOTER_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def is_continuation(line):
|
||||||
|
"""Check if line continues engine data (not a new model/brand/year)."""
|
||||||
|
tokens = line.split()
|
||||||
|
if not tokens:
|
||||||
|
return False
|
||||||
|
first = tokens[0]
|
||||||
|
if first in ('Electric/Gas', 'Turbo', 'Diesel', 'Hybrid', 'O'):
|
||||||
|
return True
|
||||||
|
if first.startswith('N/'):
|
||||||
|
return True
|
||||||
|
if first.startswith('MT'):
|
||||||
|
return True
|
||||||
|
if re.match(r'^(WL|WA|WP|WF)\d', first):
|
||||||
|
return True
|
||||||
|
if re.match(r'^\d{5}', first):
|
||||||
|
return True
|
||||||
|
if first == '-':
|
||||||
|
return True
|
||||||
|
# Single/double digit + more tokens with part numbers
|
||||||
|
if re.match(r'^\d{1,2}$', first) and len(tokens) > 1:
|
||||||
|
for t in tokens[1:4]:
|
||||||
|
if extract_wix_part(t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- PDF parsing ---
|
||||||
|
|
||||||
|
def parse_wix_pdf(pdf_path):
|
||||||
|
"""Parse WIX 2021 catalog pages 77-687."""
|
||||||
|
pdf = pypdf.PdfReader(pdf_path)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
current_brand = None
|
||||||
|
current_year = None
|
||||||
|
current_model = None
|
||||||
|
current_tokens = []
|
||||||
|
|
||||||
|
def flush_engine():
|
||||||
|
nonlocal current_tokens
|
||||||
|
if current_brand and current_year and current_model and current_tokens:
|
||||||
|
parts = extract_parts_from_tokens(current_tokens)
|
||||||
|
if parts:
|
||||||
|
entries.append({
|
||||||
|
'brand': current_brand,
|
||||||
|
'model': current_model,
|
||||||
|
'year': current_year,
|
||||||
|
'parts': parts,
|
||||||
|
})
|
||||||
|
current_tokens = []
|
||||||
|
|
||||||
|
total_pages = min(len(pdf.pages), 687)
|
||||||
|
for page_num in range(76, total_pages):
|
||||||
|
if (page_num - 76) % 50 == 0:
|
||||||
|
print(f" Procesando página {page_num + 1}/{total_pages}...")
|
||||||
|
|
||||||
|
text = pdf.pages[page_num].extract_text()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip footer lines
|
||||||
|
if is_footer_line(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean continuation markers
|
||||||
|
clean = re.sub(r"\s*\(Cont'd/Suite\)\s*", '', line).strip()
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Brand header
|
||||||
|
upper_clean = clean.upper()
|
||||||
|
if upper_clean in BRAND_HEADERS:
|
||||||
|
flush_engine()
|
||||||
|
current_brand = clean
|
||||||
|
current_year = None
|
||||||
|
current_model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Year
|
||||||
|
year_match = re.match(r'^(\d{4})$', clean)
|
||||||
|
if year_match:
|
||||||
|
y = int(year_match.group(1))
|
||||||
|
if 1940 <= y <= 2025:
|
||||||
|
flush_engine()
|
||||||
|
current_year = y
|
||||||
|
current_model = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current_brand or not current_year:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Engine line
|
||||||
|
if ENGINE_RE.match(clean):
|
||||||
|
flush_engine()
|
||||||
|
current_tokens = clean.split()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Continuation of engine data
|
||||||
|
if current_tokens and is_continuation(clean):
|
||||||
|
current_tokens.extend(clean.split())
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Model name (must contain alpha characters)
|
||||||
|
if re.search(r'[A-Za-z]', clean):
|
||||||
|
flush_engine()
|
||||||
|
current_model = clean
|
||||||
|
continue
|
||||||
|
|
||||||
|
flush_engine()
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("IMPORTADOR - CATÁLOGO WIX 2021")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n[1/6] Leyendo PDF: {PDF_PATH}")
|
||||||
|
entries = parse_wix_pdf(PDF_PATH)
|
||||||
|
print(f" Entradas parseadas: {len(entries)}")
|
||||||
|
|
||||||
|
unique_parts = {}
|
||||||
|
for entry in entries:
|
||||||
|
for pn, ftype in entry['parts']:
|
||||||
|
if pn not in unique_parts:
|
||||||
|
unique_parts[pn] = ftype
|
||||||
|
|
||||||
|
unique_brands = set(e['brand'] for e in entries)
|
||||||
|
print(f" Partes únicas: {len(unique_parts)}")
|
||||||
|
print(f" Marcas de vehículos: {len(unique_brands)}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
print("\n[2/6] Creando fabricante WIX...")
|
||||||
|
wix_mfr_id = ensure_manufacturer(cursor, 'WIX', 'aftermarket', 'premium', 'USA')
|
||||||
|
print(f" WIX manufacturer_id: {wix_mfr_id}")
|
||||||
|
|
||||||
|
print("\n[3/6] Creando partes de filtros...")
|
||||||
|
group_ids = {}
|
||||||
|
for ftype in FILTER_GROUPS:
|
||||||
|
group_ids[ftype] = get_filter_group(cursor, ftype)
|
||||||
|
group_ids[f"{ftype}_xp"] = group_ids[ftype]
|
||||||
|
|
||||||
|
part_ids = {}
|
||||||
|
parts_created = 0
|
||||||
|
for pn, ftype in sorted(unique_parts.items()):
|
||||||
|
gid = group_ids.get(ftype)
|
||||||
|
if not gid:
|
||||||
|
continue
|
||||||
|
name_en, name_es = TYPE_NAMES.get(ftype, ('Filter', 'Filtro'))
|
||||||
|
part_id, created = get_or_create_part(
|
||||||
|
cursor, pn, gid,
|
||||||
|
f"{name_en} {pn}", f"{name_es} {pn}",
|
||||||
|
f"WIX {name_en}")
|
||||||
|
part_ids[pn] = part_id
|
||||||
|
if created:
|
||||||
|
parts_created += 1
|
||||||
|
|
||||||
|
print(f" Partes creadas: {parts_created}")
|
||||||
|
print(f" Partes existentes: {len(unique_parts) - parts_created}")
|
||||||
|
|
||||||
|
print("\n[4/6] Creando vehículos y fitments...")
|
||||||
|
vehicles_created = 0
|
||||||
|
fitments_created = 0
|
||||||
|
mye_cache = {}
|
||||||
|
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if i % 5000 == 0 and i > 0:
|
||||||
|
print(f" Procesando entrada {i}/{len(entries)}...")
|
||||||
|
|
||||||
|
cache_key = (entry['brand'].upper(), entry['model'].upper(), entry['year'])
|
||||||
|
if cache_key not in mye_cache:
|
||||||
|
brand_id = ensure_brand(cursor, entry['brand'])
|
||||||
|
model_id = ensure_model(cursor, brand_id, entry['model'])
|
||||||
|
year_id = ensure_year(cursor, entry['year'])
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON mye.model_id = m.id
|
||||||
|
JOIN brands b ON m.brand_id = b.id
|
||||||
|
JOIN years y ON mye.year_id = y.id
|
||||||
|
WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", (entry['brand'], entry['model'], entry['year']))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
mye_cache[cache_key] = existing['id']
|
||||||
|
else:
|
||||||
|
mye_id = ensure_mye(cursor, model_id, year_id)
|
||||||
|
mye_cache[cache_key] = mye_id
|
||||||
|
vehicles_created += 1
|
||||||
|
|
||||||
|
mye_id = mye_cache[cache_key]
|
||||||
|
|
||||||
|
for pn, ftype in entry['parts']:
|
||||||
|
part_id = part_ids.get(pn)
|
||||||
|
if not part_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM vehicle_parts WHERE model_year_engine_id = ? AND part_id = ?",
|
||||||
|
(mye_id, part_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
notes = f"Catálogo WIX 2021 - {ftype.replace('_', ' ').upper()}"
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO vehicle_parts (model_year_engine_id, part_id, quantity_required, fitment_notes) VALUES (?, ?, 1, ?)",
|
||||||
|
(mye_id, part_id, notes))
|
||||||
|
fitments_created += 1
|
||||||
|
|
||||||
|
print(f" Vehículos creados: {vehicles_created}")
|
||||||
|
print(f" Fitments creados: {fitments_created}")
|
||||||
|
|
||||||
|
print("\n[5/6] Creando referencias cruzadas...")
|
||||||
|
xrefs_created = 0
|
||||||
|
wix_part_id_set = set(part_ids.values())
|
||||||
|
|
||||||
|
for i, (pn, part_id) in enumerate(part_ids.items()):
|
||||||
|
if i % 200 == 0 and i > 0:
|
||||||
|
print(f" Procesando cross-ref {i}/{len(part_ids)}...")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DISTINCT p2.id, p2.oem_part_number
|
||||||
|
FROM vehicle_parts vp1
|
||||||
|
JOIN vehicle_parts vp2 ON vp1.model_year_engine_id = vp2.model_year_engine_id
|
||||||
|
JOIN parts p2 ON vp2.part_id = p2.id
|
||||||
|
WHERE vp1.part_id = ?
|
||||||
|
AND p2.id != ?
|
||||||
|
AND p2.group_id = (SELECT group_id FROM parts WHERE id = ?)
|
||||||
|
LIMIT 50
|
||||||
|
""", (part_id, part_id, part_id))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row['id'] in wix_part_id_set:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'WIX 2021 Catalog')",
|
||||||
|
(part_id, row['oem_part_number']))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM part_cross_references WHERE part_id = ? AND cross_reference_number = ?",
|
||||||
|
(row['id'], pn))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source) VALUES (?, ?, 'interchange', 'WIX 2021 Catalog')",
|
||||||
|
(row['id'], pn))
|
||||||
|
xrefs_created += 1
|
||||||
|
|
||||||
|
print(f" Cross-refs creadas: {xrefs_created}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IMPORTACIÓN WIX COMPLETADA")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"""
|
||||||
|
RESUMEN:
|
||||||
|
- Partes creadas: {parts_created:,}
|
||||||
|
- Vehículos creados: {vehicles_created:,}
|
||||||
|
- Fitments creados: {fitments_created:,}
|
||||||
|
- Cross-refs creadas: {xrefs_created:,}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Binary file not shown.
Reference in New Issue
Block a user