Compare commits

...

39 Commits

Author SHA1 Message Date
fb6ea31100 docs: add README, API reference, and architecture documentation
- README.md: project overview, features, quick start, API overview
- docs/API.md: full endpoint reference with examples
- docs/ARCHITECTURE.md: system diagram, DB schema, data pipeline, auth flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:30:53 +00:00
d269bc1ffb feat: add TecDoc import pipeline scripts
- import_tecdoc.py: 2-phase TecDoc download + import (brands, models, vehicles)
- import_live.py: real-time streaming importer for part details
- run_all_brands.sh: automated sequential brand processing pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:48 +00:00
5e6bf788db docs: add design and implementation plans
- SaaS + aftermarket design spec
- SaaS + aftermarket implementation plan (15 tasks)
- Captura partes design
- POS + cuentas design and plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:38 +00:00
fe6542c45c feat: add captura, POS, cuentas, and tienda pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:32 +00:00
b1adf536f6 feat: add demo catalog page with image display and part detail modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:27 +00:00
eff04a5e60 fix: stop creating AFT- placeholder parts in import pipeline
- import_phase1.py: skip AFT- part creation when no OEM data
- link_vehicle_parts.py: remove AFT- fallback lookup in part cache
- import_tecdoc_parts.py: add VW to TOP_BRANDS list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:21 +00:00
4b01c57c88 feat: add aftermarket migration script — move AFT- parts to proper table
Migrates 357K AFT-prefixed parts from parts table to aftermarket_parts.
Parses part_number and manufacturer from AFT-{partNo}-{manufacturer} format.
Links to OEM parts via cross-references. Batch processing with progress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:15 +00:00
e5d074687a feat: add users management tab to admin panel
New Sistema > Usuarios section with user listing, role badges
(ADMIN=blue, OWNER=purple, TALLER=green, BODEGA=orange),
activate/deactivate toggle, and pending users badge count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:09 +00:00
6c6a9eecd6 feat: add auth UI to nav — login/logout button, bodega link
Shows business name + logout button when authenticated.
Shows login link when not authenticated. Adds bodega to nav links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:25:03 +00:00
340d2fcef8 feat: add bodega dashboard — column mapping, inventory upload, listing
Three-tab panel for warehouse operators:
- Column mapping configuration (flexible CSV/Excel field mapping)
- File upload with drag-and-drop, progress tracking, error reporting
- Searchable paginated inventory view with clear-all option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:58 +00:00
565f11aca6 feat: add login/register page with JWT auth flow
Login form with role-based redirect (ADMIN→demo, BODEGA→bodega, TALLER→demo).
Register form for TALLER/BODEGA with admin approval required.
Includes authFetch() wrapper with automatic token refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:51 +00:00
744df6b3b8 feat: add SaaS endpoints — auth, inventory, availability, admin users
New endpoints:
- Auth: register, login, refresh, me
- Admin: list users, activate/deactivate
- Inventory: mapping CRUD, file upload (CSV/Excel), history, items list
- Parts: availability across warehouses, aftermarket alternatives
- Routes: login.html, bodega pages
- Fix: admin stats use pg_class estimates for large tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:45 +00:00
09d3304b21 feat: add JWT auth module — login, tokens, role-based middleware
Implements hash_password, check_password, create_access_token,
create_refresh_token, decode_token, and require_auth() decorator
for role-based endpoint protection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:38 +00:00
c5e5f6ef7e feat: add SaaS schema migration — sessions, inventory, mappings tables
Creates sessions, warehouse_inventory, inventory_uploads,
inventory_column_mappings tables. Extends users with business_name,
is_active, last_login. Updates roles to ADMIN/OWNER/TALLER/BODEGA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:33 +00:00
6ef39d212c feat: add JWT auth and inventory dependencies
Add PyJWT, bcrypt, openpyxl to requirements.
Add JWT_SECRET, JWT_ACCESS_EXPIRES, JWT_REFRESH_EXPIRES to config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:26 +00:00
f89d591fa9 chore: update .gitignore — exclude data/, WAL files, and diagram images
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:20 +00:00
2c6b6e0160 fix(console): fix PostgreSQL connection + add Metabase Actions guide
- Fix console/main.py: import DB_URL instead of missing DB_PATH
- Add sqlalchemy text() import for connection test
- Replace file-exists check with actual PostgreSQL connection test
- Mask password in startup banner
- Add docs/METABASE_ACTIONS.md: complete guide for data entry via
  Metabase Actions (models, forms, dashboard layout, workflows)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:52:12 +00:00
7b2a904498 feat: migrate to PostgreSQL + SQLAlchemy ORM, rebrand to Nexus Autoparts
- Migrate from SQLite to PostgreSQL with normalized schema
- Add 11 lookup tables (fuel_type, body_type, drivetrain, transmission,
  materials, position_part, manufacture_type, quality_tier, countries,
  reference_type, shapes)
- Rewrite dashboard/server.py (76 routes) using SQLAlchemy text() queries
- Rewrite console/db.py (27 methods) using SQLAlchemy ORM
- Add models.py with 27 SQLAlchemy model definitions
- Add config.py for centralized DB_URL configuration
- Add migrate_to_postgres.py migration script
- Add docs/METABASE_GUIDE.md with complete data entry guide
- Rebrand from "AUTOPARTS DB" to "NEXUS AUTOPARTS"
- Fill vehicle data gaps via NHTSA API + heuristics:
  engines (cylinders, power, torque), brands (country, founded_year),
  models (body_type, production years), MYE (drivetrain, transmission, trim)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:24:47 +00:00
