Compare commits
39 Commits
140117a8e5
...
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 | |||
| 4af3a09b03 | |||
| 64503ca363 | |||
| 7bf50a2c67 | |||
| 8194167c51 | |||
| 15f3c9c9fe | |||
| b042853408 | |||
| 69fb26723d | |||
| e3ad101d56 | |||
| 269bb9030b | |||
| 211883393e | |||
| ceacab789b | |||
| 3b884e24d3 | |||
| 7cf3ddc758 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -51,3 +51,13 @@ Thumbs.db
|
||||
# Backup files
|
||||
*.bak
|
||||
*.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/
|
||||
|
||||
417
README.md
417
README.md
@@ -1,306 +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
|
||||
- Dashboard web moderno y responsivo para consultar y explorar datos
|
||||
- Herramientas de web scraping para recopilar datos de RockAuto.com
|
||||
- Interfaces de línea de comandos (CLI) y programática
|
||||
- Scripts de utilidad para gestión y mantenimiento de datos
|
||||
| Componente | Tecnologia |
|
||||
|------------|-----------|
|
||||
| Backend | Python 3, Flask |
|
||||
| Base de datos | PostgreSQL |
|
||||
| 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 |
|
||||
|----------|----------|
|
||||
| Marcas | 12 |
|
||||
| Modelos | 10,923 |
|
||||
| Motores | 10,919 |
|
||||
| Combinaciones modelo-año-motor | 12,075 |
|
||||
- **1.4M+** partes OEM
|
||||
- **300K+** partes aftermarket
|
||||
- **13M+** cross-references (numeros alternos, supersesiones, intercambios)
|
||||
- **12B+** vehicle-part links (fitment)
|
||||
- **100+** marcas, miles de modelos, anos 1956-2026
|
||||
|
||||
## Tecnologías Utilizadas
|
||||
## Features
|
||||
|
||||
### Backend
|
||||
- **Python 3** - Lenguaje principal
|
||||
- **SQLite 3** - Base de datos
|
||||
- **Flask 2.3.3** - Framework web
|
||||
- **BeautifulSoup4** - Web scraping
|
||||
- **requests** - HTTP client
|
||||
- **lxml** - Parser XML/HTML
|
||||
- **Catalogo de autopartes** con navegacion jerarquica: Marca > Modelo > Ano > Motor > Categoria > Grupo > Parte
|
||||
- **TecDoc integration** (via Apify) para importar datos OEM y aftermarket de Europa/Mexico
|
||||
- **SaaS multi-tenant** con roles: `ADMIN`, `OWNER`, `TALLER`, `BODEGA`
|
||||
- **JWT authentication** con access tokens (15 min) y refresh tokens (30 dias)
|
||||
- **Gestion de inventario** para bodegas con mapeo flexible de columnas CSV/Excel
|
||||
- **Disponibilidad de partes** en multiples bodegas con precios comparativos
|
||||
- **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
|
||||
- **HTML5** - Estructura
|
||||
- **Bootstrap 5.3.0** - Framework CSS
|
||||
- **JavaScript (ES6+)** - Lógica cliente
|
||||
- **Font Awesome 6.0.0** - Iconos
|
||||
## Quick Start
|
||||
|
||||
## Estructura del Proyecto
|
||||
### Requisitos previos
|
||||
|
||||
```
|
||||
Autopartes/
|
||||
├── 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
|
||||
│
|
||||
├── 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
|
||||
```
|
||||
- Python 3.8+
|
||||
- PostgreSQL con la base `nexus_autoparts`
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
3. **Inicializar la base de datos (opcional - ya incluye datos)**
|
||||
```bash
|
||||
cd vehicle_database
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### Iniciar el Dashboard Web
|
||||
### Instalacion
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
cd /home/Autopartes
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Ejecutar el servidor
|
||||
|
||||
```bash
|
||||
cd /home/Autopartes/dashboard
|
||||
python3 server.py
|
||||
```
|
||||
|
||||
El dashboard estará disponible en: `http://localhost:5000`
|
||||
El servidor arranca en `http://localhost:5000`.
|
||||
|
||||
### Usar la Interfaz CLI
|
||||
### Importar datos de TecDoc
|
||||
|
||||
```bash
|
||||
cd vehicle_database/scripts
|
||||
python3 query_interface.py
|
||||
# Fase 1: descargar datos de TecDoc a JSON
|
||||
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
|
||||
```
|
||||
|
||||
### Ejecutar Web Scraping
|
||||
### Importar partes y linkar vehiculos
|
||||
|
||||
```bash
|
||||
cd vehicle_scraper
|
||||
python3 rockauto_scraper_v2.py
|
||||
# Importar partes TecDoc (OEM + aftermarket)
|
||||
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
|
||||
```
|
||||
|
||||
### Agregar Datos Manualmente
|
||||
## Paginas del Dashboard
|
||||
|
||||
```bash
|
||||
cd vehicle_scraper
|
||||
python3 manual_input.py
|
||||
```
|
||||
| Ruta | Archivo | Descripcion |
|
||||
|------|---------|-------------|
|
||||
| `/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 |
|
||||
|
||||
## API REST
|
||||
## API Overview
|
||||
|
||||
El dashboard expone los siguientes endpoints:
|
||||
Documentacion completa en [`docs/API.md`](docs/API.md).
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| `/api/brands` | GET | Obtiene todas las marcas |
|
||||
| `/api/models?brand=X` | GET | Obtiene modelos por marca |
|
||||
| `/api/years` | GET | Obtiene años disponibles |
|
||||
| `/api/engines` | GET | Obtiene motores disponibles |
|
||||
| `/api/vehicles` | GET | Búsqueda con filtros |
|
||||
### 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
|
||||
|
||||
### Ejemplo de Uso
|
||||
### 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)
|
||||
|
||||
```bash
|
||||
# Obtener todas las marcas
|
||||
curl http://localhost:5000/api/brands
|
||||
### Inventario (`/api/inventory/`)
|
||||
- `GET/PUT /api/inventory/mapping` - Mapeo de columnas CSV
|
||||
- `POST /api/inventory/upload` - Subir CSV/Excel de inventario
|
||||
- `GET /api/inventory/items` - Listar inventario propio
|
||||
- `DELETE /api/inventory/items` - Limpiar inventario
|
||||
|
||||
# Buscar vehículos por marca y año
|
||||
curl "http://localhost:5000/api/vehicles?brand=Toyota&year=2020"
|
||||
```
|
||||
### 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)
|
||||
|
||||
## Esquema de Base de Datos
|
||||
### Admin (`/api/admin/`)
|
||||
- `GET /api/admin/users` - Listar usuarios (auth: ADMIN/OWNER)
|
||||
- `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}`
|
||||
|
||||
### Tablas
|
||||
### VIN Decoder
|
||||
- `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
|
||||
|
||||
#### brands
|
||||
| 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 |
|
||||
## Scripts
|
||||
|
||||
#### models
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
#### engines
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | INTEGER | Clave primaria |
|
||||
| name | TEXT | Nombre del motor |
|
||||
| displacement_cc | INTEGER | Cilindrada en cc |
|
||||
| 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 |
|
||||
## Configuracion
|
||||
|
||||
#### years
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| id | INTEGER | Clave primaria |
|
||||
| year | INTEGER | Año |
|
||||
Archivo principal: [`config.py`](config.py)
|
||||
|
||||
#### model_year_engine
|
||||
| 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 |
|
||||
| Variable | Default | Descripcion |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql://nexus:...@localhost/nexus_autoparts` | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | `nexus-saas-secret-change-in-prod-2026` | Secreto para firmar tokens JWT |
|
||||
| `JWT_ACCESS_EXPIRES` | `900` (15 min) | Duracion del access token en segundos |
|
||||
| `JWT_REFRESH_EXPIRES` | `2592000` (30 dias) | Duracion del refresh token en segundos |
|
||||
|
||||
### Diagrama de Relaciones
|
||||
## Arquitectura
|
||||
|
||||
Documentacion detallada en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
```
|
||||
brands ──┐
|
||||
│
|
||||
├──< models ──┐
|
||||
│ │
|
||||
years ───┼─────────────┼──< model_year_engine
|
||||
│ │
|
||||
engines ─┴─────────────┘
|
||||
+------------------+
|
||||
| TecDoc (Apify) |
|
||||
+--------+---------+
|
||||
|
|
||||
download/import
|
||||
|
|
||||
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 │ │ CLI Interface │ │ CSV Importer │
|
||||
└────────┬────────┘ └──────────────────┘ └──────────────────┘
|
||||
│
|
||||
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"
|
||||
166
console/README.md
Normal file
166
console/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# NEXUS AUTOPARTS Console - Sistema Pick/VT220
|
||||
|
||||
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
|
||||
|
||||
- Python 3.8+
|
||||
- SQLite 3 (incluido con Python)
|
||||
|
||||
No requiere dependencias externas.
|
||||
|
||||
## Inicio Rápido
|
||||
|
||||
```bash
|
||||
# Iniciar la consola
|
||||
python -m console
|
||||
|
||||
# Especificar base de datos
|
||||
python -m console --db /ruta/a/vehicle_database.db
|
||||
|
||||
# Ver versión
|
||||
python -m console --version
|
||||
```
|
||||
|
||||
## Menú Principal
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ MENU PRINCIPAL │
|
||||
├──────────────────────────────────────────┤
|
||||
│ ▸ 1. Consulta por Vehiculo │
|
||||
│ 2. Busqueda por Numero de Parte │
|
||||
│ 3. Busqueda por Descripcion │
|
||||
│ 4. Decodificador VIN │
|
||||
│ 5. Catalogo de Categorias │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 6. Administracion de Partes │
|
||||
│ 7. Administracion de Fabricantes │
|
||||
│ 8. Cross-References │
|
||||
│ 9. Importar / Exportar Datos │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 0. Estadisticas del Sistema │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Teclas de Función
|
||||
|
||||
| Tecla | Acción |
|
||||
|-------|--------|
|
||||
| `0-9` | Seleccionar opción del menú / saltar a campo |
|
||||
| `ENTER` | Confirmar selección |
|
||||
| `ESC` | Regresar / Cancelar |
|
||||
| `F1` | Ayuda / Lista de búsqueda |
|
||||
| `F2` | Modo edición |
|
||||
| `F3` | Buscar |
|
||||
| `F4` | Referencias cruzadas |
|
||||
| `F5` | Refrescar |
|
||||
| `F6` | Vehículos relacionados |
|
||||
| `F9` | Guardar |
|
||||
| `F10` | Menú principal |
|
||||
| `TAB` / `↓` | Siguiente campo |
|
||||
| `↑` | Campo anterior |
|
||||
| `PgUp/PgDn` | Navegación por páginas |
|
||||
| `←→` | Scroll horizontal (comparador) |
|
||||
|
||||
## Pantallas
|
||||
|
||||
### 1. Búsqueda por Vehículo
|
||||
Navegación jerárquica: Marca → Modelo → Año → Motor.
|
||||
Cada nivel muestra una lista filtrable con búsqueda incremental.
|
||||
|
||||
### 2. Búsqueda por Número de Parte
|
||||
Campo de entrada para número de parte. Busca en partes OEM, aftermarket y referencias cruzadas.
|
||||
|
||||
### 3. Búsqueda por Texto
|
||||
Búsqueda full-text (FTS5) en nombres y descripciones de partes con resultados paginados.
|
||||
|
||||
### 4. Decodificador VIN
|
||||
Ingresa un VIN de 17 caracteres. Consulta la API de NHTSA (con caché de 30 días) y muestra información del vehículo.
|
||||
|
||||
### 5. Catálogo por Categoría
|
||||
Navega: Categorías → Grupos → Partes, independiente de la selección de vehículo.
|
||||
|
||||
### 6-9. Administración
|
||||
- **Partes**: CRUD completo de partes OEM
|
||||
- **Fabricantes**: CRUD de fabricantes aftermarket
|
||||
- **Referencias Cruzadas**: CRUD de referencias cruzadas entre partes
|
||||
- **Import/Export**: Importar CSV, exportar JSON
|
||||
|
||||
### Detalle de Parte
|
||||
Vista completa de la parte con alternativas aftermarket. F4 para referencias cruzadas, F6 para vehículos compatibles.
|
||||
|
||||
### Comparador
|
||||
Columnas lado a lado: OEM vs alternativas aftermarket con barras de calidad, porcentaje de ahorro y scroll horizontal.
|
||||
|
||||
### Estadísticas
|
||||
Dashboard con contadores de la base de datos (marcas, modelos, partes, etc.) y métricas de cobertura.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
console/
|
||||
├── main.py # Punto de entrada
|
||||
├── config.py # Configuración (DB, colores, paginación)
|
||||
├── db.py # Capa de datos abstracta (SQLite)
|
||||
│
|
||||
├── core/
|
||||
│ ├── app.py # Controlador principal
|
||||
│ ├── screens.py # Clase base Screen
|
||||
│ ├── navigation.py # Pila de navegación y breadcrumbs
|
||||
│ └── keybindings.py # Constantes de teclas y registro
|
||||
│
|
||||
├── screens/
|
||||
│ ├── menu_principal.py # Menú principal (12 opciones)
|
||||
│ ├── vehiculo_nav.py # Drill-down: marca → modelo → año → motor
|
||||
│ ├── buscar_parte.py # Búsqueda por número de parte
|
||||
│ ├── buscar_texto.py # Búsqueda full-text (FTS)
|
||||
│ ├── vin_decoder.py # Decodificador VIN (API NHTSA)
|
||||
│ ├── catalogo.py # Categorías → grupos → partes
|
||||
│ ├── parte_detalle.py # Detalle con alternativas
|
||||
│ ├── comparador.py # Comparador OEM vs aftermarket
|
||||
│ ├── estadisticas.py # Dashboard de estadísticas
|
||||
│ ├── admin_partes.py # CRUD partes
|
||||
│ ├── admin_fabricantes.py # CRUD fabricantes
|
||||
│ ├── admin_crossref.py # CRUD referencias cruzadas
|
||||
│ └── admin_import.py # Import/Export CSV/JSON
|
||||
│
|
||||
├── renderers/
|
||||
│ ├── base.py # Interfaz abstracta BaseRenderer
|
||||
│ └── curses_renderer.py # Renderer VT220 (curses)
|
||||
│
|
||||
├── utils/
|
||||
│ ├── formatting.py # Formato de tablas, moneda, números
|
||||
│ └── vin_api.py # Cliente API NHTSA
|
||||
│
|
||||
└── tests/
|
||||
├── test_db.py # 36 tests - capa de datos
|
||||
├── test_core.py # 31 tests - keybindings, navigation, screens
|
||||
├── test_utils.py # 30 tests - utilidades de formato
|
||||
└── test_integration.py # 19 tests - integración con MockRenderer
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Ejecutar todos los tests (116 total)
|
||||
python -m pytest console/tests/ -v
|
||||
|
||||
# Ejecutar por módulo
|
||||
python -m pytest console/tests/test_db.py -v
|
||||
python -m pytest console/tests/test_core.py -v
|
||||
python -m pytest console/tests/test_utils.py -v
|
||||
python -m pytest console/tests/test_integration.py -v
|
||||
```
|
||||
|
||||
## Capa de Datos
|
||||
|
||||
La clase `Database` en `db.py` abstrae todas las consultas SQL. Diseñada para migrar de SQLite a PostgreSQL cambiando solo la implementación interna.
|
||||
|
||||
Métodos principales:
|
||||
- `get_brands()`, `get_models()`, `get_years()`, `get_engines()`
|
||||
- `get_categories()`, `get_groups()`, `get_parts()`
|
||||
- `get_part()`, `get_alternatives()`, `get_cross_references()`
|
||||
- `search_parts()`, `search_part_number()`
|
||||
- `decode_vin()`, `get_stats()`
|
||||
- Métodos CRUD para administración
|
||||
0
console/__init__.py
Normal file
0
console/__init__.py
Normal file
7
console/__main__.py
Normal file
7
console/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Allow running the package with: python -m console
|
||||
"""
|
||||
|
||||
from console.main import main
|
||||
|
||||
main()
|
||||
38
console/config.py
Normal file
38
console/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Configuration settings for the NEXUS AUTOPARTS console application.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Application metadata
|
||||
VERSION = "2.0.0"
|
||||
APP_NAME = "NEXUS AUTOPARTS"
|
||||
APP_SUBTITLE = "Tu conexión directa con las partes que necesitas"
|
||||
|
||||
# Database URL (PostgreSQL)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||
from config import DB_URL
|
||||
|
||||
# NHTSA VIN Decoder API
|
||||
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
|
||||
VIN_CACHE_DAYS = 30
|
||||
|
||||
# Display defaults
|
||||
PAGE_SIZE = 15
|
||||
|
||||
# VT220 color pairs: (foreground, background)
|
||||
# These map to curses color pair indices used by the renderer.
|
||||
COLORS_VT220 = {
|
||||
"header": ("green", "black"),
|
||||
"footer": ("black", "green"),
|
||||
"normal": ("green", "black"),
|
||||
"highlight": ("black", "green"),
|
||||
"border": ("green", "black"),
|
||||
"title": ("white", "black"),
|
||||
"error": ("red", "black"),
|
||||
"info": ("cyan", "black"),
|
||||
"field_label": ("green", "black"),
|
||||
"field_value": ("white", "black"),
|
||||
"field_active": ("black", "cyan"),
|
||||
}
|
||||
0
console/core/__init__.py
Normal file
0
console/core/__init__.py
Normal file
196
console/core/app.py
Normal file
196
console/core/app.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Main application controller for the NEXUS AUTOPARTS console application.
|
||||
|
||||
The :class:`App` class owns the screen lifecycle loop: it renders the
|
||||
current screen, reads a keypress, dispatches it, and follows any
|
||||
navigation instruction the screen returns.
|
||||
"""
|
||||
|
||||
from console.core.navigation import Navigation
|
||||
from console.core.keybindings import Key
|
||||
|
||||
|
||||
class App:
|
||||
"""Top-level application controller.
|
||||
|
||||
Parameters:
|
||||
renderer: A :class:`BaseRenderer` implementation (e.g. CursesRenderer).
|
||||
db: A :class:`Database` instance for data access.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer, db):
|
||||
self.renderer = renderer
|
||||
self.db = db
|
||||
self.nav = Navigation()
|
||||
self.screens = {}
|
||||
self.running = False
|
||||
self._register_screens()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _register_screens(self):
|
||||
"""Import and register all screen instances.
|
||||
|
||||
Each screen is wrapped in a try/except so that screens not yet
|
||||
implemented do not prevent the application from starting.
|
||||
"""
|
||||
# --- Required screens (Task 6) --------------------------------
|
||||
try:
|
||||
from console.screens.menu_principal import MenuPrincipalScreen
|
||||
s = MenuPrincipalScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.estadisticas import EstadisticasScreen
|
||||
s = EstadisticasScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# --- Optional screens (added by later tasks) -------------------
|
||||
try:
|
||||
from console.screens.vehiculo_nav import VehiculoNavScreen
|
||||
s = VehiculoNavScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.buscar_parte import BuscarParteScreen
|
||||
s = BuscarParteScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.buscar_texto import BuscarTextoScreen
|
||||
s = BuscarTextoScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.vin_decoder import VinDecoderScreen
|
||||
s = VinDecoderScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.catalogo import CatalogoScreen
|
||||
s = CatalogoScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.parte_detalle import ParteDetalleScreen
|
||||
s = ParteDetalleScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.comparador import ComparadorScreen
|
||||
s = ComparadorScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_partes import AdminPartesScreen
|
||||
s = AdminPartesScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_fabricantes import AdminFabricantesScreen
|
||||
s = AdminFabricantesScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_crossref import AdminCrossrefScreen
|
||||
s = AdminCrossrefScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from console.screens.admin_import import AdminImportScreen
|
||||
s = AdminImportScreen()
|
||||
self.screens[s.name] = s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self):
|
||||
"""Enter the main event loop.
|
||||
|
||||
Initialises the renderer, pushes the main menu onto the
|
||||
navigation stack, and loops until the user quits or the stack
|
||||
empties.
|
||||
"""
|
||||
self.renderer.init_screen()
|
||||
self.running = True
|
||||
self.nav.push('menu', {}, label='Menu')
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
current = self.nav.current()
|
||||
if current is None:
|
||||
break
|
||||
|
||||
screen_name, context = current
|
||||
screen = self.screens.get(screen_name)
|
||||
|
||||
if screen is None:
|
||||
self.renderer.show_message(
|
||||
f'Pantalla "{screen_name}" no disponible', 'error'
|
||||
)
|
||||
self.nav.pop()
|
||||
continue
|
||||
|
||||
# Render
|
||||
self.renderer.clear()
|
||||
screen.render(context, self.db, self.renderer)
|
||||
self.renderer.refresh()
|
||||
|
||||
# Input
|
||||
key = self.renderer.get_key()
|
||||
|
||||
# Global key: F10 = back to main menu
|
||||
if key == Key.F10:
|
||||
self.nav.clear()
|
||||
self.nav.push('menu', {}, label='Menu')
|
||||
continue
|
||||
|
||||
# Screen-specific key handling
|
||||
result = screen.on_key(
|
||||
key, context, self.db, self.renderer, self.nav
|
||||
)
|
||||
|
||||
if result == 'quit':
|
||||
self.running = False
|
||||
elif result == 'back':
|
||||
self.nav.pop()
|
||||
elif isinstance(result, tuple) and len(result) == 3:
|
||||
name, ctx, label = result
|
||||
self.nav.push(name, ctx, label=label)
|
||||
elif isinstance(result, str):
|
||||
self.nav.push(result, {}, label=result)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.renderer.cleanup()
|
||||
self.db.close()
|
||||
87
console/core/keybindings.py
Normal file
87
console/core/keybindings.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Key constants and key-binding registry for the console UI.
|
||||
|
||||
Key provides named constants matching curses key codes so that screens
|
||||
and renderers never need to import curses directly.
|
||||
|
||||
KeyBindings maps key codes to callable actions and tracks the footer
|
||||
labels displayed at the bottom of the screen.
|
||||
"""
|
||||
|
||||
import curses
|
||||
|
||||
|
||||
class Key:
|
||||
"""Key constants matching curses key codes."""
|
||||
|
||||
ESCAPE = 27
|
||||
ENTER = 10
|
||||
TAB = 9
|
||||
BACKSPACE = 127
|
||||
|
||||
UP = curses.KEY_UP
|
||||
DOWN = curses.KEY_DOWN
|
||||
LEFT = curses.KEY_LEFT
|
||||
RIGHT = curses.KEY_RIGHT
|
||||
|
||||
PGUP = curses.KEY_PPAGE
|
||||
PGDN = curses.KEY_NPAGE
|
||||
HOME = curses.KEY_HOME
|
||||
END = curses.KEY_END
|
||||
|
||||
F1 = curses.KEY_F1
|
||||
F2 = curses.KEY_F2
|
||||
F3 = curses.KEY_F3
|
||||
F4 = curses.KEY_F4
|
||||
F5 = curses.KEY_F5
|
||||
F6 = curses.KEY_F6
|
||||
F7 = curses.KEY_F7
|
||||
F8 = curses.KEY_F8
|
||||
F9 = curses.KEY_F9
|
||||
F10 = curses.KEY_F10
|
||||
|
||||
|
||||
class KeyBindings:
|
||||
"""Registry that maps key codes to callable actions.
|
||||
|
||||
Usage::
|
||||
|
||||
kb = KeyBindings()
|
||||
kb.bind(Key.ENTER, lambda: do_something())
|
||||
handled = kb.handle(Key.ENTER) # True, callback was invoked
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._bindings: dict[int, callable] = {}
|
||||
self._footer_labels: list[tuple[str, str]] = []
|
||||
|
||||
def bind(self, key: int, action: callable) -> None:
|
||||
"""Register *action* as the callback for *key*.
|
||||
|
||||
If *key* already has a binding it is replaced.
|
||||
"""
|
||||
self._bindings[key] = action
|
||||
|
||||
def handle(self, key: int) -> bool:
|
||||
"""Look up *key* and invoke its callback if one exists.
|
||||
|
||||
Returns ``True`` if a callback was found and executed,
|
||||
``False`` otherwise.
|
||||
"""
|
||||
action = self._bindings.get(key)
|
||||
if action is not None:
|
||||
action()
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_footer(self, labels: list[tuple[str, str]]) -> None:
|
||||
"""Set the footer bar labels.
|
||||
|
||||
*labels* is a list of ``(key_label, description)`` tuples, e.g.
|
||||
``[("F1", "Help"), ("F10", "Quit")]``.
|
||||
"""
|
||||
self._footer_labels = list(labels)
|
||||
|
||||
def get_footer_labels(self) -> list[tuple[str, str]]:
|
||||
"""Return the current footer labels list."""
|
||||
return list(self._footer_labels)
|
||||
60
console/core/navigation.py
Normal file
60
console/core/navigation.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Screen-stack navigation for the console UI.
|
||||
|
||||
Navigation maintains a stack of ``(screen_name, context, label)`` entries.
|
||||
Screens push onto the stack when the user drills into a sub-view and pop
|
||||
when they press Escape / Backspace to go back.
|
||||
"""
|
||||
|
||||
|
||||
class Navigation:
|
||||
"""A simple stack-based navigator.
|
||||
|
||||
Each entry is a tuple ``(screen_name, context, label)`` where
|
||||
*screen_name* identifies which screen to display, *context* carries
|
||||
any data the screen needs, and *label* is the human-readable text
|
||||
shown in the breadcrumb trail.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._stack: list[tuple[str, object, str]] = []
|
||||
|
||||
def push(self, screen_name: str, context=None, label: str | None = None) -> None:
|
||||
"""Push a new screen onto the stack.
|
||||
|
||||
If *label* is ``None`` the *screen_name* is used as fallback in
|
||||
the breadcrumb.
|
||||
"""
|
||||
self._stack.append((screen_name, context, label if label is not None else screen_name))
|
||||
|
||||
def pop(self) -> tuple[str, object] | None:
|
||||
"""Remove and return the top entry as ``(screen_name, context)``.
|
||||
|
||||
Returns ``None`` if the stack is empty.
|
||||
"""
|
||||
if not self._stack:
|
||||
return None
|
||||
screen_name, context, _label = self._stack.pop()
|
||||
return (screen_name, context)
|
||||
|
||||
def current(self) -> tuple[str, object] | None:
|
||||
"""Return the top entry as ``(screen_name, context)`` without removing it.
|
||||
|
||||
Returns ``None`` if the stack is empty.
|
||||
"""
|
||||
if not self._stack:
|
||||
return None
|
||||
screen_name, context, _label = self._stack[-1]
|
||||
return (screen_name, context)
|
||||
|
||||
def breadcrumb(self) -> list[str]:
|
||||
"""Return the list of labels from bottom to top of the stack."""
|
||||
return [label for _name, _ctx, label in self._stack]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all entries from the stack."""
|
||||
self._stack.clear()
|
||||
|
||||
def depth(self) -> int:
|
||||
"""Return the number of entries on the stack."""
|
||||
return len(self._stack)
|
||||
46
console/core/screens.py
Normal file
46
console/core/screens.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Base screen class for the console UI.
|
||||
|
||||
Every screen in the application inherits from :class:`Screen` and overrides
|
||||
:meth:`on_enter`, :meth:`on_key`, and :meth:`render`.
|
||||
"""
|
||||
|
||||
|
||||
class Screen:
|
||||
"""Abstract base for all console screens.
|
||||
|
||||
Subclasses must override the three lifecycle methods to provide real
|
||||
behaviour. The base implementations are intentional no-ops so that
|
||||
simple screens (e.g. a static splash page) need not implement every
|
||||
method.
|
||||
|
||||
Attributes:
|
||||
name: Machine-readable identifier used by :class:`Navigation`.
|
||||
title: Human-readable heading displayed at the top of the screen.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, title: str):
|
||||
self.name = name
|
||||
self.title = title
|
||||
|
||||
def on_enter(self, context, db, renderer) -> None:
|
||||
"""Called once when this screen becomes the active screen.
|
||||
|
||||
Use this hook to load data, reset scroll positions, or set up
|
||||
key bindings specific to the screen.
|
||||
"""
|
||||
|
||||
def on_key(self, key: int, context, db, renderer, nav):
|
||||
"""Handle a single keypress.
|
||||
|
||||
Returns a navigation instruction (e.g. a dict or tuple) when the
|
||||
screen wants to push/pop, or ``None`` to stay on the current
|
||||
screen.
|
||||
"""
|
||||
return None
|
||||
|
||||
def render(self, context, db, renderer) -> None:
|
||||
"""Draw the screen contents using *renderer*.
|
||||
|
||||
Called after every keypress and on initial display.
|
||||
"""
|
||||
786
console/db.py
Normal file
786
console/db.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""
|
||||
Database abstraction layer for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Provides all data access methods the console app needs, reading from the
|
||||
PostgreSQL database used by the Flask web dashboard.
|
||||
"""
|
||||
|
||||
import json as json_module
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||
from config import DB_URL
|
||||
|
||||
|
||||
class Database:
|
||||
"""Thin abstraction over the nexus_autoparts PostgreSQL database."""
|
||||
|
||||
def __init__(self, db_url: Optional[str] = None):
|
||||
self.db_url = db_url or DB_URL
|
||||
self._engine = None
|
||||
self._Session = None
|
||||
self._cache: dict = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_engine(self):
|
||||
if self._engine is None:
|
||||
self._engine = create_engine(self.db_url, pool_pre_ping=True)
|
||||
self._Session = sessionmaker(bind=self._engine)
|
||||
return self._engine
|
||||
|
||||
def _session(self):
|
||||
self._get_engine()
|
||||
return self._Session()
|
||||
|
||||
def close(self):
|
||||
"""Dispose the engine connection pool."""
|
||||
if self._engine is not None:
|
||||
self._engine.dispose()
|
||||
self._engine = None
|
||||
self._Session = None
|
||||
|
||||
def _query(self, sql: str, params: dict = None, one: bool = False):
|
||||
"""Execute a SELECT and return list[dict] (or a single dict if *one*)."""
|
||||
session = self._session()
|
||||
try:
|
||||
rows = session.execute(text(sql), params or {}).mappings().all()
|
||||
if one:
|
||||
return dict(rows[0]) if rows else None
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _query_cached(self, cache_key: str, sql: str, params: dict = None):
|
||||
"""Execute a SELECT with in-memory caching for repeated queries."""
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
result = self._query(sql, params)
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _execute(self, sql: str, params: dict = None) -> Optional[int]:
|
||||
"""Execute an INSERT/UPDATE/DELETE. Returns scalar result if RETURNING used."""
|
||||
session = self._session()
|
||||
try:
|
||||
result = session.execute(text(sql), params or {})
|
||||
session.commit()
|
||||
self._cache.clear()
|
||||
# If the query has RETURNING, get the scalar
|
||||
try:
|
||||
return result.scalar()
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==================================================================
|
||||
# Vehicle navigation
|
||||
# ==================================================================
|
||||
|
||||
def get_brands(self) -> list[dict]:
|
||||
"""Return all brands ordered by name: [{id, name, country}]."""
|
||||
return self._query_cached(
|
||||
"brands",
|
||||
"SELECT id_brand AS id, name_brand AS name, country FROM brands ORDER BY name_brand",
|
||||
)
|
||||
|
||||
def get_models(self, brand: Optional[str] = None) -> list[dict]:
|
||||
"""Return models, optionally filtered by brand name (case-insensitive)."""
|
||||
if brand:
|
||||
key = f"models:{brand.upper()}"
|
||||
return self._query_cached(
|
||||
key,
|
||||
"""
|
||||
SELECT MIN(m.id_model) AS id, m.name_model AS name
|
||||
FROM models m
|
||||
JOIN brands b ON m.brand_id = b.id_brand
|
||||
WHERE b.name_brand ILIKE :brand
|
||||
GROUP BY UPPER(m.name_model), m.name_model
|
||||
ORDER BY m.name_model
|
||||
""",
|
||||
{"brand": brand},
|
||||
)
|
||||
return self._query_cached(
|
||||
"models:all",
|
||||
"SELECT MIN(id_model) AS id, name_model AS name FROM models GROUP BY UPPER(name_model), name_model ORDER BY name_model",
|
||||
)
|
||||
|
||||
def get_years(
|
||||
self, brand: Optional[str] = None, model: Optional[str] = None
|
||||
) -> list[dict]:
|
||||
"""Return years, optionally filtered by brand and/or model."""
|
||||
sql = """
|
||||
SELECT DISTINCT y.id_year AS id, y.year_car AS year
|
||||
FROM years y
|
||||
JOIN model_year_engine mye ON y.id_year = mye.year_id
|
||||
JOIN models m ON mye.model_id = m.id_model
|
||||
JOIN brands b ON m.brand_id = b.id_brand
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: dict = {}
|
||||
if brand:
|
||||
sql += " AND b.name_brand ILIKE :brand"
|
||||
params["brand"] = brand
|
||||
if model:
|
||||
sql += " AND m.name_model ILIKE :model"
|
||||
params["model"] = model
|
||||
sql += " ORDER BY y.year_car DESC"
|
||||
return self._query(sql, params)
|
||||
|
||||
def get_engines(
|
||||
self,
|
||||
brand: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
"""Return engines, optionally filtered by brand/model/year."""
|
||||
sql = """
|
||||
SELECT MIN(e.id_engine) AS id, e.name_engine AS name,
|
||||
MAX(e.displacement_cc) AS displacement_cc,
|
||||
MAX(e.cylinders) AS cylinders,
|
||||
MAX(ft.name_fuel) AS fuel_type,
|
||||
MAX(e.power_hp) AS power_hp,
|
||||
MAX(e.torque_nm) AS torque_nm,
|
||||
MAX(e.engine_code) AS engine_code
|
||||
FROM engines e
|
||||
LEFT JOIN fuel_type ft ON e.id_fuel = ft.id_fuel
|
||||
JOIN model_year_engine mye ON e.id_engine = mye.engine_id
|
||||
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 1=1
|
||||
"""
|
||||
params: dict = {}
|
||||
if brand:
|
||||
sql += " AND b.name_brand ILIKE :brand"
|
||||
params["brand"] = brand
|
||||
if model:
|
||||
sql += " AND m.name_model ILIKE :model"
|
||||
params["model"] = model
|
||||
if year:
|
||||
sql += " AND y.year_car = :year"
|
||||
params["year"] = int(year)
|
||||
sql += " GROUP BY UPPER(e.name_engine), e.name_engine ORDER BY e.name_engine"
|
||||
return self._query(sql, params)
|
||||
|
||||
def get_model_year_engine(
|
||||
self,
|
||||
brand: str,
|
||||
model: str,
|
||||
year: int,
|
||||
engine_id: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
"""Return model_year_engine records for a specific vehicle config."""
|
||||
sql = """
|
||||
SELECT
|
||||
mye.id_mye AS id,
|
||||
b.name_brand AS brand,
|
||||
m.name_model AS model,
|
||||
y.year_car AS year,
|
||||
e.id_engine AS engine_id,
|
||||
e.name_engine AS engine,
|
||||
mye.trim_level,
|
||||
dt.name_drivetrain AS drivetrain,
|
||||
tr.name_transmission AS transmission
|
||||
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
|
||||
LEFT JOIN drivetrain dt ON mye.id_drivetrain = dt.id_drivetrain
|
||||
LEFT JOIN transmission tr ON mye.id_transmission = tr.id_transmission
|
||||
WHERE b.name_brand ILIKE :brand
|
||||
AND m.name_model ILIKE :model
|
||||
AND y.year_car = :year
|
||||
"""
|
||||
params: dict = {"brand": brand, "model": model, "year": int(year)}
|
||||
if engine_id:
|
||||
sql += " AND e.id_engine = :engine_id"
|
||||
params["engine_id"] = engine_id
|
||||
sql += " ORDER BY e.name_engine, mye.trim_level"
|
||||
return self._query(sql, params)
|
||||
|
||||
# ==================================================================
|
||||
# Parts catalog
|
||||
# ==================================================================
|
||||
|
||||
def get_categories(self) -> list[dict]:
|
||||
"""Return all part categories ordered by display_order."""
|
||||
return self._query_cached(
|
||||
"categories",
|
||||
"""
|
||||
SELECT id_part_category AS id, name_part_category AS name,
|
||||
name_es, slug, icon_name, display_order
|
||||
FROM part_categories
|
||||
ORDER BY display_order, name_part_category
|
||||
""",
|
||||
)
|
||||
|
||||
def get_groups(self, category_id: int) -> list[dict]:
|
||||
"""Return part groups for a given category."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT id_part_group AS id, name_part_group AS name,
|
||||
name_es, slug, display_order
|
||||
FROM part_groups
|
||||
WHERE category_id = :cat_id
|
||||
ORDER BY display_order, name_part_group
|
||||
""",
|
||||
{"cat_id": category_id},
|
||||
)
|
||||
|
||||
def get_parts(
|
||||
self,
|
||||
group_id: Optional[int] = None,
|
||||
mye_id: Optional[int] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 15,
|
||||
) -> list[dict]:
|
||||
"""Return parts with optional group/vehicle filter and pagination."""
|
||||
per_page = min(per_page, 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
p.id_part AS id,
|
||||
p.oem_part_number,
|
||||
p.name_part AS name,
|
||||
p.name_es,
|
||||
p.group_id,
|
||||
pg.name_part_group AS group_name,
|
||||
pc.name_part_category AS category_name
|
||||
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
|
||||
"""
|
||||
where_parts: list[str] = []
|
||||
params: dict = {}
|
||||
|
||||
if group_id:
|
||||
where_parts.append("p.group_id = :group_id")
|
||||
params["group_id"] = group_id
|
||||
if mye_id:
|
||||
where_parts.append(
|
||||
"p.id_part IN (SELECT part_id FROM vehicle_parts WHERE model_year_engine_id = :mye_id)"
|
||||
)
|
||||
params["mye_id"] = mye_id
|
||||
|
||||
if where_parts:
|
||||
sql += " WHERE " + " AND ".join(where_parts)
|
||||
|
||||
sql += " ORDER BY p.name_part LIMIT :limit OFFSET :offset"
|
||||
params["limit"] = per_page
|
||||
params["offset"] = offset
|
||||
|
||||
return self._query(sql, params)
|
||||
|
||||
def get_part(self, part_id: int) -> Optional[dict]:
|
||||
"""Return a single part with group/category info, or None."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
p.id_part AS id,
|
||||
p.oem_part_number,
|
||||
p.name_part AS name,
|
||||
p.name_es,
|
||||
p.description,
|
||||
p.description_es,
|
||||
p.weight_kg,
|
||||
mat.name_material AS material,
|
||||
p.group_id,
|
||||
pg.name_part_group AS group_name,
|
||||
pg.name_es AS group_name_es,
|
||||
pc.id_part_category AS category_id,
|
||||
pc.name_part_category AS category_name,
|
||||
pc.name_es AS category_name_es
|
||||
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
|
||||
WHERE p.id_part = :part_id
|
||||
""",
|
||||
{"part_id": part_id},
|
||||
one=True,
|
||||
)
|
||||
|
||||
def get_alternatives(self, part_id: int) -> list[dict]:
|
||||
"""Return aftermarket alternatives for an OEM part."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
ap.id_aftermarket_parts AS id,
|
||||
ap.part_number,
|
||||
ap.name_aftermarket_parts AS name,
|
||||
ap.name_es,
|
||||
m.name_manufacture AS manufacturer_name,
|
||||
ap.manufacturer_id,
|
||||
qt.name_quality AS quality_tier,
|
||||
ap.price_usd,
|
||||
ap.warranty_months
|
||||
FROM aftermarket_parts ap
|
||||
JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture
|
||||
LEFT JOIN quality_tier qt ON ap.id_quality_tier = qt.id_quality_tier
|
||||
WHERE ap.oem_part_id = :part_id
|
||||
ORDER BY qt.name_quality DESC, ap.price_usd ASC
|
||||
""",
|
||||
{"part_id": part_id},
|
||||
)
|
||||
|
||||
def get_cross_references(self, part_id: int) -> list[dict]:
|
||||
"""Return cross-reference numbers for a part."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT id_part_cross_ref AS id, cross_reference_number,
|
||||
rt.name_ref_type AS reference_type,
|
||||
source_ref AS source, notes
|
||||
FROM part_cross_references pcr
|
||||
LEFT JOIN reference_type rt ON pcr.id_ref_type = rt.id_ref_type
|
||||
WHERE pcr.part_id = :part_id
|
||||
ORDER BY rt.name_ref_type, pcr.cross_reference_number
|
||||
""",
|
||||
{"part_id": part_id},
|
||||
)
|
||||
|
||||
def get_vehicles_for_part(self, part_id: int) -> list[dict]:
|
||||
"""Return vehicles that use a specific part."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
b.name_brand AS brand,
|
||||
m.name_model AS model,
|
||||
y.year_car AS year,
|
||||
e.name_engine AS engine,
|
||||
mye.trim_level,
|
||||
vp.quantity_required,
|
||||
pp.name_position_part AS position,
|
||||
vp.fitment_notes
|
||||
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
|
||||
LEFT JOIN position_part pp ON vp.id_position_part = pp.id_position_part
|
||||
WHERE vp.part_id = :part_id
|
||||
ORDER BY b.name_brand, m.name_model, y.year_car
|
||||
""",
|
||||
{"part_id": part_id},
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# Search
|
||||
# ==================================================================
|
||||
|
||||
def search_parts(
|
||||
self, query: str, page: int = 1, per_page: int = 15
|
||||
) -> list[dict]:
|
||||
"""Full-text search using PostgreSQL tsvector."""
|
||||
per_page = min(per_page, 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
p.id_part AS id,
|
||||
p.oem_part_number,
|
||||
p.name_part AS name,
|
||||
p.name_es,
|
||||
p.description,
|
||||
pg.name_part_group AS group_name,
|
||||
pc.name_part_category AS category_name,
|
||||
ts_rank(p.search_vector, plainto_tsquery('spanish', :q)) AS rank
|
||||
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
|
||||
WHERE p.search_vector @@ plainto_tsquery('spanish', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""",
|
||||
{"q": query, "limit": per_page, "offset": offset},
|
||||
)
|
||||
|
||||
def search_part_number(self, number: str) -> list[dict]:
|
||||
"""Search OEM, aftermarket, and cross-reference part numbers."""
|
||||
results: list[dict] = []
|
||||
search_term = f"%{number}%"
|
||||
|
||||
# OEM parts
|
||||
rows = self._query(
|
||||
"""
|
||||
SELECT id_part AS id, oem_part_number, name_part AS name, name_es
|
||||
FROM parts
|
||||
WHERE oem_part_number ILIKE :term
|
||||
""",
|
||||
{"term": search_term},
|
||||
)
|
||||
for row in rows:
|
||||
results.append({
|
||||
**row,
|
||||
"match_type": "oem",
|
||||
"matched_number": row["oem_part_number"],
|
||||
})
|
||||
|
||||
# Aftermarket parts
|
||||
rows = self._query(
|
||||
"""
|
||||
SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name,
|
||||
p.name_es, ap.part_number
|
||||
FROM aftermarket_parts ap
|
||||
JOIN parts p ON ap.oem_part_id = p.id_part
|
||||
WHERE ap.part_number ILIKE :term
|
||||
""",
|
||||
{"term": search_term},
|
||||
)
|
||||
for row in rows:
|
||||
results.append({
|
||||
"id": row["id"],
|
||||
"oem_part_number": row["oem_part_number"],
|
||||
"name": row["name"],
|
||||
"name_es": row["name_es"],
|
||||
"match_type": "aftermarket",
|
||||
"matched_number": row["part_number"],
|
||||
})
|
||||
|
||||
# Cross-references
|
||||
rows = self._query(
|
||||
"""
|
||||
SELECT p.id_part AS id, p.oem_part_number, p.name_part AS name,
|
||||
p.name_es, pcr.cross_reference_number
|
||||
FROM part_cross_references pcr
|
||||
JOIN parts p ON pcr.part_id = p.id_part
|
||||
WHERE pcr.cross_reference_number ILIKE :term
|
||||
""",
|
||||
{"term": search_term},
|
||||
)
|
||||
for row in rows:
|
||||
results.append({
|
||||
"id": row["id"],
|
||||
"oem_part_number": row["oem_part_number"],
|
||||
"name": row["name"],
|
||||
"name_es": row["name_es"],
|
||||
"match_type": "cross_reference",
|
||||
"matched_number": row["cross_reference_number"],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# ==================================================================
|
||||
# VIN cache
|
||||
# ==================================================================
|
||||
|
||||
def get_vin_cache(self, vin: str) -> Optional[dict]:
|
||||
"""Return cached VIN decode data if still valid, else None."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
vin, decoded_data, make, model, year,
|
||||
engine_info, body_class, drive_type,
|
||||
model_year_engine_id, created_at, expires_at
|
||||
FROM vin_cache
|
||||
WHERE vin = :vin AND expires_at > NOW()
|
||||
""",
|
||||
{"vin": vin.upper().strip()},
|
||||
one=True,
|
||||
)
|
||||
|
||||
def save_vin_cache(
|
||||
self,
|
||||
vin: str,
|
||||
data: str,
|
||||
make: str,
|
||||
model: str,
|
||||
year: int,
|
||||
engine_info: str,
|
||||
body_class: str,
|
||||
drive_type: str,
|
||||
) -> Optional[int]:
|
||||
"""Insert or update a VIN cache entry (30-day expiry)."""
|
||||
expires = datetime.utcnow() + timedelta(days=30)
|
||||
decoded = json_module.loads(data) if isinstance(data, str) else data
|
||||
return self._execute(
|
||||
"""
|
||||
INSERT INTO vin_cache
|
||||
(vin, decoded_data, make, model, year,
|
||||
engine_info, body_class, drive_type, expires_at)
|
||||
VALUES (:vin, :decoded_data, :make, :model, :year,
|
||||
:engine_info, :body_class, :drive_type, :expires_at)
|
||||
ON CONFLICT (vin) DO UPDATE SET
|
||||
decoded_data = EXCLUDED.decoded_data,
|
||||
make = EXCLUDED.make,
|
||||
model = EXCLUDED.model,
|
||||
year = EXCLUDED.year,
|
||||
engine_info = EXCLUDED.engine_info,
|
||||
body_class = EXCLUDED.body_class,
|
||||
drive_type = EXCLUDED.drive_type,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING id
|
||||
""",
|
||||
{
|
||||
"vin": vin.upper().strip(),
|
||||
"decoded_data": json_module.dumps(decoded),
|
||||
"make": make,
|
||||
"model": model,
|
||||
"year": year,
|
||||
"engine_info": engine_info,
|
||||
"body_class": body_class,
|
||||
"drive_type": drive_type,
|
||||
"expires_at": expires.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# Stats
|
||||
# ==================================================================
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return counts for all major tables plus top brands by fitment."""
|
||||
session = self._session()
|
||||
try:
|
||||
stats: dict = {}
|
||||
table_map = {
|
||||
"brands": "brands",
|
||||
"models": "models",
|
||||
"years": "years",
|
||||
"engines": "engines",
|
||||
"part_categories": "part_categories",
|
||||
"part_groups": "part_groups",
|
||||
"parts": "parts",
|
||||
"aftermarket_parts": "aftermarket_parts",
|
||||
"manufacturers": "manufacturers",
|
||||
"vehicle_parts": "vehicle_parts",
|
||||
"part_cross_references": "part_cross_references",
|
||||
}
|
||||
for key, table in table_map.items():
|
||||
row = session.execute(text(f"SELECT COUNT(*) AS cnt FROM {table}")).mappings().one()
|
||||
stats[key] = row["cnt"]
|
||||
|
||||
# Top brands by number of fitments
|
||||
rows = session.execute(text("""
|
||||
SELECT b.name_brand AS name, COUNT(DISTINCT vp.id_vehicle_part) AS cnt
|
||||
FROM brands b
|
||||
JOIN models m ON m.brand_id = b.id_brand
|
||||
JOIN model_year_engine mye ON mye.model_id = m.id_model
|
||||
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id_mye
|
||||
GROUP BY b.name_brand
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""")).mappings().all()
|
||||
stats["top_brands"] = [
|
||||
{"name": r["name"], "count": r["cnt"]} for r in rows
|
||||
]
|
||||
return stats
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==================================================================
|
||||
# Admin — Manufacturers
|
||||
# ==================================================================
|
||||
|
||||
def get_manufacturers(self) -> list[dict]:
|
||||
"""Return all manufacturers ordered by name."""
|
||||
return self._query(
|
||||
"""
|
||||
SELECT m.id_manufacture AS id, m.name_manufacture AS name,
|
||||
mt.name_type_manu AS type,
|
||||
qt.name_quality AS quality_tier,
|
||||
c.name_country AS country,
|
||||
m.logo_url, m.website
|
||||
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
|
||||
"""
|
||||
)
|
||||
|
||||
def create_manufacturer(self, data: dict) -> Optional[int]:
|
||||
"""Insert a new manufacturer and return its id."""
|
||||
return self._execute(
|
||||
"""
|
||||
INSERT INTO manufacturers (name_manufacture, id_type_manu, id_quality_tier,
|
||||
id_country, logo_url, website)
|
||||
VALUES (:name,
|
||||
(SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type),
|
||||
(SELECT id_quality_tier FROM quality_tier WHERE name_quality = :quality_tier),
|
||||
(SELECT id_country FROM countries WHERE name_country = :country),
|
||||
:logo_url, :website)
|
||||
RETURNING id_manufacture
|
||||
""",
|
||||
{
|
||||
"name": data["name"],
|
||||
"type": data.get("type"),
|
||||
"quality_tier": data.get("quality_tier"),
|
||||
"country": data.get("country"),
|
||||
"logo_url": data.get("logo_url"),
|
||||
"website": data.get("website"),
|
||||
},
|
||||
)
|
||||
|
||||
def update_manufacturer(self, mfr_id: int, data: dict) -> None:
|
||||
"""Update an existing manufacturer."""
|
||||
self._execute(
|
||||
"""
|
||||
UPDATE manufacturers
|
||||
SET name_manufacture = :name,
|
||||
id_type_manu = (SELECT id_type_manu FROM manufacture_type WHERE name_type_manu = :type),
|
||||
id_quality_tier = (SELECT id_quality_tier FROM quality_tier WHERE name_quality = :quality_tier),
|
||||
id_country = (SELECT id_country FROM countries WHERE name_country = :country),
|
||||
logo_url = :logo_url, website = :website
|
||||
WHERE id_manufacture = :mfr_id
|
||||
""",
|
||||
{
|
||||
"name": data["name"],
|
||||
"type": data.get("type"),
|
||||
"quality_tier": data.get("quality_tier"),
|
||||
"country": data.get("country"),
|
||||
"logo_url": data.get("logo_url"),
|
||||
"website": data.get("website"),
|
||||
"mfr_id": mfr_id,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_manufacturer(self, mfr_id: int) -> None:
|
||||
"""Delete a manufacturer by id."""
|
||||
self._execute("DELETE FROM manufacturers WHERE id_manufacture = :id", {"id": mfr_id})
|
||||
|
||||
# ==================================================================
|
||||
# Admin — Parts
|
||||
# ==================================================================
|
||||
|
||||
def create_part(self, data: dict) -> Optional[int]:
|
||||
"""Insert a new part and return its id."""
|
||||
return self._execute(
|
||||
"""
|
||||
INSERT INTO parts
|
||||
(oem_part_number, name_part, name_es, group_id,
|
||||
description, description_es, weight_kg,
|
||||
id_material)
|
||||
VALUES (:oem_part_number, :name, :name_es, :group_id,
|
||||
:description, :description_es, :weight_kg,
|
||||
(SELECT id_material FROM materials WHERE name_material = :material))
|
||||
RETURNING id_part
|
||||
""",
|
||||
{
|
||||
"oem_part_number": data["oem_part_number"],
|
||||
"name": data["name"],
|
||||
"name_es": data.get("name_es"),
|
||||
"group_id": data.get("group_id"),
|
||||
"description": data.get("description"),
|
||||
"description_es": data.get("description_es"),
|
||||
"weight_kg": data.get("weight_kg"),
|
||||
"material": data.get("material"),
|
||||
},
|
||||
)
|
||||
|
||||
def update_part(self, part_id: int, data: dict) -> None:
|
||||
"""Update an existing part."""
|
||||
self._execute(
|
||||
"""
|
||||
UPDATE parts
|
||||
SET oem_part_number = :oem_part_number, name_part = :name,
|
||||
name_es = :name_es, group_id = :group_id,
|
||||
description = :description, description_es = :description_es,
|
||||
weight_kg = :weight_kg,
|
||||
id_material = (SELECT id_material FROM materials WHERE name_material = :material)
|
||||
WHERE id_part = :part_id
|
||||
""",
|
||||
{
|
||||
"oem_part_number": data["oem_part_number"],
|
||||
"name": data["name"],
|
||||
"name_es": data.get("name_es"),
|
||||
"group_id": data.get("group_id"),
|
||||
"description": data.get("description"),
|
||||
"description_es": data.get("description_es"),
|
||||
"weight_kg": data.get("weight_kg"),
|
||||
"material": data.get("material"),
|
||||
"part_id": part_id,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_part(self, part_id: int) -> None:
|
||||
"""Delete a part by id."""
|
||||
self._execute("DELETE FROM parts WHERE id_part = :id", {"id": part_id})
|
||||
|
||||
# ==================================================================
|
||||
# Admin — Cross-references
|
||||
# ==================================================================
|
||||
|
||||
def create_crossref(self, data: dict) -> Optional[int]:
|
||||
"""Insert a new cross-reference and return its id."""
|
||||
return self._execute(
|
||||
"""
|
||||
INSERT INTO part_cross_references
|
||||
(part_id, cross_reference_number, id_ref_type, source_ref, notes)
|
||||
VALUES (:part_id, :cross_reference_number,
|
||||
(SELECT id_ref_type FROM reference_type WHERE name_ref_type = :reference_type),
|
||||
:source, :notes)
|
||||
RETURNING id_part_cross_ref
|
||||
""",
|
||||
{
|
||||
"part_id": data["part_id"],
|
||||
"cross_reference_number": data["cross_reference_number"],
|
||||
"reference_type": data.get("reference_type"),
|
||||
"source": data.get("source"),
|
||||
"notes": data.get("notes"),
|
||||
},
|
||||
)
|
||||
|
||||
def update_crossref(self, xref_id: int, data: dict) -> None:
|
||||
"""Update an existing cross-reference."""
|
||||
self._execute(
|
||||
"""
|
||||
UPDATE part_cross_references
|
||||
SET part_id = :part_id, cross_reference_number = :cross_reference_number,
|
||||
id_ref_type = (SELECT id_ref_type FROM reference_type WHERE name_ref_type = :reference_type),
|
||||
source_ref = :source, notes = :notes
|
||||
WHERE id_part_cross_ref = :xref_id
|
||||
""",
|
||||
{
|
||||
"part_id": data["part_id"],
|
||||
"cross_reference_number": data["cross_reference_number"],
|
||||
"reference_type": data.get("reference_type"),
|
||||
"source": data.get("source"),
|
||||
"notes": data.get("notes"),
|
||||
"xref_id": xref_id,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_crossref(self, xref_id: int) -> None:
|
||||
"""Delete a cross-reference by id."""
|
||||
self._execute(
|
||||
"DELETE FROM part_cross_references WHERE id_part_cross_ref = :id", {"id": xref_id}
|
||||
)
|
||||
|
||||
def get_crossrefs_paginated(
|
||||
self, page: int = 1, per_page: int = 15
|
||||
) -> list[dict]:
|
||||
"""Return paginated cross-references with part info."""
|
||||
per_page = min(per_page, 100)
|
||||
offset = (page - 1) * per_page
|
||||
return self._query(
|
||||
"""
|
||||
SELECT
|
||||
pcr.id_part_cross_ref AS id,
|
||||
pcr.part_id,
|
||||
pcr.cross_reference_number,
|
||||
rt.name_ref_type AS reference_type,
|
||||
pcr.source_ref AS source,
|
||||
pcr.notes,
|
||||
p.oem_part_number,
|
||||
p.name_part AS part_name
|
||||
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 pcr.id_part_cross_ref
|
||||
LIMIT :limit OFFSET :offset
|
||||
""",
|
||||
{"limit": per_page, "offset": offset},
|
||||
)
|
||||
113
console/main.py
Normal file
113
console/main.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Entry point for the NEXUS AUTOPARTS Pick/VT220-style console application.
|
||||
|
||||
Usage:
|
||||
python -m console # via package
|
||||
python -m console.main # via module
|
||||
python console/main.py # direct
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_URL
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=APP_NAME.lower(),
|
||||
description=f"{APP_NAME} - {APP_SUBTITLE}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"{APP_NAME} {VERSION}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=DB_URL,
|
||||
help="PostgreSQL connection URL (default: from config)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _print_banner(db_url):
|
||||
"""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
|
||||
print(border)
|
||||
print(f" {APP_NAME} v{VERSION}")
|
||||
print(f" {APP_SUBTITLE}")
|
||||
print(border)
|
||||
print(f" DB : {display_url}")
|
||||
print(border)
|
||||
print()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Main entry point: parse args, set up renderer, DB, and launch the app."""
|
||||
args = parse_args(argv)
|
||||
|
||||
db_url = args.db
|
||||
|
||||
# Lazy imports so the module can be loaded without curses available
|
||||
# (e.g. during tests or when just checking --version).
|
||||
from console.db import Database
|
||||
from console.renderers.curses_renderer import CursesRenderer
|
||||
from console.core.app import App
|
||||
|
||||
# Print startup banner
|
||||
_print_banner(db_url)
|
||||
|
||||
# Test database connection before entering curses mode
|
||||
db = Database(db_url)
|
||||
try:
|
||||
db._get_engine()
|
||||
session = db._session()
|
||||
session.execute(text("SELECT 1"))
|
||||
session.close()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error: Cannot connect to database.\n"
|
||||
f"\n"
|
||||
f" URL: {db_url}\n"
|
||||
f" Error: {e}\n"
|
||||
f"\n"
|
||||
f"Make sure PostgreSQL is running and the connection URL is correct.\n"
|
||||
f"You can specify a custom URL with the --db flag:\n"
|
||||
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)
|
||||
|
||||
try:
|
||||
app.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
# Ensure terminal is restored before printing the traceback
|
||||
try:
|
||||
renderer.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
console/renderers/__init__.py
Normal file
0
console/renderers/__init__.py
Normal file
152
console/renderers/base.py
Normal file
152
console/renderers/base.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Abstract base renderer interface for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Every renderer (curses VT220, Textual/Rich, etc.) must subclass
|
||||
:class:`BaseRenderer` and implement all of its methods. Screens call
|
||||
these methods without knowing which backend is active.
|
||||
"""
|
||||
|
||||
|
||||
class BaseRenderer:
|
||||
"""Abstract interface that all renderers must implement.
|
||||
|
||||
Methods raise :exc:`NotImplementedError` so that missing overrides
|
||||
are caught immediately at runtime.
|
||||
"""
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
def init_screen(self):
|
||||
"""Initialise the terminal / display backend."""
|
||||
raise NotImplementedError
|
||||
|
||||
def cleanup(self):
|
||||
"""Restore the terminal to its original state."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── Screen queries ───────────────────────────────────────────────
|
||||
|
||||
def get_size(self) -> tuple:
|
||||
"""Return ``(height, width)`` of the usable display area."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── Primitive operations ─────────────────────────────────────────
|
||||
|
||||
def clear(self):
|
||||
"""Clear the entire screen buffer."""
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self):
|
||||
"""Flush the screen buffer to the terminal."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_key(self) -> int:
|
||||
"""Block until a key is pressed and return its key code."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── High-level widgets ───────────────────────────────────────────
|
||||
|
||||
def draw_header(self, title, subtitle=''):
|
||||
"""Draw the application header bar on the top two rows.
|
||||
|
||||
*title* is left-aligned; *subtitle* is right-aligned.
|
||||
Row 1 is a horizontal separator.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_footer(self, key_labels):
|
||||
"""Draw the footer bar on the bottom two rows.
|
||||
|
||||
*key_labels* is a list of ``(key, description)`` tuples,
|
||||
e.g. ``[("F1", "Ayuda"), ("ESC", "Atras")]``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_menu(self, items, selected_index=0, title=''):
|
||||
"""Draw a numbered menu list starting at row 3.
|
||||
|
||||
*items* is a list of ``(number, label)`` tuples.
|
||||
Separator items have ``number == '---'``.
|
||||
The item at *selected_index* is highlighted.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_table(self, headers, rows, widths, page_info=None,
|
||||
selected_row=-1):
|
||||
"""Draw a columnar data table.
|
||||
|
||||
*headers*: list of column header strings.
|
||||
*rows*: list of row tuples (each tuple matches *headers*).
|
||||
*widths*: list of int column widths.
|
||||
*page_info*: optional dict ``{page, total_pages, total_rows}``.
|
||||
*selected_row*: index of the highlighted row (-1 = none).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_detail(self, fields, title=''):
|
||||
"""Draw a detail view with label-value pairs.
|
||||
|
||||
*fields* is a list of ``(label, value)`` tuples displayed as
|
||||
``Label........: Value``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_form(self, fields, focused_index=0, title=''):
|
||||
"""Draw an editable form.
|
||||
|
||||
*fields* is a list of dicts with keys:
|
||||
``label``, ``value``, ``width``, ``type``, ``hint``.
|
||||
The field at *focused_index* uses the active style.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_filter_list(self, items, filter_text, selected_index,
|
||||
title=''):
|
||||
"""Draw a filterable list with a text input at the top.
|
||||
|
||||
*items*: list of ``(number, label)`` tuples.
|
||||
*filter_text*: current filter string.
|
||||
*selected_index*: highlighted item index.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_comparison(self, columns, title=''):
|
||||
"""Draw a side-by-side comparison view.
|
||||
|
||||
*columns* is a list of dicts, each with:
|
||||
``header`` (str) and ``rows`` (list of ``(label, value)``).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── Low-level drawing ────────────────────────────────────────────
|
||||
|
||||
def draw_text(self, row, col, text, style='normal'):
|
||||
"""Draw *text* at ``(row, col)`` using the named *style*."""
|
||||
raise NotImplementedError
|
||||
|
||||
def draw_box(self, top, left, height, width, title=''):
|
||||
"""Draw a box with Unicode line-drawing characters.
|
||||
|
||||
Optional *title* is rendered in the top border.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── Dialogs ──────────────────────────────────────────────────────
|
||||
|
||||
def show_message(self, text, msg_type='info') -> bool:
|
||||
"""Show a centred message box.
|
||||
|
||||
*msg_type* is one of ``'info'``, ``'error'``, or ``'confirm'``.
|
||||
For ``'confirm'`` the user must press S (si) or N (no);
|
||||
returns ``True`` for S, ``False`` for N.
|
||||
For other types, waits for any key and returns ``True``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def show_input(self, prompt, max_len=40):
|
||||
"""Show a centred input dialog.
|
||||
|
||||
Returns the entered string, or ``None`` if the user pressed
|
||||
Escape to cancel.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
570
console/renderers/curses_renderer.py
Normal file
570
console/renderers/curses_renderer.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
Curses-based VT220 renderer for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Implements :class:`BaseRenderer` with a green-on-black aesthetic inspired
|
||||
by classic Pick/UNIX VT220 terminals. All drawing is done through Python's
|
||||
built-in :mod:`curses` library.
|
||||
"""
|
||||
|
||||
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.renderers.base import BaseRenderer
|
||||
from console.utils.formatting import pad_right, truncate
|
||||
|
||||
# ── Colour-name-to-curses mapping ────────────────────────────────────
|
||||
|
||||
_CURSES_COLORS = {
|
||||
"black": curses.COLOR_BLACK,
|
||||
"red": curses.COLOR_RED,
|
||||
"green": curses.COLOR_GREEN,
|
||||
"yellow": curses.COLOR_YELLOW,
|
||||
"blue": curses.COLOR_BLUE,
|
||||
"magenta": curses.COLOR_MAGENTA,
|
||||
"cyan": curses.COLOR_CYAN,
|
||||
"white": curses.COLOR_WHITE,
|
||||
}
|
||||
|
||||
# Box-drawing characters
|
||||
_BOX_H = "\u2500" # ─
|
||||
_BOX_V = "\u2502" # │
|
||||
_BOX_TL = "\u250c" # ┌
|
||||
_BOX_TR = "\u2510" # ┐
|
||||
_BOX_BL = "\u2514" # └
|
||||
_BOX_BR = "\u2518" # ┘
|
||||
|
||||
|
||||
class CursesRenderer(BaseRenderer):
|
||||
"""Full curses implementation of the VT220 green-on-black renderer."""
|
||||
|
||||
def __init__(self):
|
||||
self._screen = None
|
||||
self._color_pairs: dict[str, int] = {}
|
||||
self._size_cache: tuple = (24, 80)
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
def init_screen(self):
|
||||
"""Set up curses: raw mode, no echo, hidden cursor, colours."""
|
||||
self._screen = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
curses.curs_set(0)
|
||||
self._screen.keypad(True)
|
||||
self._init_colors()
|
||||
|
||||
def cleanup(self):
|
||||
"""Restore the terminal to a usable state."""
|
||||
if self._screen is None:
|
||||
return
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self._screen.keypad(False)
|
||||
curses.echo()
|
||||
except curses.error:
|
||||
pass
|
||||
curses.endwin()
|
||||
self._screen = None
|
||||
|
||||
# ── Screen queries ───────────────────────────────────────────────
|
||||
|
||||
def get_size(self) -> tuple:
|
||||
"""Return ``(height, width)`` with cached value per render cycle."""
|
||||
return self._size_cache
|
||||
|
||||
# ── Primitive operations ─────────────────────────────────────────
|
||||
|
||||
def clear(self):
|
||||
self._size_cache = self._screen.getmaxyx()
|
||||
self._screen.erase()
|
||||
|
||||
def refresh(self):
|
||||
self._screen.refresh()
|
||||
|
||||
def get_key(self) -> int:
|
||||
return self._screen.getch()
|
||||
|
||||
# ── Colour helpers ───────────────────────────────────────────────
|
||||
|
||||
def _init_colors(self):
|
||||
"""Initialise curses colour pairs from ``COLORS_VT220``."""
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
for idx, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), start=1):
|
||||
curses.init_pair(idx, _CURSES_COLORS[fg], _CURSES_COLORS[bg])
|
||||
self._color_pairs[name] = idx
|
||||
|
||||
def _attr(self, style: str) -> int:
|
||||
"""Return the curses attribute for a named style.
|
||||
|
||||
Falls back to the *normal* pair if *style* is unknown.
|
||||
"""
|
||||
pair_id = self._color_pairs.get(style,
|
||||
self._color_pairs.get("normal", 1))
|
||||
attr = curses.color_pair(pair_id)
|
||||
if style in ("header", "title"):
|
||||
attr |= curses.A_BOLD
|
||||
return attr
|
||||
|
||||
# ── Safe drawing helpers ─────────────────────────────────────────
|
||||
|
||||
def _safe_addstr(self, row, col, text, attr=None):
|
||||
"""Write *text* at (row, col), silently ignoring edge overflows."""
|
||||
if attr is None:
|
||||
attr = self._attr("normal")
|
||||
h, w = self.get_size()
|
||||
if row < 0 or row >= h or col >= w:
|
||||
return
|
||||
# Truncate to fit within the screen width
|
||||
max_chars = w - col
|
||||
if max_chars <= 0:
|
||||
return
|
||||
text = text[:max_chars]
|
||||
try:
|
||||
self._screen.addstr(row, col, text, attr)
|
||||
except curses.error:
|
||||
# Writing to the bottom-right corner raises an error after
|
||||
# the character is actually drawn. Safe to ignore.
|
||||
pass
|
||||
|
||||
def _hline(self, row, col, width, char=_BOX_H, style="border"):
|
||||
"""Draw a horizontal line of *char* across *width* columns."""
|
||||
self._safe_addstr(row, col, char * width, self._attr(style))
|
||||
|
||||
# ── High-level widgets ───────────────────────────────────────────
|
||||
|
||||
def draw_header(self, title, subtitle=''):
|
||||
h, w = self.get_size()
|
||||
attr = self._attr("header")
|
||||
# Row 0: title (left) + subtitle (right)
|
||||
header_line = pad_right(title, w)
|
||||
if subtitle:
|
||||
sub = subtitle[:w - len(title) - 1]
|
||||
header_line = (title
|
||||
+ " " * max(w - len(title) - len(sub), 0)
|
||||
+ sub)
|
||||
header_line = pad_right(header_line, w)
|
||||
self._safe_addstr(0, 0, header_line, attr | curses.A_BOLD)
|
||||
# Row 1: separator
|
||||
self._hline(1, 0, w)
|
||||
|
||||
def draw_footer(self, key_labels):
|
||||
h, w = self.get_size()
|
||||
if h < 3:
|
||||
return
|
||||
# Row h-2: separator
|
||||
self._hline(h - 2, 0, w)
|
||||
# Row h-1: key labels
|
||||
attr = self._attr("footer")
|
||||
parts = [f"{k}={d}" for k, d in key_labels]
|
||||
line = " ".join(parts)
|
||||
self._safe_addstr(h - 1, 0, pad_right(line, w), attr)
|
||||
|
||||
def draw_menu(self, items, selected_index=0, title=''):
|
||||
h, w = self.get_size()
|
||||
|
||||
# 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:
|
||||
box_h += 2
|
||||
|
||||
# Center the box vertically and horizontally
|
||||
start_row = max((h - box_h) // 2 - 1, 2)
|
||||
start_col = max((w - box_w) // 2, 1)
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Separator
|
||||
if num == "\u2500" or num == "---":
|
||||
self._hline(row, start_col + 1, box_w - 2)
|
||||
row += 1
|
||||
drawn += 1
|
||||
continue
|
||||
|
||||
marker = "\u25b8 " if idx == selected_index else " "
|
||||
text = f"{marker}{num}. {label}"
|
||||
style = "highlight" if idx == selected_index else "normal"
|
||||
self._safe_addstr(row, inner_left, pad_right(text, inner_w),
|
||||
self._attr(style))
|
||||
row += 1
|
||||
drawn += 1
|
||||
|
||||
def draw_table(self, headers, rows, widths, page_info=None,
|
||||
selected_row=-1):
|
||||
h, w = self.get_size()
|
||||
start_row = 3
|
||||
|
||||
# Header row
|
||||
header_cells = [pad_right(hdr, wd) for hdr, wd in zip(headers, widths)]
|
||||
header_text = " # " + " \u2502 ".join(header_cells)
|
||||
self._safe_addstr(start_row, 0, pad_right(header_text, w),
|
||||
self._attr("title"))
|
||||
# Separator
|
||||
self._hline(start_row + 1, 0, w)
|
||||
|
||||
visible = h - start_row - 5 # room for header, sep, footer
|
||||
if visible < 1:
|
||||
return
|
||||
|
||||
for i, row_data in enumerate(rows):
|
||||
if i >= visible:
|
||||
break
|
||||
row_num = start_row + 2 + i
|
||||
row_idx_str = pad_right(str(i + 1), 3)
|
||||
cells = [pad_right(str(v), wd) for v, wd in zip(row_data, widths)]
|
||||
line = f" {row_idx_str}\u2502 " + " \u2502 ".join(cells)
|
||||
style = "highlight" if i == selected_row else "normal"
|
||||
self._safe_addstr(row_num, 0, pad_right(line, w),
|
||||
self._attr(style))
|
||||
|
||||
# Page info
|
||||
if page_info:
|
||||
info_row = start_row + 2 + min(len(rows), visible)
|
||||
page = page_info.get("page", 1)
|
||||
total = page_info.get("total_pages", 1)
|
||||
total_rows = page_info.get("total_rows", len(rows))
|
||||
info_text = (f" Pagina {page}/{total}"
|
||||
f" ({total_rows} registros)")
|
||||
self._safe_addstr(info_row, 0, info_text, self._attr("info"))
|
||||
|
||||
def draw_detail(self, fields, title=''):
|
||||
h, w = self.get_size()
|
||||
start_row = 3
|
||||
|
||||
if title:
|
||||
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
||||
self._hline(start_row + 1, 2, w - 4)
|
||||
start_row += 3
|
||||
|
||||
# Determine max label width for alignment
|
||||
max_label = max((len(lbl) for lbl, _ in fields), default=10)
|
||||
dot_total = max_label + 4 # label + dots
|
||||
|
||||
for i, (label, value) in enumerate(fields):
|
||||
row = start_row + i
|
||||
if row >= h - 3:
|
||||
break
|
||||
dots = "." * (dot_total - len(label))
|
||||
label_part = f" {label}{dots}: "
|
||||
self._safe_addstr(row, 0, label_part,
|
||||
self._attr("field_label"))
|
||||
self._safe_addstr(row, len(label_part), str(value),
|
||||
self._attr("field_value"))
|
||||
|
||||
def draw_form(self, fields, focused_index=0, title=''):
|
||||
h, w = self.get_size()
|
||||
start_row = 3
|
||||
|
||||
if title:
|
||||
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
||||
self._hline(start_row + 1, 2, w - 4)
|
||||
start_row += 3
|
||||
|
||||
max_label = max((len(f.get("label", "")) for f in fields),
|
||||
default=10)
|
||||
dot_total = max_label + 4
|
||||
|
||||
for i, field in enumerate(fields):
|
||||
row = start_row + i * 2 # space between fields
|
||||
if row >= h - 3:
|
||||
break
|
||||
|
||||
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}. "
|
||||
label_part = f" {num_str}{label}{dots}: "
|
||||
|
||||
self._safe_addstr(row, 0, label_part,
|
||||
self._attr("field_label"))
|
||||
|
||||
# Editable field value in brackets
|
||||
style = "field_active" if i == focused_index else "field_value"
|
||||
display_val = pad_right(str(value), fw)
|
||||
field_text = f"[{display_val}]"
|
||||
self._safe_addstr(row, len(label_part), field_text,
|
||||
self._attr(style))
|
||||
|
||||
# Optional hint
|
||||
if hint:
|
||||
hint_col = len(label_part) + len(field_text) + 2
|
||||
self._safe_addstr(row, hint_col, hint,
|
||||
self._attr("info"))
|
||||
|
||||
def draw_filter_list(self, items, filter_text, selected_index,
|
||||
title=''):
|
||||
h, w = self.get_size()
|
||||
start_row = 3
|
||||
|
||||
if title:
|
||||
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
||||
start_row += 1
|
||||
|
||||
# Separator
|
||||
self._hline(start_row, 2, w - 4)
|
||||
start_row += 1
|
||||
|
||||
# Filter input
|
||||
prompt = "Filtro: "
|
||||
self._safe_addstr(start_row, 2, prompt,
|
||||
self._attr("field_label"))
|
||||
self._safe_addstr(start_row, 2 + len(prompt),
|
||||
filter_text + "_",
|
||||
self._attr("field_active"))
|
||||
start_row += 1
|
||||
|
||||
# Separator
|
||||
self._hline(start_row, 2, w - 4)
|
||||
start_row += 1
|
||||
|
||||
# Scrollable list
|
||||
visible = h - start_row - 4
|
||||
if visible < 1:
|
||||
return
|
||||
|
||||
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
|
||||
row = start_row + drawn
|
||||
marker = "\u25b8 " if idx == selected_index else " "
|
||||
text = f"{marker}{num}. {label}"
|
||||
style = "highlight" if idx == selected_index else "normal"
|
||||
self._safe_addstr(row, 2, pad_right(text, w - 4),
|
||||
self._attr(style))
|
||||
drawn += 1
|
||||
|
||||
# Count at bottom
|
||||
count_row = start_row + min(drawn, visible)
|
||||
count_text = f" {len(items)} elementos"
|
||||
self._safe_addstr(count_row, 2, count_text, self._attr("info"))
|
||||
|
||||
def draw_comparison(self, columns, title=''):
|
||||
h, w = self.get_size()
|
||||
start_row = 3
|
||||
|
||||
if title:
|
||||
self._safe_addstr(start_row, 2, title, self._attr("title"))
|
||||
self._hline(start_row + 1, 2, w - 4)
|
||||
start_row += 3
|
||||
|
||||
n_cols = len(columns)
|
||||
if n_cols == 0:
|
||||
return
|
||||
|
||||
# Determine label width from the first column's row labels
|
||||
all_labels = []
|
||||
for col in columns:
|
||||
for lbl, _ in col.get("rows", []):
|
||||
all_labels.append(lbl)
|
||||
label_w = max((len(l) for l in all_labels), default=8) + 2
|
||||
|
||||
# Available width for data columns
|
||||
avail = w - label_w - 4
|
||||
col_w = max(avail // n_cols, 10)
|
||||
|
||||
# Header row
|
||||
header_line = pad_right("", label_w)
|
||||
for col in columns:
|
||||
header_line += " \u2502 " + pad_right(col.get("header", ""), col_w)
|
||||
self._safe_addstr(start_row, 2, header_line, self._attr("title"))
|
||||
|
||||
# Separator
|
||||
self._hline(start_row + 1, 2, w - 4)
|
||||
|
||||
# Data rows — use the first column's labels as the canonical set
|
||||
if not columns[0].get("rows"):
|
||||
return
|
||||
n_rows = len(columns[0]["rows"])
|
||||
for i in range(n_rows):
|
||||
row = start_row + 2 + i
|
||||
if row >= h - 3:
|
||||
break
|
||||
lbl = columns[0]["rows"][i][0] if i < len(columns[0]["rows"]) else ""
|
||||
line = pad_right(lbl, label_w)
|
||||
for col in columns:
|
||||
rows_data = col.get("rows", [])
|
||||
val = rows_data[i][1] if i < len(rows_data) else ""
|
||||
line += " \u2502 " + pad_right(str(val), col_w)
|
||||
self._safe_addstr(row, 2, line, self._attr("normal"))
|
||||
|
||||
# ── Low-level drawing ────────────────────────────────────────────
|
||||
|
||||
def draw_text(self, row, col, text, style='normal'):
|
||||
self._safe_addstr(row, col, text, self._attr(style))
|
||||
|
||||
def draw_box(self, top, left, height, width, title=''):
|
||||
if height < 2 or width < 2:
|
||||
return
|
||||
attr = self._attr("border")
|
||||
|
||||
# Top border
|
||||
top_line = _BOX_TL + _BOX_H * (width - 2) + _BOX_TR
|
||||
if title:
|
||||
t = truncate(title, width - 4)
|
||||
top_line = (_BOX_TL + _BOX_H + t
|
||||
+ _BOX_H * (width - 3 - len(t)) + _BOX_TR)
|
||||
self._safe_addstr(top, left, top_line, attr)
|
||||
|
||||
# Side borders
|
||||
for r in range(1, height - 1):
|
||||
self._safe_addstr(top + r, left, _BOX_V, attr)
|
||||
self._safe_addstr(top + r, left + width - 1, _BOX_V, attr)
|
||||
|
||||
# Bottom border
|
||||
bottom_line = _BOX_BL + _BOX_H * (width - 2) + _BOX_BR
|
||||
self._safe_addstr(top + height - 1, left, bottom_line, attr)
|
||||
|
||||
# ── 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)
|
||||
|
||||
style = "error" if msg_type == "error" else "info"
|
||||
self.draw_box(top, left, box_h, box_w)
|
||||
|
||||
# Fill interior with spaces
|
||||
interior_attr = self._attr(style)
|
||||
for r in range(1, box_h - 1):
|
||||
self._safe_addstr(top + r, left + 1,
|
||||
" " * (box_w - 2), interior_attr)
|
||||
|
||||
# Draw message lines
|
||||
for i, line in enumerate(lines):
|
||||
x = left + max((box_w - len(line)) // 2, 2)
|
||||
self._safe_addstr(top + 1 + i, x, line, interior_attr)
|
||||
|
||||
# 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)
|
||||
self._safe_addstr(top + box_h - 2, px, prompt,
|
||||
self._attr("highlight"))
|
||||
self.refresh()
|
||||
|
||||
# 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"), 27): # 27 = ESC
|
||||
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 = []
|
||||
|
||||
try:
|
||||
curses.curs_set(1) # show cursor during input
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
while True:
|
||||
self.draw_box(top, left, box_h, box_w)
|
||||
# Fill interior
|
||||
interior_attr = self._attr("normal")
|
||||
for r in range(1, box_h - 1):
|
||||
self._safe_addstr(top + r, left + 1,
|
||||
" " * (box_w - 2), interior_attr)
|
||||
|
||||
# Prompt
|
||||
self._safe_addstr(top + 1, left + 2, prompt,
|
||||
self._attr("field_label"))
|
||||
|
||||
# Input field
|
||||
val = "".join(buf)
|
||||
field_text = "[" + pad_right(val, max_len) + "]"
|
||||
self._safe_addstr(top + 2, left + 2, field_text,
|
||||
self._attr("field_active"))
|
||||
|
||||
# Hint
|
||||
hint = "ENTER=Aceptar ESC=Cancelar"
|
||||
hx = left + max((box_w - len(hint)) // 2, 2)
|
||||
self._safe_addstr(top + 3, hx, hint, self._attr("info"))
|
||||
|
||||
self.refresh()
|
||||
|
||||
key = self.get_key()
|
||||
if key == 27: # ESC
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
return None
|
||||
elif key in (10, curses.KEY_ENTER): # ENTER
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
return "".join(buf)
|
||||
elif key in (127, curses.KEY_BACKSPACE, 8): # BACKSPACE
|
||||
if buf:
|
||||
buf.pop()
|
||||
elif 32 <= key <= 126: # printable ASCII
|
||||
if len(buf) < max_len:
|
||||
buf.append(chr(key))
|
||||
0
console/screens/__init__.py
Normal file
0
console/screens/__init__.py
Normal file
302
console/screens/admin_crossref.py
Normal file
302
console/screens/admin_crossref.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Admin CRUD screen for Cross-References in the NEXUS AUTOPARTS console application.
|
||||
|
||||
Provides a paginated list view with create (F3), edit (ENTER), and
|
||||
delete (F8/Del) operations for the part_cross_references table.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate
|
||||
|
||||
|
||||
# Form field definitions for create/edit
|
||||
_FIELDS = [
|
||||
{'label': 'Part ID', 'key': 'part_id', 'width': 8, 'hint': 'F1=Buscar parte'},
|
||||
{'label': 'Numero cruzado', 'key': 'cross_reference_number', 'width': 25},
|
||||
{'label': 'Tipo', 'key': 'reference_type', 'width': 15, 'hint': 'supersession/interchange/competitor'},
|
||||
{'label': 'Fuente', 'key': 'source', 'width': 20},
|
||||
{'label': 'Notas', 'key': 'notes', 'width': 40},
|
||||
]
|
||||
|
||||
# Footer labels per mode
|
||||
_FOOTER_LIST = [
|
||||
("F3", "Nuevo"),
|
||||
("ENTER", "Editar"),
|
||||
("F8", "Eliminar"),
|
||||
("PgUp/Dn", "Paginar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_FORM = [
|
||||
("TAB/Down", "Siguiente"),
|
||||
("Up", "Anterior"),
|
||||
("F9", "Guardar"),
|
||||
("ESC", "Cancelar"),
|
||||
]
|
||||
|
||||
|
||||
class AdminCrossrefScreen(Screen):
|
||||
"""Admin CRUD screen for the part_cross_references table."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="admin_crossref", title="Cross-References")
|
||||
self._mode = 'list' # 'list' or 'form'
|
||||
self._page = 1
|
||||
self._per_page = 15
|
||||
self._selected = 0
|
||||
self._crossrefs = []
|
||||
self._editing_id = None # None = creating, int = editing
|
||||
self._focused_field = 0
|
||||
self._form_data = {}
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_crossrefs(self, db):
|
||||
"""Load the current page of cross-references."""
|
||||
self._crossrefs = db.get_crossrefs_paginated(
|
||||
page=self._page, per_page=self._per_page
|
||||
)
|
||||
|
||||
def _init_form(self, xref=None):
|
||||
"""Initialise form_data from an existing cross-reference or blank."""
|
||||
self._form_data = {}
|
||||
if xref:
|
||||
for f in _FIELDS:
|
||||
val = xref.get(f['key'], '')
|
||||
self._form_data[f['key']] = str(val) if val is not None else ''
|
||||
else:
|
||||
for f in _FIELDS:
|
||||
self._form_data[f['key']] = ''
|
||||
self._focused_field = 0
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" CROSS-REFERENCES ",
|
||||
)
|
||||
|
||||
if self._mode == 'list':
|
||||
self._render_list(db, renderer)
|
||||
else:
|
||||
self._render_form(renderer)
|
||||
|
||||
def _render_list(self, db, renderer):
|
||||
"""Render the paginated cross-references list."""
|
||||
self._load_crossrefs(db)
|
||||
|
||||
headers = ["PARTE OEM", "NUMERO CRUZADO", "TIPO", "FUENTE"]
|
||||
widths = [18, 22, 14, 16]
|
||||
rows = []
|
||||
for x in self._crossrefs:
|
||||
rows.append((
|
||||
truncate(x.get("oem_part_number", ""), 18),
|
||||
truncate(x.get("cross_reference_number", ""), 22),
|
||||
truncate(x.get("reference_type", "") or "", 14),
|
||||
truncate(x.get("source", "") or "", 16),
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
page_info={
|
||||
"page": self._page,
|
||||
"total_pages": self._page,
|
||||
"total_rows": len(rows),
|
||||
},
|
||||
selected_row=self._selected,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_LIST)
|
||||
|
||||
def _render_form(self, renderer):
|
||||
"""Render the create/edit form."""
|
||||
title = "EDITAR CROSS-REFERENCE" if self._editing_id else "NUEVA CROSS-REFERENCE"
|
||||
fields = []
|
||||
for f in _FIELDS:
|
||||
fields.append({
|
||||
'label': f['label'],
|
||||
'value': self._form_data.get(f['key'], ''),
|
||||
'width': f['width'],
|
||||
'hint': f.get('hint', ''),
|
||||
})
|
||||
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
|
||||
renderer.draw_footer(_FOOTER_FORM)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
if self._mode == 'list':
|
||||
return self._handle_list_key(key, db, renderer)
|
||||
else:
|
||||
return self._handle_form_key(key, db, renderer)
|
||||
|
||||
def _handle_list_key(self, key, db, renderer):
|
||||
"""Handle keys in list mode."""
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._crossrefs and self._selected < len(self._crossrefs) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# PgDn: next page
|
||||
if key == Key.PGDN:
|
||||
if len(self._crossrefs) == self._per_page:
|
||||
self._page += 1
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
# PgUp: previous page
|
||||
if key == Key.PGUP:
|
||||
if self._page > 1:
|
||||
self._page -= 1
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
# F3: create new cross-reference
|
||||
if key == Key.F3:
|
||||
self._mode = 'form'
|
||||
self._editing_id = None
|
||||
self._init_form()
|
||||
return None
|
||||
|
||||
# ENTER: edit selected cross-reference
|
||||
if key == Key.ENTER:
|
||||
if self._crossrefs and 0 <= self._selected < len(self._crossrefs):
|
||||
xref = self._crossrefs[self._selected]
|
||||
self._editing_id = xref["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(xref)
|
||||
return None
|
||||
|
||||
# Number keys 1-9: edit cross-reference at that row index
|
||||
if 49 <= key <= 57:
|
||||
idx = key - 49
|
||||
if 0 <= idx < len(self._crossrefs):
|
||||
xref = self._crossrefs[idx]
|
||||
self._editing_id = xref["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(xref)
|
||||
return None
|
||||
|
||||
# F8 or DEL: delete selected cross-reference
|
||||
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
|
||||
if self._crossrefs and 0 <= self._selected < len(self._crossrefs):
|
||||
xref = self._crossrefs[self._selected]
|
||||
oem = xref.get("oem_part_number", "")
|
||||
xnum = xref.get("cross_reference_number", "")
|
||||
confirmed = renderer.show_message(
|
||||
f"Eliminar cross-reference?\n{oem} -> {xnum}",
|
||||
"confirm",
|
||||
)
|
||||
if confirmed:
|
||||
try:
|
||||
db.delete_crossref(xref["id"])
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error:\n{exc}", "error")
|
||||
return None
|
||||
if self._selected >= len(self._crossrefs) - 1:
|
||||
self._selected = max(0, self._selected - 1)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _handle_form_key(self, key, db, renderer):
|
||||
"""Handle keys in form mode."""
|
||||
# ESC: cancel form (with dirty check)
|
||||
if key == Key.ESCAPE:
|
||||
if self._dirty:
|
||||
confirmed = renderer.show_message(
|
||||
"Descartar cambios?", "confirm"
|
||||
)
|
||||
if not confirmed:
|
||||
return None
|
||||
self._mode = 'list'
|
||||
return None
|
||||
|
||||
# TAB / Down: next field
|
||||
if key in (Key.TAB, Key.DOWN):
|
||||
if self._focused_field < len(_FIELDS) - 1:
|
||||
self._focused_field += 1
|
||||
return None
|
||||
|
||||
# Up: previous field
|
||||
if key == Key.UP:
|
||||
if self._focused_field > 0:
|
||||
self._focused_field -= 1
|
||||
return None
|
||||
|
||||
# F9: save
|
||||
if key == Key.F9:
|
||||
return self._save(db, renderer)
|
||||
|
||||
# Backspace: delete last char from current field value
|
||||
if key in (Key.BACKSPACE, 8):
|
||||
field_key = _FIELDS[self._focused_field]['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if val:
|
||||
self._form_data[field_key] = val[:-1]
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
# Printable characters: append to current field
|
||||
if 32 <= key <= 126:
|
||||
field_def = _FIELDS[self._focused_field]
|
||||
field_key = field_def['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if len(val) < field_def['width']:
|
||||
self._form_data[field_key] = val + chr(key)
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _save(self, db, renderer):
|
||||
"""Validate and save the form data."""
|
||||
data = dict(self._form_data)
|
||||
|
||||
# Validate required fields
|
||||
pid = data.get('part_id', '').strip()
|
||||
if not pid or not pid.isdigit():
|
||||
renderer.show_message("Part ID debe ser un numero valido", "error")
|
||||
return None
|
||||
data['part_id'] = int(pid)
|
||||
|
||||
if not data.get('cross_reference_number', '').strip():
|
||||
renderer.show_message("Numero cruzado es requerido", "error")
|
||||
return None
|
||||
|
||||
try:
|
||||
if self._editing_id:
|
||||
db.update_crossref(self._editing_id, data)
|
||||
renderer.show_message("Cross-reference actualizada correctamente", "info")
|
||||
else:
|
||||
db.create_crossref(data)
|
||||
renderer.show_message("Cross-reference creada correctamente", "info")
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error al guardar:\n{exc}", "error")
|
||||
return None
|
||||
|
||||
self._mode = 'list'
|
||||
self._dirty = False
|
||||
return None
|
||||
277
console/screens/admin_fabricantes.py
Normal file
277
console/screens/admin_fabricantes.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Admin CRUD screen for Manufacturers in the NEXUS AUTOPARTS console application.
|
||||
|
||||
Provides a list view with create (F3), edit (ENTER), and delete (F8/Del)
|
||||
operations for the manufacturers table.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate
|
||||
|
||||
|
||||
# Form field definitions for create/edit
|
||||
_FIELDS = [
|
||||
{'label': 'Nombre', 'key': 'name', 'width': 30},
|
||||
{'label': 'Tipo', 'key': 'type', 'width': 15, 'hint': 'oem/aftermarket/remanufactured'},
|
||||
{'label': 'Calidad', 'key': 'quality_tier', 'width': 10, 'hint': 'premium/standard/economy'},
|
||||
{'label': 'Pais', 'key': 'country', 'width': 20},
|
||||
{'label': 'Website', 'key': 'website', 'width': 40},
|
||||
]
|
||||
|
||||
# Footer labels per mode
|
||||
_FOOTER_LIST = [
|
||||
("F3", "Nuevo"),
|
||||
("ENTER", "Editar"),
|
||||
("F8", "Eliminar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_FORM = [
|
||||
("TAB/Down", "Siguiente"),
|
||||
("Up", "Anterior"),
|
||||
("F9", "Guardar"),
|
||||
("ESC", "Cancelar"),
|
||||
]
|
||||
|
||||
|
||||
class AdminFabricantesScreen(Screen):
|
||||
"""Admin CRUD screen for the manufacturers table."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="admin_fabricantes", title="Administracion de Fabricantes")
|
||||
self._mode = 'list' # 'list' or 'form'
|
||||
self._selected = 0
|
||||
self._manufacturers = []
|
||||
self._editing_id = None # None = creating, int = editing
|
||||
self._focused_field = 0
|
||||
self._form_data = {}
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_manufacturers(self, db):
|
||||
"""Load all manufacturers."""
|
||||
self._manufacturers = db.get_manufacturers()
|
||||
|
||||
def _init_form(self, mfr=None):
|
||||
"""Initialise form_data from an existing manufacturer or blank."""
|
||||
self._form_data = {}
|
||||
if mfr:
|
||||
for f in _FIELDS:
|
||||
val = mfr.get(f['key'], '')
|
||||
self._form_data[f['key']] = str(val) if val is not None else ''
|
||||
else:
|
||||
for f in _FIELDS:
|
||||
self._form_data[f['key']] = ''
|
||||
self._focused_field = 0
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" ADMINISTRACION DE FABRICANTES ",
|
||||
)
|
||||
|
||||
if self._mode == 'list':
|
||||
self._render_list(db, renderer)
|
||||
else:
|
||||
self._render_form(renderer)
|
||||
|
||||
def _render_list(self, db, renderer):
|
||||
"""Render the manufacturers list."""
|
||||
self._load_manufacturers(db)
|
||||
|
||||
headers = ["NOMBRE", "TIPO", "CALIDAD", "PAIS", "WEBSITE"]
|
||||
widths = [20, 14, 10, 14, 20]
|
||||
rows = []
|
||||
for m in self._manufacturers:
|
||||
rows.append((
|
||||
truncate(m.get("name", ""), 20),
|
||||
truncate(m.get("type", "") or "", 14),
|
||||
truncate(m.get("quality_tier", "") or "", 10),
|
||||
truncate(m.get("country", "") or "", 14),
|
||||
truncate(m.get("website", "") or "", 20),
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
page_info={
|
||||
"page": 1,
|
||||
"total_pages": 1,
|
||||
"total_rows": len(rows),
|
||||
},
|
||||
selected_row=self._selected,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_LIST)
|
||||
|
||||
def _render_form(self, renderer):
|
||||
"""Render the create/edit form."""
|
||||
title = "EDITAR FABRICANTE" if self._editing_id else "NUEVO FABRICANTE"
|
||||
fields = []
|
||||
for f in _FIELDS:
|
||||
fields.append({
|
||||
'label': f['label'],
|
||||
'value': self._form_data.get(f['key'], ''),
|
||||
'width': f['width'],
|
||||
'hint': f.get('hint', ''),
|
||||
})
|
||||
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
|
||||
renderer.draw_footer(_FOOTER_FORM)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
if self._mode == 'list':
|
||||
return self._handle_list_key(key, db, renderer)
|
||||
else:
|
||||
return self._handle_form_key(key, db, renderer)
|
||||
|
||||
def _handle_list_key(self, key, db, renderer):
|
||||
"""Handle keys in list mode."""
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._manufacturers and self._selected < len(self._manufacturers) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# F3: create new manufacturer
|
||||
if key == Key.F3:
|
||||
self._mode = 'form'
|
||||
self._editing_id = None
|
||||
self._init_form()
|
||||
return None
|
||||
|
||||
# ENTER: edit selected manufacturer
|
||||
if key == Key.ENTER:
|
||||
if self._manufacturers and 0 <= self._selected < len(self._manufacturers):
|
||||
mfr = self._manufacturers[self._selected]
|
||||
self._editing_id = mfr["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(mfr)
|
||||
return None
|
||||
|
||||
# Number keys 1-9: edit manufacturer at that row index
|
||||
if 49 <= key <= 57:
|
||||
idx = key - 49
|
||||
if 0 <= idx < len(self._manufacturers):
|
||||
mfr = self._manufacturers[idx]
|
||||
self._editing_id = mfr["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(mfr)
|
||||
return None
|
||||
|
||||
# F8 or DEL: delete selected manufacturer
|
||||
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
|
||||
if self._manufacturers and 0 <= self._selected < len(self._manufacturers):
|
||||
mfr = self._manufacturers[self._selected]
|
||||
name = mfr.get("name", "")
|
||||
confirmed = renderer.show_message(
|
||||
f"Eliminar fabricante?\n{name}",
|
||||
"confirm",
|
||||
)
|
||||
if confirmed:
|
||||
try:
|
||||
db.delete_manufacturer(mfr["id"])
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error:\n{exc}", "error")
|
||||
return None
|
||||
if self._selected >= len(self._manufacturers) - 1:
|
||||
self._selected = max(0, self._selected - 1)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _handle_form_key(self, key, db, renderer):
|
||||
"""Handle keys in form mode."""
|
||||
# ESC: cancel form (with dirty check)
|
||||
if key == Key.ESCAPE:
|
||||
if self._dirty:
|
||||
confirmed = renderer.show_message(
|
||||
"Descartar cambios?", "confirm"
|
||||
)
|
||||
if not confirmed:
|
||||
return None
|
||||
self._mode = 'list'
|
||||
return None
|
||||
|
||||
# TAB / Down: next field
|
||||
if key in (Key.TAB, Key.DOWN):
|
||||
if self._focused_field < len(_FIELDS) - 1:
|
||||
self._focused_field += 1
|
||||
return None
|
||||
|
||||
# Up: previous field
|
||||
if key == Key.UP:
|
||||
if self._focused_field > 0:
|
||||
self._focused_field -= 1
|
||||
return None
|
||||
|
||||
# F9: save
|
||||
if key == Key.F9:
|
||||
return self._save(db, renderer)
|
||||
|
||||
# Backspace: delete last char from current field value
|
||||
if key in (Key.BACKSPACE, 8):
|
||||
field_key = _FIELDS[self._focused_field]['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if val:
|
||||
self._form_data[field_key] = val[:-1]
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
# Printable characters: append to current field
|
||||
if 32 <= key <= 126:
|
||||
field_def = _FIELDS[self._focused_field]
|
||||
field_key = field_def['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if len(val) < field_def['width']:
|
||||
self._form_data[field_key] = val + chr(key)
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _save(self, db, renderer):
|
||||
"""Validate and save the form data."""
|
||||
data = dict(self._form_data)
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('name', '').strip():
|
||||
renderer.show_message("Nombre es requerido", "error")
|
||||
return None
|
||||
|
||||
try:
|
||||
if self._editing_id:
|
||||
db.update_manufacturer(self._editing_id, data)
|
||||
renderer.show_message("Fabricante actualizado correctamente", "info")
|
||||
else:
|
||||
db.create_manufacturer(data)
|
||||
renderer.show_message("Fabricante creado correctamente", "info")
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error al guardar:\n{exc}", "error")
|
||||
return None
|
||||
|
||||
self._mode = 'list'
|
||||
self._dirty = False
|
||||
return None
|
||||
325
console/screens/admin_import.py
Normal file
325
console/screens/admin_import.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Import/Export screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
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
|
||||
show_message dialogs for all user interaction.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
|
||||
|
||||
# Import type mapping: menu choice -> (label, table hint)
|
||||
_IMPORT_TYPES = {
|
||||
'1': ('Categorias', 'categories'),
|
||||
'2': ('Grupos', 'groups'),
|
||||
'3': ('Partes', 'parts'),
|
||||
'4': ('Fabricantes', 'manufacturers'),
|
||||
'5': ('Aftermarket', 'aftermarket'),
|
||||
'6': ('CrossRef', 'crossref'),
|
||||
'7': ('Fitment', 'fitment'),
|
||||
}
|
||||
|
||||
# Export type mapping
|
||||
_EXPORT_TYPES = {
|
||||
'1': ('Categorias', 'categories'),
|
||||
'2': ('Grupos', 'groups'),
|
||||
'3': ('Partes', 'parts'),
|
||||
'4': ('Fabricantes', 'manufacturers'),
|
||||
'5': ('Cross-References', 'crossref'),
|
||||
}
|
||||
|
||||
# Footer for the main menu
|
||||
_FOOTER_MENU = [
|
||||
("1-3", "Seleccionar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class AdminImportScreen(Screen):
|
||||
"""Import/Export data screen with simple menu flow."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="admin_import", title="Importar / Exportar Datos")
|
||||
self._selected = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" IMPORTAR / EXPORTAR DATOS ",
|
||||
)
|
||||
|
||||
menu_items = [
|
||||
("1", "Importar CSV"),
|
||||
("2", "Exportar datos a JSON"),
|
||||
("3", "Volver"),
|
||||
]
|
||||
|
||||
renderer.draw_menu(
|
||||
menu_items,
|
||||
selected_index=self._selected,
|
||||
title="IMPORTAR / EXPORTAR DATOS",
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_MENU)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# ESC or '3': go back
|
||||
if key == Key.ESCAPE or key == ord('3'):
|
||||
return "back"
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._selected < 2:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# ENTER: activate selected
|
||||
if key == Key.ENTER:
|
||||
if self._selected == 0:
|
||||
self._do_import(db, renderer)
|
||||
elif self._selected == 1:
|
||||
self._do_export(db, renderer)
|
||||
else:
|
||||
return "back"
|
||||
return None
|
||||
|
||||
# Direct number keys
|
||||
if key == ord('1'):
|
||||
self._do_import(db, renderer)
|
||||
return None
|
||||
|
||||
if key == ord('2'):
|
||||
self._do_export(db, renderer)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Import flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _do_import(self, db, renderer):
|
||||
"""Run the CSV import flow using dialogs."""
|
||||
# Ask for import type
|
||||
type_prompt = (
|
||||
"Tipo de datos:\n"
|
||||
"1=Categorias 2=Grupos 3=Partes\n"
|
||||
"4=Fabricantes 5=Aftermarket\n"
|
||||
"6=CrossRef 7=Fitment"
|
||||
)
|
||||
renderer.show_message(type_prompt, "info")
|
||||
type_choice = renderer.show_input("Tipo (1-7)", max_len=1)
|
||||
if type_choice is None or type_choice not in _IMPORT_TYPES:
|
||||
renderer.show_message("Tipo no valido o cancelado", "error")
|
||||
return
|
||||
|
||||
type_label, type_key = _IMPORT_TYPES[type_choice]
|
||||
|
||||
# Ask for file path
|
||||
file_path = renderer.show_input("Ruta del archivo CSV", max_len=60)
|
||||
if file_path is None or not file_path.strip():
|
||||
renderer.show_message("Importacion cancelada", "info")
|
||||
return
|
||||
|
||||
file_path = file_path.strip()
|
||||
if not os.path.isfile(file_path):
|
||||
renderer.show_message(f"Archivo no encontrado:\n{file_path}", "error")
|
||||
return
|
||||
|
||||
# Confirm
|
||||
confirmed = renderer.show_message(
|
||||
f"Importar {type_label} desde:\n{file_path}",
|
||||
"confirm",
|
||||
)
|
||||
if not confirmed:
|
||||
return
|
||||
|
||||
# Process the CSV
|
||||
try:
|
||||
count = self._process_csv(db, type_key, file_path)
|
||||
renderer.show_message(
|
||||
f"Importacion completada\n{count} registros procesados",
|
||||
"info",
|
||||
)
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error en importacion:\n{exc}", "error")
|
||||
|
||||
def _process_csv(self, db, type_key, file_path):
|
||||
"""Read a CSV file and insert records into the database.
|
||||
|
||||
Returns the number of records processed.
|
||||
"""
|
||||
count = 0
|
||||
with open(file_path, newline='', encoding='utf-8') as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
for row in reader:
|
||||
self._insert_row(db, type_key, row)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _insert_row(self, db, type_key, row):
|
||||
"""Insert a single CSV row into the appropriate table."""
|
||||
if type_key == 'categories':
|
||||
db._execute(
|
||||
"INSERT INTO part_categories (name, name_es, slug, icon_name, display_order) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
row.get('name', ''),
|
||||
row.get('name_es', ''),
|
||||
row.get('slug', ''),
|
||||
row.get('icon_name', ''),
|
||||
int(row.get('display_order', 0) or 0),
|
||||
),
|
||||
)
|
||||
elif type_key == 'groups':
|
||||
db._execute(
|
||||
"INSERT INTO part_groups (category_id, name, name_es, slug, display_order) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
int(row.get('category_id', 0) or 0),
|
||||
row.get('name', ''),
|
||||
row.get('name_es', ''),
|
||||
row.get('slug', ''),
|
||||
int(row.get('display_order', 0) or 0),
|
||||
),
|
||||
)
|
||||
elif type_key == 'parts':
|
||||
db.create_part({
|
||||
'oem_part_number': row.get('oem_part_number', ''),
|
||||
'name': row.get('name', ''),
|
||||
'name_es': row.get('name_es', ''),
|
||||
'group_id': int(row['group_id']) if row.get('group_id') else None,
|
||||
'description': row.get('description', ''),
|
||||
'description_es': row.get('description_es', ''),
|
||||
'weight_kg': float(row['weight_kg']) if row.get('weight_kg') else None,
|
||||
'material': row.get('material', ''),
|
||||
})
|
||||
elif type_key == 'manufacturers':
|
||||
db.create_manufacturer({
|
||||
'name': row.get('name', ''),
|
||||
'type': row.get('type', ''),
|
||||
'quality_tier': row.get('quality_tier', ''),
|
||||
'country': row.get('country', ''),
|
||||
'logo_url': row.get('logo_url', ''),
|
||||
'website': row.get('website', ''),
|
||||
})
|
||||
elif type_key == 'aftermarket':
|
||||
db._execute(
|
||||
"INSERT INTO aftermarket_parts "
|
||||
"(oem_part_id, manufacturer_id, part_number, name, name_es, "
|
||||
" quality_tier, price_usd, warranty_months, in_stock) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
int(row.get('oem_part_id', 0) or 0),
|
||||
int(row.get('manufacturer_id', 0) or 0),
|
||||
row.get('part_number', ''),
|
||||
row.get('name', ''),
|
||||
row.get('name_es', ''),
|
||||
row.get('quality_tier', ''),
|
||||
float(row['price_usd']) if row.get('price_usd') else None,
|
||||
int(row['warranty_months']) if row.get('warranty_months') else None,
|
||||
int(row.get('in_stock', 1) or 1),
|
||||
),
|
||||
)
|
||||
elif type_key == 'crossref':
|
||||
db.create_crossref({
|
||||
'part_id': int(row.get('part_id', 0) or 0),
|
||||
'cross_reference_number': row.get('cross_reference_number', ''),
|
||||
'reference_type': row.get('reference_type', ''),
|
||||
'source': row.get('source', ''),
|
||||
'notes': row.get('notes', ''),
|
||||
})
|
||||
elif type_key == 'fitment':
|
||||
db._execute(
|
||||
"INSERT INTO vehicle_parts "
|
||||
"(part_id, model_year_engine_id, quantity_required, position, fitment_notes) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
int(row.get('part_id', 0) or 0),
|
||||
int(row.get('model_year_engine_id', 0) or 0),
|
||||
int(row.get('quantity_required', 1) or 1),
|
||||
row.get('position', ''),
|
||||
row.get('fitment_notes', ''),
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _do_export(self, db, renderer):
|
||||
"""Run the JSON export flow using dialogs."""
|
||||
# Ask for export type
|
||||
type_prompt = (
|
||||
"Tipo de datos:\n"
|
||||
"1=Categorias 2=Grupos 3=Partes\n"
|
||||
"4=Fabricantes 5=CrossRef"
|
||||
)
|
||||
renderer.show_message(type_prompt, "info")
|
||||
type_choice = renderer.show_input("Tipo (1-5)", max_len=1)
|
||||
if type_choice is None or type_choice not in _EXPORT_TYPES:
|
||||
renderer.show_message("Tipo no valido o cancelado", "error")
|
||||
return
|
||||
|
||||
type_label, type_key = _EXPORT_TYPES[type_choice]
|
||||
|
||||
# Ask for output path
|
||||
default_name = f"{type_key}_export.json"
|
||||
out_path = renderer.show_input(
|
||||
f"Archivo de salida [{default_name}]", max_len=60
|
||||
)
|
||||
if out_path is None:
|
||||
renderer.show_message("Exportacion cancelada", "info")
|
||||
return
|
||||
if not out_path.strip():
|
||||
out_path = default_name
|
||||
|
||||
# Fetch data and write
|
||||
try:
|
||||
data = self._fetch_export_data(db, type_key)
|
||||
with open(out_path.strip(), 'w', encoding='utf-8') as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
renderer.show_message(
|
||||
f"Exportacion completada\n{len(data)} registros -> {out_path.strip()}",
|
||||
"info",
|
||||
)
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error en exportacion:\n{exc}", "error")
|
||||
|
||||
def _fetch_export_data(self, db, type_key):
|
||||
"""Fetch the data to export based on the type key."""
|
||||
if type_key == 'categories':
|
||||
return db.get_categories()
|
||||
elif type_key == 'groups':
|
||||
# Export all groups across all categories
|
||||
categories = db.get_categories()
|
||||
groups = []
|
||||
for cat in categories:
|
||||
groups.extend(db.get_groups(cat['id']))
|
||||
return groups
|
||||
elif type_key == 'parts':
|
||||
return db.get_parts(page=1, per_page=100)
|
||||
elif type_key == 'manufacturers':
|
||||
return db.get_manufacturers()
|
||||
elif type_key == 'crossref':
|
||||
return db.get_crossrefs_paginated(page=1, per_page=100)
|
||||
return []
|
||||
321
console/screens/admin_partes.py
Normal file
321
console/screens/admin_partes.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Admin CRUD screen for Parts in the NEXUS AUTOPARTS console application.
|
||||
|
||||
Provides a paginated list view with create (F3), edit (ENTER), and
|
||||
delete (F8/Del) operations. Form editing is handled inline with
|
||||
field-by-field navigation.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate, pad_right
|
||||
|
||||
|
||||
# Form field definitions for create/edit
|
||||
_FIELDS = [
|
||||
{'label': 'Numero OEM', 'key': 'oem_part_number', 'width': 20},
|
||||
{'label': 'Nombre', 'key': 'name', 'width': 40},
|
||||
{'label': 'Nombre (ES)', 'key': 'name_es', 'width': 40},
|
||||
{'label': 'Grupo ID', 'key': 'group_id', 'width': 5, 'hint': 'F1=Lista'},
|
||||
{'label': 'Descripcion', 'key': 'description', 'width': 50},
|
||||
{'label': 'Descripcion ES', 'key': 'description_es', 'width': 50},
|
||||
{'label': 'Material', 'key': 'material', 'width': 20},
|
||||
{'label': 'Peso (kg)', 'key': 'weight_kg', 'width': 8},
|
||||
{'label': 'Descontinuada', 'key': 'is_discontinued', 'width': 1, 'hint': 'S/N'},
|
||||
]
|
||||
|
||||
# Footer labels per mode
|
||||
_FOOTER_LIST = [
|
||||
("F3", "Nuevo"),
|
||||
("ENTER", "Editar"),
|
||||
("F8", "Eliminar"),
|
||||
("PgUp/Dn", "Paginar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_FORM = [
|
||||
("TAB/Down", "Siguiente"),
|
||||
("Up", "Anterior"),
|
||||
("F9", "Guardar"),
|
||||
("ESC", "Cancelar"),
|
||||
]
|
||||
|
||||
|
||||
class AdminPartesScreen(Screen):
|
||||
"""Admin CRUD screen for the parts table."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="admin_partes", title="Administracion de Partes")
|
||||
self._mode = 'list' # 'list' or 'form'
|
||||
self._page = 1
|
||||
self._per_page = 15
|
||||
self._selected = 0
|
||||
self._parts = []
|
||||
self._editing_id = None # None = creating, int = editing
|
||||
self._focused_field = 0
|
||||
self._form_data = {}
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_parts(self, db):
|
||||
"""Load the current page of parts."""
|
||||
self._parts = db.get_parts(page=self._page, per_page=self._per_page)
|
||||
|
||||
def _init_form(self, part=None):
|
||||
"""Initialise form_data from an existing part or blank."""
|
||||
self._form_data = {}
|
||||
if part:
|
||||
for f in _FIELDS:
|
||||
key = f['key']
|
||||
val = part.get(key, '')
|
||||
if key == 'is_discontinued':
|
||||
val = 'S' if val else 'N'
|
||||
self._form_data[key] = str(val) if val is not None else ''
|
||||
else:
|
||||
for f in _FIELDS:
|
||||
self._form_data[f['key']] = ''
|
||||
self._focused_field = 0
|
||||
self._dirty = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" ADMINISTRACION DE PARTES ",
|
||||
)
|
||||
|
||||
if self._mode == 'list':
|
||||
self._render_list(db, renderer)
|
||||
else:
|
||||
self._render_form(renderer)
|
||||
|
||||
def _render_list(self, db, renderer):
|
||||
"""Render the paginated parts list."""
|
||||
self._load_parts(db)
|
||||
|
||||
headers = ["NUMERO OEM", "NOMBRE", "GRUPO", "MATERIAL", "DISCONT"]
|
||||
widths = [18, 25, 15, 12, 7]
|
||||
rows = []
|
||||
for p in self._parts:
|
||||
disc = "Si" if p.get("is_discontinued") else ""
|
||||
rows.append((
|
||||
truncate(p.get("oem_part_number", ""), 18),
|
||||
truncate(p.get("name_es") or p.get("name", ""), 25),
|
||||
truncate(p.get("group_name", ""), 15),
|
||||
truncate(p.get("material", "") or "", 12),
|
||||
disc,
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
page_info={
|
||||
"page": self._page,
|
||||
"total_pages": self._page,
|
||||
"total_rows": len(rows),
|
||||
},
|
||||
selected_row=self._selected,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_LIST)
|
||||
|
||||
def _render_form(self, renderer):
|
||||
"""Render the create/edit form."""
|
||||
title = "EDITAR PARTE" if self._editing_id else "NUEVA PARTE"
|
||||
fields = []
|
||||
for f in _FIELDS:
|
||||
fields.append({
|
||||
'label': f['label'],
|
||||
'value': self._form_data.get(f['key'], ''),
|
||||
'width': f['width'],
|
||||
'hint': f.get('hint', ''),
|
||||
})
|
||||
renderer.draw_form(fields, focused_index=self._focused_field, title=title)
|
||||
renderer.draw_footer(_FOOTER_FORM)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
if self._mode == 'list':
|
||||
return self._handle_list_key(key, context, db, renderer)
|
||||
else:
|
||||
return self._handle_form_key(key, db, renderer)
|
||||
|
||||
def _handle_list_key(self, key, context, db, renderer):
|
||||
"""Handle keys in list mode."""
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._parts and self._selected < len(self._parts) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# PgDn: next page
|
||||
if key == Key.PGDN:
|
||||
if len(self._parts) == self._per_page:
|
||||
self._page += 1
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
# PgUp: previous page
|
||||
if key == Key.PGUP:
|
||||
if self._page > 1:
|
||||
self._page -= 1
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
# F3: create new part
|
||||
if key == Key.F3:
|
||||
self._mode = 'form'
|
||||
self._editing_id = None
|
||||
self._init_form()
|
||||
return None
|
||||
|
||||
# ENTER: edit selected part
|
||||
if key == Key.ENTER:
|
||||
if self._parts and 0 <= self._selected < len(self._parts):
|
||||
part_row = self._parts[self._selected]
|
||||
part = db.get_part(part_row["id"])
|
||||
if part:
|
||||
self._editing_id = part["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(part)
|
||||
return None
|
||||
|
||||
# Number keys 1-9: edit part at that row index
|
||||
if 49 <= key <= 57:
|
||||
idx = key - 49
|
||||
if 0 <= idx < len(self._parts):
|
||||
part_row = self._parts[idx]
|
||||
part = db.get_part(part_row["id"])
|
||||
if part:
|
||||
self._editing_id = part["id"]
|
||||
self._mode = 'form'
|
||||
self._init_form(part)
|
||||
return None
|
||||
|
||||
# F8 or DEL: delete selected part
|
||||
if key in (Key.F8, 330): # 330 = KEY_DC (Delete)
|
||||
if self._parts and 0 <= self._selected < len(self._parts):
|
||||
part = self._parts[self._selected]
|
||||
name = part.get("name_es") or part.get("name", "")
|
||||
oem = part.get("oem_part_number", "")
|
||||
confirmed = renderer.show_message(
|
||||
f"Eliminar parte?\n{oem} - {name}",
|
||||
"confirm",
|
||||
)
|
||||
if confirmed:
|
||||
db.delete_part(part["id"])
|
||||
# Adjust selection
|
||||
if self._selected >= len(self._parts) - 1:
|
||||
self._selected = max(0, self._selected - 1)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _handle_form_key(self, key, db, renderer):
|
||||
"""Handle keys in form mode."""
|
||||
# ESC: cancel form (with dirty check)
|
||||
if key == Key.ESCAPE:
|
||||
if self._dirty:
|
||||
confirmed = renderer.show_message(
|
||||
"Descartar cambios?", "confirm"
|
||||
)
|
||||
if not confirmed:
|
||||
return None
|
||||
self._mode = 'list'
|
||||
return None
|
||||
|
||||
# TAB / Down: next field
|
||||
if key in (Key.TAB, Key.DOWN):
|
||||
if self._focused_field < len(_FIELDS) - 1:
|
||||
self._focused_field += 1
|
||||
return None
|
||||
|
||||
# Up: previous field
|
||||
if key == Key.UP:
|
||||
if self._focused_field > 0:
|
||||
self._focused_field -= 1
|
||||
return None
|
||||
|
||||
# F9: save
|
||||
if key == Key.F9:
|
||||
return self._save(db, renderer)
|
||||
|
||||
# Backspace: delete last char from current field value
|
||||
if key in (Key.BACKSPACE, 8):
|
||||
field_key = _FIELDS[self._focused_field]['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if val:
|
||||
self._form_data[field_key] = val[:-1]
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
# Printable characters: append to current field
|
||||
if 32 <= key <= 126:
|
||||
field_def = _FIELDS[self._focused_field]
|
||||
field_key = field_def['key']
|
||||
val = self._form_data.get(field_key, '')
|
||||
if len(val) < field_def['width']:
|
||||
self._form_data[field_key] = val + chr(key)
|
||||
self._dirty = True
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _save(self, db, renderer):
|
||||
"""Validate and save the form data."""
|
||||
data = dict(self._form_data)
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('oem_part_number', '').strip():
|
||||
renderer.show_message("Numero OEM es requerido", "error")
|
||||
return None
|
||||
if not data.get('name', '').strip():
|
||||
renderer.show_message("Nombre es requerido", "error")
|
||||
return None
|
||||
|
||||
# Convert types
|
||||
gid = data.get('group_id', '').strip()
|
||||
data['group_id'] = int(gid) if gid.isdigit() else None
|
||||
|
||||
wkg = data.get('weight_kg', '').strip()
|
||||
try:
|
||||
data['weight_kg'] = float(wkg) if wkg else None
|
||||
except ValueError:
|
||||
data['weight_kg'] = None
|
||||
|
||||
disc = data.get('is_discontinued', '').strip().upper()
|
||||
data['is_discontinued'] = 1 if disc == 'S' else 0
|
||||
|
||||
try:
|
||||
if self._editing_id:
|
||||
db.update_part(self._editing_id, data)
|
||||
renderer.show_message("Parte actualizada correctamente", "info")
|
||||
else:
|
||||
db.create_part(data)
|
||||
renderer.show_message("Parte creada correctamente", "info")
|
||||
except Exception as exc:
|
||||
renderer.show_message(f"Error al guardar:\n{exc}", "error")
|
||||
return None
|
||||
|
||||
self._mode = 'list'
|
||||
self._dirty = False
|
||||
return None
|
||||
153
console/screens/buscar_parte.py
Normal file
153
console/screens/buscar_parte.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Part number search screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Prompts the user for a part number (OEM, aftermarket, or cross-reference)
|
||||
and displays matching results in a table. Selecting a result navigates
|
||||
to the part detail screen.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate
|
||||
|
||||
|
||||
# Match type labels in Spanish
|
||||
_TYPE_LABELS = {
|
||||
"oem": "OEM",
|
||||
"aftermarket": "Aftermarket",
|
||||
"cross_reference": "X-Ref",
|
||||
}
|
||||
|
||||
# Footer labels
|
||||
_FOOTER_INPUT = [
|
||||
("ENTER", "Buscar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_RESULTS = [
|
||||
("1-9", "Ver parte"),
|
||||
("ENTER", "Ver parte"),
|
||||
("F3", "Nueva busqueda"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class BuscarParteScreen(Screen):
|
||||
"""Search by part number (OEM, aftermarket, cross-reference)."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="buscar_parte", title="Buscar por Numero de Parte")
|
||||
self._results = None
|
||||
self._search_term = None
|
||||
self._selected = 0
|
||||
self._needs_input = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" BUSCAR POR NUMERO DE PARTE ",
|
||||
)
|
||||
|
||||
if self._needs_input:
|
||||
# Show the input dialog (handled in on_key via on_enter-like flow)
|
||||
# Just draw footer; the input dialog will overlay
|
||||
renderer.draw_footer(_FOOTER_INPUT)
|
||||
return
|
||||
|
||||
if self._results is None:
|
||||
renderer.draw_text(5, 4, "Presione F3 para buscar", "info")
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
return
|
||||
|
||||
if not self._results:
|
||||
renderer.draw_text(
|
||||
5, 4,
|
||||
f'No se encontraron resultados para "{self._search_term}"',
|
||||
"info",
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
return
|
||||
|
||||
# Display results table
|
||||
headers = ["TIPO", "NUMERO", "DESCRIPCION", "FUENTE"]
|
||||
widths = [12, 20, 30, 20]
|
||||
rows = []
|
||||
for r in self._results:
|
||||
rows.append((
|
||||
_TYPE_LABELS.get(r.get("match_type", ""), r.get("match_type", "")),
|
||||
truncate(r.get("matched_number", ""), 20),
|
||||
truncate(r.get("name_es") or r.get("name", ""), 30),
|
||||
truncate(r.get("oem_part_number", ""), 20),
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
selected_row=self._selected,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# If we need input, show the input dialog
|
||||
if self._needs_input:
|
||||
self._needs_input = False
|
||||
value = renderer.show_input("Numero de parte", max_len=30)
|
||||
if value is None:
|
||||
# User pressed ESC in input dialog
|
||||
if self._results is not None:
|
||||
# Go back to results view
|
||||
return None
|
||||
return "back"
|
||||
if value.strip():
|
||||
self._search_term = value.strip()
|
||||
self._results = db.search_part_number(self._search_term)
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# F3: new search
|
||||
if key == Key.F3:
|
||||
self._needs_input = True
|
||||
return None
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._results and self._selected < len(self._results) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# ENTER: view selected part
|
||||
if key == Key.ENTER:
|
||||
if self._results and 0 <= self._selected < len(self._results):
|
||||
part = self._results[self._selected]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
# Number keys: direct selection (1-9)
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if self._results and 0 <= idx < len(self._results):
|
||||
part = self._results[idx]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
return None
|
||||
174
console/screens/buscar_texto.py
Normal file
174
console/screens/buscar_texto.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Full-text search screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Prompts the user for a search query and displays matching parts using
|
||||
the FTS5 full-text search engine (with LIKE fallback). Results are
|
||||
paginated and selecting a row navigates to the part detail screen.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate
|
||||
|
||||
|
||||
# Footer labels
|
||||
_FOOTER_INPUT = [
|
||||
("ENTER", "Buscar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_RESULTS = [
|
||||
("1-9", "Ver parte"),
|
||||
("ENTER", "Ver parte"),
|
||||
("PgUp/Dn", "Paginar"),
|
||||
("F3", "Nueva busqueda"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class BuscarTextoScreen(Screen):
|
||||
"""Full-text search by description / name."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="buscar_texto", title="Buscar por Descripcion")
|
||||
self._results = None
|
||||
self._search_term = None
|
||||
self._selected = 0
|
||||
self._page = 1
|
||||
self._per_page = 15
|
||||
self._needs_input = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" BUSCAR POR DESCRIPCION ",
|
||||
)
|
||||
|
||||
if self._needs_input:
|
||||
renderer.draw_footer(_FOOTER_INPUT)
|
||||
return
|
||||
|
||||
if self._results is None:
|
||||
renderer.draw_text(5, 4, "Presione F3 para buscar", "info")
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
return
|
||||
|
||||
if not self._results:
|
||||
renderer.draw_text(
|
||||
5, 4,
|
||||
f'No se encontraron resultados para "{self._search_term}"',
|
||||
"info",
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
return
|
||||
|
||||
# Display results table
|
||||
headers = ["NUMERO OEM", "NOMBRE", "CATEGORIA", "GRUPO"]
|
||||
widths = [18, 28, 18, 18]
|
||||
rows = []
|
||||
for r in self._results:
|
||||
rows.append((
|
||||
truncate(r.get("oem_part_number", ""), 18),
|
||||
truncate(r.get("name_es") or r.get("name", ""), 28),
|
||||
truncate(r.get("category_name", ""), 18),
|
||||
truncate(r.get("group_name", ""), 18),
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
page_info={
|
||||
"page": self._page,
|
||||
"total_pages": self._page,
|
||||
"total_rows": len(rows),
|
||||
},
|
||||
selected_row=self._selected,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_RESULTS)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _do_search(self, db):
|
||||
"""Execute the full-text search with current parameters."""
|
||||
self._results = db.search_parts(
|
||||
self._search_term,
|
||||
page=self._page,
|
||||
per_page=self._per_page,
|
||||
)
|
||||
self._selected = 0
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# If we need input, show the input dialog
|
||||
if self._needs_input:
|
||||
self._needs_input = False
|
||||
value = renderer.show_input("Buscar", max_len=40)
|
||||
if value is None:
|
||||
# User pressed ESC in input dialog
|
||||
if self._results is not None:
|
||||
return None
|
||||
return "back"
|
||||
if value.strip():
|
||||
self._search_term = value.strip()
|
||||
self._page = 1
|
||||
self._do_search(db)
|
||||
return None
|
||||
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# F3: new search
|
||||
if key == Key.F3:
|
||||
self._needs_input = True
|
||||
return None
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._results and self._selected < len(self._results) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# ENTER: view selected part
|
||||
if key == Key.ENTER:
|
||||
if self._results and 0 <= self._selected < len(self._results):
|
||||
part = self._results[self._selected]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
# Number keys: direct selection (1-9)
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if self._results and 0 <= idx < len(self._results):
|
||||
part = self._results[idx]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
# PgDn: next page
|
||||
if key == Key.PGDN:
|
||||
if self._results and len(self._results) >= self._per_page:
|
||||
self._page += 1
|
||||
self._do_search(db)
|
||||
return None
|
||||
|
||||
# PgUp: previous page
|
||||
if key == Key.PGUP:
|
||||
if self._page > 1:
|
||||
self._page -= 1
|
||||
self._do_search(db)
|
||||
return None
|
||||
|
||||
return None
|
||||
354
console/screens/catalogo.py
Normal file
354
console/screens/catalogo.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Catalog navigation screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Provides a three-level drill-down through the parts hierarchy:
|
||||
Categories -> Groups -> Parts. An optional vehicle filter (mye_id)
|
||||
restricts the parts list to those that fit a specific vehicle
|
||||
configuration.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import truncate
|
||||
|
||||
|
||||
# Footer labels for each navigation level
|
||||
_FOOTER_CATEGORIES = [
|
||||
("1-9", "Seleccionar"),
|
||||
("Filtro", "Teclear"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_GROUPS = [
|
||||
("1-9", "Seleccionar"),
|
||||
("Filtro", "Teclear"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_PARTS = [
|
||||
("1-9", "Ver parte"),
|
||||
("ENTER", "Ver parte"),
|
||||
("PgUp/Dn", "Paginar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class CatalogoScreen(Screen):
|
||||
"""Hierarchical catalog browser: Categories -> Groups -> Parts."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="catalogo", title="Catalogo")
|
||||
self._filter_text = ""
|
||||
self._selected = 0
|
||||
self._items = []
|
||||
self._parts_data = []
|
||||
self._selected_part = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_defaults(self, context):
|
||||
"""Set default context values if missing."""
|
||||
context.setdefault("level", "categories")
|
||||
context.setdefault("mye_id", None)
|
||||
context.setdefault("brand", "")
|
||||
context.setdefault("model", "")
|
||||
context.setdefault("year", "")
|
||||
context.setdefault("engine", "")
|
||||
context.setdefault("category_id", None)
|
||||
context.setdefault("category_name", "")
|
||||
context.setdefault("group_id", None)
|
||||
context.setdefault("group_name", "")
|
||||
context.setdefault("page", 1)
|
||||
context.setdefault("per_page", 15)
|
||||
|
||||
def _build_header_title(self, context):
|
||||
"""Build the header title based on context."""
|
||||
level = context["level"]
|
||||
parts = []
|
||||
|
||||
# Vehicle info if available
|
||||
if context.get("brand"):
|
||||
vehicle = " ".join(
|
||||
filter(None, [
|
||||
context["brand"],
|
||||
context["model"],
|
||||
str(context["year"]) if context["year"] else "",
|
||||
])
|
||||
)
|
||||
parts.append(vehicle)
|
||||
|
||||
if level == "categories":
|
||||
parts.append("Categorias")
|
||||
elif level == "groups":
|
||||
parts.append(context.get("category_name", "Grupos"))
|
||||
elif level == "parts":
|
||||
cat = context.get("category_name", "")
|
||||
grp = context.get("group_name", "")
|
||||
if cat and grp:
|
||||
parts.append(f"{cat} > {grp}")
|
||||
elif grp:
|
||||
parts.append(grp)
|
||||
|
||||
return " — ".join(parts) if parts else "CATALOGO DE CATEGORIAS"
|
||||
|
||||
def _load_categories(self, db):
|
||||
"""Load and filter categories."""
|
||||
categories = db.get_categories()
|
||||
if self._filter_text:
|
||||
ft = self._filter_text.upper()
|
||||
categories = [
|
||||
c for c in categories
|
||||
if ft in (c.get("name_es") or c.get("name") or "").upper()
|
||||
or ft in (c.get("name") or "").upper()
|
||||
]
|
||||
self._items = [
|
||||
(str(i + 1), c.get("name_es") or c.get("name", ""), c["id"])
|
||||
for i, c in enumerate(categories)
|
||||
]
|
||||
|
||||
def _load_groups(self, db, category_id):
|
||||
"""Load and filter groups for a category."""
|
||||
groups = db.get_groups(category_id)
|
||||
if self._filter_text:
|
||||
ft = self._filter_text.upper()
|
||||
groups = [
|
||||
g for g in groups
|
||||
if ft in (g.get("name_es") or g.get("name") or "").upper()
|
||||
or ft in (g.get("name") or "").upper()
|
||||
]
|
||||
self._items = [
|
||||
(str(i + 1), g.get("name_es") or g.get("name", ""), g["id"])
|
||||
for i, g in enumerate(groups)
|
||||
]
|
||||
|
||||
def _load_parts(self, db, context):
|
||||
"""Load parts for the current group/vehicle with pagination."""
|
||||
self._parts_data = db.get_parts(
|
||||
group_id=context.get("group_id"),
|
||||
mye_id=context.get("mye_id"),
|
||||
page=context.get("page", 1),
|
||||
per_page=context.get("per_page", 15),
|
||||
)
|
||||
|
||||
def _reset_filter(self):
|
||||
"""Reset filter text and selection."""
|
||||
self._filter_text = ""
|
||||
self._selected = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
self._ensure_defaults(context)
|
||||
level = context["level"]
|
||||
|
||||
# Header
|
||||
header_title = self._build_header_title(context)
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
f" {header_title} ",
|
||||
)
|
||||
|
||||
if level == "categories":
|
||||
self._load_categories(db)
|
||||
display_items = [(num, label) for num, label, _id in self._items]
|
||||
renderer.draw_filter_list(
|
||||
display_items,
|
||||
self._filter_text,
|
||||
self._selected,
|
||||
title="CATALOGO DE CATEGORIAS",
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_CATEGORIES)
|
||||
|
||||
elif level == "groups":
|
||||
self._load_groups(db, context["category_id"])
|
||||
display_items = [(num, label) for num, label, _id in self._items]
|
||||
renderer.draw_filter_list(
|
||||
display_items,
|
||||
self._filter_text,
|
||||
self._selected,
|
||||
title=context.get("category_name", "GRUPOS"),
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_GROUPS)
|
||||
|
||||
elif level == "parts":
|
||||
self._load_parts(db, context)
|
||||
headers = ["NUMERO OEM", "DESCRIPCION", "GRUPO", "ALT"]
|
||||
widths = [18, 30, 18, 5]
|
||||
rows = []
|
||||
for p in self._parts_data:
|
||||
alts = len(db.get_alternatives(p["id"]))
|
||||
rows.append((
|
||||
truncate(p.get("oem_part_number", ""), 18),
|
||||
truncate(
|
||||
p.get("name_es") or p.get("name", ""), 30
|
||||
),
|
||||
truncate(p.get("group_name", ""), 18),
|
||||
str(alts) if alts > 0 else "",
|
||||
))
|
||||
|
||||
page = context.get("page", 1)
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
page_info={"page": page, "total_pages": page, "total_rows": len(rows)},
|
||||
selected_row=self._selected_part,
|
||||
)
|
||||
renderer.draw_footer(_FOOTER_PARTS)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
self._ensure_defaults(context)
|
||||
level = context["level"]
|
||||
|
||||
if level in ("categories", "groups"):
|
||||
return self._handle_filter_level(key, context)
|
||||
elif level == "parts":
|
||||
return self._handle_parts_level(key, context)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_filter_level(self, key, context):
|
||||
"""Handle keys for categories and groups levels (filter list)."""
|
||||
level = context["level"]
|
||||
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
if level == "groups":
|
||||
context["level"] = "categories"
|
||||
context["category_id"] = None
|
||||
context["category_name"] = ""
|
||||
self._reset_filter()
|
||||
return None
|
||||
return "back"
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected > 0:
|
||||
self._selected -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._items and self._selected < len(self._items) - 1:
|
||||
self._selected += 1
|
||||
return None
|
||||
|
||||
# ENTER: select current item
|
||||
if key == Key.ENTER:
|
||||
if self._items and 0 <= self._selected < len(self._items):
|
||||
return self._select_item(context, self._selected)
|
||||
return None
|
||||
|
||||
# Number keys: direct selection (1-9)
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if 0 <= idx < len(self._items):
|
||||
return self._select_item(context, idx)
|
||||
return None
|
||||
|
||||
# Backspace: remove last filter character
|
||||
if key in (Key.BACKSPACE, 8):
|
||||
if self._filter_text:
|
||||
self._filter_text = self._filter_text[:-1]
|
||||
self._selected = 0
|
||||
elif context["level"] == "groups":
|
||||
context["level"] = "categories"
|
||||
context["category_id"] = None
|
||||
context["category_name"] = ""
|
||||
self._reset_filter()
|
||||
else:
|
||||
return "back"
|
||||
return None
|
||||
|
||||
# Printable characters: add to filter
|
||||
if 32 <= key <= 126:
|
||||
self._filter_text += chr(key)
|
||||
self._selected = 0
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _select_item(self, context, idx):
|
||||
"""Handle selection of an item at the given index."""
|
||||
_num, label, item_id = self._items[idx]
|
||||
level = context["level"]
|
||||
|
||||
if level == "categories":
|
||||
context["level"] = "groups"
|
||||
context["category_id"] = item_id
|
||||
context["category_name"] = label
|
||||
self._reset_filter()
|
||||
return None
|
||||
|
||||
elif level == "groups":
|
||||
context["level"] = "parts"
|
||||
context["group_id"] = item_id
|
||||
context["group_name"] = label
|
||||
context["page"] = 1
|
||||
self._selected_part = 0
|
||||
self._reset_filter()
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _handle_parts_level(self, key, context):
|
||||
"""Handle keys for the parts table level."""
|
||||
# ESC: go back to groups
|
||||
if key == Key.ESCAPE:
|
||||
context["level"] = "groups"
|
||||
context["group_id"] = None
|
||||
context["group_name"] = ""
|
||||
self._selected_part = 0
|
||||
self._reset_filter()
|
||||
return None
|
||||
|
||||
# Arrow navigation
|
||||
if key == Key.UP:
|
||||
if self._selected_part > 0:
|
||||
self._selected_part -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._parts_data and self._selected_part < len(self._parts_data) - 1:
|
||||
self._selected_part += 1
|
||||
return None
|
||||
|
||||
# ENTER: view selected part detail
|
||||
if key == Key.ENTER:
|
||||
if self._parts_data and 0 <= self._selected_part < len(self._parts_data):
|
||||
part = self._parts_data[self._selected_part]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
# Number keys: direct selection (1-9)
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if 0 <= idx < len(self._parts_data):
|
||||
part = self._parts_data[idx]
|
||||
return ("parte_detalle", {"part_id": part["id"]}, "Parte")
|
||||
return None
|
||||
|
||||
# PgDn: next page
|
||||
if key == Key.PGDN:
|
||||
context["page"] = context.get("page", 1) + 1
|
||||
self._selected_part = 0
|
||||
return None
|
||||
|
||||
# PgUp: previous page
|
||||
if key == Key.PGUP:
|
||||
if context.get("page", 1) > 1:
|
||||
context["page"] = context["page"] - 1
|
||||
self._selected_part = 0
|
||||
return None
|
||||
|
||||
return None
|
||||
282
console/screens/comparador.py
Normal file
282
console/screens/comparador.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Part comparator screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Displays a side-by-side comparison of an OEM part against its aftermarket
|
||||
alternatives. The first column is always the OEM part; subsequent columns
|
||||
are aftermarket options. Below the comparison table, cross-reference
|
||||
numbers are shown grouped by type.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import format_currency, quality_bar
|
||||
|
||||
|
||||
# Footer labels
|
||||
_FOOTER = [
|
||||
("\u2190\u2192", "Scroll"),
|
||||
("#", "Ver detalle"),
|
||||
("F3", "Otra parte"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class ComparadorScreen(Screen):
|
||||
"""Side-by-side OEM vs aftermarket comparison."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="comparador", title="Comparador")
|
||||
self._part = None
|
||||
self._alternatives = []
|
||||
self._cross_refs = []
|
||||
self._manufacturers = {} # id -> dict
|
||||
self._col_offset = 0 # horizontal scroll offset
|
||||
self._selected_alt = 0 # currently highlighted alternative
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self, context, db):
|
||||
"""Load OEM part, alternatives, cross-refs, and manufacturer info."""
|
||||
part_id = context.get("part_id")
|
||||
if part_id is None:
|
||||
self._part = None
|
||||
self._alternatives = []
|
||||
self._cross_refs = []
|
||||
return
|
||||
|
||||
self._part = db.get_part(part_id)
|
||||
self._alternatives = db.get_alternatives(part_id) if self._part else []
|
||||
self._cross_refs = db.get_cross_references(part_id) if self._part else []
|
||||
|
||||
# Build manufacturer lookup for country info
|
||||
try:
|
||||
mfrs = db.get_manufacturers()
|
||||
self._manufacturers = {m["id"]: m for m in mfrs}
|
||||
except Exception:
|
||||
self._manufacturers = {}
|
||||
|
||||
# Set initial column offset to show the selected alternative
|
||||
selected = context.get("selected_alt_index", 0)
|
||||
if 0 <= selected < len(self._alternatives):
|
||||
self._selected_alt = selected
|
||||
else:
|
||||
self._selected_alt = 0
|
||||
self._col_offset = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_warranty(months):
|
||||
"""Format warranty months as 'X meses' or '──' if missing."""
|
||||
if months is None:
|
||||
return "\u2500\u2500"
|
||||
return f"{months} meses"
|
||||
|
||||
@staticmethod
|
||||
def _calc_savings(oem_price, alt_price):
|
||||
"""Calculate percentage savings of alt vs OEM.
|
||||
|
||||
Returns a formatted string like '-28%' or '──' when prices are
|
||||
unavailable.
|
||||
"""
|
||||
if oem_price is None or alt_price is None or oem_price == 0:
|
||||
return "\u2500\u2500"
|
||||
pct = ((oem_price - alt_price) / oem_price) * 100
|
||||
if pct > 0:
|
||||
return f"-{pct:.0f}%"
|
||||
elif pct < 0:
|
||||
return f"+{abs(pct):.0f}%"
|
||||
return "0%"
|
||||
|
||||
@staticmethod
|
||||
def _format_stock(in_stock):
|
||||
"""Format boolean in_stock as Si/No."""
|
||||
if in_stock is None:
|
||||
return "\u2500\u2500"
|
||||
return "Si" if in_stock else "No"
|
||||
|
||||
def _build_columns(self):
|
||||
"""Build the column list for draw_comparison.
|
||||
|
||||
First column is the OEM part, followed by each aftermarket
|
||||
alternative. Returns a list of dicts with 'header' and 'rows'.
|
||||
"""
|
||||
if self._part is None:
|
||||
return []
|
||||
|
||||
p = self._part
|
||||
oem_price = None # OEM parts don't have price_usd in our schema
|
||||
|
||||
# ── OEM column ──
|
||||
oem_col = {
|
||||
"header": "OEM",
|
||||
"rows": [
|
||||
("Numero", p.get("oem_part_number", "")),
|
||||
("Calidad", quality_bar("oem")),
|
||||
("Tier", "OEM"),
|
||||
("Precio USD", "\u2500\u2500"),
|
||||
("Ahorro", "\u2500\u2500"),
|
||||
("Garantia", "\u2500\u2500"),
|
||||
("En stock", "\u2500\u2500"),
|
||||
("Fabricante", p.get("category_name", "")),
|
||||
],
|
||||
}
|
||||
|
||||
columns = [oem_col]
|
||||
|
||||
# ── Aftermarket columns ──
|
||||
for alt in self._alternatives:
|
||||
tier = alt.get("quality_tier", "") or ""
|
||||
price = alt.get("price_usd")
|
||||
mfr_id = alt.get("manufacturer_id")
|
||||
mfr_name = alt.get("manufacturer_name", "")
|
||||
mfr_country = ""
|
||||
if mfr_id and mfr_id in self._manufacturers:
|
||||
mfr_country = self._manufacturers[mfr_id].get("country", "") or ""
|
||||
|
||||
alt_col = {
|
||||
"header": mfr_name,
|
||||
"rows": [
|
||||
("Numero", alt.get("part_number", "")),
|
||||
("Calidad", quality_bar(tier.lower()) if tier else "\u2500\u2500"),
|
||||
("Tier", tier.capitalize()),
|
||||
("Precio USD", format_currency(price)),
|
||||
("Ahorro", self._calc_savings(oem_price, price)),
|
||||
("Garantia", self._format_warranty(alt.get("warranty_months"))),
|
||||
("En stock", self._format_stock(alt.get("in_stock"))),
|
||||
("Fabricante", mfr_country if mfr_country else mfr_name),
|
||||
],
|
||||
}
|
||||
columns.append(alt_col)
|
||||
|
||||
return columns
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
self._load(context, db)
|
||||
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" COMPARADOR OEM vs AFTERMARKET ",
|
||||
)
|
||||
|
||||
if self._part is None:
|
||||
renderer.draw_text(5, 4, "Parte no encontrada", "error")
|
||||
renderer.draw_footer([("ESC", "Atras")])
|
||||
return
|
||||
|
||||
# Build comparison columns
|
||||
all_columns = self._build_columns()
|
||||
|
||||
# Apply horizontal scroll: always show OEM (col 0) + offset slice
|
||||
if len(all_columns) <= 1:
|
||||
visible_columns = all_columns
|
||||
else:
|
||||
# Determine how many alt columns we can show.
|
||||
# The renderer will auto-size, but let's allow scrolling
|
||||
# through alternatives.
|
||||
h, w = renderer.get_size()
|
||||
# Rough estimate: label_w ~12, each col ~15-20 chars
|
||||
# We keep it simple: show OEM + up to 3 alternatives at a time
|
||||
max_visible_alts = max((w - 20) // 18, 1)
|
||||
alt_cols = all_columns[1:] # all aftermarket columns
|
||||
end = min(self._col_offset + max_visible_alts, len(alt_cols))
|
||||
visible_columns = [all_columns[0]] + alt_cols[self._col_offset:end]
|
||||
|
||||
part_name = self._part.get("name_es") or self._part.get("name", "")
|
||||
oem_number = self._part.get("oem_part_number", "")
|
||||
title = f"COMPARACION: {oem_number} - {part_name}"
|
||||
|
||||
renderer.draw_comparison(visible_columns, title=title)
|
||||
|
||||
# ── Cross-references below the comparison ──
|
||||
h, w = renderer.get_size()
|
||||
# Estimate row where comparison ends:
|
||||
# title(3) + header(1) + sep(1) + 8 data rows + 1 gap = 14
|
||||
xref_row = 3 + 3 + 1 + 8 + 2
|
||||
|
||||
if self._cross_refs and xref_row < h - 4:
|
||||
section_title = (
|
||||
"\u2500\u2500 CROSS-REFERENCES "
|
||||
+ "\u2500" * max(w - 24, 4)
|
||||
)
|
||||
renderer.draw_text(xref_row, 2, section_title, "title")
|
||||
xref_row += 1
|
||||
|
||||
# Group by reference type
|
||||
by_type = {}
|
||||
for xr in self._cross_refs:
|
||||
rtype = xr.get("reference_type", "other") or "other"
|
||||
by_type.setdefault(rtype, []).append(
|
||||
xr.get("cross_reference_number", "")
|
||||
)
|
||||
for rtype, numbers in by_type.items():
|
||||
if xref_row >= h - 3:
|
||||
break
|
||||
line = f"{rtype.capitalize()}: {', '.join(numbers)}"
|
||||
renderer.draw_text(xref_row, 4, line, "normal")
|
||||
xref_row += 1
|
||||
|
||||
# Scroll indicator
|
||||
if len(all_columns) > 1:
|
||||
alt_count = len(all_columns) - 1
|
||||
indicator = (
|
||||
f" Mostrando alternativas "
|
||||
f"{self._col_offset + 1}-"
|
||||
f"{min(self._col_offset + len(visible_columns) - 1, alt_count)}"
|
||||
f" de {alt_count}"
|
||||
)
|
||||
indicator_row = min(xref_row + 1, h - 4)
|
||||
if indicator_row > 0:
|
||||
renderer.draw_text(indicator_row, 2, indicator, "info")
|
||||
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# ESC: back to part detail
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# Left arrow: scroll columns left
|
||||
if key == Key.LEFT:
|
||||
if self._col_offset > 0:
|
||||
self._col_offset -= 1
|
||||
return None
|
||||
|
||||
# Right arrow: scroll columns right
|
||||
if key == Key.RIGHT:
|
||||
max_offset = max(len(self._alternatives) - 1, 0)
|
||||
if self._col_offset < max_offset:
|
||||
self._col_offset += 1
|
||||
return None
|
||||
|
||||
# Number keys (1-9): view alternative detail
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if self._alternatives and 0 <= idx < len(self._alternatives):
|
||||
part_id = context.get("part_id")
|
||||
return (
|
||||
"comparador",
|
||||
{"part_id": part_id, "selected_alt_index": idx},
|
||||
"Comparador",
|
||||
)
|
||||
return None
|
||||
|
||||
# F3: search for another part (go to buscar_parte)
|
||||
if key == Key.F3:
|
||||
return ("buscar_parte", {}, "Buscar Parte")
|
||||
|
||||
return None
|
||||
167
console/screens/estadisticas.py
Normal file
167
console/screens/estadisticas.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Statistics dashboard screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Displays database table counts and coverage metrics retrieved via
|
||||
:meth:`Database.get_stats`.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import format_number
|
||||
|
||||
|
||||
# Human-readable labels for each database table counter.
|
||||
_TABLE_LABELS = [
|
||||
("brands", "Marcas"),
|
||||
("models", "Modelos"),
|
||||
("engines", "Motores"),
|
||||
("years", "Anos"),
|
||||
("part_categories", "Categorias"),
|
||||
("part_groups", "Grupos de Partes"),
|
||||
("parts", "Partes OEM"),
|
||||
("aftermarket_parts", "Partes Aftermarket"),
|
||||
("manufacturers", "Fabricantes"),
|
||||
("part_cross_references","Cross-References"),
|
||||
]
|
||||
|
||||
# Footer key labels
|
||||
_FOOTER = [
|
||||
("F5", "Refrescar"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class EstadisticasScreen(Screen):
|
||||
"""Read-only statistics dashboard showing database counters."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="estadisticas", title="Estadisticas del Sistema")
|
||||
self._stats = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_stats(self, db):
|
||||
"""Fetch fresh statistics from the database."""
|
||||
try:
|
||||
self._stats = db.get_stats()
|
||||
except Exception:
|
||||
self._stats = None
|
||||
|
||||
def _build_fields(self):
|
||||
"""Build the detail fields list from the cached stats dict."""
|
||||
if self._stats is None:
|
||||
return [("Error", "No se pudieron cargar las estadisticas")]
|
||||
|
||||
fields = []
|
||||
|
||||
# -- Section: BASE DE DATOS --
|
||||
for key, label in _TABLE_LABELS:
|
||||
value = self._stats.get(key, 0)
|
||||
fields.append((label, format_number(value)))
|
||||
|
||||
return fields
|
||||
|
||||
def _build_coverage_fields(self):
|
||||
"""Build coverage / summary fields."""
|
||||
if self._stats is None:
|
||||
return []
|
||||
|
||||
fields = []
|
||||
|
||||
# Vehicle-part fitments
|
||||
fitments = self._stats.get("vehicle_parts", 0)
|
||||
fields.append(("Fitments", format_number(fitments)))
|
||||
|
||||
# Top brands by fitment count
|
||||
top_brands = self._stats.get("top_brands", [])
|
||||
if top_brands:
|
||||
parts = []
|
||||
for b in top_brands[:5]:
|
||||
parts.append(f"{b['name']}({format_number(b['count'])})")
|
||||
fields.append(("Top marcas", " ".join(parts)))
|
||||
|
||||
return fields
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Load stats on first render (or after refresh)
|
||||
if self._stats is None:
|
||||
self._load_stats(db)
|
||||
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" Estadisticas ",
|
||||
)
|
||||
|
||||
h, w = renderer.get_size()
|
||||
|
||||
# -- Section title: BASE DE DATOS --
|
||||
section_title = " BASE DE DATOS "
|
||||
border_char = "\u2500" # ─
|
||||
pad_len = max(w - 4 - len(section_title), 0)
|
||||
section_line = border_char * 2 + section_title + border_char * pad_len
|
||||
renderer.draw_text(3, 2, section_line[:w - 4], "title")
|
||||
|
||||
# Database counters
|
||||
db_fields = self._build_fields()
|
||||
max_label = max((len(lbl) for lbl, _ in db_fields), default=10)
|
||||
dot_total = max_label + 4
|
||||
|
||||
row = 5
|
||||
for label, value in db_fields:
|
||||
if row >= h - 6:
|
||||
break
|
||||
dots = "." * (dot_total - len(label))
|
||||
label_part = f" {label}{dots}: "
|
||||
renderer.draw_text(row, 0, label_part, "field_label")
|
||||
renderer.draw_text(row, len(label_part), str(value), "field_value")
|
||||
row += 1
|
||||
|
||||
# -- Section title: COBERTURA --
|
||||
row += 1
|
||||
if row < h - 5:
|
||||
section_title2 = " COBERTURA "
|
||||
pad_len2 = max(w - 4 - len(section_title2), 0)
|
||||
section_line2 = border_char * 2 + section_title2 + border_char * pad_len2
|
||||
renderer.draw_text(row, 2, section_line2[:w - 4], "title")
|
||||
row += 2
|
||||
|
||||
coverage_fields = self._build_coverage_fields()
|
||||
cov_max_label = max(
|
||||
(len(lbl) for lbl, _ in coverage_fields), default=10
|
||||
)
|
||||
cov_dot_total = cov_max_label + 4
|
||||
|
||||
for label, value in coverage_fields:
|
||||
if row >= h - 3:
|
||||
break
|
||||
dots = "." * (cov_dot_total - len(label))
|
||||
label_part = f" {label}{dots}: "
|
||||
renderer.draw_text(row, 0, label_part, "field_label")
|
||||
renderer.draw_text(
|
||||
row, len(label_part), str(value), "field_value"
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Footer
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# F5: refresh stats
|
||||
if key == Key.F5:
|
||||
self._stats = None # will reload on next render
|
||||
return None
|
||||
|
||||
# ESC or Backspace: go back
|
||||
if key in (Key.ESCAPE, Key.BACKSPACE):
|
||||
return "back"
|
||||
|
||||
return None
|
||||
137
console/screens/menu_principal.py
Normal file
137
console/screens/menu_principal.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Main menu screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Displays a numbered Pick-style menu with navigation options for all
|
||||
application sections. Number keys jump directly; arrow keys move the
|
||||
selection; ENTER activates.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, APP_SUBTITLE, VERSION
|
||||
|
||||
|
||||
# Menu items: list of (display_number, label, screen_name).
|
||||
# Separators use display_number None and screen_name None.
|
||||
_MENU_ITEMS = [
|
||||
("1", "Consulta por Vehiculo", "vehiculo_nav"),
|
||||
("2", "Busqueda por Numero de Parte", "buscar_parte"),
|
||||
("3", "Busqueda por Descripcion", "buscar_texto"),
|
||||
("4", "Decodificador VIN", "vin_decoder"),
|
||||
("5", "Catalogo de Categorias", "catalogo"),
|
||||
(None, None, None), # separator
|
||||
("6", "Administracion de Partes", "admin_partes"),
|
||||
("7", "Administracion de Fabricantes", "admin_fabricantes"),
|
||||
("8", "Cross-References", "admin_crossref"),
|
||||
("9", "Importar / Exportar Datos", "admin_import"),
|
||||
(None, None, None), # separator
|
||||
("0", "Estadisticas del Sistema", "estadisticas"),
|
||||
]
|
||||
|
||||
# Quick lookup: digit character -> screen name
|
||||
_KEY_MAP = {item[0]: item[2] for item in _MENU_ITEMS if item[0] is not None}
|
||||
|
||||
# Footer key labels
|
||||
_FOOTER = [
|
||||
("F1", "Ayuda"),
|
||||
("F3", "Buscar"),
|
||||
("F10", "Menu"),
|
||||
("ESC", "Salir"),
|
||||
]
|
||||
|
||||
|
||||
class MenuPrincipalScreen(Screen):
|
||||
"""Main menu screen with numbered items and arrow-key navigation."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="menu", title="Menu Principal")
|
||||
self._selected = 0 # index into _MENU_ITEMS (skipping separators)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _selectable_indices(self):
|
||||
"""Return list of indices in _MENU_ITEMS that are not separators."""
|
||||
return [i for i, item in enumerate(_MENU_ITEMS) if item[0] is not None]
|
||||
|
||||
def _move_selection(self, direction):
|
||||
"""Move selection up (-1) or down (+1), skipping separators."""
|
||||
indices = self._selectable_indices()
|
||||
if not indices:
|
||||
return
|
||||
try:
|
||||
pos = indices.index(self._selected)
|
||||
except ValueError:
|
||||
pos = 0
|
||||
pos = max(0, min(len(indices) - 1, pos + direction))
|
||||
self._selected = indices[pos]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
f"{APP_SUBTITLE} ",
|
||||
)
|
||||
|
||||
# Build items list for draw_menu.
|
||||
# Separators use the special "---" marker understood by the renderer.
|
||||
menu_items = []
|
||||
for num, label, _screen in _MENU_ITEMS:
|
||||
if num is None:
|
||||
menu_items.append(("---", ""))
|
||||
else:
|
||||
menu_items.append((num, label))
|
||||
|
||||
renderer.draw_menu(
|
||||
menu_items,
|
||||
selected_index=self._selected,
|
||||
title="MENU PRINCIPAL",
|
||||
)
|
||||
|
||||
# Footer
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# --- Number keys: direct navigation ---
|
||||
if 48 <= key <= 57: # ord('0') .. ord('9')
|
||||
digit = chr(key)
|
||||
screen_name = _KEY_MAP.get(digit)
|
||||
if screen_name:
|
||||
label = next(
|
||||
(lbl for num, lbl, _ in _MENU_ITEMS if num == digit),
|
||||
screen_name,
|
||||
)
|
||||
return (screen_name, {}, label)
|
||||
|
||||
# --- Arrow keys ---
|
||||
if key == Key.UP:
|
||||
self._move_selection(-1)
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
self._move_selection(1)
|
||||
return None
|
||||
|
||||
# --- ENTER: activate selected ---
|
||||
if key == Key.ENTER:
|
||||
item = _MENU_ITEMS[self._selected]
|
||||
num, label, screen_name = item
|
||||
if screen_name is not None:
|
||||
return (screen_name, {}, label)
|
||||
return None
|
||||
|
||||
# --- ESC: quit confirmation ---
|
||||
if key == Key.ESCAPE:
|
||||
confirmed = renderer.show_message(
|
||||
"Desea salir de la aplicacion?", "confirm"
|
||||
)
|
||||
if confirmed:
|
||||
return "quit"
|
||||
return None
|
||||
|
||||
return None
|
||||
242
console/screens/parte_detalle.py
Normal file
242
console/screens/parte_detalle.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Part detail screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Shows full part information (OEM number, name, group, category, etc.)
|
||||
with a table of aftermarket alternatives. Number keys navigate to
|
||||
the comparator screen; F4 shows cross-references; F6 lists compatible
|
||||
vehicles.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.formatting import format_currency, truncate, quality_bar
|
||||
|
||||
|
||||
# Footer labels
|
||||
_FOOTER = [
|
||||
("#", "Comparar"),
|
||||
("F4", "Cross-Ref"),
|
||||
("F6", "Vehiculos"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class ParteDetalleScreen(Screen):
|
||||
"""Detail view for a single OEM part with aftermarket alternatives."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="parte_detalle", title="Detalle de Parte")
|
||||
self._part = None
|
||||
self._alternatives = []
|
||||
self._selected_alt = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self, context, db):
|
||||
"""Load part and alternatives from context['part_id']."""
|
||||
part_id = context.get("part_id")
|
||||
if part_id is None:
|
||||
self._part = None
|
||||
self._alternatives = []
|
||||
return
|
||||
self._part = db.get_part(part_id)
|
||||
self._alternatives = db.get_alternatives(part_id) if self._part else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_warranty(months):
|
||||
"""Format warranty months as 'X meses' or '──' if missing."""
|
||||
if months is None:
|
||||
return "──"
|
||||
return f"{months} meses"
|
||||
|
||||
@staticmethod
|
||||
def _format_weight(kg):
|
||||
"""Format weight in kilograms or '──' if missing."""
|
||||
if kg is None:
|
||||
return "──"
|
||||
return f"{kg} kg"
|
||||
|
||||
@staticmethod
|
||||
def _format_discontinued(flag):
|
||||
"""Format the is_discontinued flag as Si/No."""
|
||||
if flag:
|
||||
return "Si"
|
||||
return "No"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
self._load(context, db)
|
||||
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" DETALLE DE PARTE ",
|
||||
)
|
||||
|
||||
if self._part is None:
|
||||
renderer.draw_text(5, 4, "Parte no encontrada", "error")
|
||||
renderer.draw_footer([("ESC", "Atras")])
|
||||
return
|
||||
|
||||
p = self._part
|
||||
|
||||
# ── Top section: part detail fields ──
|
||||
fields = [
|
||||
("Numero OEM", p.get("oem_part_number", "")),
|
||||
("Nombre", p.get("name", "")),
|
||||
("Nombre (ES)", p.get("name_es", "") or ""),
|
||||
("Grupo", p.get("group_name_es") or p.get("group_name", "")),
|
||||
("Categoria", p.get("category_name_es") or p.get("category_name", "")),
|
||||
("Descripcion", p.get("description_es") or p.get("description", "") or ""),
|
||||
("Material", p.get("material", "") or "──"),
|
||||
("Peso", self._format_weight(p.get("weight_kg"))),
|
||||
("Descontinuada", self._format_discontinued(p.get("is_discontinued"))),
|
||||
]
|
||||
renderer.draw_detail(fields, title="INFORMACION DE LA PARTE")
|
||||
|
||||
# ── Bottom section: alternatives table ──
|
||||
h, w = renderer.get_size()
|
||||
# Calculate where the detail section ends (title=3 rows + fields + 1 gap)
|
||||
table_start_row = 3 + 3 + len(fields) + 1
|
||||
|
||||
if self._alternatives:
|
||||
# Draw section title
|
||||
section_title = "\u2500\u2500 ALTERNATIVAS AFTERMARKET " + "\u2500" * max(w - 32, 4)
|
||||
renderer.draw_text(table_start_row, 2, section_title, "title")
|
||||
table_start_row += 1
|
||||
|
||||
headers = ["FABRICANTE", "NUMERO", "CALIDAD", "PRECIO", "GARANTIA"]
|
||||
widths = [14, 16, 10, 10, 10]
|
||||
rows = []
|
||||
for alt in self._alternatives:
|
||||
rows.append((
|
||||
truncate(alt.get("manufacturer_name", ""), 14),
|
||||
truncate(alt.get("part_number", ""), 16),
|
||||
(alt.get("quality_tier", "") or "").capitalize(),
|
||||
format_currency(alt.get("price_usd")),
|
||||
self._format_warranty(alt.get("warranty_months")),
|
||||
))
|
||||
|
||||
renderer.draw_table(
|
||||
headers,
|
||||
rows,
|
||||
widths,
|
||||
selected_row=self._selected_alt,
|
||||
)
|
||||
else:
|
||||
renderer.draw_text(
|
||||
table_start_row, 4,
|
||||
"No hay alternativas aftermarket registradas",
|
||||
"info",
|
||||
)
|
||||
|
||||
renderer.draw_footer(_FOOTER)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# Arrow navigation for alternatives
|
||||
if key == Key.UP:
|
||||
if self._selected_alt > 0:
|
||||
self._selected_alt -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if self._alternatives and self._selected_alt < len(self._alternatives) - 1:
|
||||
self._selected_alt += 1
|
||||
return None
|
||||
|
||||
# Number keys (1-9): navigate to comparador for the selected alternative
|
||||
if 49 <= key <= 57: # '1'..'9'
|
||||
idx = key - 49 # 0-based
|
||||
if self._alternatives and 0 <= idx < len(self._alternatives):
|
||||
part_id = context.get("part_id")
|
||||
return (
|
||||
"comparador",
|
||||
{"part_id": part_id, "selected_alt_index": idx},
|
||||
"Comparador",
|
||||
)
|
||||
return None
|
||||
|
||||
# ENTER: navigate to comparador for the currently highlighted alternative
|
||||
if key == Key.ENTER:
|
||||
if self._alternatives and 0 <= self._selected_alt < len(self._alternatives):
|
||||
part_id = context.get("part_id")
|
||||
return (
|
||||
"comparador",
|
||||
{"part_id": part_id, "selected_alt_index": self._selected_alt},
|
||||
"Comparador",
|
||||
)
|
||||
return None
|
||||
|
||||
# F4: show cross-references
|
||||
if key == Key.F4:
|
||||
part_id = context.get("part_id")
|
||||
if part_id is None:
|
||||
return None
|
||||
xrefs = db.get_cross_references(part_id)
|
||||
if not xrefs:
|
||||
renderer.show_message("No hay cross-references para esta parte", "info")
|
||||
return None
|
||||
# Build message text grouped by reference type
|
||||
lines = []
|
||||
by_type = {}
|
||||
for xr in xrefs:
|
||||
rtype = xr.get("reference_type", "other") or "other"
|
||||
by_type.setdefault(rtype, []).append(
|
||||
xr.get("cross_reference_number", "")
|
||||
)
|
||||
for rtype, numbers in by_type.items():
|
||||
lines.append(f"{rtype.capitalize()}: {', '.join(numbers)}")
|
||||
msg = "CROSS-REFERENCES\n" + "\n".join(lines)
|
||||
renderer.show_message(msg, "info")
|
||||
return None
|
||||
|
||||
# F6: show vehicles that use this part
|
||||
if key == Key.F6:
|
||||
part_id = context.get("part_id")
|
||||
if part_id is None:
|
||||
return None
|
||||
vehicles = db.get_vehicles_for_part(part_id)
|
||||
if not vehicles:
|
||||
renderer.show_message(
|
||||
"No hay vehiculos registrados para esta parte", "info"
|
||||
)
|
||||
return None
|
||||
# Build message with vehicle list (limit to avoid overflow)
|
||||
lines = []
|
||||
for v in vehicles[:10]:
|
||||
brand = v.get("brand", "")
|
||||
model = v.get("model", "")
|
||||
year = v.get("year", "")
|
||||
engine = v.get("engine", "")
|
||||
line = f"{brand} {model} {year}"
|
||||
if engine:
|
||||
line += f" ({engine})"
|
||||
position = v.get("position", "")
|
||||
if position:
|
||||
line += f" - {position}"
|
||||
lines.append(line)
|
||||
if len(vehicles) > 10:
|
||||
lines.append(f"... y {len(vehicles) - 10} mas")
|
||||
msg = "VEHICULOS COMPATIBLES\n" + "\n".join(lines)
|
||||
renderer.show_message(msg, "info")
|
||||
return None
|
||||
|
||||
return None
|
||||
239
console/screens/vehiculo_nav.py
Normal file
239
console/screens/vehiculo_nav.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Vehicle drill-down navigation screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Guides the user through a four-level hierarchy:
|
||||
|
||||
Brand -> Model -> Year -> Engine
|
||||
|
||||
Each level presents a filterable list. After engine selection the screen
|
||||
navigates to the catalogue (``catalogo``) with the resolved
|
||||
``model_year_engine`` id.
|
||||
"""
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
|
||||
# Ordered sequence of drill-down levels.
|
||||
_LEVELS = ("brand", "model", "year", "engine")
|
||||
|
||||
# Human-readable titles for each level (Spanish).
|
||||
_LEVEL_TITLES = {
|
||||
"brand": "Seleccione Marca",
|
||||
"model": "Seleccione Modelo",
|
||||
"year": "Seleccione Ano",
|
||||
"engine": "Seleccione Motor",
|
||||
}
|
||||
|
||||
|
||||
class VehiculoNavScreen(Screen):
|
||||
"""Four-level vehicle drill-down: Brand -> Model -> Year -> Engine."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("vehiculo_nav", "Consulta por Vehiculo")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_title_for_level(self, context):
|
||||
"""Return the title string for the current drill-down level."""
|
||||
level = context.get("level", "brand")
|
||||
return _LEVEL_TITLES.get(level, "Seleccione")
|
||||
|
||||
def _get_subtitle(self, context):
|
||||
"""Build a breadcrumb subtitle from selections made so far.
|
||||
|
||||
Example: ``"TOYOTA > CAMRY > 2023 > Seleccione motor"``
|
||||
"""
|
||||
parts = []
|
||||
if context.get("brand"):
|
||||
parts.append(context["brand"])
|
||||
if context.get("model"):
|
||||
parts.append(context["model"])
|
||||
if context.get("year") is not None:
|
||||
parts.append(str(context["year"]))
|
||||
|
||||
level = context.get("level", "brand")
|
||||
parts.append(_LEVEL_TITLES.get(level, ""))
|
||||
return " > ".join(parts)
|
||||
|
||||
def _load_items(self, context, db):
|
||||
"""Fetch the item list from the database for the current level."""
|
||||
level = context.get("level", "brand")
|
||||
|
||||
if level == "brand":
|
||||
context["all_items"] = db.get_brands()
|
||||
elif level == "model":
|
||||
context["all_items"] = db.get_models(brand=context.get("brand"))
|
||||
elif level == "year":
|
||||
context["all_items"] = db.get_years(
|
||||
brand=context.get("brand"),
|
||||
model=context.get("model"),
|
||||
)
|
||||
elif level == "engine":
|
||||
context["all_items"] = db.get_engines(
|
||||
brand=context.get("brand"),
|
||||
model=context.get("model"),
|
||||
year=context.get("year"),
|
||||
)
|
||||
else:
|
||||
context["all_items"] = []
|
||||
|
||||
self._apply_filter(context)
|
||||
|
||||
def _apply_filter(self, context):
|
||||
"""Reduce ``all_items`` to those matching ``filter_text``.
|
||||
|
||||
Matching is a case-insensitive substring test on the display name.
|
||||
"""
|
||||
level = context.get("level", "brand")
|
||||
ft = context.get("filter_text", "").lower()
|
||||
all_items = context.get("all_items", [])
|
||||
|
||||
if ft:
|
||||
context["filtered_items"] = [
|
||||
item for item in all_items
|
||||
if ft in self._get_display_name(item, level).lower()
|
||||
]
|
||||
else:
|
||||
context["filtered_items"] = list(all_items)
|
||||
|
||||
@staticmethod
|
||||
def _get_display_name(item, level):
|
||||
"""Extract the human-readable display string from an item dict."""
|
||||
if level == "year":
|
||||
return str(item.get("year", ""))
|
||||
return item.get("name", "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screen interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# First-render initialisation
|
||||
if "level" not in context:
|
||||
context["level"] = "brand"
|
||||
context["filter_text"] = ""
|
||||
context["selected_index"] = 0
|
||||
self._load_items(context, db)
|
||||
|
||||
level = context["level"]
|
||||
title = self._get_title_for_level(context)
|
||||
subtitle = self._get_subtitle(context)
|
||||
renderer.draw_header(title, subtitle)
|
||||
|
||||
# Build the (number, label) tuples expected by draw_filter_list.
|
||||
filtered = context.get("filtered_items", [])
|
||||
display_items = [
|
||||
(str(idx + 1), self._get_display_name(item, level))
|
||||
for idx, item in enumerate(filtered)
|
||||
]
|
||||
|
||||
renderer.draw_filter_list(
|
||||
display_items,
|
||||
context.get("filter_text", ""),
|
||||
context.get("selected_index", 0),
|
||||
title=f"SELECCIONAR {level.upper()}",
|
||||
)
|
||||
|
||||
renderer.draw_footer([
|
||||
("Escriba", "Filtrar"),
|
||||
("ENTER", "Seleccionar"),
|
||||
("\u2191\u2193", "Mover"),
|
||||
("ESC", "Atras"),
|
||||
])
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
filtered = context.get("filtered_items", [])
|
||||
level = context.get("level", "brand")
|
||||
|
||||
# -- ESC: go back one level, or return to menu ----------------
|
||||
if key == Key.ESCAPE:
|
||||
if level == "brand":
|
||||
return "back"
|
||||
prev = _LEVELS[_LEVELS.index(level) - 1]
|
||||
context["level"] = prev
|
||||
context["filter_text"] = ""
|
||||
context["selected_index"] = 0
|
||||
self._load_items(context, db)
|
||||
return None
|
||||
|
||||
# -- ENTER: select item and advance ---------------------------
|
||||
if key == Key.ENTER and filtered:
|
||||
idx = context.get("selected_index", 0)
|
||||
if idx >= len(filtered):
|
||||
return None
|
||||
selected = filtered[idx]
|
||||
|
||||
if level == "brand":
|
||||
context["brand"] = selected["name"]
|
||||
context["level"] = "model"
|
||||
elif level == "model":
|
||||
context["model"] = selected["name"]
|
||||
context["level"] = "year"
|
||||
elif level == "year":
|
||||
context["year"] = selected["year"]
|
||||
context["level"] = "engine"
|
||||
elif level == "engine":
|
||||
context["engine_id"] = selected["id"]
|
||||
context["engine_name"] = selected["name"]
|
||||
# Resolve the model_year_engine row
|
||||
mye_list = db.get_model_year_engine(
|
||||
context["brand"],
|
||||
context["model"],
|
||||
context["year"],
|
||||
context["engine_id"],
|
||||
)
|
||||
if mye_list:
|
||||
mye_id = mye_list[0]["id"]
|
||||
return (
|
||||
"catalogo",
|
||||
{
|
||||
"mye_id": mye_id,
|
||||
"brand": context["brand"],
|
||||
"model": context["model"],
|
||||
"year": context["year"],
|
||||
"engine": context["engine_name"],
|
||||
},
|
||||
f"{context['brand']} {context['model']} {context['year']}",
|
||||
)
|
||||
else:
|
||||
renderer.show_message(
|
||||
"No se encontro configuracion para este vehiculo",
|
||||
"error",
|
||||
)
|
||||
return None
|
||||
|
||||
# Reset filter for the new level
|
||||
context["filter_text"] = ""
|
||||
context["selected_index"] = 0
|
||||
self._load_items(context, db)
|
||||
return None
|
||||
|
||||
# -- Arrow keys: move selection cursor ------------------------
|
||||
if key == Key.UP:
|
||||
if context.get("selected_index", 0) > 0:
|
||||
context["selected_index"] -= 1
|
||||
return None
|
||||
|
||||
if key == Key.DOWN:
|
||||
if context.get("selected_index", 0) < len(filtered) - 1:
|
||||
context["selected_index"] += 1
|
||||
return None
|
||||
|
||||
# -- Backspace: trim filter text ------------------------------
|
||||
if key in (Key.BACKSPACE, 8, 263):
|
||||
if context.get("filter_text"):
|
||||
context["filter_text"] = context["filter_text"][:-1]
|
||||
self._apply_filter(context)
|
||||
context["selected_index"] = 0
|
||||
return None
|
||||
|
||||
# -- Printable characters: append to filter -------------------
|
||||
if isinstance(key, int) and 32 <= key <= 126:
|
||||
context["filter_text"] = context.get("filter_text", "") + chr(key)
|
||||
self._apply_filter(context)
|
||||
context["selected_index"] = 0
|
||||
return None
|
||||
|
||||
return None
|
||||
259
console/screens/vin_decoder.py
Normal file
259
console/screens/vin_decoder.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
VIN decoder screen for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Prompts for a 17-character Vehicle Identification Number, decodes it
|
||||
via the NHTSA vPIC API (with local caching), and displays the decoded
|
||||
vehicle information. The user can then navigate to the parts catalog
|
||||
filtered by the matched vehicle.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from console.core.screens import Screen
|
||||
from console.core.keybindings import Key
|
||||
from console.config import APP_NAME, VERSION
|
||||
from console.utils.vin_api import decode_vin_nhtsa
|
||||
|
||||
|
||||
# Footer labels
|
||||
_FOOTER_INPUT = [
|
||||
("ENTER", "Decodificar"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_RESULT = [
|
||||
("1", "Ver partes"),
|
||||
("2/F3", "Nuevo VIN"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
_FOOTER_ERROR = [
|
||||
("F3", "Nuevo VIN"),
|
||||
("ESC", "Atras"),
|
||||
]
|
||||
|
||||
|
||||
class VinDecoderScreen(Screen):
|
||||
"""VIN decoder with NHTSA API integration and local cache."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="vin_decoder", title="Decodificador VIN")
|
||||
self._vin = None
|
||||
self._decoded = None
|
||||
self._error = None
|
||||
self._needs_input = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _decode(self, vin, db):
|
||||
"""Decode a VIN using cache first, then NHTSA API."""
|
||||
self._vin = vin.upper().strip()
|
||||
self._decoded = None
|
||||
self._error = None
|
||||
|
||||
# Check cache
|
||||
cached = db.get_vin_cache(self._vin)
|
||||
if cached:
|
||||
self._decoded = {
|
||||
"make": cached.get("make", ""),
|
||||
"model": cached.get("model", ""),
|
||||
"year": cached.get("year", ""),
|
||||
"engine_info": cached.get("engine_info", ""),
|
||||
"body_class": cached.get("body_class", ""),
|
||||
"drive_type": cached.get("drive_type", ""),
|
||||
}
|
||||
return
|
||||
|
||||
# Call NHTSA API
|
||||
result = decode_vin_nhtsa(self._vin)
|
||||
|
||||
if "error" in result:
|
||||
self._error = result["error"]
|
||||
return
|
||||
|
||||
# Extract fields
|
||||
make = result.get("make", "")
|
||||
model = result.get("model", "")
|
||||
year = result.get("year", "")
|
||||
body_class = result.get("body_class", "")
|
||||
drive_type = result.get("drive_type", "")
|
||||
|
||||
# Build engine info string
|
||||
engine_info_dict = result.get("engine_info", {})
|
||||
engine_parts = []
|
||||
if engine_info_dict.get("displacement_l"):
|
||||
engine_parts.append(f"{engine_info_dict['displacement_l']}L")
|
||||
if engine_info_dict.get("cylinders"):
|
||||
engine_parts.append(f"{engine_info_dict['cylinders']}cil")
|
||||
if engine_info_dict.get("fuel_type"):
|
||||
engine_parts.append(engine_info_dict["fuel_type"])
|
||||
if engine_info_dict.get("power_hp"):
|
||||
engine_parts.append(f"{engine_info_dict['power_hp']}hp")
|
||||
engine_info = " ".join(engine_parts)
|
||||
|
||||
self._decoded = {
|
||||
"make": make,
|
||||
"model": model,
|
||||
"year": year,
|
||||
"engine_info": engine_info,
|
||||
"body_class": body_class,
|
||||
"drive_type": drive_type,
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
try:
|
||||
year_int = int(year) if year else 0
|
||||
except (ValueError, TypeError):
|
||||
year_int = 0
|
||||
|
||||
try:
|
||||
db.save_vin_cache(
|
||||
vin=self._vin,
|
||||
data=json.dumps(result),
|
||||
make=make,
|
||||
model=model,
|
||||
year=year_int,
|
||||
engine_info=engine_info,
|
||||
body_class=body_class,
|
||||
drive_type=drive_type,
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-critical: caching failure should not break the flow
|
||||
|
||||
def _try_match_vehicle(self, db):
|
||||
"""Try to match the decoded VIN to a vehicle in the database.
|
||||
|
||||
Returns a context dict for the catalogo screen if a match is
|
||||
found, or None if no match exists.
|
||||
"""
|
||||
if not self._decoded:
|
||||
return None
|
||||
|
||||
make = self._decoded.get("make", "")
|
||||
model = self._decoded.get("model", "")
|
||||
year = self._decoded.get("year", "")
|
||||
|
||||
if not make or not model:
|
||||
return None
|
||||
|
||||
try:
|
||||
year_int = int(year) if year else None
|
||||
except (ValueError, TypeError):
|
||||
year_int = None
|
||||
|
||||
# Try to find matching model_year_engine records
|
||||
if year_int:
|
||||
mye_records = db.get_model_year_engine(make, model, year_int)
|
||||
else:
|
||||
mye_records = []
|
||||
|
||||
ctx = {
|
||||
"level": "categories",
|
||||
"brand": make,
|
||||
"model": model,
|
||||
"year": year,
|
||||
"engine": self._decoded.get("engine_info", ""),
|
||||
}
|
||||
|
||||
if mye_records:
|
||||
# Use the first match
|
||||
ctx["mye_id"] = mye_records[0]["id"]
|
||||
|
||||
return ctx
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self, context, db, renderer):
|
||||
# Header
|
||||
renderer.draw_header(
|
||||
f" {APP_NAME} v{VERSION}",
|
||||
" DECODIFICADOR VIN ",
|
||||
)
|
||||
|
||||
if self._needs_input:
|
||||
renderer.draw_footer(_FOOTER_INPUT)
|
||||
return
|
||||
|
||||
if self._error:
|
||||
renderer.draw_text(5, 4, f"Error: {self._error}", "error")
|
||||
renderer.draw_footer(_FOOTER_ERROR)
|
||||
return
|
||||
|
||||
if self._decoded is None:
|
||||
renderer.draw_text(5, 4, "Presione F3 para ingresar un VIN", "info")
|
||||
renderer.draw_footer(_FOOTER_ERROR)
|
||||
return
|
||||
|
||||
# Display decoded VIN info
|
||||
fields = [
|
||||
("VIN", self._vin or ""),
|
||||
("Marca", self._decoded.get("make", "")),
|
||||
("Modelo", self._decoded.get("model", "")),
|
||||
("Ano", str(self._decoded.get("year", ""))),
|
||||
("Motor", self._decoded.get("engine_info", "")),
|
||||
("Carroceria", self._decoded.get("body_class", "")),
|
||||
("Traccion", self._decoded.get("drive_type", "")),
|
||||
]
|
||||
renderer.draw_detail(fields, title="INFORMACION DEL VEHICULO")
|
||||
|
||||
# Action menu below detail
|
||||
h, _w = renderer.get_size()
|
||||
action_row = 5 + len(fields) + 3
|
||||
if action_row < h - 4:
|
||||
renderer.draw_text(action_row, 4, "1. Ver partes compatibles", "normal")
|
||||
renderer.draw_text(action_row + 1, 4, "2. Nueva consulta VIN", "normal")
|
||||
|
||||
renderer.draw_footer(_FOOTER_RESULT)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Key handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_key(self, key, context, db, renderer, nav):
|
||||
# If we need input, show the input dialog
|
||||
if self._needs_input:
|
||||
self._needs_input = False
|
||||
value = renderer.show_input("VIN (17 caracteres)", max_len=17)
|
||||
if value is None:
|
||||
# User pressed ESC in input dialog
|
||||
if self._decoded is not None:
|
||||
return None
|
||||
return "back"
|
||||
value = value.strip()
|
||||
if len(value) != 17:
|
||||
self._error = "El VIN debe tener exactamente 17 caracteres"
|
||||
self._decoded = None
|
||||
return None
|
||||
self._decode(value, db)
|
||||
return None
|
||||
|
||||
# ESC: go back
|
||||
if key == Key.ESCAPE:
|
||||
return "back"
|
||||
|
||||
# F3 or '2': new VIN input
|
||||
if key == Key.F3 or key == ord("2"):
|
||||
self._needs_input = True
|
||||
self._error = None
|
||||
return None
|
||||
|
||||
# '1': view compatible parts
|
||||
if key == ord("1"):
|
||||
if self._decoded:
|
||||
cat_context = self._try_match_vehicle(db)
|
||||
if cat_context:
|
||||
return ("catalogo", cat_context, "Catalogo")
|
||||
else:
|
||||
renderer.show_message(
|
||||
"No se encontro el vehiculo en la base de datos.\n"
|
||||
"Se mostrara el catalogo completo.",
|
||||
"info",
|
||||
)
|
||||
return ("catalogo", {"level": "categories"}, "Catalogo")
|
||||
return None
|
||||
|
||||
return None
|
||||
0
console/tests/__init__.py
Normal file
0
console/tests/__init__.py
Normal file
214
console/tests/test_core.py
Normal file
214
console/tests/test_core.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Tests for the core framework: keybindings, navigation, and screen base class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from console.core.keybindings import Key, KeyBindings
|
||||
from console.core.navigation import Navigation
|
||||
from console.core.screens import Screen
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Key constants
|
||||
# =========================================================================
|
||||
|
||||
class TestKeyConstants:
|
||||
def test_escape_is_27(self):
|
||||
assert Key.ESCAPE == 27
|
||||
|
||||
def test_enter_is_10(self):
|
||||
assert Key.ENTER == 10
|
||||
|
||||
def test_tab_is_9(self):
|
||||
assert Key.TAB == 9
|
||||
|
||||
def test_backspace_is_127(self):
|
||||
assert Key.BACKSPACE == 127
|
||||
|
||||
def test_arrow_keys_are_not_none(self):
|
||||
assert Key.UP is not None
|
||||
assert Key.DOWN is not None
|
||||
assert Key.LEFT is not None
|
||||
assert Key.RIGHT is not None
|
||||
|
||||
def test_page_keys_are_not_none(self):
|
||||
assert Key.PGUP is not None
|
||||
assert Key.PGDN is not None
|
||||
|
||||
def test_home_end_are_not_none(self):
|
||||
assert Key.HOME is not None
|
||||
assert Key.END is not None
|
||||
|
||||
def test_f1_is_not_none(self):
|
||||
assert Key.F1 is not None
|
||||
|
||||
def test_f10_is_not_none(self):
|
||||
assert Key.F10 is not None
|
||||
|
||||
def test_f_keys_are_sequential(self):
|
||||
"""F1 through F10 should be sequential curses key codes."""
|
||||
for i in range(1, 10):
|
||||
f_current = getattr(Key, f"F{i}")
|
||||
f_next = getattr(Key, f"F{i + 1}")
|
||||
assert f_next == f_current + 1
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# KeyBindings
|
||||
# =========================================================================
|
||||
|
||||
class TestKeyBindings:
|
||||
def test_bind_and_handle_calls_callback(self):
|
||||
kb = KeyBindings()
|
||||
called = []
|
||||
kb.bind(Key.ENTER, lambda: called.append(True))
|
||||
result = kb.handle(Key.ENTER)
|
||||
assert result is True
|
||||
assert len(called) == 1
|
||||
|
||||
def test_handle_returns_false_for_unbound_key(self):
|
||||
kb = KeyBindings()
|
||||
result = kb.handle(Key.ESCAPE)
|
||||
assert result is False
|
||||
|
||||
def test_bind_overwrites_previous(self):
|
||||
kb = KeyBindings()
|
||||
called_a = []
|
||||
called_b = []
|
||||
kb.bind(Key.ENTER, lambda: called_a.append(True))
|
||||
kb.bind(Key.ENTER, lambda: called_b.append(True))
|
||||
kb.handle(Key.ENTER)
|
||||
assert len(called_a) == 0
|
||||
assert len(called_b) == 1
|
||||
|
||||
def test_multiple_bindings(self):
|
||||
kb = KeyBindings()
|
||||
results = {}
|
||||
kb.bind(Key.ENTER, lambda: results.update(enter=True))
|
||||
kb.bind(Key.ESCAPE, lambda: results.update(escape=True))
|
||||
kb.handle(Key.ENTER)
|
||||
kb.handle(Key.ESCAPE)
|
||||
assert results == {"enter": True, "escape": True}
|
||||
|
||||
def test_set_footer_and_get_footer_labels(self):
|
||||
kb = KeyBindings()
|
||||
labels = [("F1", "Help"), ("F10", "Quit")]
|
||||
kb.set_footer(labels)
|
||||
assert kb.get_footer_labels() == labels
|
||||
|
||||
def test_get_footer_labels_empty_by_default(self):
|
||||
kb = KeyBindings()
|
||||
assert kb.get_footer_labels() == []
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Navigation
|
||||
# =========================================================================
|
||||
|
||||
class TestNavigation:
|
||||
def test_initial_state_is_empty(self):
|
||||
nav = Navigation()
|
||||
assert nav.current() is None
|
||||
assert nav.depth() == 0
|
||||
|
||||
def test_push_and_current(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands", context={"page": 1}, label="Brands")
|
||||
result = nav.current()
|
||||
assert result is not None
|
||||
screen_name, context = result
|
||||
assert screen_name == "brands"
|
||||
assert context == {"page": 1}
|
||||
|
||||
def test_push_increases_depth(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands", label="Brands")
|
||||
assert nav.depth() == 1
|
||||
nav.push("models", label="Models")
|
||||
assert nav.depth() == 2
|
||||
|
||||
def test_pop_returns_previous_screen(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands", context={"page": 1}, label="Brands")
|
||||
nav.push("models", context={"brand": "TOYOTA"}, label="Models")
|
||||
popped = nav.pop()
|
||||
assert popped is not None
|
||||
screen_name, context = popped
|
||||
assert screen_name == "models"
|
||||
assert context == {"brand": "TOYOTA"}
|
||||
# Current should now be brands
|
||||
current = nav.current()
|
||||
assert current[0] == "brands"
|
||||
|
||||
def test_pop_on_empty_returns_none(self):
|
||||
nav = Navigation()
|
||||
result = nav.pop()
|
||||
assert result is None
|
||||
|
||||
def test_pop_on_single_item_returns_it_and_empties(self):
|
||||
nav = Navigation()
|
||||
nav.push("home", label="Home")
|
||||
popped = nav.pop()
|
||||
assert popped is not None
|
||||
assert popped[0] == "home"
|
||||
assert nav.current() is None
|
||||
assert nav.depth() == 0
|
||||
|
||||
def test_breadcrumb_returns_label_list(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands", label="Brands")
|
||||
nav.push("models", label="Toyota Models")
|
||||
nav.push("years", label="2020")
|
||||
assert nav.breadcrumb() == ["Brands", "Toyota Models", "2020"]
|
||||
|
||||
def test_breadcrumb_empty_when_no_items(self):
|
||||
nav = Navigation()
|
||||
assert nav.breadcrumb() == []
|
||||
|
||||
def test_breadcrumb_uses_screen_name_as_fallback(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands")
|
||||
assert nav.breadcrumb() == ["brands"]
|
||||
|
||||
def test_clear_empties_stack(self):
|
||||
nav = Navigation()
|
||||
nav.push("brands", label="Brands")
|
||||
nav.push("models", label="Models")
|
||||
nav.clear()
|
||||
assert nav.depth() == 0
|
||||
assert nav.current() is None
|
||||
assert nav.breadcrumb() == []
|
||||
|
||||
def test_context_defaults_to_none(self):
|
||||
nav = Navigation()
|
||||
nav.push("home", label="Home")
|
||||
screen_name, context = nav.current()
|
||||
assert context is None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Screen base class
|
||||
# =========================================================================
|
||||
|
||||
class TestScreen:
|
||||
def test_has_name_and_title(self):
|
||||
screen = Screen("brands", "Select Brand")
|
||||
assert screen.name == "brands"
|
||||
assert screen.title == "Select Brand"
|
||||
|
||||
def test_on_enter_is_callable(self):
|
||||
screen = Screen("test", "Test")
|
||||
# Should not raise
|
||||
screen.on_enter(context=None, db=None, renderer=None)
|
||||
|
||||
def test_on_key_is_callable(self):
|
||||
screen = Screen("test", "Test")
|
||||
# Should not raise, returns None by default
|
||||
result = screen.on_key(key=10, context=None, db=None, renderer=None, nav=None)
|
||||
assert result is None
|
||||
|
||||
def test_render_is_callable(self):
|
||||
screen = Screen("test", "Test")
|
||||
# Should not raise
|
||||
screen.render(context=None, db=None, renderer=None)
|
||||
273
console/tests/test_db.py
Normal file
273
console/tests/test_db.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Tests for the Database abstraction layer.
|
||||
|
||||
All tests run against the real SQLite database at vehicle_database/vehicle_database.db.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from console.db import Database
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db():
|
||||
"""Provide a shared Database instance for all tests in this module."""
|
||||
return Database()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Vehicle navigation
|
||||
# =========================================================================
|
||||
|
||||
class TestGetBrands:
|
||||
def test_returns_nonempty_list(self, db):
|
||||
brands = db.get_brands()
|
||||
assert isinstance(brands, list)
|
||||
assert len(brands) > 0
|
||||
|
||||
def test_each_brand_has_name_key(self, db):
|
||||
brands = db.get_brands()
|
||||
for b in brands:
|
||||
assert "name" in b
|
||||
|
||||
def test_each_brand_has_id_and_country(self, db):
|
||||
brands = db.get_brands()
|
||||
for b in brands:
|
||||
assert "id" in b
|
||||
assert "country" in b
|
||||
|
||||
|
||||
class TestGetModels:
|
||||
def test_no_filter_returns_nonempty(self, db):
|
||||
models = db.get_models()
|
||||
assert isinstance(models, list)
|
||||
assert len(models) > 0
|
||||
|
||||
def test_filter_by_uppercase_brand(self, db):
|
||||
models = db.get_models(brand="TOYOTA")
|
||||
assert isinstance(models, list)
|
||||
assert len(models) > 0
|
||||
|
||||
def test_filter_by_lowercase_brand(self, db):
|
||||
"""Brand filtering must be case-insensitive."""
|
||||
models = db.get_models(brand="toyota")
|
||||
assert isinstance(models, list)
|
||||
assert len(models) > 0
|
||||
|
||||
def test_each_model_has_id_and_name(self, db):
|
||||
models = db.get_models()
|
||||
for m in models[:5]:
|
||||
assert "id" in m
|
||||
assert "name" in m
|
||||
|
||||
|
||||
class TestGetYears:
|
||||
def test_returns_list(self, db):
|
||||
years = db.get_years()
|
||||
assert isinstance(years, list)
|
||||
assert len(years) > 0
|
||||
|
||||
def test_filter_by_brand(self, db):
|
||||
years = db.get_years(brand="TOYOTA")
|
||||
assert isinstance(years, list)
|
||||
assert len(years) > 0
|
||||
|
||||
def test_each_year_has_id_and_year(self, db):
|
||||
years = db.get_years()
|
||||
for y in years[:5]:
|
||||
assert "id" in y
|
||||
assert "year" in y
|
||||
|
||||
|
||||
class TestGetEngines:
|
||||
def test_returns_list(self, db):
|
||||
engines = db.get_engines()
|
||||
assert isinstance(engines, list)
|
||||
assert len(engines) > 0
|
||||
|
||||
def test_filter_by_brand(self, db):
|
||||
engines = db.get_engines(brand="TOYOTA")
|
||||
assert isinstance(engines, list)
|
||||
assert len(engines) > 0
|
||||
|
||||
|
||||
class TestGetModelYearEngine:
|
||||
def test_returns_list(self, db):
|
||||
result = db.get_model_year_engine(
|
||||
brand="TOYOTA", model="Corolla", year=2020, engine_id=None
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Parts catalog
|
||||
# =========================================================================
|
||||
|
||||
class TestGetCategories:
|
||||
def test_returns_exactly_12(self, db):
|
||||
categories = db.get_categories()
|
||||
assert isinstance(categories, list)
|
||||
assert len(categories) == 12
|
||||
|
||||
def test_each_has_expected_keys(self, db):
|
||||
categories = db.get_categories()
|
||||
for c in categories:
|
||||
assert "id" in c
|
||||
assert "name" in c
|
||||
|
||||
|
||||
class TestGetGroups:
|
||||
def test_returns_nonempty_for_known_category(self, db):
|
||||
groups = db.get_groups(category_id=2)
|
||||
assert isinstance(groups, list)
|
||||
assert len(groups) > 0
|
||||
|
||||
def test_each_group_has_name(self, db):
|
||||
groups = db.get_groups(category_id=2)
|
||||
for g in groups:
|
||||
assert "name" in g
|
||||
|
||||
|
||||
class TestGetParts:
|
||||
def test_returns_list(self, db):
|
||||
parts = db.get_parts()
|
||||
assert isinstance(parts, list)
|
||||
assert len(parts) > 0
|
||||
|
||||
def test_pagination(self, db):
|
||||
page1 = db.get_parts(page=1, per_page=5)
|
||||
page2 = db.get_parts(page=2, per_page=5)
|
||||
assert len(page1) <= 5
|
||||
assert len(page2) <= 5
|
||||
# Pages should contain different items (if enough data)
|
||||
if page1 and page2:
|
||||
ids1 = {p["id"] for p in page1}
|
||||
ids2 = {p["id"] for p in page2}
|
||||
assert ids1.isdisjoint(ids2)
|
||||
|
||||
|
||||
class TestGetPart:
|
||||
def test_returns_dict_with_oem_part_number(self, db):
|
||||
part = db.get_part(1)
|
||||
assert isinstance(part, dict)
|
||||
assert "oem_part_number" in part
|
||||
|
||||
def test_includes_group_and_category_info(self, db):
|
||||
part = db.get_part(1)
|
||||
assert "group_name" in part
|
||||
assert "category_name" in part
|
||||
|
||||
def test_nonexistent_returns_none(self, db):
|
||||
part = db.get_part(999999)
|
||||
assert part is None
|
||||
|
||||
|
||||
class TestGetAlternatives:
|
||||
def test_returns_list(self, db):
|
||||
alts = db.get_alternatives(1)
|
||||
assert isinstance(alts, list)
|
||||
|
||||
|
||||
class TestGetCrossReferences:
|
||||
def test_returns_list(self, db):
|
||||
refs = db.get_cross_references(1)
|
||||
assert isinstance(refs, list)
|
||||
|
||||
|
||||
class TestGetVehiclesForPart:
|
||||
def test_returns_list(self, db):
|
||||
vehicles = db.get_vehicles_for_part(1)
|
||||
assert isinstance(vehicles, list)
|
||||
assert len(vehicles) > 0
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Search
|
||||
# =========================================================================
|
||||
|
||||
class TestSearchParts:
|
||||
def test_returns_results_for_brake(self, db):
|
||||
results = db.search_parts("brake")
|
||||
assert isinstance(results, list)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_each_result_has_expected_keys(self, db):
|
||||
results = db.search_parts("brake")
|
||||
for r in results[:3]:
|
||||
assert "id" in r
|
||||
assert "name" in r
|
||||
assert "oem_part_number" in r
|
||||
|
||||
|
||||
class TestSearchPartNumber:
|
||||
def test_returns_results_for_04465(self, db):
|
||||
results = db.search_part_number("04465")
|
||||
assert isinstance(results, list)
|
||||
assert len(results) > 0
|
||||
|
||||
def test_each_result_has_match_type(self, db):
|
||||
results = db.search_part_number("04465")
|
||||
for r in results:
|
||||
assert "match_type" in r
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# VIN cache
|
||||
# =========================================================================
|
||||
|
||||
class TestVinCache:
|
||||
def test_get_nonexistent_vin_returns_none(self, db):
|
||||
result = db.get_vin_cache("00000000000000000")
|
||||
assert result is None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Stats
|
||||
# =========================================================================
|
||||
|
||||
class TestGetStats:
|
||||
def test_returns_dict_with_required_keys(self, db):
|
||||
stats = db.get_stats()
|
||||
assert isinstance(stats, dict)
|
||||
assert "brands" in stats
|
||||
assert "models" in stats
|
||||
assert "parts" in stats
|
||||
|
||||
def test_counts_are_positive(self, db):
|
||||
stats = db.get_stats()
|
||||
assert stats["brands"] > 0
|
||||
assert stats["models"] > 0
|
||||
assert stats["parts"] > 0
|
||||
|
||||
def test_includes_top_brands(self, db):
|
||||
stats = db.get_stats()
|
||||
assert "top_brands" in stats
|
||||
assert isinstance(stats["top_brands"], list)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Manufacturers
|
||||
# =========================================================================
|
||||
|
||||
class TestGetManufacturers:
|
||||
def test_returns_nonempty_list(self, db):
|
||||
manufacturers = db.get_manufacturers()
|
||||
assert isinstance(manufacturers, list)
|
||||
assert len(manufacturers) > 0
|
||||
|
||||
def test_each_has_name(self, db):
|
||||
manufacturers = db.get_manufacturers()
|
||||
for m in manufacturers:
|
||||
assert "name" in m
|
||||
assert "id" in m
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Admin CRUD — smoke tests
|
||||
# =========================================================================
|
||||
|
||||
class TestCrossrefsPaginated:
|
||||
def test_returns_list(self, db):
|
||||
refs = db.get_crossrefs_paginated(page=1, per_page=5)
|
||||
assert isinstance(refs, list)
|
||||
assert len(refs) <= 5
|
||||
277
console/tests/test_integration.py
Normal file
277
console/tests/test_integration.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Integration tests for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Uses a MockRenderer that records draw calls instead of painting to a real
|
||||
terminal, allowing end-to-end testing of the screen -> renderer pipeline
|
||||
without curses.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from console.renderers.base import BaseRenderer
|
||||
from console.core.navigation import Navigation
|
||||
from console.db import Database
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# MockRenderer
|
||||
# =========================================================================
|
||||
|
||||
class MockRenderer(BaseRenderer):
|
||||
"""A renderer that records all draw calls for later assertion.
|
||||
|
||||
Pre-load ``self.keys`` with a sequence of key codes; ``get_key()``
|
||||
pops from the front and returns ESC (27) when the list is exhausted.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = [] # list of (method_name, args, kwargs)
|
||||
self.keys = [] # pre-loaded key presses
|
||||
self._size = (24, 80) # rows, cols
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------
|
||||
|
||||
def init_screen(self):
|
||||
self.calls.append(('init_screen', (), {}))
|
||||
|
||||
def cleanup(self):
|
||||
self.calls.append(('cleanup', (), {}))
|
||||
|
||||
# -- Screen queries -----------------------------------------------------
|
||||
|
||||
def get_size(self):
|
||||
return self._size
|
||||
|
||||
# -- Primitive operations -----------------------------------------------
|
||||
|
||||
def clear(self):
|
||||
self.calls.append(('clear', (), {}))
|
||||
|
||||
def refresh(self):
|
||||
self.calls.append(('refresh', (), {}))
|
||||
|
||||
def get_key(self):
|
||||
if self.keys:
|
||||
return self.keys.pop(0)
|
||||
return 27 # ESC to exit
|
||||
|
||||
# -- High-level widgets -------------------------------------------------
|
||||
|
||||
def draw_header(self, title, subtitle=''):
|
||||
self.calls.append(('draw_header', (title, subtitle), {}))
|
||||
|
||||
def draw_footer(self, key_labels):
|
||||
self.calls.append(('draw_footer', (key_labels,), {}))
|
||||
|
||||
def draw_menu(self, items, selected_index=0, title=''):
|
||||
self.calls.append(('draw_menu', (items, selected_index), {}))
|
||||
|
||||
def draw_table(self, headers, rows, widths, page_info=None,
|
||||
selected_row=-1):
|
||||
self.calls.append(('draw_table', (headers, rows, widths), {}))
|
||||
|
||||
def draw_detail(self, fields, title=''):
|
||||
self.calls.append(('draw_detail', (fields,), {}))
|
||||
|
||||
def draw_form(self, fields, focused_index=0, title=''):
|
||||
self.calls.append(('draw_form', (fields, focused_index), {}))
|
||||
|
||||
def draw_filter_list(self, items, filter_text, selected_index, title=''):
|
||||
self.calls.append(('draw_filter_list', (items, filter_text, selected_index), {}))
|
||||
|
||||
def draw_comparison(self, columns, title=''):
|
||||
self.calls.append(('draw_comparison', (columns,), {}))
|
||||
|
||||
# -- Low-level drawing --------------------------------------------------
|
||||
|
||||
def draw_text(self, row, col, text, style='normal'):
|
||||
self.calls.append(('draw_text', (row, col, text, style), {}))
|
||||
|
||||
def draw_box(self, top, left, height, width, title=''):
|
||||
self.calls.append(('draw_box', (top, left, height, width), {}))
|
||||
|
||||
# -- Dialogs ------------------------------------------------------------
|
||||
|
||||
def show_message(self, text, msg_type='info'):
|
||||
self.calls.append(('show_message', (text, msg_type), {}))
|
||||
if msg_type == 'confirm':
|
||||
return True # auto-confirm
|
||||
return True
|
||||
|
||||
def show_input(self, prompt, max_len=40):
|
||||
self.calls.append(('show_input', (prompt, max_len), {}))
|
||||
return None # cancel by default
|
||||
|
||||
# -- Helpers for assertions ---------------------------------------------
|
||||
|
||||
def method_names(self):
|
||||
"""Return a list of just the method names from recorded calls."""
|
||||
return [name for name, _args, _kwargs in self.calls]
|
||||
|
||||
def calls_for(self, method_name):
|
||||
"""Return only the calls matching *method_name*."""
|
||||
return [(args, kwargs) for name, args, kwargs in self.calls
|
||||
if name == method_name]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixtures
|
||||
# =========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_renderer():
|
||||
"""Provide a fresh MockRenderer for each test."""
|
||||
return MockRenderer()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db():
|
||||
"""Provide a shared Database instance for integration tests."""
|
||||
return Database()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 1: App creates with screens
|
||||
# =========================================================================
|
||||
|
||||
class TestAppCreatesWithScreens:
|
||||
def test_app_creates_with_screens(self, mock_renderer, db):
|
||||
"""App should register at least 'menu' and 'estadisticas' screens."""
|
||||
from console.core.app import App
|
||||
app = App(renderer=mock_renderer, db=db)
|
||||
|
||||
assert 'menu' in app.screens
|
||||
assert 'estadisticas' in app.screens
|
||||
assert len(app.screens) >= 2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 2: App runs and quits
|
||||
# =========================================================================
|
||||
|
||||
class TestAppRunsAndQuits:
|
||||
def test_app_runs_and_quits(self, mock_renderer, db):
|
||||
"""Pre-load ESC + confirm-yes (ord('s')). App should exit cleanly."""
|
||||
from console.core.app import App
|
||||
|
||||
# ESC triggers quit dialog, show_message auto-confirms True
|
||||
mock_renderer.keys = [27] # ESC
|
||||
|
||||
app = App(renderer=mock_renderer, db=db)
|
||||
app.run() # should not raise
|
||||
|
||||
# Verify init_screen and cleanup were both called
|
||||
names = mock_renderer.method_names()
|
||||
assert 'init_screen' in names
|
||||
assert 'cleanup' in names
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 3: Menu renders
|
||||
# =========================================================================
|
||||
|
||||
class TestMenuRenders:
|
||||
def test_menu_renders(self, mock_renderer, db):
|
||||
"""MenuPrincipalScreen.render() should call draw_header and draw_menu."""
|
||||
from console.screens.menu_principal import MenuPrincipalScreen
|
||||
|
||||
screen = MenuPrincipalScreen()
|
||||
screen.render(context={}, db=db, renderer=mock_renderer)
|
||||
|
||||
names = mock_renderer.method_names()
|
||||
assert 'draw_header' in names
|
||||
assert 'draw_menu' in names
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 4: Estadisticas renders
|
||||
# =========================================================================
|
||||
|
||||
class TestEstadisticasRenders:
|
||||
def test_estadisticas_renders(self, mock_renderer, db):
|
||||
"""EstadisticasScreen.render() should call draw_header and draw_text."""
|
||||
from console.screens.estadisticas import EstadisticasScreen
|
||||
|
||||
screen = EstadisticasScreen()
|
||||
screen.render(context={}, db=db, renderer=mock_renderer)
|
||||
|
||||
names = mock_renderer.method_names()
|
||||
assert 'draw_header' in names
|
||||
# EstadisticasScreen uses draw_text for its detail fields
|
||||
assert 'draw_text' in names
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 5: Navigation integration
|
||||
# =========================================================================
|
||||
|
||||
class TestNavigationIntegration:
|
||||
def test_navigation_push_pop_breadcrumb(self):
|
||||
"""Push menu, push estadisticas, verify breadcrumb, pop, verify current."""
|
||||
nav = Navigation()
|
||||
nav.push('menu', {}, label='Menu')
|
||||
nav.push('estadisticas', {}, label='Estadisticas')
|
||||
|
||||
# Breadcrumb should show both
|
||||
assert nav.breadcrumb() == ['Menu', 'Estadisticas']
|
||||
assert nav.depth() == 2
|
||||
|
||||
# Current should be estadisticas
|
||||
current = nav.current()
|
||||
assert current is not None
|
||||
assert current[0] == 'estadisticas'
|
||||
|
||||
# Pop estadisticas
|
||||
popped = nav.pop()
|
||||
assert popped[0] == 'estadisticas'
|
||||
|
||||
# Now current should be menu
|
||||
current = nav.current()
|
||||
assert current is not None
|
||||
assert current[0] == 'menu'
|
||||
assert nav.breadcrumb() == ['Menu']
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Test 6: All screens instantiate
|
||||
# =========================================================================
|
||||
|
||||
class TestAllScreensInstantiate:
|
||||
"""Import and instantiate all 13 screen classes, verifying each has
|
||||
name and title attributes."""
|
||||
|
||||
# (module_path, class_name)
|
||||
_SCREEN_CLASSES = [
|
||||
("console.screens.menu_principal", "MenuPrincipalScreen"),
|
||||
("console.screens.estadisticas", "EstadisticasScreen"),
|
||||
("console.screens.vehiculo_nav", "VehiculoNavScreen"),
|
||||
("console.screens.buscar_parte", "BuscarParteScreen"),
|
||||
("console.screens.buscar_texto", "BuscarTextoScreen"),
|
||||
("console.screens.vin_decoder", "VinDecoderScreen"),
|
||||
("console.screens.catalogo", "CatalogoScreen"),
|
||||
("console.screens.parte_detalle", "ParteDetalleScreen"),
|
||||
("console.screens.comparador", "ComparadorScreen"),
|
||||
("console.screens.admin_partes", "AdminPartesScreen"),
|
||||
("console.screens.admin_fabricantes", "AdminFabricantesScreen"),
|
||||
("console.screens.admin_crossref", "AdminCrossrefScreen"),
|
||||
("console.screens.admin_import", "AdminImportScreen"),
|
||||
]
|
||||
|
||||
def test_all_13_screens_exist(self):
|
||||
"""All 13 screen modules should be importable."""
|
||||
assert len(self._SCREEN_CLASSES) == 13
|
||||
|
||||
@pytest.mark.parametrize("module_path,class_name", _SCREEN_CLASSES)
|
||||
def test_screen_instantiates(self, module_path, class_name):
|
||||
"""Each screen class should instantiate and have name + title."""
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
cls = getattr(mod, class_name)
|
||||
|
||||
instance = cls()
|
||||
assert hasattr(instance, 'name')
|
||||
assert hasattr(instance, 'title')
|
||||
assert isinstance(instance.name, str)
|
||||
assert isinstance(instance.title, str)
|
||||
assert len(instance.name) > 0
|
||||
assert len(instance.title) > 0
|
||||
168
console/tests/test_utils.py
Normal file
168
console/tests/test_utils.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests for the formatting utility functions.
|
||||
|
||||
VIN API tests are excluded because they require network access.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from console.utils.formatting import (
|
||||
format_currency,
|
||||
format_number,
|
||||
truncate,
|
||||
pad_right,
|
||||
format_table_row,
|
||||
quality_bar,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# format_currency
|
||||
# =========================================================================
|
||||
|
||||
class TestFormatCurrency:
|
||||
def test_none_returns_dash(self):
|
||||
assert format_currency(None) == "──"
|
||||
|
||||
def test_zero_returns_zero_dollars(self):
|
||||
assert format_currency(0) == "$0.00"
|
||||
|
||||
def test_positive_value(self):
|
||||
assert format_currency(45.99) == "$45.99"
|
||||
|
||||
def test_integer_value(self):
|
||||
assert format_currency(100) == "$100.00"
|
||||
|
||||
def test_large_value_with_commas(self):
|
||||
assert format_currency(1234.56) == "$1,234.56"
|
||||
|
||||
def test_small_decimal(self):
|
||||
assert format_currency(0.5) == "$0.50"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# format_number
|
||||
# =========================================================================
|
||||
|
||||
class TestFormatNumber:
|
||||
def test_none_returns_zero(self):
|
||||
assert format_number(None) == "0"
|
||||
|
||||
def test_zero(self):
|
||||
assert format_number(0) == "0"
|
||||
|
||||
def test_thousands_separator(self):
|
||||
assert format_number(13685) == "13,685"
|
||||
|
||||
def test_small_number(self):
|
||||
assert format_number(42) == "42"
|
||||
|
||||
def test_million(self):
|
||||
assert format_number(1000000) == "1,000,000"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# truncate
|
||||
# =========================================================================
|
||||
|
||||
class TestTruncate:
|
||||
def test_none_returns_empty(self):
|
||||
assert truncate(None, 10) == ""
|
||||
|
||||
def test_short_string_unchanged(self):
|
||||
assert truncate("hello", 10) == "hello"
|
||||
|
||||
def test_exact_length_unchanged(self):
|
||||
assert truncate("hello", 5) == "hello"
|
||||
|
||||
def test_long_string_truncated_with_ellipsis(self):
|
||||
assert truncate("hello world!", 8) == "hello..."
|
||||
|
||||
def test_very_short_max_len(self):
|
||||
result = truncate("hello world", 3)
|
||||
assert result == "..."
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# pad_right
|
||||
# =========================================================================
|
||||
|
||||
class TestPadRight:
|
||||
def test_none_returns_empty(self):
|
||||
assert pad_right(None, 10) == ""
|
||||
|
||||
def test_short_string_padded(self):
|
||||
result = pad_right("hi", 5)
|
||||
assert result == "hi "
|
||||
assert len(result) == 5
|
||||
|
||||
def test_exact_length_unchanged(self):
|
||||
result = pad_right("hello", 5)
|
||||
assert result == "hello"
|
||||
|
||||
def test_long_string_truncated(self):
|
||||
result = pad_right("hello world", 5)
|
||||
assert result == "hello"
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# format_table_row
|
||||
# =========================================================================
|
||||
|
||||
class TestFormatTableRow:
|
||||
def test_basic_row(self):
|
||||
result = format_table_row(["A", "B", "C"], [5, 5, 5])
|
||||
assert " │ " in result
|
||||
assert len(result.split(" │ ")) == 3
|
||||
|
||||
def test_values_padded_to_widths(self):
|
||||
result = format_table_row(["hi", "there"], [5, 7])
|
||||
parts = result.split(" │ ")
|
||||
assert len(parts[0]) == 5
|
||||
assert len(parts[1]) == 7
|
||||
|
||||
def test_custom_separator(self):
|
||||
result = format_table_row(["A", "B"], [3, 3], separator=" | ")
|
||||
assert " | " in result
|
||||
|
||||
def test_truncation_when_value_exceeds_width(self):
|
||||
result = format_table_row(["toolongvalue", "ok"], [5, 5])
|
||||
parts = result.split(" │ ")
|
||||
assert len(parts[0]) == 5
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# quality_bar
|
||||
# =========================================================================
|
||||
|
||||
class TestQualityBar:
|
||||
def test_oem(self):
|
||||
result = quality_bar("oem")
|
||||
assert "█" in result
|
||||
assert len(result) > 0
|
||||
|
||||
def test_premium(self):
|
||||
result = quality_bar("premium")
|
||||
assert "█" in result
|
||||
|
||||
def test_standard(self):
|
||||
result = quality_bar("standard")
|
||||
assert "█" in result
|
||||
assert "░" in result
|
||||
|
||||
def test_economy(self):
|
||||
result = quality_bar("economy")
|
||||
assert "█" in result
|
||||
assert "░" in result
|
||||
|
||||
def test_oem_longer_than_economy(self):
|
||||
oem = quality_bar("oem")
|
||||
economy = quality_bar("economy")
|
||||
oem_blocks = oem.count("█")
|
||||
economy_blocks = economy.count("█")
|
||||
assert oem_blocks > economy_blocks
|
||||
|
||||
def test_unknown_tier_returns_string(self):
|
||||
result = quality_bar("unknown")
|
||||
assert isinstance(result, str)
|
||||
0
console/utils/__init__.py
Normal file
0
console/utils/__init__.py
Normal file
86
console/utils/formatting.py
Normal file
86
console/utils/formatting.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Display formatting utilities for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Functions for currency, numbers, text truncation, table layout, and
|
||||
quality-tier visual bars.
|
||||
"""
|
||||
|
||||
|
||||
def format_currency(value) -> str:
|
||||
"""Format a numeric value as USD currency.
|
||||
|
||||
None -> '──'
|
||||
0 -> '$0.00'
|
||||
45.99 -> '$45.99'
|
||||
"""
|
||||
if value is None:
|
||||
return "──"
|
||||
return f"${value:,.2f}"
|
||||
|
||||
|
||||
def format_number(value) -> str:
|
||||
"""Format an integer with thousands separators.
|
||||
|
||||
None -> '0'
|
||||
13685 -> '13,685'
|
||||
"""
|
||||
if value is None:
|
||||
return "0"
|
||||
return f"{value:,}"
|
||||
|
||||
|
||||
def truncate(text, max_len) -> str:
|
||||
"""Truncate text to *max_len* characters, appending '...' if trimmed.
|
||||
|
||||
None -> ''
|
||||
fits -> text unchanged
|
||||
too long -> text[:max_len-3] + '...'
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def pad_right(text, width) -> str:
|
||||
"""Pad *text* to *width* with spaces on the right, or truncate if longer.
|
||||
|
||||
None -> ''
|
||||
fits -> ljust(width)
|
||||
too long -> text[:width]
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
if len(text) > width:
|
||||
return text[:width]
|
||||
return text.ljust(width)
|
||||
|
||||
|
||||
def format_table_row(values, widths, separator=" │ ") -> str:
|
||||
"""Join *values* padded to corresponding *widths* with *separator*.
|
||||
|
||||
Each value is passed through :func:`pad_right` to ensure uniform column
|
||||
widths, then all columns are joined by the separator string.
|
||||
"""
|
||||
cells = [pad_right(str(v), w) for v, w in zip(values, widths)]
|
||||
return separator.join(cells)
|
||||
|
||||
|
||||
# ── Quality-tier bars ──────────────────────────────────────────────────
|
||||
|
||||
_QUALITY_BARS = {
|
||||
"oem": "███████████",
|
||||
"premium": "██████████░",
|
||||
"standard": "███████░░░░",
|
||||
"economy": "█████░░░░░░",
|
||||
}
|
||||
|
||||
|
||||
def quality_bar(tier) -> str:
|
||||
"""Return a Unicode block-bar representing a quality tier.
|
||||
|
||||
Recognised tiers: oem, premium, standard, economy.
|
||||
Unknown tiers fall back to a minimal bar.
|
||||
"""
|
||||
return _QUALITY_BARS.get(tier, "░░░░░░░░░░░")
|
||||
93
console/utils/vin_api.py
Normal file
93
console/utils/vin_api.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
NHTSA VIN Decoder API client for the NEXUS AUTOPARTS console application.
|
||||
|
||||
Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle
|
||||
Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle
|
||||
specifications from a 17-character VIN.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
from console.config import NHTSA_API_URL
|
||||
|
||||
|
||||
# NHTSA result variables we care about, mapped to our internal keys.
|
||||
_FIELD_MAP = {
|
||||
"Make": "make",
|
||||
"Model": "model",
|
||||
"Model Year": "year",
|
||||
"Body Class": "body_class",
|
||||
"Drive Type": "drive_type",
|
||||
"Displacement (L)": "displacement_l",
|
||||
"Engine Number of Cylinders": "cylinders",
|
||||
"Fuel Type - Primary": "fuel_type",
|
||||
"Engine Brake (hp) From": "power_hp",
|
||||
}
|
||||
|
||||
|
||||
def decode_vin_nhtsa(vin: str) -> dict:
|
||||
"""Decode a VIN using the NHTSA vPIC API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
vin : str
|
||||
A 17-character Vehicle Identification Number.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
On success::
|
||||
|
||||
{
|
||||
"make": "TOYOTA",
|
||||
"model": "Corolla",
|
||||
"year": "2020",
|
||||
"body_class": "Sedan/Saloon",
|
||||
"drive_type": "FWD",
|
||||
"engine_info": {
|
||||
"displacement_l": "2.0",
|
||||
"cylinders": "4",
|
||||
"fuel_type": "Gasoline",
|
||||
"power_hp": "169",
|
||||
"raw": { ... full variable->value mapping ... },
|
||||
},
|
||||
}
|
||||
|
||||
On error::
|
||||
|
||||
{"error": "<description>"}
|
||||
"""
|
||||
try:
|
||||
url = f"{NHTSA_API_URL}/{vin}"
|
||||
response = requests.get(url, params={"format": "json"}, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
results = data.get("Results", [])
|
||||
|
||||
# Build a flat lookup: variable name -> value (skip empty/None)
|
||||
raw: dict[str, str] = {}
|
||||
for item in results:
|
||||
var = item.get("Variable", "")
|
||||
val = item.get("Value")
|
||||
if val and str(val).strip():
|
||||
raw[var] = str(val).strip()
|
||||
|
||||
# Extract top-level vehicle fields
|
||||
vehicle: dict = {}
|
||||
engine_info: dict = {"raw": raw}
|
||||
|
||||
engine_keys = {"displacement_l", "cylinders", "fuel_type", "power_hp"}
|
||||
|
||||
for nhtsa_var, our_key in _FIELD_MAP.items():
|
||||
value = raw.get(nhtsa_var, "")
|
||||
if our_key in engine_keys:
|
||||
engine_info[our_key] = value
|
||||
else:
|
||||
vehicle[our_key] = value
|
||||
|
||||
vehicle["engine_info"] = engine_info
|
||||
return vehicle
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -3,78 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 rel="stylesheet" href="/shared.css">
|
||||
<style>
|
||||
/* Admin-specific variable overrides */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-tertiary: #1a1a25;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #8888aa;
|
||||
--accent: #ff6b35;
|
||||
--accent-hover: #ff8555;
|
||||
--success: #00d68f;
|
||||
--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 */
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
@@ -257,39 +201,7 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Forms - admin-specific */
|
||||
select.form-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -451,28 +363,6 @@
|
||||
.badge-premium { background: #5a5a2a; color: #ffff7f; }
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -701,21 +591,12 @@
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/" class="logo">AUTOPARTES DB</a>
|
||||
<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>
|
||||
<!-- Shared Navigation -->
|
||||
<div id="shared-nav"></div>
|
||||
<script src="/nav.js"></script>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sidebar -->
|
||||
@@ -768,6 +649,14 @@
|
||||
</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">
|
||||
<h3>Importar/Exportar</h3>
|
||||
<div class="sidebar-item" data-section="import">
|
||||
@@ -779,6 +668,15 @@
|
||||
<span>Exportar CSV</span>
|
||||
</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>
|
||||
|
||||
<!-- Main Content -->
|
||||
@@ -1237,6 +1135,116 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Admin Panel JavaScript
|
||||
* CRUD operations and CSV import/export for Autopartes DB
|
||||
* CRUD operations and CSV import/export for Nexus Autoparts
|
||||
*/
|
||||
|
||||
// State
|
||||
@@ -115,6 +115,12 @@ function showSection(sectionId) {
|
||||
case 'fitment':
|
||||
loadFitment();
|
||||
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;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/model-year-engine');
|
||||
const vehicles = await response.json();
|
||||
const response = await fetch('/api/model-year-engine?per_page=100');
|
||||
const result = await response.json();
|
||||
const vehicles = result.data || result;
|
||||
|
||||
select.innerHTML = '<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>`
|
||||
).join('');
|
||||
} catch (e) {
|
||||
@@ -1222,18 +1229,18 @@ function renderPagination(containerId, pagination, pageKey, loadFunction) {
|
||||
let html = '';
|
||||
|
||||
// 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
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(total_pages, page + 2);
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
@@ -1558,8 +1565,9 @@ async function loadBulkEngines() {
|
||||
const engines = await response.json();
|
||||
|
||||
// Get MYE IDs for each engine
|
||||
const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}`);
|
||||
const myeData = await myeResponse.json();
|
||||
const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}&per_page=100`);
|
||||
const myeResult = await myeResponse.json();
|
||||
const myeData = myeResult.data || myeResult;
|
||||
|
||||
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>' +
|
||||
myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).join('');
|
||||
@@ -1707,3 +1715,362 @@ showSection = function(sectionId) {
|
||||
initBulkEditor();
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Diagram Hotspot Editor
|
||||
// ============================================================================
|
||||
|
||||
let currentEditorDiagramId = null;
|
||||
let currentEditorHotspots = [];
|
||||
let partSearchTimeout = null;
|
||||
|
||||
async function searchDiagramsAdmin() {
|
||||
const q = document.getElementById('diagramSearchInput').value.trim();
|
||||
const container = document.getElementById('diagramSearchResults');
|
||||
|
||||
if (!q) {
|
||||
container.innerHTML = '<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>
|
||||
<meta charset="UTF-8">
|
||||
<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 rel="stylesheet" href="/shared.css">
|
||||
<style>
|
||||
* {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Landing page-specific header extras */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -165,29 +49,34 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 0.9rem;
|
||||
/* Footer logo (reuses .logo classes) */
|
||||
.footer .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
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;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 4px 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.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);
|
||||
.footer .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;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
@@ -1060,30 +949,23 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="customer-landing.html" class="logo">
|
||||
<div class="logo-icon">⚙️</div>
|
||||
<div class="logo-text">AUTOPARTS DB</div>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="customer-landing.html" class="active">Inicio</a>
|
||||
<a href="index.html">Catálogo</a>
|
||||
<a href="#brands-section">Marcas</a>
|
||||
<a href="#featured-section">Productos</a>
|
||||
<a href="#cta-section">Contacto</a>
|
||||
<a href="admin.html" class="admin-link">⚡ Admin</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="search-btn" onclick="openSearchModal()">🔍</button>
|
||||
<button class="cart-btn">
|
||||
🛒
|
||||
<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>
|
||||
<!-- Shared Navigation -->
|
||||
<div id="shared-nav"></div>
|
||||
<script src="/nav.js"></script>
|
||||
<script>
|
||||
// Inject landing-page-specific header extras (search, cart, dashboard btn)
|
||||
(function() {
|
||||
var extra = document.getElementById('shared-nav-extra');
|
||||
if (!extra) return;
|
||||
extra.innerHTML = ''
|
||||
+ '<div class="header-actions">'
|
||||
+ '<button class="search-btn" onclick="openSearchModal()">\uD83D\uDD0D</button>'
|
||||
+ '<button class="cart-btn">\uD83D\uDED2<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">\u2630</button>'
|
||||
+ '</div>';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<div class="search-modal" id="searchModal" onclick="closeSearchModal(event)">
|
||||
@@ -1213,7 +1095,7 @@
|
||||
<div class="footer-brand">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">⚙️</div>
|
||||
<div class="logo-text">AUTOPARTS DB</div>
|
||||
<div class="logo-text">NEXUS AUTOPARTS</div>
|
||||
</div>
|
||||
<p>Sistema de catálogo de autopartes con navegación jerárquica, diagramas explosionados y decodificador de VIN.</p>
|
||||
<div class="social-links">
|
||||
@@ -1249,7 +1131,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -41,36 +41,31 @@ class VehicleDashboard {
|
||||
|
||||
async loadStats() {
|
||||
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/vehicles'),
|
||||
fetch('/api/parts'),
|
||||
fetch('/api/categories')
|
||||
]);
|
||||
|
||||
if (brandsRes.ok && vehiclesRes.ok) {
|
||||
const brands = await brandsRes.json();
|
||||
const vehicles = await vehiclesRes.json();
|
||||
|
||||
// Contar modelos únicos
|
||||
const uniqueModels = new Set(vehicles.map(v => `${v.brand}-${v.model}`));
|
||||
|
||||
this.stats.brands = brands.length;
|
||||
this.stats.models = uniqueModels.size;
|
||||
this.stats.vehicles = vehicles.length;
|
||||
if (statsRes.ok) {
|
||||
const s = await statsRes.json();
|
||||
this.stats.brands = s.brands;
|
||||
this.stats.models = s.models;
|
||||
this.stats.vehicles = s.vehicles;
|
||||
this.stats.parts = s.parts;
|
||||
|
||||
const fmt = n => n > 1000 ? Math.floor(n/1000) + 'K+' : n;
|
||||
const brandsEl = document.getElementById('totalBrands');
|
||||
const modelsEl = document.getElementById('totalModels');
|
||||
if (brandsEl) brandsEl.textContent = this.stats.brands;
|
||||
if (modelsEl) modelsEl.textContent = this.stats.models > 1000 ? Math.floor(this.stats.models/1000) + 'K+' : this.stats.models;
|
||||
const partsEl = document.getElementById('totalParts');
|
||||
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) {
|
||||
const partsData = await partsRes.json();
|
||||
// Handle paginated response
|
||||
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 (brandsRes.ok) {
|
||||
// Still needed for brand list rendering
|
||||
await brandsRes.json();
|
||||
}
|
||||
|
||||
if (categoriesRes.ok) {
|
||||
@@ -300,29 +295,18 @@ class VehicleDashboard {
|
||||
`;
|
||||
|
||||
try {
|
||||
const [brandsRes, vehiclesRes] = await Promise.all([
|
||||
fetch('/api/brands'),
|
||||
fetch('/api/vehicles')
|
||||
]);
|
||||
const brandsRes = await fetch('/api/brands?detailed=true');
|
||||
|
||||
if (!brandsRes.ok || !vehiclesRes.ok) {
|
||||
if (!brandsRes.ok) {
|
||||
throw new Error('Error al cargar datos');
|
||||
}
|
||||
|
||||
const brands = await brandsRes.json();
|
||||
const vehicles = await vehiclesRes.json();
|
||||
|
||||
// Contar modelos y vehículos por marca
|
||||
// Build brandStats from detailed response
|
||||
const brandStats = {};
|
||||
brands.forEach(brand => {
|
||||
brandStats[brand] = { models: new Set(), vehicles: 0 };
|
||||
});
|
||||
|
||||
vehicles.forEach(v => {
|
||||
if (brandStats[v.brand]) {
|
||||
brandStats[v.brand].models.add(v.model);
|
||||
brandStats[v.brand].vehicles++;
|
||||
}
|
||||
brands.forEach(b => {
|
||||
brandStats[b.name] = { models: { size: b.model_count }, vehicles: b.vehicle_count };
|
||||
});
|
||||
|
||||
if (brands.length === 0) {
|
||||
@@ -337,17 +321,17 @@ class VehicleDashboard {
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="content-grid brands-grid">
|
||||
${brands.map(brand => `
|
||||
<div class="brand-card" onclick="dashboard.goToModels('${brand}')">
|
||||
${brands.map(b => `
|
||||
<div class="brand-card" onclick="dashboard.goToModels('${b.name}')">
|
||||
<div class="brand-icon">
|
||||
<i class="fas fa-car"></i>
|
||||
</div>
|
||||
<div class="brand-name">${brand}</div>
|
||||
<div class="brand-name">${b.name}</div>
|
||||
<div class="brand-count">
|
||||
${brandStats[brand].models.size} modelos
|
||||
${b.model_count} modelos
|
||||
</div>
|
||||
<div class="brand-count">
|
||||
${brandStats[brand].vehicles} vehículos
|
||||
${b.vehicle_count} vehículos
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -386,31 +370,13 @@ class VehicleDashboard {
|
||||
`;
|
||||
|
||||
try {
|
||||
const [modelsRes, vehiclesRes] = await Promise.all([
|
||||
fetch(`/api/models?brand=${encodeURIComponent(brand)}`),
|
||||
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}`)
|
||||
]);
|
||||
const modelsRes = await fetch(`/api/models?brand=${encodeURIComponent(brand)}&detailed=true`);
|
||||
|
||||
if (!modelsRes.ok || !vehiclesRes.ok) {
|
||||
if (!modelsRes.ok) {
|
||||
throw new Error('Error al cargar datos');
|
||||
}
|
||||
|
||||
const models = await modelsRes.json();
|
||||
const vehicles = await vehiclesRes.json();
|
||||
|
||||
// Contar vehículos y años por modelo
|
||||
const modelStats = {};
|
||||
models.forEach(model => {
|
||||
modelStats[model] = { years: new Set(), vehicles: 0, engines: new Set() };
|
||||
});
|
||||
|
||||
vehicles.forEach(v => {
|
||||
if (modelStats[v.model]) {
|
||||
modelStats[v.model].years.add(v.year);
|
||||
modelStats[v.model].vehicles++;
|
||||
modelStats[v.model].engines.add(v.engine);
|
||||
}
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
container.innerHTML = `
|
||||
@@ -427,26 +393,22 @@ class VehicleDashboard {
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="content-grid models-grid">
|
||||
${models.map(model => {
|
||||
const stats = modelStats[model];
|
||||
const yearsArray = Array.from(stats.years).sort((a, b) => b - a);
|
||||
const yearRange = yearsArray.length > 0
|
||||
? (yearsArray.length > 1
|
||||
? `${yearsArray[yearsArray.length - 1]} - ${yearsArray[0]}`
|
||||
: `${yearsArray[0]}`)
|
||||
: 'N/A';
|
||||
${models.map(m => {
|
||||
const yearRange = m.year_count > 1
|
||||
? `${m.year_min} - ${m.year_max}`
|
||||
: `${m.year_min}`;
|
||||
|
||||
return `
|
||||
<div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${model}')">
|
||||
<div class="model-name">${model}</div>
|
||||
<div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${m.name}')">
|
||||
<div class="model-name">${m.name}</div>
|
||||
<div class="model-info">
|
||||
<i class="fas fa-calendar-alt"></i> ${yearRange}
|
||||
</div>
|
||||
<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 class="model-info">
|
||||
<i class="fas fa-list"></i> ${stats.vehicles} variantes
|
||||
<i class="fas fa-list"></i> ${m.vehicle_count} variantes
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -491,16 +453,18 @@ class VehicleDashboard {
|
||||
try {
|
||||
// Fetch both vehicles info and model_year_engine IDs
|
||||
const [vehiclesRes, myeRes] = await Promise.all([
|
||||
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`),
|
||||
fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`)
|
||||
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`),
|
||||
fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`)
|
||||
]);
|
||||
|
||||
if (!vehiclesRes.ok || !myeRes.ok) {
|
||||
throw new Error('Error al cargar vehículos');
|
||||
}
|
||||
|
||||
const vehicles = await vehiclesRes.json();
|
||||
const myeRecords = await myeRes.json();
|
||||
const vehiclesData = await vehiclesRes.json();
|
||||
const myeData = await myeRes.json();
|
||||
const vehicles = vehiclesData.data || vehiclesData;
|
||||
const myeRecords = myeData.data || myeData;
|
||||
|
||||
// Merge mye_id into vehicles based on matching fields
|
||||
// Only keep vehicles that have a matching mye_id (i.e., have parts)
|
||||
@@ -911,7 +875,24 @@ class VehicleDashboard {
|
||||
}
|
||||
|
||||
const groups = await response.json();
|
||||
this.displayGroups(groups, categoryId);
|
||||
|
||||
// Fetch diagrams for Suspension (11) or Steering (10) when vehicle is selected
|
||||
let vehicleDiagrams = [];
|
||||
if (this.selectedVehicleId && (categoryId === 10 || categoryId === 11)) {
|
||||
try {
|
||||
const diagRes = await fetch(`/api/vehicles/${this.selectedVehicleId}/diagrams/by-category?category_id=${categoryId}`);
|
||||
if (diagRes.ok) {
|
||||
const catGroups = await diagRes.json();
|
||||
for (const cg of catGroups) {
|
||||
vehicleDiagrams.push(...cg.diagrams);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading diagrams for strip:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.displayGroups(groups, categoryId, vehicleDiagrams);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
@@ -928,10 +909,10 @@ class VehicleDashboard {
|
||||
}
|
||||
}
|
||||
|
||||
displayGroups(groups, categoryId) {
|
||||
displayGroups(groups, categoryId, vehicleDiagrams = []) {
|
||||
const container = document.getElementById('mainContent');
|
||||
|
||||
if (groups.length === 0) {
|
||||
if (groups.length === 0 && vehicleDiagrams.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
@@ -944,8 +925,42 @@ class VehicleDashboard {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build diagram strip HTML if diagrams are available
|
||||
let diagramStripHtml = '';
|
||||
if (vehicleDiagrams.length > 0) {
|
||||
// Store diagram list for the viewer
|
||||
this._currentDiagramList = vehicleDiagrams;
|
||||
|
||||
diagramStripHtml = `
|
||||
<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 = `
|
||||
<h4 class="mb-3">${this.selectedCategory.name_es || this.selectedCategory.name}</h4>
|
||||
${diagramStripHtml}
|
||||
<div class="content-grid categories-grid">
|
||||
${groups.map(group => `
|
||||
<div class="category-card">
|
||||
@@ -1123,6 +1138,11 @@ class VehicleDashboard {
|
||||
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
|
||||
</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">
|
||||
<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>
|
||||
@@ -1602,6 +1622,305 @@ class VehicleDashboard {
|
||||
wrapper.style.transform = `scale(${this.currentDiagramZoom})`;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// FASE 6: Full-screen Diagram Viewer (split layout)
|
||||
// ================================================================
|
||||
|
||||
openDiagramViewer(diagramId, indexInList) {
|
||||
this._dvCurrentIndex = typeof indexInList === 'number' ? indexInList : -1;
|
||||
this._dvDiagramList = this._currentDiagramList || [];
|
||||
this._dvZoom = 1;
|
||||
this._dvDragging = false;
|
||||
|
||||
const overlay = document.getElementById('diagramViewerOverlay');
|
||||
overlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
this._loadDiagramInViewer(diagramId);
|
||||
this._bindDiagramViewerEvents();
|
||||
}
|
||||
|
||||
closeDiagramViewer() {
|
||||
const overlay = document.getElementById('diagramViewerOverlay');
|
||||
overlay.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
this._unbindDiagramViewerEvents();
|
||||
}
|
||||
|
||||
async _loadDiagramInViewer(diagramId) {
|
||||
const titleEl = document.getElementById('dvTitle');
|
||||
const subtitleEl = document.getElementById('dvSubtitle');
|
||||
const imgWrapper = document.getElementById('dvImgWrapper');
|
||||
const img = document.getElementById('dvImg');
|
||||
const partsList = document.getElementById('dvPartsList');
|
||||
const partsCount = document.getElementById('dvPartsCount');
|
||||
|
||||
// Show loading in parts
|
||||
partsList.innerHTML = '<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
|
||||
openVinDecoder() {
|
||||
// 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,
|
||||
maxResults: 8,
|
||||
maxRecent: 5,
|
||||
storageKey: 'autopartes_recent_searches'
|
||||
storageKey: 'nexus_recent_searches'
|
||||
},
|
||||
|
||||
// State
|
||||
|
||||
@@ -3,92 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 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="/shared.css">
|
||||
<style>
|
||||
* {
|
||||
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 & Header extras (page-specific) */
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
@@ -637,43 +558,6 @@
|
||||
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 {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
@@ -1168,40 +1052,7 @@
|
||||
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-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 {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
@@ -1539,45 +1390,6 @@
|
||||
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 */
|
||||
@media (max-width: 1024px) {
|
||||
.header-stats {
|
||||
@@ -1652,167 +1464,532 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link */
|
||||
.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;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
/* ========== Diagram Strip (horizontal scroll above groups) ========== */
|
||||
.diagrams-strip {
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#mainContent" class="skip-link">Saltar al contenido</a>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<a href="customer-landing.html" class="logo">
|
||||
<div class="logo-icon">⚙️</div>
|
||||
<div class="logo-text">AUTOPARTS DB</div>
|
||||
</a>
|
||||
|
||||
<div class="search-container">
|
||||
<div class="search-box-enhanced">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" class="search-input" id="searchInput"
|
||||
placeholder="Buscar partes, números OEM, vehículos... (presiona /)"
|
||||
aria-label="Buscar partes"
|
||||
autocomplete="off"
|
||||
oninput="enhancedSearch.onInput(this.value)"
|
||||
onkeydown="enhancedSearch.onKeydown(event)"
|
||||
onfocus="enhancedSearch.onFocus()">
|
||||
<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</div>
|
||||
<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">
|
||||
<i class="fas fa-barcode"></i>
|
||||
</button>
|
||||
<div class="search-loading" id="searchLoading" style="display: none;">
|
||||
<div class="search-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown de resultados -->
|
||||
<div class="search-dropdown" id="searchDropdown">
|
||||
<!-- Filtros -->
|
||||
<div class="search-filters" id="searchFilters" style="display: none;">
|
||||
<div class="filter-group">
|
||||
<label>Categoría</label>
|
||||
<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()">
|
||||
<option value="">Todas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Buscar en</label>
|
||||
<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">
|
||||
<option value="all">Todo</option>
|
||||
<option value="parts">Solo Partes</option>
|
||||
<option value="vehicles">Solo Vehículos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Búsquedas recientes -->
|
||||
<div class="search-recent" id="searchRecent">
|
||||
<div class="search-section-title">
|
||||
<i class="fas fa-history"></i> Búsquedas recientes
|
||||
<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span>
|
||||
</div>
|
||||
<div class="search-recent-items" id="searchRecentItems"></div>
|
||||
</div>
|
||||
|
||||
<!-- Resultados -->
|
||||
<div class="search-results-container" id="searchResultsContainer">
|
||||
<!-- Parts results -->
|
||||
<div class="search-results-section" id="partsResults" style="display: none;">
|
||||
<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>
|
||||
<div class="search-results-list" id="partsResultsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicles results -->
|
||||
<div class="search-results-section" id="vehiclesResults" style="display: none;">
|
||||
<div class="search-section-title"><i class="fas fa-car"></i> Vehículos</div>
|
||||
<div class="search-results-list" id="vehiclesResultsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div class="search-no-results" id="searchNoResults" style="display: none;">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>No se encontraron resultados</p>
|
||||
<span>Intenta con otros términos de búsqueda</span>
|
||||
<div class="search-suggestions" style="margin-top: 1rem;">
|
||||
<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">Búsquedas populares:</span>
|
||||
<div class="search-suggestion-tags">
|
||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('brake')">brake</span>
|
||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('filter')">filter</span>
|
||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('spark plug')">spark plug</span>
|
||||
<span class="search-tag" onclick="enhancedSearch.searchRecent('camry')">camry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer con acciones -->
|
||||
<div class="search-dropdown-footer" id="searchFooter" style="display: none;">
|
||||
<span class="search-hint">
|
||||
<kbd>↑↓</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar
|
||||
</span>
|
||||
<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">
|
||||
Ver todos los resultados <i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<!-- Shared Navigation -->
|
||||
<div id="shared-nav"></div>
|
||||
<script src="/nav.js"></script>
|
||||
<script>
|
||||
// Inject page-specific search bar and stats into the shared nav header
|
||||
(function() {
|
||||
var extra = document.getElementById('shared-nav-extra');
|
||||
if (!extra) return;
|
||||
extra.innerHTML = ''
|
||||
+ '<div class="search-container">'
|
||||
+ '<div class="search-box-enhanced">'
|
||||
+ '<div class="search-input-wrapper">'
|
||||
+ '<i class="fas fa-search search-icon"></i>'
|
||||
+ '<input type="text" class="search-input" id="searchInput"'
|
||||
+ ' placeholder="Buscar partes, n\u00fameros OEM, veh\u00edculos... (presiona /)"'
|
||||
+ ' aria-label="Buscar partes"'
|
||||
+ ' autocomplete="off"'
|
||||
+ ' oninput="enhancedSearch.onInput(this.value)"'
|
||||
+ ' onkeydown="enhancedSearch.onKeydown(event)"'
|
||||
+ ' onfocus="enhancedSearch.onFocus()">'
|
||||
+ '<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">'
|
||||
+ '<i class="fas fa-sliders-h"></i>'
|
||||
+ '</div>'
|
||||
+ '<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">'
|
||||
+ '<i class="fas fa-barcode"></i>'
|
||||
+ '</button>'
|
||||
+ '<div class="search-loading" id="searchLoading" style="display: none;">'
|
||||
+ '<div class="search-spinner"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-dropdown" id="searchDropdown">'
|
||||
+ '<div class="search-filters" id="searchFilters" style="display: none;">'
|
||||
+ '<div class="filter-group"><label>Categor\u00eda</label>'
|
||||
+ '<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()"><option value="">Todas</option></select>'
|
||||
+ '</div>'
|
||||
+ '<div class="filter-group"><label>Buscar en</label>'
|
||||
+ '<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">'
|
||||
+ '<option value="all">Todo</option><option value="parts">Solo Partes</option><option value="vehicles">Solo Veh\u00edculos</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-recent" id="searchRecent">'
|
||||
+ '<div class="search-section-title"><i class="fas fa-history"></i> B\u00fasquedas recientes '
|
||||
+ '<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span></div>'
|
||||
+ '<div class="search-recent-items" id="searchRecentItems"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-results-container" id="searchResultsContainer">'
|
||||
+ '<div class="search-results-section" id="partsResults" style="display: none;">'
|
||||
+ '<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>'
|
||||
+ '<div class="search-results-list" id="partsResultsList"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-results-section" id="vehiclesResults" style="display: none;">'
|
||||
+ '<div class="search-section-title"><i class="fas fa-car"></i> Veh\u00edculos</div>'
|
||||
+ '<div class="search-results-list" id="vehiclesResultsList"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-no-results" id="searchNoResults" style="display: none;">'
|
||||
+ '<i class="fas fa-search"></i><p>No se encontraron resultados</p>'
|
||||
+ '<span>Intenta con otros t\u00e9rminos de b\u00fasqueda</span>'
|
||||
+ '<div class="search-suggestions" style="margin-top: 1rem;">'
|
||||
+ '<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">B\u00fasquedas populares:</span>'
|
||||
+ '<div class="search-suggestion-tags">'
|
||||
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'brake\')">brake</span>'
|
||||
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'filter\')">filter</span>'
|
||||
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'spark plug\')">spark plug</span>'
|
||||
+ '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'camry\')">camry</span>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="search-dropdown-footer" id="searchFooter" style="display: none;">'
|
||||
+ '<span class="search-hint"><kbd>\u2191\u2193</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\u00f3n"><i class="fas fa-cog"></i></a>'
|
||||
+ '</div>';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="main-container">
|
||||
@@ -1924,6 +2101,54 @@
|
||||
</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="enhanced-search.js"></script>
|
||||
</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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Guía de Instalación - Autoparts DB
|
||||
# Guía de Instalación - Nexus Autoparts
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
@@ -28,10 +28,10 @@ El proyecto es compatible con:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
cd Autoparts-DB
|
||||
cd Nexus-Autoparts
|
||||
|
||||
# 3. Instalar dependencias
|
||||
pip install -r requirements.txt
|
||||
@@ -48,8 +48,8 @@ python3 server.py
|
||||
### Paso 1: Clonar el Repositorio
|
||||
|
||||
```bash
|
||||
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git
|
||||
cd Autoparts-DB
|
||||
git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
|
||||
cd Nexus-Autoparts
|
||||
```
|
||||
|
||||
### Paso 2: Crear Entorno Virtual (Recomendado)
|
||||
@@ -254,16 +254,16 @@ gunicorn -w 4 -b 0.0.0.0:8080 server:app
|
||||
|
||||
### Usando systemd
|
||||
|
||||
Crear archivo `/etc/systemd/system/autoparts-db.service`:
|
||||
Crear archivo `/etc/systemd/system/nexus-autoparts.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Autoparts DB Dashboard
|
||||
Description=Nexus Autoparts Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/path/to/Autoparts-DB/dashboard
|
||||
WorkingDirectory=/path/to/Nexus-Autoparts/dashboard
|
||||
ExecStart=/usr/bin/python3 server.py
|
||||
Restart=always
|
||||
|
||||
@@ -274,8 +274,8 @@ WantedBy=multi-user.target
|
||||
Habilitar e iniciar:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable autoparts-db
|
||||
sudo systemctl start autoparts-db
|
||||
sudo systemctl enable nexus-autoparts
|
||||
sudo systemctl start nexus-autoparts
|
||||
```
|
||||
|
||||
### Usando Docker (Opcional)
|
||||
@@ -294,8 +294,8 @@ CMD ["python3", "dashboard/server.py"]
|
||||
```
|
||||
|
||||
```bash
|
||||
docker build -t autoparts-db .
|
||||
docker run -p 5000:5000 autoparts-db
|
||||
docker build -t nexus-autoparts .
|
||||
docker run -p 5000:5000 nexus-autoparts
|
||||
```
|
||||
|
||||
---
|
||||
@@ -319,7 +319,7 @@ pip install --upgrade -r requirements.txt
|
||||
deactivate
|
||||
|
||||
# Eliminar directorio del proyecto
|
||||
rm -rf Autoparts-DB
|
||||
rm -rf Nexus-Autoparts
|
||||
|
||||
# Eliminar entorno virtual (si está separado)
|
||||
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;
|
||||
```
|
||||
198
docs/plans/2026-02-14-pick-console-design.md
Normal file
198
docs/plans/2026-02-14-pick-console-design.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Pick-Style Console System - Design Document
|
||||
|
||||
**Date:** 2026-02-14
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Console-based autoparts catalog system inspired by Pick/D3 operating systems with VT220 terminal aesthetics. Runs entirely from keyboard in a real terminal (CLI), with two selectable rendering modes: classic VT220 (curses) and modern TUI (textual).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Platform:** Real CLI terminal (Python), no web browser
|
||||
- **Users:** Sales counter staff AND warehouse/admin personnel
|
||||
- **Style:** Pick-inspired with ANSI colors, box drawing, formatted tables
|
||||
- **Data:** Abstract DB layer (SQLite today, PostgreSQL migration planned)
|
||||
- **Renderers:** Two modes selectable via `--mode vt220|modern`
|
||||
- **Input:** 100% keyboard-driven with F-keys, menus, and incremental search
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Capa de Presentación │
|
||||
│ ┌──────────┐ ┌───────────────┐ │
|
||||
│ │ curses │ │ textual │ │
|
||||
│ │ (VT220) │ │ (moderno) │ │
|
||||
│ └─────┬─────┘ └──────┬────────┘ │
|
||||
│ └────────┬────────┘ │
|
||||
│ Interface común │
|
||||
├─────────────────────────────────────┤
|
||||
│ Capa de Lógica / Screens │
|
||||
│ Menús, Navegación, Formularios, │
|
||||
│ Búsqueda, CRUD │
|
||||
├─────────────────────────────────────┤
|
||||
│ Capa de Datos (DB) │
|
||||
│ SQLite hoy → PostgreSQL mañana │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
console/
|
||||
├── main.py # Entry point, --mode vt220|modern
|
||||
├── config.py # DB path, colors, key mappings
|
||||
├── db.py # Abstract DB layer (SQLite/PostgreSQL)
|
||||
│
|
||||
├── core/
|
||||
│ ├── screens.py # Screen base class
|
||||
│ ├── widgets.py # Lista, Formulario, Tabla, Barra
|
||||
│ ├── navigation.py # Screen stack, breadcrumb, history
|
||||
│ └── keybindings.py # F-keys, ESC, TAB mappings
|
||||
│
|
||||
├── screens/
|
||||
│ ├── menu_principal.py # Main menu (9 options + exit)
|
||||
│ ├── vehiculo_nav.py # Drill-down: brand → model → year → engine
|
||||
│ ├── buscar_parte.py # Search by part number
|
||||
│ ├── buscar_texto.py # Full-text search (FTS)
|
||||
│ ├── vin_decoder.py # VIN decoder (NHTSA API)
|
||||
│ ├── catalogo.py # Categories → groups → parts
|
||||
│ ├── parte_detalle.py # Part detail with alternatives
|
||||
│ ├── comparador.py # OEM vs aftermarket comparison
|
||||
│ ├── estadisticas.py # System statistics dashboard
|
||||
│ ├── admin_partes.py # Parts CRUD
|
||||
│ ├── admin_fabricantes.py # Manufacturers CRUD
|
||||
│ ├── admin_crossref.py # Cross-references CRUD
|
||||
│ └── admin_import.py # Import/Export CSV
|
||||
│
|
||||
├── renderers/
|
||||
│ ├── curses_renderer.py # VT220 mode (curses)
|
||||
│ └── textual_renderer.py # Modern mode (textual/rich)
|
||||
│
|
||||
└── utils/
|
||||
├── formatting.py # Table formatting, numbers, currency
|
||||
└── vin_api.py # NHTSA VIN API client
|
||||
```
|
||||
|
||||
## Screens
|
||||
|
||||
### Main Menu
|
||||
- 9 numbered options + 0 to exit
|
||||
- F-key bar at bottom
|
||||
- Header with system name and version
|
||||
|
||||
### 1. Vehicle Navigation (Drill-Down)
|
||||
- Sequential selection: Brand → Model → Year → Engine
|
||||
- Each step shows filterable list with incremental search
|
||||
- Arrow keys + ENTER to select, ESC to go back
|
||||
- Leads to categories/groups/parts for selected vehicle
|
||||
|
||||
### 2. Part Number Search
|
||||
- Single input field for part number
|
||||
- Searches OEM, aftermarket, and cross-references
|
||||
- Results show type, number, description, source
|
||||
- Select result to see full detail
|
||||
|
||||
### 3. Text Search (FTS)
|
||||
- Uses SQLite FTS5 full-text search
|
||||
- Searches part names and descriptions
|
||||
- Paginated results with relevance ranking
|
||||
|
||||
### 4. VIN Decoder
|
||||
- Input 17-character VIN
|
||||
- Calls NHTSA API (with cache)
|
||||
- Shows decoded vehicle info
|
||||
- Option to view compatible parts
|
||||
|
||||
### 5. Category Catalog
|
||||
- Browse: Categories → Groups → Parts
|
||||
- Independent of vehicle selection
|
||||
|
||||
### 6-9. Administration
|
||||
- CRUD screens with Pick-style positional forms
|
||||
- Numbered fields, TAB/arrow navigation
|
||||
- F1 for lookup lists on foreign key fields
|
||||
- F9 to save, ESC to cancel (with dirty check)
|
||||
- Import/Export CSV with file path input
|
||||
|
||||
### 10. Part Detail
|
||||
- Full part info in form layout (label.....: value)
|
||||
- Aftermarket alternatives table below
|
||||
- F4 for cross-references, F6 for vehicles
|
||||
|
||||
### 11. Part Comparator
|
||||
- Side-by-side columns: OEM vs aftermarket alternatives
|
||||
- Visual quality bars, savings percentage
|
||||
- Cross-reference numbers at bottom
|
||||
- Horizontal scroll if more than 3 columns
|
||||
|
||||
### 12. Statistics Dashboard
|
||||
- Database counters (brands, models, parts, etc.)
|
||||
- Coverage metrics (vehicles with parts, top brands)
|
||||
- VIN cache status
|
||||
|
||||
## Key Bindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| 0-9 | Select menu option / jump to field |
|
||||
| ENTER | Confirm selection |
|
||||
| ESC | Go back / Cancel |
|
||||
| F1 | Help / Lookup list |
|
||||
| F2 | Edit mode |
|
||||
| F3 | Search |
|
||||
| F4 | Cross-references |
|
||||
| F5 | Refresh |
|
||||
| F6 | Related vehicles |
|
||||
| F9 | Save |
|
||||
| F10 | Main menu |
|
||||
| TAB / ↓ | Next field |
|
||||
| ↑ | Previous field |
|
||||
| PgUp/PgDn | Page navigation |
|
||||
| ←→ | Scroll columns (comparator) |
|
||||
|
||||
## Data Layer
|
||||
|
||||
Abstract interface with two implementations:
|
||||
|
||||
```python
|
||||
class Database:
|
||||
def get_brands() -> list
|
||||
def get_models(brand=None) -> list
|
||||
def get_vehicles(brand, model, year, engine) -> list
|
||||
def get_categories() -> list
|
||||
def get_groups(category_id) -> list
|
||||
def get_parts(group_id=None, mye_id=None) -> list
|
||||
def get_part(part_id) -> dict
|
||||
def get_alternatives(part_id) -> list
|
||||
def get_cross_references(part_id) -> list
|
||||
def search_parts(query) -> list
|
||||
def search_part_number(number) -> list
|
||||
def decode_vin(vin) -> dict
|
||||
def get_stats() -> dict
|
||||
# CRUD methods for admin...
|
||||
```
|
||||
|
||||
SQLite implementation reads directly from `vehicle_database.db`. PostgreSQL implementation will use psycopg2 with same interface.
|
||||
|
||||
## Renderer Interface
|
||||
|
||||
```python
|
||||
class Renderer:
|
||||
def init_screen()
|
||||
def clear()
|
||||
def draw_header(title, subtitle)
|
||||
def draw_footer(keys)
|
||||
def draw_menu(items, selected)
|
||||
def draw_table(headers, rows, page_info)
|
||||
def draw_form(fields, focused_field)
|
||||
def draw_detail(labels_values)
|
||||
def draw_comparison(columns)
|
||||
def draw_filter_list(items, filter_text, selected)
|
||||
def draw_stats(data)
|
||||
def get_key() -> key_event
|
||||
def show_message(text, type) # info/error/confirm
|
||||
```
|
||||
|
||||
Curses implementation uses box drawing chars, ANSI colors (green/amber on black). Textual implementation uses Rich widgets with modern styling.
|
||||
1982
docs/plans/2026-02-14-pick-console-plan.md
Normal file
1982
docs/plans/2026-02-14-pick-console-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
beautifulsoup4>=4.11.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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user