7ecf1295a5 fix: performance improvements, shared UI, and cross-reference data quality
Backend (server.py):
- Fix N+1 query in /api/diagrams/<id>/parts with batch cross-ref query
- Add LIMIT safety nets to 15 endpoints (50-5000 per data type)
- Add pagination to /api/vehicles, /api/model-year-engine, /api/vehicles/<id>/parts, /api/admin/export
- Optimize search_vehicles() EXISTS subquery to JOIN
- Restrict static route to /static/* subdir (security fix)
- Add detailed=true support to /api/brands and /api/models

Frontend:
- Extract shared CSS into shared.css (variables, reset, buttons, forms, scrollbar)
- Create shared nav.js component (logo + navigation links, auto-highlights)
- Update all 4 HTML pages to use shared CSS and nav
- Update JS to handle paginated API responses

Data quality:
- Fix cross-reference source field: map 72K records from catalog names to actual brands
- Fix aftermarket_parts manufacturer_id: correct 8K records with wrong brand attribution
- Delete 98MB backup file, orphan records, and garbage cross-references
- Add import scripts for DAR, FRAM, WIX, MOOG, Cartek catalogs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 03:09:22 +00:00
3ea2de61e2 refactor(console): remove modern renderer, keep VT220 only
Remove the Rich-based textual renderer and all --mode modern references.
The console now runs exclusively in VT220 curses mode (green on black).
No external dependencies required.

Removed: console/renderers/textual_renderer.py, --mode flag, DEFAULT_MODE
Updated: main.py, config.py, README.md, console/README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:37:35 +00:00
7866194e65 ui(console): center menu in a bordered box with better spacing
Both renderers now draw the menu inside a centered box with:
- Rounded corners (modern) / box-drawing (VT220)
- Title centered inside the box top
- Section separators as horizontal lines within the box
- Selected item highlighted across the full box width
- Vertical and horizontal centering on screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:31:54 +00:00
a3aa2a7608 fix(console): arrow keys and layout in modern renderer
Two bugs fixed:

1. Arrow keys detected as ESC: sys.stdin.read(1) uses Python's internal
   buffer, so after reading ESC byte, the remaining escape sequence
   bytes ([A for up-arrow) were in Python's buffer but not visible to
   select.select() on the OS fd. Switched to os.read(fd, 1) which
   reads directly from the file descriptor, bypassing Python's buffer.

2. Footer positioned wrong: draw_footer() counted buffer items to
   calculate padding, but a Rich Table renders as multiple lines.
   Added _line_count tracker with _add_line() and _add_lines(n) so
   footer padding is calculated from actual rendered line count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:29:32 +00:00
8c7caf3969 fix(console): rewrite modern renderer with buffered output
Major issues fixed:
- Rich printed directly to stdout causing visible flicker on every redraw
- get_key() toggled raw mode per keypress causing glitches and slowness
- No alternate screen buffer — output contaminated terminal scrollback

Rewrite approach:
- Use alternate screen buffer (ESC[?1049h) for clean enter/exit
- Persistent raw mode for entire session instead of per-keypress toggle
- Buffer all Rich renderables during render cycle, flush once in refresh()
- Render to StringIO then write entire frame in single sys.stdout.write()
- Reduced ESC sequence timeout from 50ms to 20ms for snappier response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:25:35 +00:00
f5e0525dfc perf(console): reduce ESC key delay from 1000ms to 25ms
Curses waits up to 1 second after ESC to distinguish it from escape
sequences (arrow keys, F-keys). Set ESCDELAY=25 before curses.initscr()
so ESC responds near-instantly while still handling escape sequences.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:22:01 +00:00
274cf30e79 perf(console): persistent DB connection, query cache, PRAGMA tuning
- Reuse a single SQLite connection instead of open/close per query
- Add in-memory cache for frequently accessed data (brands, models,
  categories) — 1000x faster on repeated access
- Enable WAL journal mode, 8MB cache, 64MB mmap for faster reads
- Cache terminal size per render cycle to avoid repeated getmaxyx()
- Close DB connection on app exit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:19:50 +00:00
5444cf660a fix(console): deduplicate models and engines in vehicle navigation
Models and engines tables have many duplicate names (e.g. 24 ASTRA rows,
210 F-150 rows, 398 "5.7L 350CID V8" rows). Changed get_models() and
get_engines() to use GROUP BY UPPER(name) instead of DISTINCT on id+name
so each model/engine appears only once in the navigation lists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:12:36 +00:00
4af3a09b03 docs: add console system documentation and design docs
Console README with usage instructions, keybindings reference, architecture
overview, and test commands. Updated root README with console section, updated
architecture diagram, and installation instructions. Includes approved design
doc and implementation plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:03:22 +00:00
64503ca363 feat(console): add integration tests and polish error handling
Add MockRenderer-based integration tests that verify the full screen-to-
renderer pipeline without a real terminal. Update main.py with proper
--db flag handling, database existence check, startup banner, and
graceful error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:59:08 +00:00
7bf50a2c67 feat(console): add Rich-based modern renderer
Implement TextualRenderer in console/renderers/textual_renderer.py using
the Rich library for a modern dark-themed TUI with blue/cyan accents.
All 18 BaseRenderer methods are implemented: lifecycle (init_screen,
cleanup), primitives (clear, refresh, get_key, get_size), widgets
(draw_header, draw_footer, draw_menu, draw_table, draw_detail,
draw_form, draw_filter_list, draw_comparison, draw_text, draw_box),
and dialogs (show_message, show_input). Keyboard input uses raw
terminal mode via tty/termios with full escape sequence decoding
for arrow keys, F-keys, Page Up/Down, Home/End.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:57:25 +00:00
8194167c51 feat(console): add admin CRUD screens for parts, manufacturers, crossref, import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:53:36 +00:00
15f3c9c9fe feat(console): add part detail and comparator screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:52:46 +00:00
b042853408 feat(console): add catalog, search, and VIN decoder screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:49:15 +00:00
69fb26723d feat(console): add vehicle drill-down navigation screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:47:23 +00:00
e3ad101d56 feat(console): add app controller, main menu and statistics screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:45:03 +00:00
269bb9030b feat(console): add curses VT220 renderer with full widget set
Implements BaseRenderer abstract interface and CursesRenderer with
green-on-black VT220 aesthetic. Includes all 18 widget methods: header,
footer, menu, table, detail, form, filter list, comparison view, box
drawing, message dialogs, and input prompts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:41:51 +00:00
211883393e feat(console): add formatting utils and VIN API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:38:28 +00:00
ceacab789b feat(console): add core framework - keybindings, navigation, screen base
Add the three core modules that all screens depend on:
- keybindings.py: Key constants (curses codes) and KeyBindings registry
- navigation.py: Stack-based screen navigation with breadcrumbs
- screens.py: Screen base class with on_enter/on_key/render lifecycle

Includes 31 tests covering all public APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:38:02 +00:00
3b884e24d3 feat(console): add database abstraction layer with tests
Implement console/db.py with Database class providing all data access
methods for the console application, plus 36 passing tests in
console/tests/test_db.py covering vehicle navigation, parts catalog,
search, VIN cache, stats, manufacturers, and admin CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:34:22 +00:00
7cf3ddc758 feat(console): scaffold project structure and config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:29:54 +00:00
101 changed files with 33595 additions and 3861 deletions

10
.gitignore vendored
View File

@@ -51,3 +51,13 @@ Thumbs.db
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# Data files (TecDoc downloads, too large for git)
data/
# SQLite WAL files
*.db-shm
*.db-wal
# Diagram images (served from static, too large for git)
dashboard/static/diagrams/

417
README.md
View File

@@ -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 | Componente | Tecnologia |
- Dashboard web moderno y responsivo para consultar y explorar datos |------------|-----------|
- Herramientas de web scraping para recopilar datos de RockAuto.com | Backend | Python 3, Flask |
- Interfaces de línea de comandos (CLI) y programática | Base de datos | PostgreSQL |
- Scripts de utilidad para gestión y mantenimiento de datos | ORM / SQL | SQLAlchemy (`text()` raw SQL) |
| Autenticacion | JWT (PyJWT) + bcrypt |
| Data import | TecDoc via Apify, NHTSA VIN API |
| Frontend | HTML/CSS/JS vanilla (sin framework) |
| Dependencias extra | openpyxl (Excel), csv (CSV import) |
## Estadísticas de la Base de Datos ## Estadisticas de la Base de Datos
| Elemento | Cantidad | - **1.4M+** partes OEM
|----------|----------| - **300K+** partes aftermarket
| Marcas | 12 | - **13M+** cross-references (numeros alternos, supersesiones, intercambios)
| Modelos | 10,923 | - **12B+** vehicle-part links (fitment)
| Motores | 10,919 | - **100+** marcas, miles de modelos, anos 1956-2026
| Combinaciones modelo-año-motor | 12,075 |
## Tecnologías Utilizadas ## Features
### Backend - **Catalogo de autopartes** con navegacion jerarquica: Marca > Modelo > Ano > Motor > Categoria > Grupo > Parte
- **Python 3** - Lenguaje principal - **TecDoc integration** (via Apify) para importar datos OEM y aftermarket de Europa/Mexico
- **SQLite 3** - Base de datos - **SaaS multi-tenant** con roles: `ADMIN`, `OWNER`, `TALLER`, `BODEGA`
- **Flask 2.3.3** - Framework web - **JWT authentication** con access tokens (15 min) y refresh tokens (30 dias)
- **BeautifulSoup4** - Web scraping - **Gestion de inventario** para bodegas con mapeo flexible de columnas CSV/Excel
- **requests** - HTTP client - **Disponibilidad de partes** en multiples bodegas con precios comparativos
- **lxml** - Parser XML/HTML - **Alternativas aftermarket** con cross-references por cada parte OEM
- **Panel de administracion** con gestion de usuarios, import/export CSV, CRUD de categorias/grupos/partes/fabricantes/fitment
- **Busqueda full-text** en el catalogo de partes (PostgreSQL `tsvector`)
- **Busqueda combinada** vehiculo + parte (e.g., "Toyota Corolla 2020 frenos")
- **VIN decoder** via NHTSA API con cache en base de datos
- **Diagramas explosionados** con hotspots clickeables
- **Vehicle-to-part linking** (12B+ vehicle_parts links)
### Frontend ## Quick Start
- **HTML5** - Estructura
- **Bootstrap 5.3.0** - Framework CSS
- **JavaScript (ES6+)** - Lógica cliente
- **Font Awesome 6.0.0** - Iconos
## Estructura del Proyecto ### Requisitos previos
``` - Python 3.8+
Autopartes/ - PostgreSQL con la base `nexus_autoparts`
├── vehicle_database/ # Sistema principal de base de datos
│ ├── sql/
│ │ └── schema.sql # Esquema de la base de datos
│ ├── scripts/
│ │ ├── database_manager.py # Gestión de la BD
│ │ ├── query_interface.py # Interfaz CLI
│ │ └── csv_importer.py # Importador CSV
│ ├── data/
│ │ ├── brands.csv # Datos de marcas
│ │ ├── engines.csv # Datos de motores
│ │ └── models.csv # Datos de modelos
│ ├── vehicle_database.db # Base de datos SQLite
│ └── setup.sh # Script de inicialización
├── dashboard/ # Interfaz web
│ ├── server.py # Backend Flask
│ ├── index.html # Frontend HTML
│ ├── dashboard.js # Lógica JavaScript
│ └── start_dashboard.sh # Script de inicio
├── 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
```
## Instalación ### Instalacion
### 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
```bash ```bash
cd dashboard cd /home/Autopartes
pip install -r requirements.txt
```
### Ejecutar el servidor
```bash
cd /home/Autopartes/dashboard
python3 server.py python3 server.py
``` ```
El dashboard estará disponible en: `http://localhost:5000` El servidor arranca en `http://localhost:5000`.
### Usar la Interfaz CLI ### Importar datos de TecDoc
```bash ```bash
cd vehicle_database/scripts # Fase 1: descargar datos de TecDoc a JSON
python3 query_interface.py 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 ```bash
cd vehicle_scraper # Importar partes TecDoc (OEM + aftermarket)
python3 rockauto_scraper_v2.py python3 scripts/import_tecdoc_parts.py
# Importar datos en vivo desde TecDoc API
python3 scripts/import_live.py
# Crear links vehiculo-parte (fitment masivo)
python3 scripts/link_vehicle_parts.py
# Migrar datos aftermarket
python3 scripts/migrate_aftermarket.py
# Aplicar schema SaaS (roles, users, inventory tables)
python3 scripts/migrate_saas_schema.py
``` ```
### Agregar Datos Manualmente ## Paginas del Dashboard
```bash | Ruta | Archivo | Descripcion |
cd vehicle_scraper |------|---------|-------------|
python3 manual_input.py | `/login.html` | `login.html` | Login con JWT |
``` | `/demo.html` | `demo.html` | Catalogo publico / demo |
| `/admin` | `admin.html` | Panel de administracion (ADMIN/OWNER) |
| `/bodega.html` | `bodega.html` | Gestion de inventario para bodegas |
| `/tienda.html` | `tienda.html` | Vista de tienda/catalogo para talleres |
| `/pos.html` | `pos.html` | Punto de venta |
| `/captura.html` | `captura.html` | Captura de partes |
| `/cuentas.html` | `cuentas.html` | Gestion de cuentas |
## API REST ## API Overview
El dashboard expone los siguientes endpoints: Documentacion completa en [`docs/API.md`](docs/API.md).
| Endpoint | Método | Descripción | ### Auth (`/api/auth/`)
|----------|--------|-------------| - `POST /api/auth/register` - Registrar usuario (TALLER/BODEGA)
| `/api/brands` | GET | Obtiene todas las marcas | - `POST /api/auth/login` - Login, retorna access + refresh tokens
| `/api/models?brand=X` | GET | Obtiene modelos por marca | - `POST /api/auth/refresh` - Renovar access token
| `/api/years` | GET | Obtiene años disponibles | - `GET /api/auth/me` - Info del usuario autenticado
| `/api/engines` | GET | Obtiene motores disponibles |
| `/api/vehicles` | GET | Búsqueda con filtros |
### 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 ### Inventario (`/api/inventory/`)
# Obtener todas las marcas - `GET/PUT /api/inventory/mapping` - Mapeo de columnas CSV
curl http://localhost:5000/api/brands - `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 ### Disponibilidad y Aftermarket
curl "http://localhost:5000/api/vehicles?brand=Toyota&year=2020" - `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 ## Scripts
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| name | TEXT | Nombre de la marca |
| country | TEXT | País de origen |
| founded_year | INTEGER | Año de fundación |
#### models | Script | Funcion |
| Campo | Tipo | Descripción | |--------|---------|
|-------|------|-------------| | `import_tecdoc.py` | Descarga datos de TecDoc API (vehiculos, modelos, marcas) a JSON |
| id | INTEGER | Clave primaria | | `import_tecdoc_parts.py` | Importa partes OEM y aftermarket desde TecDoc |
| brand_id | INTEGER | FK a brands | | `import_live.py` | Importacion en vivo desde TecDoc API |
| name | TEXT | Nombre del modelo | | `link_vehicle_parts.py` | Genera links vehiculo-parte (fitment masivo) |
| body_type | TEXT | Tipo de carrocería | | `migrate_aftermarket.py` | Migra datos aftermarket a la estructura normalizada |
| generation | TEXT | Generación | | `migrate_saas_schema.py` | Crea tablas SaaS: sessions, warehouse_inventory, roles, etc. |
| production_start_year | INTEGER | Año inicio producción | | `import_phase1.py` | Importacion inicial fase 1 |
| production_end_year | INTEGER | Año fin producción | | `run_all_brands.sh` | Script auxiliar para importar todas las marcas |
#### engines ## Configuracion
| 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 |
#### years Archivo principal: [`config.py`](config.py)
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | INTEGER | Clave primaria |
| year | INTEGER | Año |
#### model_year_engine | Variable | Default | Descripcion |
| Campo | Tipo | Descripción | |----------|---------|-------------|
|-------|------|-------------| | `DATABASE_URL` | `postgresql://nexus:...@localhost/nexus_autoparts` | PostgreSQL connection string |
| id | INTEGER | Clave primaria | | `JWT_SECRET` | `nexus-saas-secret-change-in-prod-2026` | Secreto para firmar tokens JWT |
| model_id | INTEGER | FK a models | | `JWT_ACCESS_EXPIRES` | `900` (15 min) | Duracion del access token en segundos |
| year_id | INTEGER | FK a years | | `JWT_REFRESH_EXPIRES` | `2592000` (30 dias) | Duracion del refresh token en segundos |
| engine_id | INTEGER | FK a engines |
| trim_level | TEXT | Nivel de equipamiento |
| drivetrain | TEXT | Tracción |
| transmission | TEXT | Transmisión |
### Diagrama de Relaciones ## Arquitectura
Documentacion detallada en [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
``` ```
brands ──┐ +------------------+
| TecDoc (Apify) |
├──< models ──┐ +--------+---------+
|
years ───┼─────────────┼──< model_year_engine download/import
|
engines ─┴─────────────┘ v
+----------+ +--------+---------+ +----------------+
| Frontend |<--->| Flask Server |<--->| PostgreSQL |
| (HTML/JS)| | (server.py) | | nexus_autoparts|
+----------+ +--------+---------+ +----------------+
|
JWT auth (PyJWT)
|
+------------+------------+
| | |
TALLER BODEGA ADMIN
(consulta) (inventario) (gestion)
``` ```
## Scripts Disponibles
### Scripts de Datos
| Script | Descripción |
|--------|-------------|
| `add_toyota_data.py` | Agrega datos de Toyota |
| `add_honda_data.py` | Agrega datos de Honda |
| `add_nissan_data.py` | Agrega datos de Nissan |
| `add_ford_data.py` | Agrega datos de Ford |
| `add_chevrolet_data.py` | Agrega datos de Chevrolet |
| `add_audi_data.py` | Agrega datos de Audi |
| `add_acura_data.py` | Agrega datos de Acura |
| ... | Y más marcas |
### Scripts de Mantenimiento
| Script | Descripción |
|--------|-------------|
| `remove_brands_and_cleanup.py` | Limpia marcas innecesarias |
| `check_and_remove_brands.py` | Verifica y elimina marcas |
## Funcionalidades del Dashboard
### Panel de Filtros
- Selección de marca
- Selección de modelo (dinámico según marca)
- Filtro por año
- Filtro por motor
### Panel de Resultados
- Visualización en tarjetas
- Información detallada del vehículo
- Especificaciones del motor
- Datos de transmisión y tracción
### Características
- Diseño responsivo
- Actualización en tiempo real
- Animaciones y transiciones suaves
- Soporte para múltiples idiomas
## Arquitectura del Sistema
```
┌─────────────────┐ ┌──────────────────┐
│ RockAuto.com │────>│ Web Scraper │
└─────────────────┘ └────────┬─────────┘
v
┌─────────────────┐ ┌──────────────────┐
│ Manual Input │────>│ SQLite Database │
└─────────────────┘ └────────┬─────────┘
┌───────────────────────┼───────────────────────┐
│ │ │
v v v
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Flask API │ │ 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
View 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
View 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
View File

7
console/__main__.py Normal file
View 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
View 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
View File

196
console/core/app.py Normal file
View 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()

View 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)

View 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
View 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
View 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
View 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()

View File

152
console/renderers/base.py Normal file
View 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

View 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))

View File

View 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

View 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

View 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 []

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

214
console/tests/test_core.py Normal file
View 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
View 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

View 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
View 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)

View File

View 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
View 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)}

View File

@@ -3,78 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - Autopartes DB</title> <title>Admin Panel - Nexus Autoparts</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<style> <style>
/* Admin-specific variable overrides */
:root { :root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a25;
--text-primary: #ffffff;
--text-secondary: #8888aa; --text-secondary: #8888aa;
--accent: #ff6b35;
--accent-hover: #ff8555;
--success: #00d68f; --success: #00d68f;
--warning: #ffaa00; --warning: #ffaa00;
--danger: #ff4444;
--border: #2a2a3a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* Header */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-family: 'Orbitron', sans-serif;
font-size: 1.5rem;
color: var(--accent);
text-decoration: none;
}
.header-nav {
display: flex;
gap: 1rem;
}
.header-nav a {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: all 0.2s;
}
.header-nav a:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
} }
/* Layout */ /* Layout */
.container { .container {
display: flex; display: flex;
min-height: calc(100vh - 60px); min-height: calc(100vh - 60px);
padding-top: 60px;
} }
/* Sidebar */ /* Sidebar */
@@ -257,39 +201,7 @@
gap: 0.5rem; gap: 0.5rem;
} }
/* Forms */ /* Forms - admin-specific */
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}
.form-input::placeholder {
color: var(--text-secondary);
}
select.form-input { select.form-input {
cursor: pointer; cursor: pointer;
} }
@@ -451,28 +363,6 @@
.badge-premium { background: #5a5a2a; color: #ffff7f; } .badge-premium { background: #5a5a2a; color: #ffff7f; }
.badge-oem { background: #2a2a5a; color: #7f7fff; } .badge-oem { background: #2a2a5a; color: #7f7fff; }
/* Alert */
.alert {
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-success {
background: rgba(0, 214, 143, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.alert-error {
background: rgba(255, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
/* Pagination */ /* Pagination */
.pagination { .pagination {
display: flex; display: flex;
@@ -701,21 +591,12 @@
width: 60px; width: 60px;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
</style> </style>
</head> </head>
<body> <body>
<!-- Header --> <!-- Shared Navigation -->
<header class="header"> <div id="shared-nav"></div>
<a href="/" class="logo">AUTOPARTES DB</a> <script src="/nav.js"></script>
<nav class="header-nav">
<a href="/">Catálogo</a>
<a href="/customer-landing.html">Landing</a>
<a href="/admin.html" style="color: var(--accent);">Admin</a>
</nav>
</header>
<div class="container"> <div class="container">
<!-- Sidebar --> <!-- Sidebar -->
@@ -768,6 +649,14 @@
</div> </div>
</div> </div>
<div class="sidebar-section">
<h3>Diagramas</h3>
<div class="sidebar-item" data-section="diagrams">
<span class="icon">📐</span>
<span>Hotspot Editor</span>
</div>
</div>
<div class="sidebar-section"> <div class="sidebar-section">
<h3>Importar/Exportar</h3> <h3>Importar/Exportar</h3>
<div class="sidebar-item" data-section="import"> <div class="sidebar-item" data-section="import">
@@ -779,6 +668,15 @@
<span>Exportar CSV</span> <span>Exportar CSV</span>
</div> </div>
</div> </div>
<div class="sidebar-section">
<h3>Sistema</h3>
<div class="sidebar-item" data-section="users">
<span class="icon">👤</span>
<span>Usuarios</span>
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
</div>
</div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
@@ -1237,6 +1135,116 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Diagrams / Hotspot Editor Section -->
<section id="section-diagrams" class="admin-section">
<div class="page-header">
<h1 class="page-title">Editor de Hotspots</h1>
</div>
<div class="card" style="margin-bottom: 1.5rem;">
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Busca un diagrama por código y haz clic en la imagen para agregar hotspots vinculados a partes.
</p>
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
<input type="text" class="form-input" id="diagramSearchInput"
placeholder="Buscar diagrama (ej: F200, S341...)"
style="max-width: 300px;"
onkeypress="if(event.key==='Enter') searchDiagramsAdmin()">
<button class="btn btn-primary" onclick="searchDiagramsAdmin()">
Buscar
</button>
</div>
</div>
<!-- Diagram search results grid -->
<div id="diagramSearchResults" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;"></div>
<!-- Hotspot Editor Area (shown when a diagram is selected) -->
<div id="hotspotEditorArea" style="display: none;">
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 id="hotspotEditorTitle" style="margin: 0; font-size: 1.1rem;">Diagrama</h2>
<button class="btn btn-secondary" onclick="closeHotspotEditor()">Cerrar Editor</button>
</div>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap;">
<!-- Image with click-to-place -->
<div style="flex: 1; min-width: 400px; position: relative; background: #f0f0f0; border-radius: 8px; overflow: hidden; cursor: crosshair;" id="hotspotImageContainer">
<img id="hotspotEditorImg" src="" alt="Diagram"
style="width: 100%; display: block;"
onclick="onHotspotImageClick(event)">
<div id="hotspotMarkersContainer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
</div>
<!-- Hotspot form + list -->
<div style="width: 320px; flex-shrink: 0;">
<h3 style="font-size: 0.95rem; margin-bottom: 0.75rem;">Agregar / Editar Hotspot</h3>
<form id="hotspotForm" style="margin-bottom: 1rem;">
<input type="hidden" id="hsEditId">
<div class="form-group">
<label class="form-label">Posición (x%, y%)</label>
<input type="text" class="form-input" id="hsCoords" placeholder="Clic en imagen..." readonly>
</div>
<div class="form-group">
<label class="form-label"># Callout</label>
<input type="number" class="form-input" id="hsCallout" min="1" value="1">
</div>
<div class="form-group">
<label class="form-label">Parte OEM (buscar)</label>
<input type="text" class="form-input" id="hsPartSearch"
placeholder="Buscar parte por nombre o #..."
oninput="searchPartsForHotspot(this.value)">
<select class="form-input" id="hsPartSelect" size="4" style="margin-top: 0.25rem; display: none;">
</select>
<input type="hidden" id="hsPartId">
</div>
<div class="form-group">
<label class="form-label">Etiqueta</label>
<input type="text" class="form-input" id="hsLabel" placeholder="Opcional">
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-primary" onclick="saveHotspot()">Guardar</button>
<button type="button" class="btn btn-secondary" onclick="clearHotspotForm()">Limpiar</button>
</div>
</form>
<h3 style="font-size: 0.95rem; margin-bottom: 0.5rem;">Hotspots Existentes</h3>
<div id="hotspotsList" style="max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
</div>
</div>
</section>
<!-- Users Section -->
<section id="section-users" class="admin-section">
<div class="page-header">
<h1 class="page-title">Usuarios</h1>
</div>
<div class="card">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Email</th>
<th>Negocio</th>
<th>Rol</th>
<th>Activo</th>
<th>Último Login</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="usersTable">
<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main> </main>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** /**
* Admin Panel JavaScript * Admin Panel JavaScript
* CRUD operations and CSV import/export for Autopartes DB * CRUD operations and CSV import/export for Nexus Autoparts
*/ */
// State // State
@@ -115,6 +115,12 @@ function showSection(sectionId) {
case 'fitment': case 'fitment':
loadFitment(); loadFitment();
break; break;
case 'diagrams':
// Just show section, user uses search
break;
case 'users':
loadUsers();
break;
} }
} }
@@ -1175,11 +1181,12 @@ async function loadVehiclesForSelect(selectId) {
if (!select) return; if (!select) return;
try { try {
const response = await fetch('/api/model-year-engine'); const response = await fetch('/api/model-year-engine?per_page=100');
const vehicles = await response.json(); const result = await response.json();
const vehicles = result.data || result;
select.innerHTML = '<option value="">Selecciona vehículo...</option>' + select.innerHTML = '<option value="">Selecciona vehículo...</option>' +
vehicles.slice(0, 100).map(v => vehicles.map(v =>
`<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>` `<option value="${v.id}">${v.brand} ${v.model} ${v.year} - ${v.engine}</option>`
).join(''); ).join('');
} catch (e) { } catch (e) {
@@ -1222,18 +1229,18 @@ function renderPagination(containerId, pagination, pageKey, loadFunction) {
let html = ''; let html = '';
// Previous button // Previous button
html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, ${loadFunction.name})">← Anterior</button>`; html += `<button ${page <= 1 ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page - 1}, '${loadFunction.name}')">← Anterior</button>`;
// Page numbers // Page numbers
const startPage = Math.max(1, page - 2); const startPage = Math.max(1, page - 2);
const endPage = Math.min(total_pages, page + 2); const endPage = Math.min(total_pages, page + 2);
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, ${loadFunction.name})">${i}</button>`; html += `<button class="${i === page ? 'active' : ''}" onclick="goToPage('${pageKey}', ${i}, '${loadFunction.name}')">${i}</button>`;
} }
// Next button // Next button
html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, ${loadFunction.name})">Siguiente →</button>`; html += `<button ${page >= total_pages ? 'disabled' : ''} onclick="goToPage('${pageKey}', ${page + 1}, '${loadFunction.name}')">Siguiente →</button>`;
container.innerHTML = html; container.innerHTML = html;
} }
@@ -1558,8 +1565,9 @@ async function loadBulkEngines() {
const engines = await response.json(); const engines = await response.json();
// Get MYE IDs for each engine // Get MYE IDs for each engine
const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}`); const myeResponse = await fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&year=${year}&per_page=100`);
const myeData = await myeResponse.json(); const myeResult = await myeResponse.json();
const myeData = myeResult.data || myeResult;
engineSelect.innerHTML = '<option value="">Selecciona motor...</option>' + engineSelect.innerHTML = '<option value="">Selecciona motor...</option>' +
myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).join(''); myeData.map(mye => `<option value="${mye.id}">${mye.engine}</option>`).join('');
@@ -1707,3 +1715,362 @@ showSection = function(sectionId) {
initBulkEditor(); initBulkEditor();
} }
}; };
// ============================================================================
// Diagram Hotspot Editor
// ============================================================================
let currentEditorDiagramId = null;
let currentEditorHotspots = [];
let partSearchTimeout = null;
async function searchDiagramsAdmin() {
const q = document.getElementById('diagramSearchInput').value.trim();
const container = document.getElementById('diagramSearchResults');
if (!q) {
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1">Ingresa un código de diagrama para buscar</p>';
return;
}
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1"><i class="fas fa-spinner fa-spin"></i> Buscando...</p>';
try {
const res = await fetch(`/api/diagrams/search?q=${encodeURIComponent(q)}`);
const diagrams = await res.json();
if (diagrams.length === 0) {
container.innerHTML = '<p style="color:var(--text-secondary);grid-column:1/-1">No se encontraron diagramas</p>';
return;
}
container.innerHTML = diagrams.map(d => {
const imgSrc = d.image_path ? '/' + d.image_path : `/static/diagrams/moog/${d.name}.jpg`;
return `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;overflow:hidden;cursor:pointer;transition:border-color 0.2s"
onclick="openHotspotEditor(${d.id})"
onmouseover="this.style.borderColor='var(--accent)'"
onmouseout="this.style.borderColor='var(--border)'">
<img src="${imgSrc}" alt="${d.name}" style="width:100%;height:120px;object-fit:contain;background:#f0f0f0;display:block"
onerror="this.style.display='none'">
<div style="padding:0.5rem 0.65rem">
<div style="font-weight:600;color:var(--accent)">${d.name}</div>
<div style="font-size:0.8rem;color:var(--text-secondary)">${d.name_es || d.source || ''}</div>
</div>
</div>`;
}).join('');
} catch (e) {
container.innerHTML = '<p style="color:#e74c3c;grid-column:1/-1">Error al buscar diagramas</p>';
}
}
async function openHotspotEditor(diagramId) {
currentEditorDiagramId = diagramId;
document.getElementById('hotspotEditorArea').style.display = 'block';
try {
const res = await fetch(`/api/diagrams/${diagramId}`);
const diagram = await res.json();
document.getElementById('hotspotEditorTitle').textContent = `${diagram.name} - ${diagram.name_es || diagram.group_name || ''}`;
const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : '');
document.getElementById('hotspotEditorImg').src = imgSrc;
currentEditorHotspots = diagram.hotspots || [];
renderEditorHotspots();
clearHotspotForm();
// Auto-set next callout number
const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0);
document.getElementById('hsCallout').value = maxCallout + 1;
// Scroll to editor
document.getElementById('hotspotEditorArea').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
showAlert('Error al cargar diagrama', 'error');
}
}
function closeHotspotEditor() {
document.getElementById('hotspotEditorArea').style.display = 'none';
currentEditorDiagramId = null;
currentEditorHotspots = [];
}
function onHotspotImageClick(event) {
const img = event.target;
const rect = img.getBoundingClientRect();
const xPct = ((event.clientX - rect.left) / rect.width * 100).toFixed(2);
const yPct = ((event.clientY - rect.top) / rect.height * 100).toFixed(2);
document.getElementById('hsCoords').value = `${xPct},${yPct}`;
// Show temporary marker
renderEditorHotspots();
const container = document.getElementById('hotspotMarkersContainer');
const tempMarker = document.createElement('div');
tempMarker.style.cssText = `position:absolute;left:${xPct}%;top:${yPct}%;width:24px;height:24px;border-radius:50%;background:rgba(46,204,113,0.5);border:2px solid #2ecc71;transform:translate(-50%,-50%);pointer-events:none;z-index:10`;
container.appendChild(tempMarker);
}
function renderEditorHotspots() {
const container = document.getElementById('hotspotMarkersContainer');
const list = document.getElementById('hotspotsList');
// Markers on image
container.innerHTML = currentEditorHotspots.map(h => {
const coords = (h.coords || '').split(',');
if (coords.length < 2) return '';
return `<div style="position:absolute;left:${coords[0]}%;top:${coords[1]}%;width:24px;height:24px;border-radius:50%;background:rgba(231,76,60,0.4);border:2px solid #e74c3c;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;font-size:0.6rem;font-weight:700;color:white;pointer-events:auto;cursor:pointer" onclick="editHotspot(${h.id})" title="${h.label || h.part_name || ''}">${h.callout_number || ''}</div>`;
}).join('');
// List
if (currentEditorHotspots.length === 0) {
list.innerHTML = '<p style="color:var(--text-secondary);font-size:0.85rem">No hay hotspots</p>';
return;
}
list.innerHTML = currentEditorHotspots.map(h => `
<div style="background:var(--bg-hover);border:1px solid var(--border);border-radius:6px;padding:0.5rem;margin-bottom:0.4rem;display:flex;align-items:center;gap:0.5rem">
<span style="background:var(--accent);color:white;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0">${h.callout_number || '?'}</span>
<div style="flex:1;min-width:0">
<div style="font-size:0.82rem;font-weight:500">${h.part_name || h.label || 'Sin parte'}</div>
<div style="font-size:0.72rem;color:var(--text-secondary)">${h.part_number || ''} | ${h.coords}</div>
</div>
<button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem" onclick="editHotspot(${h.id})">Editar</button>
<button class="btn" style="padding:0.2rem 0.5rem;font-size:0.75rem;background:#e74c3c;color:white;border:none;border-radius:4px;cursor:pointer" onclick="deleteHotspot(${h.id})">Borrar</button>
</div>
`).join('');
}
function editHotspot(hotspotId) {
const hs = currentEditorHotspots.find(h => h.id === hotspotId);
if (!hs) return;
document.getElementById('hsEditId').value = hs.id;
document.getElementById('hsCoords').value = hs.coords || '';
document.getElementById('hsCallout').value = hs.callout_number || '';
document.getElementById('hsLabel').value = hs.label || '';
document.getElementById('hsPartId').value = hs.part_id || '';
document.getElementById('hsPartSearch').value = hs.part_name ? `${hs.part_number} - ${hs.part_name}` : '';
}
function clearHotspotForm() {
document.getElementById('hsEditId').value = '';
document.getElementById('hsCoords').value = '';
document.getElementById('hsLabel').value = '';
document.getElementById('hsPartId').value = '';
document.getElementById('hsPartSearch').value = '';
document.getElementById('hsPartSelect').style.display = 'none';
// Keep callout at next number
const maxCallout = currentEditorHotspots.reduce((max, h) => Math.max(max, h.callout_number || 0), 0);
document.getElementById('hsCallout').value = maxCallout + 1;
}
async function searchPartsForHotspot(query) {
clearTimeout(partSearchTimeout);
const select = document.getElementById('hsPartSelect');
if (!query || query.length < 2) {
select.style.display = 'none';
return;
}
partSearchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/parts?search=${encodeURIComponent(query)}&per_page=20`);
const data = await res.json();
const parts = data.data || data;
if (parts.length === 0) {
select.innerHTML = '<option disabled>Sin resultados</option>';
} else {
select.innerHTML = parts.map(p =>
`<option value="${p.id}">${p.oem_part_number} - ${p.name_es || p.name}</option>`
).join('');
}
select.style.display = 'block';
select.onchange = function() {
const opt = select.options[select.selectedIndex];
document.getElementById('hsPartId').value = opt.value;
document.getElementById('hsPartSearch').value = opt.textContent;
select.style.display = 'none';
};
} catch (e) {
select.innerHTML = '<option disabled>Error buscando</option>';
select.style.display = 'block';
}
}, 300);
}
async function saveHotspot() {
const editId = document.getElementById('hsEditId').value;
const coords = document.getElementById('hsCoords').value.trim();
const callout = parseInt(document.getElementById('hsCallout').value) || null;
const partId = parseInt(document.getElementById('hsPartId').value) || null;
const label = document.getElementById('hsLabel').value.trim();
if (!coords) {
showAlert('Haz clic en la imagen para seleccionar posición', 'error');
return;
}
const body = {
diagram_id: currentEditorDiagramId,
coords: coords,
callout_number: callout,
part_id: partId,
label: label,
shape: 'circle',
color: '#e74c3c'
};
try {
let res;
if (editId) {
res = await fetch(`/api/admin/hotspots/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} else {
res = await fetch('/api/admin/hotspots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Error al guardar');
showAlert(editId ? 'Hotspot actualizado' : 'Hotspot creado');
// Reload diagram to refresh hotspots
await openHotspotEditor(currentEditorDiagramId);
} catch (e) {
showAlert(e.message, 'error');
}
}
async function deleteHotspot(hotspotId) {
if (!confirm('Eliminar este hotspot?')) return;
try {
const res = await fetch(`/api/admin/hotspots/${hotspotId}`, { method: 'DELETE' });
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Error al eliminar');
showAlert('Hotspot eliminado');
await openHotspotEditor(currentEditorDiagramId);
} catch (e) {
showAlert(e.message, 'error');
}
}
// ============================================================================
// User Management
// ============================================================================
const roleBadgeColors = {
ADMIN: '#3b82f6',
OWNER: '#8b5cf6',
TALLER: '#22c55e',
BODEGA: '#f59e0b'
};
function formatDate(dateStr) {
if (!dateStr) return '<span style="color:var(--text-secondary)">Nunca</span>';
var d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' })
+ ' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
function getRoleBadge(role) {
var color = roleBadgeColors[role] || '#6b7280';
return '<span style="background:' + color + '; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">' + (role || 'N/A') + '</span>';
}
function getActiveBadge(isActive) {
if (isActive) {
return '<span style="background:var(--success); color:#000; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Activo</span>';
}
return '<span style="background:#ef4444; color:#fff; padding:2px 8px; border-radius:10px; font-size:0.75rem; font-weight:600;">Inactivo</span>';
}
async function loadUsers() {
var token = localStorage.getItem('access_token');
var tbody = document.getElementById('usersTable');
tbody.innerHTML = '<tr><td colspan="7" class="loading"><div class="spinner"></div></td></tr>';
try {
var res = await fetch('/api/admin/users', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) throw new Error('Error al cargar usuarios (' + res.status + ')');
var data = await res.json();
var users = Array.isArray(data) ? data : (data.data || []);
// Update pending badge
var pending = users.filter(function(u) { return !u.is_active; }).length;
var badge = document.getElementById('pendingUsersBadge');
if (badge) {
if (pending > 0) {
badge.textContent = pending;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
}
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay usuarios registrados</td></tr>';
return;
}
tbody.innerHTML = users.map(function(u) {
var toggleLabel = u.is_active ? 'Desactivar' : 'Activar';
var toggleClass = u.is_active ? 'btn-secondary' : 'btn-primary';
return '<tr>' +
'<td>' + (u.name || u.nombre || '-') + '</td>' +
'<td>' + (u.email || '-') + '</td>' +
'<td>' + (u.business_name || u.negocio || '-') + '</td>' +
'<td>' + getRoleBadge(u.role || u.rol) + '</td>' +
'<td>' + getActiveBadge(u.is_active) + '</td>' +
'<td>' + formatDate(u.last_login || u.ultimo_login) + '</td>' +
'<td><button class="btn ' + toggleClass + '" style="font-size:0.8rem; padding:4px 10px;" onclick="toggleUserActive(' + u.id + ', ' + u.is_active + ')">' + toggleLabel + '</button></td>' +
'</tr>';
}).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
}
}
async function toggleUserActive(userId, currentActive) {
var token = localStorage.getItem('access_token');
var action = currentActive ? 'desactivar' : 'activar';
if (!confirm('¿Seguro que deseas ' + action + ' este usuario?')) return;
try {
var res = await fetch('/api/admin/users/' + userId + '/activate', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ is_active: !currentActive })
});
if (!res.ok) {
var err = await res.json();
throw new Error(err.error || 'Error al actualizar usuario');
}
showAlert('Usuario ' + (currentActive ? 'desactivado' : 'activado') + ' correctamente');
loadUsers();
} catch (e) {
showAlert(e.message, 'error');
}
}

98
dashboard/auth.py Normal file
View 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
View 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
View 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">&#128230;</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">&times;</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
View 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
View 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
View 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
View 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">&#128203;</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 + ' &middot; ' + esc(v.engine) +
(v.trim_level ? ' &middot; ' + 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) + '">&laquo; 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 &raquo;</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">&#9664; Volver</button>' +
'<button class="btn btn-primary" id="btn-complete-vehicle">Terminado &#10003;</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">&#9660;</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">&#10005;</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">&#10003;</button>' +
'<button class="pr-btn pr-delete" title="Quitar">&#10005;</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">&#9989;</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) + ' &rsaquo; ' + 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">&#128247;</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) + ' &middot; ' + 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
View 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
View 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">&laquo; 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
View 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) + '">&laquo;</button>' +
'<span class="page-info">Pag ' + pag.page + '/' + pag.total_pages + '</span>' +
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">&raquo;</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();
})();

View File

@@ -3,127 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AutoParts DB - Tienda de Autopartes</title> <title>Nexus Autoparts - Tu conexión directa con las partes que necesitas</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<style> <style>
* { /* Landing page-specific header extras */
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-hover: #252532;
--accent: #ff6b35;
--accent-hover: #ff8555;
--accent-glow: rgba(255, 107, 53, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--border: #2a2a3a;
--success: #22c55e;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* Header */
.header {
background: rgba(18, 18, 26, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 1rem 3rem;
display: flex;
justify-content: space-between;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
}
.logo-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 4px 20px var(--accent-glow);
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 2.5rem;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
font-size: 0.95rem;
transition: color 0.3s;
position: relative;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--accent);
}
.nav-links a::after {
content: '';
position: absolute;
bottom: -5px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 0.3s;
}
.nav-links a:hover::after,
.nav-links a.active::after {
width: 100%;
}
.nav-links a.admin-link {
color: var(--accent);
font-weight: 600;
opacity: 0.8;
transition: opacity 0.3s;
}
.nav-links a.admin-link:hover {
opacity: 1;
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -165,29 +49,34 @@
justify-content: center; justify-content: center;
} }
.btn { /* Footer logo (reuses .logo classes) */
padding: 0.7rem 1.5rem; .footer .logo {
border-radius: 10px; display: flex;
border: none; align-items: center;
font-weight: 600; gap: 0.75rem;
cursor: pointer;
transition: all 0.3s;
font-size: 0.9rem;
text-decoration: none; text-decoration: none;
display: inline-flex; }
.footer .logo-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 10px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.5rem;
box-shadow: 0 4px 20px var(--accent-glow);
} }
.btn-primary { .footer .logo-text {
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%); font-family: 'Orbitron', sans-serif;
color: white; font-size: 1.4rem;
box-shadow: 0 4px 15px var(--accent-glow); font-weight: 700;
} background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text;
.btn-primary:hover { -webkit-text-fill-color: transparent;
transform: translateY(-2px); background-clip: text;
box-shadow: 0 6px 25px var(--accent-glow);
} }
/* Hero Section */ /* Hero Section */
@@ -1060,30 +949,23 @@
</style> </style>
</head> </head>
<body> <body>
<!-- Header --> <!-- Shared Navigation -->
<header class="header"> <div id="shared-nav"></div>
<a href="customer-landing.html" class="logo"> <script src="/nav.js"></script>
<div class="logo-icon">⚙️</div> <script>
<div class="logo-text">AUTOPARTS DB</div> // Inject landing-page-specific header extras (search, cart, dashboard btn)
</a> (function() {
<nav class="nav-links"> var extra = document.getElementById('shared-nav-extra');
<a href="customer-landing.html" class="active">Inicio</a> if (!extra) return;
<a href="index.html">Catálogo</a> extra.innerHTML = ''
<a href="#brands-section">Marcas</a> + '<div class="header-actions">'
<a href="#featured-section">Productos</a> + '<button class="search-btn" onclick="openSearchModal()">\uD83D\uDD0D</button>'
<a href="#cta-section">Contacto</a> + '<button class="cart-btn">\uD83D\uDED2<span class="cart-badge" id="cart-count">0</span></button>'
<a href="admin.html" class="admin-link">⚡ Admin</a> + '<a href="index.html" class="btn btn-primary">Dashboard</a>'
</nav> + '<button class="mobile-menu-btn">\u2630</button>'
<div class="header-actions"> + '</div>';
<button class="search-btn" onclick="openSearchModal()">🔍</button> })();
<button class="cart-btn"> </script>
🛒
<span class="cart-badge" id="cart-count">0</span>
</button>
<a href="index.html" class="btn btn-primary">Dashboard</a>
<button class="mobile-menu-btn"></button>
</div>
</header>
<!-- Search Modal --> <!-- Search Modal -->
<div class="search-modal" id="searchModal" onclick="closeSearchModal(event)"> <div class="search-modal" id="searchModal" onclick="closeSearchModal(event)">
@@ -1213,7 +1095,7 @@
<div class="footer-brand"> <div class="footer-brand">
<div class="logo"> <div class="logo">
<div class="logo-icon">⚙️</div> <div class="logo-icon">⚙️</div>
<div class="logo-text">AUTOPARTS DB</div> <div class="logo-text">NEXUS AUTOPARTS</div>
</div> </div>
<p>Sistema de catálogo de autopartes con navegación jerárquica, diagramas explosionados y decodificador de VIN.</p> <p>Sistema de catálogo de autopartes con navegación jerárquica, diagramas explosionados y decodificador de VIN.</p>
<div class="social-links"> <div class="social-links">
@@ -1249,7 +1131,7 @@
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>© 2024 AutoParts DB. Sistema de Catálogo de Autopartes.</p> <p>© 2026 Nexus Autoparts. Tu conexión directa con las partes que necesitas.</p>
<p>Desarrollado con Flask + SQLite</p> <p>Desarrollado con Flask + SQLite</p>
</div> </div>
</footer> </footer>

View File

@@ -41,36 +41,31 @@ class VehicleDashboard {
async loadStats() { async loadStats() {
try { try {
const [brandsRes, vehiclesRes, partsRes, categoriesRes] = await Promise.all([ const [statsRes, brandsRes, categoriesRes] = await Promise.all([
fetch('/api/catalog/stats'),
fetch('/api/brands'), fetch('/api/brands'),
fetch('/api/vehicles'),
fetch('/api/parts'),
fetch('/api/categories') fetch('/api/categories')
]); ]);
if (brandsRes.ok && vehiclesRes.ok) { if (statsRes.ok) {
const brands = await brandsRes.json(); const s = await statsRes.json();
const vehicles = await vehiclesRes.json(); this.stats.brands = s.brands;
this.stats.models = s.models;
// Contar modelos únicos this.stats.vehicles = s.vehicles;
const uniqueModels = new Set(vehicles.map(v => `${v.brand}-${v.model}`)); this.stats.parts = s.parts;
this.stats.brands = brands.length;
this.stats.models = uniqueModels.size;
this.stats.vehicles = vehicles.length;
const fmt = n => n > 1000 ? Math.floor(n/1000) + 'K+' : n;
const brandsEl = document.getElementById('totalBrands'); const brandsEl = document.getElementById('totalBrands');
const modelsEl = document.getElementById('totalModels'); const modelsEl = document.getElementById('totalModels');
if (brandsEl) brandsEl.textContent = this.stats.brands; const partsEl = document.getElementById('totalParts');
if (modelsEl) modelsEl.textContent = this.stats.models > 1000 ? Math.floor(this.stats.models/1000) + 'K+' : this.stats.models; if (brandsEl) brandsEl.textContent = fmt(this.stats.brands);
if (modelsEl) modelsEl.textContent = fmt(this.stats.models);
if (partsEl) partsEl.textContent = fmt(this.stats.parts);
} }
if (partsRes.ok) { if (brandsRes.ok) {
const partsData = await partsRes.json(); // Still needed for brand list rendering
// Handle paginated response await brandsRes.json();
this.stats.parts = partsData.pagination ? partsData.pagination.total : (partsData.data ? partsData.data.length : partsData.length || 0);
const partsEl = document.getElementById('totalParts');
if (partsEl) partsEl.textContent = this.stats.parts;
} }
if (categoriesRes.ok) { if (categoriesRes.ok) {
@@ -300,29 +295,18 @@ class VehicleDashboard {
`; `;
try { try {
const [brandsRes, vehiclesRes] = await Promise.all([ const brandsRes = await fetch('/api/brands?detailed=true');
fetch('/api/brands'),
fetch('/api/vehicles')
]);
if (!brandsRes.ok || !vehiclesRes.ok) { if (!brandsRes.ok) {
throw new Error('Error al cargar datos'); throw new Error('Error al cargar datos');
} }
const brands = await brandsRes.json(); const brands = await brandsRes.json();
const vehicles = await vehiclesRes.json();
// Contar modelos y vehículos por marca // Build brandStats from detailed response
const brandStats = {}; const brandStats = {};
brands.forEach(brand => { brands.forEach(b => {
brandStats[brand] = { models: new Set(), vehicles: 0 }; brandStats[b.name] = { models: { size: b.model_count }, vehicles: b.vehicle_count };
});
vehicles.forEach(v => {
if (brandStats[v.brand]) {
brandStats[v.brand].models.add(v.model);
brandStats[v.brand].vehicles++;
}
}); });
if (brands.length === 0) { if (brands.length === 0) {
@@ -337,17 +321,17 @@ class VehicleDashboard {
} }
container.innerHTML = `<div class="content-grid brands-grid"> container.innerHTML = `<div class="content-grid brands-grid">
${brands.map(brand => ` ${brands.map(b => `
<div class="brand-card" onclick="dashboard.goToModels('${brand}')"> <div class="brand-card" onclick="dashboard.goToModels('${b.name}')">
<div class="brand-icon"> <div class="brand-icon">
<i class="fas fa-car"></i> <i class="fas fa-car"></i>
</div> </div>
<div class="brand-name">${brand}</div> <div class="brand-name">${b.name}</div>
<div class="brand-count"> <div class="brand-count">
${brandStats[brand].models.size} modelos ${b.model_count} modelos
</div> </div>
<div class="brand-count"> <div class="brand-count">
${brandStats[brand].vehicles} vehículos ${b.vehicle_count} vehículos
</div> </div>
</div> </div>
`).join('')} `).join('')}
@@ -386,31 +370,13 @@ class VehicleDashboard {
`; `;
try { try {
const [modelsRes, vehiclesRes] = await Promise.all([ const modelsRes = await fetch(`/api/models?brand=${encodeURIComponent(brand)}&detailed=true`);
fetch(`/api/models?brand=${encodeURIComponent(brand)}`),
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}`)
]);
if (!modelsRes.ok || !vehiclesRes.ok) { if (!modelsRes.ok) {
throw new Error('Error al cargar datos'); throw new Error('Error al cargar datos');
} }
const models = await modelsRes.json(); const models = await modelsRes.json();
const vehicles = await vehiclesRes.json();
// Contar vehículos y años por modelo
const modelStats = {};
models.forEach(model => {
modelStats[model] = { years: new Set(), vehicles: 0, engines: new Set() };
});
vehicles.forEach(v => {
if (modelStats[v.model]) {
modelStats[v.model].years.add(v.year);
modelStats[v.model].vehicles++;
modelStats[v.model].engines.add(v.engine);
}
});
if (models.length === 0) { if (models.length === 0) {
container.innerHTML = ` container.innerHTML = `
@@ -427,26 +393,22 @@ class VehicleDashboard {
} }
container.innerHTML = `<div class="content-grid models-grid"> container.innerHTML = `<div class="content-grid models-grid">
${models.map(model => { ${models.map(m => {
const stats = modelStats[model]; const yearRange = m.year_count > 1
const yearsArray = Array.from(stats.years).sort((a, b) => b - a); ? `${m.year_min} - ${m.year_max}`
const yearRange = yearsArray.length > 0 : `${m.year_min}`;
? (yearsArray.length > 1
? `${yearsArray[yearsArray.length - 1]} - ${yearsArray[0]}`
: `${yearsArray[0]}`)
: 'N/A';
return ` return `
<div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${model}')"> <div class="model-card" onclick="dashboard.goToVehicles('${brand}', '${m.name}')">
<div class="model-name">${model}</div> <div class="model-name">${m.name}</div>
<div class="model-info"> <div class="model-info">
<i class="fas fa-calendar-alt"></i> ${yearRange} <i class="fas fa-calendar-alt"></i> ${yearRange}
</div> </div>
<div class="model-info"> <div class="model-info">
<i class="fas fa-cogs"></i> ${stats.engines.size} motores <i class="fas fa-cogs"></i> ${m.engine_count} motores
</div> </div>
<div class="model-info"> <div class="model-info">
<i class="fas fa-list"></i> ${stats.vehicles} variantes <i class="fas fa-list"></i> ${m.vehicle_count} variantes
</div> </div>
</div> </div>
`; `;
@@ -491,16 +453,18 @@ class VehicleDashboard {
try { try {
// Fetch both vehicles info and model_year_engine IDs // Fetch both vehicles info and model_year_engine IDs
const [vehiclesRes, myeRes] = await Promise.all([ const [vehiclesRes, myeRes] = await Promise.all([
fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`), fetch(`/api/vehicles?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`),
fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}`) fetch(`/api/model-year-engine?brand=${encodeURIComponent(brand)}&model=${encodeURIComponent(model)}&per_page=100`)
]); ]);
if (!vehiclesRes.ok || !myeRes.ok) { if (!vehiclesRes.ok || !myeRes.ok) {
throw new Error('Error al cargar vehículos'); throw new Error('Error al cargar vehículos');
} }
const vehicles = await vehiclesRes.json(); const vehiclesData = await vehiclesRes.json();
const myeRecords = await myeRes.json(); const myeData = await myeRes.json();
const vehicles = vehiclesData.data || vehiclesData;
const myeRecords = myeData.data || myeData;
// Merge mye_id into vehicles based on matching fields // Merge mye_id into vehicles based on matching fields
// Only keep vehicles that have a matching mye_id (i.e., have parts) // Only keep vehicles that have a matching mye_id (i.e., have parts)
@@ -911,7 +875,24 @@ class VehicleDashboard {
} }
const groups = await response.json(); const groups = await response.json();
this.displayGroups(groups, categoryId);
// Fetch diagrams for Suspension (11) or Steering (10) when vehicle is selected
let vehicleDiagrams = [];
if (this.selectedVehicleId && (categoryId === 10 || categoryId === 11)) {
try {
const diagRes = await fetch(`/api/vehicles/${this.selectedVehicleId}/diagrams/by-category?category_id=${categoryId}`);
if (diagRes.ok) {
const catGroups = await diagRes.json();
for (const cg of catGroups) {
vehicleDiagrams.push(...cg.diagrams);
}
}
} catch (e) {
console.error('Error loading diagrams for strip:', e);
}
}
this.displayGroups(groups, categoryId, vehicleDiagrams);
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
@@ -928,10 +909,10 @@ class VehicleDashboard {
} }
} }
displayGroups(groups, categoryId) { displayGroups(groups, categoryId, vehicleDiagrams = []) {
const container = document.getElementById('mainContent'); const container = document.getElementById('mainContent');
if (groups.length === 0) { if (groups.length === 0 && vehicleDiagrams.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
@@ -944,8 +925,42 @@ class VehicleDashboard {
return; return;
} }
// Build diagram strip HTML if diagrams are available
let diagramStripHtml = '';
if (vehicleDiagrams.length > 0) {
// Store diagram list for the viewer
this._currentDiagramList = vehicleDiagrams;
diagramStripHtml = `
<div class="diagrams-strip">
<div class="diagrams-strip-header">
<h5><i class="fas fa-drafting-compass"></i> Diagramas MOOG para tu vehículo</h5>
<span class="strip-badge">${vehicleDiagrams.length} diagrama${vehicleDiagrams.length !== 1 ? 's' : ''}</span>
</div>
<div class="diagrams-strip-scroll">
${vehicleDiagrams.map((d, idx) => {
const type = (d.name || '')[0];
const typeLabel = type === 'F' ? 'Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Trasera' : '';
const imgSrc = d.image_url || '/static/diagrams/moog/' + d.name + '.jpg';
return `
<div class="strip-card" onclick="dashboard.openDiagramViewer(${d.id}, ${idx})"
title="${d.name_es || d.name}">
<img class="strip-card-img" src="${imgSrc}" alt="${d.name}"
loading="lazy"
onerror="this.style.display='none';this.parentElement.querySelector('.strip-card-body').style.paddingTop='3rem'">
<div class="strip-card-body">
<div class="strip-card-title">${d.name}</div>
<div class="strip-card-type">${typeLabel}</div>
</div>
</div>`;
}).join('')}
</div>
</div>`;
}
container.innerHTML = ` container.innerHTML = `
<h4 class="mb-3">${this.selectedCategory.name_es || this.selectedCategory.name}</h4> <h4 class="mb-3">${this.selectedCategory.name_es || this.selectedCategory.name}</h4>
${diagramStripHtml}
<div class="content-grid categories-grid"> <div class="content-grid categories-grid">
${groups.map(group => ` ${groups.map(group => `
<div class="category-card"> <div class="category-card">
@@ -1123,6 +1138,11 @@ class VehicleDashboard {
<h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4> <h4 class="mb-3">${part.name_es || part.name || 'Sin nombre'}</h4>
</div> </div>
</div> </div>
${part.image_url ? `
<div style="text-align:center;margin-bottom:1rem;">
<img src="${part.image_url}" alt="${part.oem_part_number || ''}" style="max-width:100%;max-height:300px;border-radius:8px;object-fit:contain;" />
</div>
` : ''}
<div class="part-detail-row"> <div class="part-detail-row">
<span class="part-detail-label">Número OEM</span> <span class="part-detail-label">Número OEM</span>
<span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span> <span class="part-detail-value"><span class="part-oem-badge">${part.oem_part_number || 'N/A'}</span></span>
@@ -1602,6 +1622,305 @@ class VehicleDashboard {
wrapper.style.transform = `scale(${this.currentDiagramZoom})`; wrapper.style.transform = `scale(${this.currentDiagramZoom})`;
} }
// ================================================================
// FASE 6: Full-screen Diagram Viewer (split layout)
// ================================================================
openDiagramViewer(diagramId, indexInList) {
this._dvCurrentIndex = typeof indexInList === 'number' ? indexInList : -1;
this._dvDiagramList = this._currentDiagramList || [];
this._dvZoom = 1;
this._dvDragging = false;
const overlay = document.getElementById('diagramViewerOverlay');
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
this._loadDiagramInViewer(diagramId);
this._bindDiagramViewerEvents();
}
closeDiagramViewer() {
const overlay = document.getElementById('diagramViewerOverlay');
overlay.classList.remove('active');
document.body.style.overflow = '';
this._unbindDiagramViewerEvents();
}
async _loadDiagramInViewer(diagramId) {
const titleEl = document.getElementById('dvTitle');
const subtitleEl = document.getElementById('dvSubtitle');
const imgWrapper = document.getElementById('dvImgWrapper');
const img = document.getElementById('dvImg');
const partsList = document.getElementById('dvPartsList');
const partsCount = document.getElementById('dvPartsCount');
// Show loading in parts
partsList.innerHTML = '<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-spinner fa-spin" style="font-size:1.5rem"></i><p style="margin-top:0.5rem">Cargando...</p></div>';
partsCount.textContent = '...';
try {
// Fetch diagram detail + parts in parallel
const [diagRes, partsRes] = await Promise.all([
fetch(`/api/diagrams/${diagramId}`),
fetch(`/api/diagrams/${diagramId}/parts${this.selectedVehicleId ? '?mye_id=' + this.selectedVehicleId : ''}`)
]);
const diagram = await diagRes.json();
const parts = await partsRes.json();
// Update title
const type = (diagram.name || '')[0];
const typeLabel = type === 'F' ? 'Suspensión Delantera' : type === 'S' ? 'Dirección' : type === 'R' ? 'Suspensión Trasera' : diagram.group_name || '';
titleEl.textContent = diagram.name || 'Diagrama';
subtitleEl.textContent = diagram.name_es || typeLabel;
// Update image
const imgSrc = diagram.image_url || (diagram.image_path ? '/' + diagram.image_path : '');
img.src = imgSrc;
img.alt = diagram.name_es || diagram.name;
this._dvZoom = 1;
imgWrapper.style.transform = '';
imgWrapper.classList.remove('zoomed');
document.getElementById('dvZoomLevel').textContent = '100%';
// Render hotspots on image
this._renderViewerHotspots(diagram.hotspots || [], imgWrapper);
// Render parts list
this._renderViewerParts(parts, diagram.hotspots || []);
// Update nav button states
const prevBtn = document.getElementById('dvPrevBtn');
const nextBtn = document.getElementById('dvNextBtn');
prevBtn.disabled = this._dvCurrentIndex <= 0;
nextBtn.disabled = this._dvCurrentIndex < 0 || this._dvCurrentIndex >= this._dvDiagramList.length - 1;
prevBtn.style.opacity = prevBtn.disabled ? '0.3' : '1';
nextBtn.style.opacity = nextBtn.disabled ? '0.3' : '1';
} catch (e) {
console.error('Error loading diagram in viewer:', e);
partsList.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary)"><i class="fas fa-exclamation-triangle" style="font-size:1.5rem;color:#f59e0b"></i><p style="margin-top:0.5rem">Error cargando diagrama</p></div>';
}
}
_renderViewerHotspots(hotspots, wrapper) {
// Remove existing hotspot markers
wrapper.querySelectorAll('.hotspot-marker').forEach(el => el.remove());
if (!hotspots || hotspots.length === 0) return;
hotspots.forEach((hs, idx) => {
// coords stored as "x%,y%" (percentage-based)
const coords = (hs.coords || '').split(',');
if (coords.length < 2) return;
const xPct = parseFloat(coords[0]);
const yPct = parseFloat(coords[1]);
if (isNaN(xPct) || isNaN(yPct)) return;
const marker = document.createElement('div');
marker.className = 'hotspot-marker pulse';
marker.style.left = xPct + '%';
marker.style.top = yPct + '%';
marker.dataset.partId = hs.part_id || '';
marker.dataset.callout = hs.callout_number || (idx + 1);
marker.title = hs.part_name || hs.label || 'Parte ' + (idx + 1);
marker.innerHTML = `<span class="hotspot-number">${hs.callout_number || (idx + 1)}</span>`;
marker.addEventListener('click', () => {
this._highlightPartInList(hs.part_id);
// Highlight this marker
wrapper.querySelectorAll('.hotspot-marker').forEach(m => m.classList.remove('active'));
marker.classList.add('active');
});
wrapper.appendChild(marker);
});
}
_renderViewerParts(parts, hotspots) {
const listEl = document.getElementById('dvPartsList');
const countEl = document.getElementById('dvPartsCount');
countEl.textContent = parts.length;
if (!parts || parts.length === 0) {
listEl.innerHTML = '<div style="text-align:center;padding:3rem;color:var(--text-secondary)"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem"></i><p>No hay partes vinculadas</p></div>';
return;
}
// Build a hotspot lookup by part_id
const hotspotMap = {};
(hotspots || []).forEach((hs, idx) => {
if (hs.part_id) hotspotMap[hs.part_id] = hs.callout_number || (idx + 1);
});
// Group by group_name
const grouped = {};
parts.forEach(p => {
const g = p.group_name_es || p.group_name || 'Otros';
if (!grouped[g]) grouped[g] = [];
grouped[g].push(p);
});
let html = '';
for (const [group, groupParts] of Object.entries(grouped)) {
html += `<div class="dv-group-label">${group}</div>`;
for (const p of groupParts) {
const callout = hotspotMap[p.id];
let xrefHtml = '';
if (p.cross_references && p.cross_references.length > 0) {
xrefHtml = `<div class="dv-xref-list">${p.cross_references.map(x => `<span class="dv-xref-tag">${x.number}</span>`).join('')}</div>`;
}
html += `
<div class="dv-part-item" data-part-id="${p.id}" onclick="dashboard._onViewerPartClick(${p.id})">
<div style="display:flex;align-items:center;gap:0.5rem">
${callout ? `<span style="background:var(--accent);color:white;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0">${callout}</span>` : ''}
<div class="dv-part-number">${p.part_number || p.oem_part_number}</div>
</div>
<div class="dv-part-name">${p.name_es || p.name || ''}</div>
${xrefHtml}
</div>`;
}
}
listEl.innerHTML = html;
}
_highlightPartInList(partId) {
if (!partId) return;
const listEl = document.getElementById('dvPartsList');
listEl.querySelectorAll('.dv-part-item').forEach(el => el.classList.remove('highlighted'));
const target = listEl.querySelector(`.dv-part-item[data-part-id="${partId}"]`);
if (target) {
target.classList.add('highlighted');
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
_onViewerPartClick(partId) {
// Highlight in list
this._highlightPartInList(partId);
// Highlight matching hotspot on image
const wrapper = document.getElementById('dvImgWrapper');
wrapper.querySelectorAll('.hotspot-marker').forEach(m => {
m.classList.remove('active');
if (m.dataset.partId == partId) {
m.classList.add('active');
}
});
}
_dvNavigate(delta) {
const newIdx = this._dvCurrentIndex + delta;
if (newIdx < 0 || newIdx >= this._dvDiagramList.length) return;
this._dvCurrentIndex = newIdx;
const d = this._dvDiagramList[newIdx];
if (d) this._loadDiagramInViewer(d.id);
}
_dvSetZoom(level) {
this._dvZoom = Math.max(0.25, Math.min(4, level));
const wrapper = document.getElementById('dvImgWrapper');
if (this._dvZoom !== 1) {
wrapper.classList.add('zoomed');
wrapper.style.transform = `scale(${this._dvZoom})`;
} else {
wrapper.classList.remove('zoomed');
wrapper.style.transform = '';
}
document.getElementById('dvZoomLevel').textContent = `${Math.round(this._dvZoom * 100)}%`;
}
_bindDiagramViewerEvents() {
// Avoid duplicate bindings
if (this._dvBound) return;
this._dvBound = true;
this._dvHandlers = {
close: () => this.closeDiagramViewer(),
prev: () => this._dvNavigate(-1),
next: () => this._dvNavigate(1),
zoomIn: () => this._dvSetZoom(this._dvZoom + 0.25),
zoomOut: () => this._dvSetZoom(this._dvZoom - 0.25),
zoomFit: () => this._dvSetZoom(1),
keydown: (e) => {
const overlay = document.getElementById('diagramViewerOverlay');
if (!overlay.classList.contains('active')) return;
if (e.key === 'Escape') this.closeDiagramViewer();
if (e.key === 'ArrowLeft') this._dvNavigate(-1);
if (e.key === 'ArrowRight') this._dvNavigate(1);
if (e.key === '+' || e.key === '=') this._dvSetZoom(this._dvZoom + 0.25);
if (e.key === '-') this._dvSetZoom(this._dvZoom - 0.25);
},
wheel: (e) => {
const overlay = document.getElementById('diagramViewerOverlay');
if (!overlay.classList.contains('active')) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
this._dvSetZoom(this._dvZoom + delta);
},
partsFilter: (e) => {
const q = e.target.value.toLowerCase();
document.querySelectorAll('#dvPartsList .dv-part-item').forEach(el => {
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
});
},
mousedown: (e) => {
if (this._dvZoom <= 1) return;
this._dvDragging = true;
this._dvDragStart = { x: e.clientX, y: e.clientY };
const container = document.getElementById('dvImgContainer');
this._dvScrollStart = { x: container.scrollLeft, y: container.scrollTop };
container.style.cursor = 'grabbing';
},
mousemove: (e) => {
if (!this._dvDragging) return;
const container = document.getElementById('dvImgContainer');
container.scrollLeft = this._dvScrollStart.x - (e.clientX - this._dvDragStart.x);
container.scrollTop = this._dvScrollStart.y - (e.clientY - this._dvDragStart.y);
},
mouseup: () => {
this._dvDragging = false;
const container = document.getElementById('dvImgContainer');
if (container) container.style.cursor = '';
}
};
document.getElementById('dvCloseBtn').addEventListener('click', this._dvHandlers.close);
document.getElementById('dvPrevBtn').addEventListener('click', this._dvHandlers.prev);
document.getElementById('dvNextBtn').addEventListener('click', this._dvHandlers.next);
document.getElementById('dvZoomIn').addEventListener('click', this._dvHandlers.zoomIn);
document.getElementById('dvZoomOut').addEventListener('click', this._dvHandlers.zoomOut);
document.getElementById('dvZoomFit').addEventListener('click', this._dvHandlers.zoomFit);
document.getElementById('dvPartsFilter').addEventListener('input', this._dvHandlers.partsFilter);
document.addEventListener('keydown', this._dvHandlers.keydown);
document.getElementById('dvImgContainer').addEventListener('wheel', this._dvHandlers.wheel, { passive: false });
document.getElementById('dvImgContainer').addEventListener('mousedown', this._dvHandlers.mousedown);
window.addEventListener('mousemove', this._dvHandlers.mousemove);
window.addEventListener('mouseup', this._dvHandlers.mouseup);
}
_unbindDiagramViewerEvents() {
if (!this._dvBound) return;
this._dvBound = false;
document.getElementById('dvCloseBtn')?.removeEventListener('click', this._dvHandlers.close);
document.getElementById('dvPrevBtn')?.removeEventListener('click', this._dvHandlers.prev);
document.getElementById('dvNextBtn')?.removeEventListener('click', this._dvHandlers.next);
document.getElementById('dvZoomIn')?.removeEventListener('click', this._dvHandlers.zoomIn);
document.getElementById('dvZoomOut')?.removeEventListener('click', this._dvHandlers.zoomOut);
document.getElementById('dvZoomFit')?.removeEventListener('click', this._dvHandlers.zoomFit);
document.getElementById('dvPartsFilter')?.removeEventListener('input', this._dvHandlers.partsFilter);
document.removeEventListener('keydown', this._dvHandlers.keydown);
document.getElementById('dvImgContainer')?.removeEventListener('wheel', this._dvHandlers.wheel);
document.getElementById('dvImgContainer')?.removeEventListener('mousedown', this._dvHandlers.mousedown);
window.removeEventListener('mousemove', this._dvHandlers.mousemove);
window.removeEventListener('mouseup', this._dvHandlers.mouseup);
}
// FASE 4: Open VIN decoder modal // FASE 4: Open VIN decoder modal
openVinDecoder() { openVinDecoder() {
// Clear previous results // Clear previous results

1221
dashboard/demo.html Normal file

File diff suppressed because it is too large Load Diff

1089
dashboard/diagrams.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ const enhancedSearch = {
debounceMs: 300, debounceMs: 300,
maxResults: 8, maxResults: 8,
maxRecent: 5, maxRecent: 5,
storageKey: 'autopartes_recent_searches' storageKey: 'nexus_recent_searches'
}, },
// State // State

View File

@@ -3,92 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Catálogo de Autopartes - AutoParts DB</title> <title>Catálogo - Nexus Autoparts</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔧</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔧</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/shared.css">
<style> <style>
* { /* Search & Header extras (page-specific) */
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-hover: #252532;
--accent: #ff6b35;
--accent-hover: #ff8555;
--accent-glow: rgba(255, 107, 53, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--border: #2a2a3a;
--success: #22c55e;
--warning: #f59e0b;
--info: #3b82f6;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* Header */
.header {
background: rgba(18, 18, 26, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.header-content {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
flex-shrink: 0;
}
.logo-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
box-shadow: 0 4px 20px var(--accent-glow);
}
.logo-text {
font-family: 'Orbitron', sans-serif;
font-size: 1.3rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-container { .search-container {
flex: 1; flex: 1;
max-width: 600px; max-width: 600px;
@@ -637,43 +558,6 @@
text-transform: uppercase; text-transform: uppercase;
} }
.btn {
padding: 0.7rem 1.5rem;
border-radius: 10px;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
font-size: 0.9rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
color: white;
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn-icon { .btn-icon {
width: 42px; width: 42px;
height: 42px; height: 42px;
@@ -1168,40 +1052,7 @@
border-color: var(--accent); border-color: var(--accent);
} }
/* Quality Badges */
.quality-badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.quality-economy { background: var(--warning); color: #000; }
.quality-standard { background: var(--info); color: white; }
.quality-premium { background: var(--success); color: white; }
.quality-oem { background: #9b59b6; color: white; }
/* Modal Styles */ /* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
align-items: center;
justify-content: center;
padding: 2rem;
}
.modal-overlay.active {
display: flex;
}
.modal-content { .modal-content {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -1539,45 +1390,6 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Loading & Empty States */
.state-container {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.state-container i {
font-size: 4rem;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.state-container h4 {
color: var(--text-primary);
margin-bottom: 0.5rem;
}
/* Back Button */
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 1.5rem;
}
.btn-back:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Responsive */ /* Responsive */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.header-stats { .header-stats {
@@ -1652,167 +1464,532 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Skip link */ /* ========== Diagram Strip (horizontal scroll above groups) ========== */
.skip-link { .diagrams-strip {
position: absolute; margin-bottom: 1.5rem;
top: -50px; background: var(--bg-card);
left: 0; border: 1px solid var(--border);
background: var(--accent); border-radius: 12px;
color: white;
padding: 0.75rem 1.5rem;
z-index: 3000;
text-decoration: none;
font-weight: 600;
border-radius: 0 0 8px 0;
}
.skip-link:focus {
top: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden; overflow: hidden;
clip: rect(0, 0, 0, 0); }
white-space: nowrap;
border: 0; .diagrams-strip-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border-bottom: 1px solid var(--border);
}
.diagrams-strip-header h5 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.diagrams-strip-header .strip-badge {
font-size: 0.75rem;
background: rgba(255, 107, 53, 0.2);
color: var(--accent);
padding: 0.15rem 0.6rem;
border-radius: 10px;
font-weight: 600;
}
.diagrams-strip-scroll {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: var(--accent) var(--bg-hover);
}
.diagrams-strip-scroll::-webkit-scrollbar {
height: 6px;
}
.diagrams-strip-scroll::-webkit-scrollbar-track {
background: var(--bg-hover);
border-radius: 3px;
}
.diagrams-strip-scroll::-webkit-scrollbar-thumb {
background: var(--accent);
border-radius: 3px;
}
.strip-card {
flex: 0 0 180px;
background: var(--bg-hover);
border: 2px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: all 0.25s ease;
overflow: hidden;
}
.strip-card:hover {
border-color: var(--accent);
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.15);
}
.strip-card-img {
width: 100%;
height: 110px;
object-fit: contain;
background: #e8e8e8;
display: block;
}
.strip-card-body {
padding: 0.5rem 0.65rem;
}
.strip-card-title {
font-family: 'Orbitron', monospace;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
}
.strip-card-type {
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: 0.15rem;
}
/* ========== Diagram Viewer Overlay (full-screen split) ========== */
.diagram-viewer-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.92);
z-index: 3000;
display: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.diagram-viewer-overlay.active {
display: flex;
opacity: 1;
}
.dv-layout {
display: flex;
width: 100%;
height: 100%;
}
/* Left: Diagram image panel */
.dv-image-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
position: relative;
}
.dv-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.65rem 1.25rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
z-index: 2;
}
.dv-toolbar .dv-title {
font-family: 'Orbitron', monospace;
font-size: 1rem;
font-weight: 600;
color: var(--accent);
flex: 1;
}
.dv-toolbar .dv-subtitle {
font-size: 0.8rem;
color: var(--text-secondary);
}
.dv-toolbar-btn {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.7rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
}
.dv-toolbar-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.dv-close-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
width: 34px; height: 34px;
border-radius: 8px;
font-size: 1.1rem;
cursor: pointer;
transition: background 0.2s;
}
.dv-close-btn:hover { background: var(--accent); }
.dv-img-container {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #e0e0e0;
position: relative;
}
.dv-img-wrapper {
position: relative;
display: inline-block;
transition: transform 0.2s ease;
transform-origin: center center;
}
.dv-img-wrapper img {
max-width: 100%;
max-height: 100%;
display: block;
user-select: none;
-webkit-user-drag: none;
}
.dv-img-wrapper.zoomed img {
max-width: none;
max-height: none;
}
.dv-zoom-controls {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.4rem;
background: rgba(0, 0, 0, 0.7);
padding: 0.35rem;
border-radius: 8px;
z-index: 5;
}
.dv-zoom-btn {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.35rem 0.65rem;
color: var(--text-primary);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.dv-zoom-btn:hover { background: var(--accent); border-color: var(--accent); }
.dv-zoom-level {
display: flex;
align-items: center;
color: var(--text-secondary);
font-size: 0.75rem;
padding: 0 0.4rem;
}
/* Nav arrows */
.dv-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
border: none;
color: white;
width: 42px; height: 42px;
border-radius: 50%;
font-size: 1.1rem;
cursor: pointer;
transition: background 0.2s;
z-index: 5;
}
.dv-nav-btn:hover { background: var(--accent); }
.dv-nav-btn.prev { left: 0.75rem; }
.dv-nav-btn.next { right: 0.75rem; }
/* Right: Parts panel */
.dv-parts-panel {
width: 400px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.dv-parts-header {
padding: 0.75rem 1rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.6rem;
}
.dv-parts-header h3 {
font-size: 0.9rem;
font-weight: 600;
flex: 1;
margin: 0;
}
.dv-parts-header .dv-parts-count {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 0.15rem 0.5rem;
border-radius: 10px;
}
.dv-parts-search {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.dv-parts-search input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.65rem;
color: var(--text-primary);
font-size: 0.82rem;
outline: none;
}
.dv-parts-search input:focus { border-color: var(--accent); }
.dv-parts-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.dv-group-label {
font-size: 0.72rem;
color: var(--accent);
padding: 0.5rem 0.25rem 0.2rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.dv-part-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.65rem 0.75rem;
margin-bottom: 0.4rem;
cursor: pointer;
transition: all 0.2s;
}
.dv-part-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.dv-part-item.highlighted {
border-color: var(--accent);
background: rgba(255, 107, 53, 0.1);
box-shadow: 0 0 0 1px var(--accent);
}
.dv-part-number {
font-family: 'Orbitron', monospace;
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
}
.dv-part-name {
font-size: 0.78rem;
color: var(--text-secondary);
margin-top: 0.15rem;
}
.dv-xref-list {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px solid var(--border);
}
.dv-xref-tag {
display: inline-block;
font-size: 0.68rem;
padding: 0.08rem 0.4rem;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border-radius: 3px;
margin: 0.08rem;
}
/* ========== Hotspot markers ========== */
.hotspot-marker {
position: absolute;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 107, 53, 0.35);
border: 2px solid var(--accent);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
z-index: 3;
}
.hotspot-marker:hover,
.hotspot-marker.active {
background: rgba(255, 107, 53, 0.6);
transform: translate(-50%, -50%) scale(1.25);
box-shadow: 0 0 12px rgba(255, 107, 53, 0.5);
}
.hotspot-marker .hotspot-number {
font-size: 0.65rem;
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
@keyframes hotspot-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 107, 53, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(255, 107, 53, 0); }
}
.hotspot-marker.pulse {
animation: hotspot-pulse 1.5s ease-in-out 3;
}
/* ========== Responsive ========== */
@media (max-width: 768px) {
.dv-layout { flex-direction: column; }
.dv-parts-panel { width: 100%; height: 45%; }
.strip-card { flex: 0 0 150px; }
.strip-card-img { height: 90px; }
} }
</style> </style>
</head> </head>
<body> <body>
<a href="#mainContent" class="skip-link">Saltar al contenido</a> <a href="#mainContent" class="skip-link">Saltar al contenido</a>
<!-- Header --> <!-- Shared Navigation -->
<header class="header"> <div id="shared-nav"></div>
<div class="header-content"> <script src="/nav.js"></script>
<a href="customer-landing.html" class="logo"> <script>
<div class="logo-icon">⚙️</div> // Inject page-specific search bar and stats into the shared nav header
<div class="logo-text">AUTOPARTS DB</div> (function() {
</a> var extra = document.getElementById('shared-nav-extra');
if (!extra) return;
<div class="search-container"> extra.innerHTML = ''
<div class="search-box-enhanced"> + '<div class="search-container">'
<div class="search-input-wrapper"> + '<div class="search-box-enhanced">'
<i class="fas fa-search search-icon"></i> + '<div class="search-input-wrapper">'
<input type="text" class="search-input" id="searchInput" + '<i class="fas fa-search search-icon"></i>'
placeholder="Buscar partes, números OEM, vehículos... (presiona /)" + '<input type="text" class="search-input" id="searchInput"'
aria-label="Buscar partes" + ' placeholder="Buscar partes, n\u00fameros OEM, veh\u00edculos... (presiona /)"'
autocomplete="off" + ' aria-label="Buscar partes"'
oninput="enhancedSearch.onInput(this.value)" + ' autocomplete="off"'
onkeydown="enhancedSearch.onKeydown(event)" + ' oninput="enhancedSearch.onInput(this.value)"'
onfocus="enhancedSearch.onFocus()"> + ' onkeydown="enhancedSearch.onKeydown(event)"'
<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()"> + ' onfocus="enhancedSearch.onFocus()">'
<i class="fas fa-sliders-h"></i> + '<div class="search-filters-toggle" onclick="enhancedSearch.toggleFilters()">'
</div> + '<i class="fas fa-sliders-h"></i>'
<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN"> + '</div>'
<i class="fas fa-barcode"></i> + '<button class="vin-btn" onclick="dashboard.openVinDecoder()" title="Decodificar VIN">'
</button> + '<i class="fas fa-barcode"></i>'
<div class="search-loading" id="searchLoading" style="display: none;"> + '</button>'
<div class="search-spinner"></div> + '<div class="search-loading" id="searchLoading" style="display: none;">'
</div> + '<div class="search-spinner"></div>'
</div> + '</div>'
+ '</div>'
<!-- Dropdown de resultados --> + '<div class="search-dropdown" id="searchDropdown">'
<div class="search-dropdown" id="searchDropdown"> + '<div class="search-filters" id="searchFilters" style="display: none;">'
<!-- Filtros --> + '<div class="filter-group"><label>Categor\u00eda</label>'
<div class="search-filters" id="searchFilters" style="display: none;"> + '<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()"><option value="">Todas</option></select>'
<div class="filter-group"> + '</div>'
<label>Categoría</label> + '<div class="filter-group"><label>Buscar en</label>'
<select id="searchCategoryFilter" onchange="enhancedSearch.applyFilters()"> + '<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()">'
<option value="">Todas</option> + '<option value="all">Todo</option><option value="parts">Solo Partes</option><option value="vehicles">Solo Veh\u00edculos</option>'
</select> + '</select>'
</div> + '</div>'
<div class="filter-group"> + '</div>'
<label>Buscar en</label> + '<div class="search-recent" id="searchRecent">'
<select id="searchTypeFilter" onchange="enhancedSearch.applyFilters()"> + '<div class="search-section-title"><i class="fas fa-history"></i> B\u00fasquedas recientes '
<option value="all">Todo</option> + '<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span></div>'
<option value="parts">Solo Partes</option> + '<div class="search-recent-items" id="searchRecentItems"></div>'
<option value="vehicles">Solo Vehículos</option> + '</div>'
</select> + '<div class="search-results-container" id="searchResultsContainer">'
</div> + '<div class="search-results-section" id="partsResults" style="display: none;">'
</div> + '<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div>'
+ '<div class="search-results-list" id="partsResultsList"></div>'
<!-- Búsquedas recientes --> + '</div>'
<div class="search-recent" id="searchRecent"> + '<div class="search-results-section" id="vehiclesResults" style="display: none;">'
<div class="search-section-title"> + '<div class="search-section-title"><i class="fas fa-car"></i> Veh\u00edculos</div>'
<i class="fas fa-history"></i> Búsquedas recientes + '<div class="search-results-list" id="vehiclesResultsList"></div>'
<span class="clear-recent" onclick="enhancedSearch.clearRecent()">Limpiar</span> + '</div>'
</div> + '<div class="search-no-results" id="searchNoResults" style="display: none;">'
<div class="search-recent-items" id="searchRecentItems"></div> + '<i class="fas fa-search"></i><p>No se encontraron resultados</p>'
</div> + '<span>Intenta con otros t\u00e9rminos de b\u00fasqueda</span>'
+ '<div class="search-suggestions" style="margin-top: 1rem;">'
<!-- Resultados --> + '<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">B\u00fasquedas populares:</span>'
<div class="search-results-container" id="searchResultsContainer"> + '<div class="search-suggestion-tags">'
<!-- Parts results --> + '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'brake\')">brake</span>'
<div class="search-results-section" id="partsResults" style="display: none;"> + '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'filter\')">filter</span>'
<div class="search-section-title"><i class="fas fa-cog"></i> Partes</div> + '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'spark plug\')">spark plug</span>'
<div class="search-results-list" id="partsResultsList"></div> + '<span class="search-tag" onclick="enhancedSearch.searchRecent(\'camry\')">camry</span>'
</div> + '</div>'
+ '</div>'
<!-- Vehicles results --> + '</div>'
<div class="search-results-section" id="vehiclesResults" style="display: none;"> + '</div>'
<div class="search-section-title"><i class="fas fa-car"></i> Vehículos</div> + '<div class="search-dropdown-footer" id="searchFooter" style="display: none;">'
<div class="search-results-list" id="vehiclesResultsList"></div> + '<span class="search-hint"><kbd>\u2191\u2193</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar</span>'
</div> + '<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">Ver todos los resultados <i class="fas fa-arrow-right"></i></button>'
+ '</div>'
<!-- No results --> + '</div>'
<div class="search-no-results" id="searchNoResults" style="display: none;"> + '</div>'
<i class="fas fa-search"></i> + '</div>'
<p>No se encontraron resultados</p> + '<div class="header-actions">'
<span>Intenta con otros términos de búsqueda</span> + '<div class="header-stats">'
<div class="search-suggestions" style="margin-top: 1rem;"> + '<div class="header-stat"><div class="header-stat-value" id="totalBrands">0</div><div class="header-stat-label">Marcas</div></div>'
<span style="display: block; margin-bottom: 0.5rem; font-size: 0.8rem;">Búsquedas populares:</span> + '<div class="header-stat"><div class="header-stat-value" id="totalModels">0</div><div class="header-stat-label">Modelos</div></div>'
<div class="search-suggestion-tags"> + '<div class="header-stat"><div class="header-stat-value" id="totalParts">0</div><div class="header-stat-label">Partes</div></div>'
<span class="search-tag" onclick="enhancedSearch.searchRecent('brake')">brake</span> + '</div>'
<span class="search-tag" onclick="enhancedSearch.searchRecent('filter')">filter</span> + '<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio"><i class="fas fa-home"></i></a>'
<span class="search-tag" onclick="enhancedSearch.searchRecent('spark plug')">spark plug</span> + '<a href="admin.html" class="btn btn-primary btn-icon" title="Panel de administraci\u00f3n"><i class="fas fa-cog"></i></a>'
<span class="search-tag" onclick="enhancedSearch.searchRecent('camry')">camry</span> + '</div>';
</div> })();
</div> </script>
</div>
</div>
<!-- Footer con acciones -->
<div class="search-dropdown-footer" id="searchFooter" style="display: none;">
<span class="search-hint">
<kbd>↑↓</kbd> navegar <kbd>Enter</kbd> seleccionar <kbd>Esc</kbd> cerrar
</span>
<button class="search-view-all" onclick="enhancedSearch.viewAllResults()">
Ver todos los resultados <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
</div>
</div>
<div class="header-actions">
<div class="header-stats">
<div class="header-stat">
<div class="header-stat-value" id="totalBrands">0</div>
<div class="header-stat-label">Marcas</div>
</div>
<div class="header-stat">
<div class="header-stat-value" id="totalModels">0</div>
<div class="header-stat-label">Modelos</div>
</div>
<div class="header-stat">
<div class="header-stat-value" id="totalParts">0</div>
<div class="header-stat-label">Partes</div>
</div>
</div>
<a href="customer-landing.html" class="btn btn-secondary btn-icon" title="Ir a inicio">
<i class="fas fa-home"></i>
</a>
<a href="admin.html" class="btn btn-primary btn-icon" title="Panel de administración">
<i class="fas fa-cog"></i>
</a>
</div>
</div>
</header>
<!-- Main Container --> <!-- Main Container -->
<div class="main-container"> <div class="main-container">
@@ -1924,6 +2101,54 @@
</div> </div>
</div> </div>
<!-- Diagram Viewer Overlay (split layout) -->
<div class="diagram-viewer-overlay" id="diagramViewerOverlay">
<div class="dv-layout">
<!-- Left: Diagram image -->
<div class="dv-image-panel">
<div class="dv-toolbar">
<div style="flex:1">
<div class="dv-title" id="dvTitle">F200</div>
<div class="dv-subtitle" id="dvSubtitle">Suspension Delantera</div>
</div>
<button class="dv-toolbar-btn" id="dvPrevBtn" title="Anterior"><i class="fas fa-chevron-left"></i></button>
<button class="dv-toolbar-btn" id="dvNextBtn" title="Siguiente"><i class="fas fa-chevron-right"></i></button>
<button class="dv-close-btn" id="dvCloseBtn" title="Cerrar"><i class="fas fa-times"></i></button>
</div>
<div class="dv-img-container" id="dvImgContainer">
<div class="dv-img-wrapper" id="dvImgWrapper">
<img id="dvImg" src="" alt="Diagram">
<!-- Hotspot markers rendered here -->
</div>
<div class="dv-zoom-controls">
<button class="dv-zoom-btn" id="dvZoomOut"><i class="fas fa-minus"></i></button>
<span class="dv-zoom-level" id="dvZoomLevel">100%</span>
<button class="dv-zoom-btn" id="dvZoomIn"><i class="fas fa-plus"></i></button>
<button class="dv-zoom-btn" id="dvZoomFit"><i class="fas fa-expand"></i></button>
</div>
</div>
</div>
<!-- Right: Parts panel -->
<div class="dv-parts-panel">
<div class="dv-parts-header">
<i class="fas fa-list-ul" style="color: var(--accent)"></i>
<h3>Partes del Diagrama</h3>
<span class="dv-parts-count" id="dvPartsCount">0</span>
</div>
<div class="dv-parts-search">
<input type="text" id="dvPartsFilter" placeholder="Filtrar partes...">
</div>
<div class="dv-parts-list" id="dvPartsList">
<div style="text-align:center;padding:3rem;color:var(--text-secondary)">
<i class="fas fa-spinner fa-spin" style="font-size:1.5rem;margin-bottom:0.5rem"></i>
<p>Cargando partes...</p>
</div>
</div>
</div>
</div>
</div>
<script src="dashboard.js"></script> <script src="dashboard.js"></script>
<script src="enhanced-search.js"></script> <script src="enhanced-search.js"></script>
</body> </body>

211
dashboard/login.css Normal file
View 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
View 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">&#9881;</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
View 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
View 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
View 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
View 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
View 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 + '">&times;</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();
})();

File diff suppressed because it is too large Load Diff

262
dashboard/shared.css Normal file
View 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
View 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
View 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
View 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);
})();

File diff suppressed because it is too large Load Diff

449
docs/ARCHITECTURE.md Normal file
View 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.

View File

@@ -1,4 +1,4 @@
# Documentación de Base de Datos - Autoparts DB # Documentación de Base de Datos - Nexus Autoparts
## Resumen ## Resumen

View File

@@ -1,4 +1,4 @@
# Guía de Instalación - Autoparts DB # Guía de Instalación - Nexus Autoparts
## Requisitos del Sistema ## Requisitos del Sistema
@@ -28,10 +28,10 @@ El proyecto es compatible con:
```bash ```bash
# 1. Clonar el repositorio # 1. Clonar el repositorio
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
# 2. Entrar al directorio # 2. Entrar al directorio
cd Autoparts-DB cd Nexus-Autoparts
# 3. Instalar dependencias # 3. Instalar dependencias
pip install -r requirements.txt pip install -r requirements.txt
@@ -48,8 +48,8 @@ python3 server.py
### Paso 1: Clonar el Repositorio ### Paso 1: Clonar el Repositorio
```bash ```bash
git clone https://git.consultoria-as.com/[usuario]/Autoparts-DB.git git clone https://git.consultoria-as.com/[usuario]/Nexus-Autoparts.git
cd Autoparts-DB cd Nexus-Autoparts
``` ```
### Paso 2: Crear Entorno Virtual (Recomendado) ### Paso 2: Crear Entorno Virtual (Recomendado)
@@ -254,16 +254,16 @@ gunicorn -w 4 -b 0.0.0.0:8080 server:app
### Usando systemd ### Usando systemd
Crear archivo `/etc/systemd/system/autoparts-db.service`: Crear archivo `/etc/systemd/system/nexus-autoparts.service`:
```ini ```ini
[Unit] [Unit]
Description=Autoparts DB Dashboard Description=Nexus Autoparts Dashboard
After=network.target After=network.target
[Service] [Service]
User=www-data User=www-data
WorkingDirectory=/path/to/Autoparts-DB/dashboard WorkingDirectory=/path/to/Nexus-Autoparts/dashboard
ExecStart=/usr/bin/python3 server.py ExecStart=/usr/bin/python3 server.py
Restart=always Restart=always
@@ -274,8 +274,8 @@ WantedBy=multi-user.target
Habilitar e iniciar: Habilitar e iniciar:
```bash ```bash
sudo systemctl enable autoparts-db sudo systemctl enable nexus-autoparts
sudo systemctl start autoparts-db sudo systemctl start nexus-autoparts
``` ```
### Usando Docker (Opcional) ### Usando Docker (Opcional)
@@ -294,8 +294,8 @@ CMD ["python3", "dashboard/server.py"]
``` ```
```bash ```bash
docker build -t autoparts-db . docker build -t nexus-autoparts .
docker run -p 5000:5000 autoparts-db docker run -p 5000:5000 nexus-autoparts
``` ```
--- ---
@@ -319,7 +319,7 @@ pip install --upgrade -r requirements.txt
deactivate deactivate
# Eliminar directorio del proyecto # Eliminar directorio del proyecto
rm -rf Autoparts-DB rm -rf Nexus-Autoparts
# Eliminar entorno virtual (si está separado) # Eliminar entorno virtual (si está separado)
rm -rf venv rm -rf venv

473
docs/METABASE_ACTIONS.md Normal file
View 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
View 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;
```

View 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.

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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"
```

View 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
```

File diff suppressed because it is too large Load Diff

470
migrate_to_postgres.py Normal file
View 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
View 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();
"""

View File

@@ -2,3 +2,9 @@ flask==2.3.3
requests>=2.28.0 requests>=2.28.0
beautifulsoup4>=4.11.0 beautifulsoup4>=4.11.0
lxml>=4.9.0 lxml>=4.9.0
sqlalchemy>=2.0
psycopg2-binary>=2.9
flask-sqlalchemy>=3.1
PyJWT>=2.8
bcrypt>=4.0
openpyxl>=3.1

144
scripts/import_live.py Normal file
View 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
View 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
View 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 (19502027)
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()

View 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()

View 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
View 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")

View 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
View 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"

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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