diff --git a/README.md b/README.md index e8759a7..a259d35 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,15 @@ Autopartes/ │ ├── dashboard.js # Lógica JavaScript │ └── start_dashboard.sh # Script de inicio │ +├── console/ # Consola Pick/VT220 +│ ├── main.py # Punto de entrada +│ ├── db.py # Capa de datos abstracta +│ ├── core/ # Framework (app, screens, nav, keys) +│ ├── screens/ # 14 pantallas (menú, CRUD, búsqueda) +│ ├── renderers/ # VT220 (curses) y moderno (Rich) +│ ├── utils/ # Formato y API VIN +│ └── tests/ # 116 tests +│ ├── vehicle_scraper/ # Herramientas de web scraping │ ├── rockauto_scraper.py # Scraper RockAuto │ ├── rockauto_scraper_v2.py # Scraper mejorado @@ -73,6 +82,25 @@ Autopartes/ └── QUICK_START.sh # Guía rápida de inicio ``` +## Consola Pick/VT220 + +Interfaz de terminal inspirada en los sistemas Pick/D3, 100% operada con teclado. Incluye dos modos de visualización: + +- **VT220** (curses): Terminal clásica verde sobre negro con caracteres de caja +- **Modern** (Rich): Interfaz moderna con colores y estilos TUI + +```bash +# Modo clásico VT220 +python -m console + +# Modo moderno +python -m console --mode modern +``` + +Funcionalidades: navegación por vehículo (marca→modelo→año→motor), búsqueda por número de parte, búsqueda full-text, decodificador VIN (NHTSA), catálogo por categorías, comparador OEM vs aftermarket, y administración CRUD completa. + +116 tests automatizados. Ver [`console/README.md`](console/README.md) para documentación completa. + ## Instalación ### Requisitos Previos @@ -91,6 +119,7 @@ Autopartes/ 2. **Instalar dependencias** ```bash pip install flask requests beautifulsoup4 lxml + pip install rich # Opcional: para modo moderno de consola ``` 3. **Inicializar la base de datos (opcional - ya incluye datos)** @@ -110,7 +139,14 @@ python3 server.py El dashboard estará disponible en: `http://localhost:5000` -### Usar la Interfaz CLI +### Iniciar la Consola Pick/VT220 + +```bash +python -m console # Modo VT220 (clásico) +python -m console --mode modern # Modo moderno (Rich) +``` + +### Usar la Interfaz CLI Legacy ```bash cd vehicle_database/scripts @@ -275,9 +311,9 @@ engines ─┴─────────────┘ │ │ │ v v v ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Flask API │ │ CLI Interface │ │ CSV Importer │ -└────────┬────────┘ └──────────────────┘ └──────────────────┘ - │ +│ Flask API │ │ Pick Console │ │ CSV Importer │ +└────────┬────────┘ │ (VT220/Rich) │ └──────────────────┘ + │ └──────────────────┘ v ┌─────────────────┐ │ Web Dashboard │ diff --git a/console/README.md b/console/README.md new file mode 100644 index 0000000..61bf136 --- /dev/null +++ b/console/README.md @@ -0,0 +1,189 @@ +# AUTOPARTES Console - Sistema Pick/VT220 + +Interfaz de consola para el catálogo de autopartes, inspirada en los sistemas Pick/D3 con estética de terminal VT220. Funciona 100% con teclado. + +## Requisitos + +- Python 3.8+ +- SQLite 3 (incluido con Python) +- Paquete `rich` (solo para modo moderno) + +```bash +pip install rich # Opcional, solo para --mode modern +``` + +## Inicio Rápido + +```bash +# Modo VT220 (clásico, verde sobre negro) +python -m console + +# Modo moderno (Rich/TUI con colores) +python -m console --mode modern + +# Especificar base de datos +python -m console --db /ruta/a/vehicle_database.db + +# Ver versión +python -m console --version +``` + +## Modos de Visualización + +### VT220 (por defecto) +- Terminal clásica verde sobre negro +- Caracteres de dibujo de cajas (box-drawing) +- Compatible con cualquier terminal +- Usa la librería `curses` (incluida en Python) + +### Modern +- Interfaz moderna con colores y estilos Rich +- Tema azul/cian +- Requiere `pip install rich` +- Si `rich` no está instalado, cae automáticamente a modo VT220 + +## Menú Principal + +``` +╔══════════════════════════════════════╗ +║ AUTOPARTES v1.0.0 ║ +║ Sistema de Catalogo de Autopartes ║ +╠══════════════════════════════════════╣ +║ 1. Buscar por Vehiculo ║ +║ 2. Buscar por Numero de Parte ║ +║ 3. Buscar por Texto ║ +║ 4. Decodificar VIN ║ +║ 5. Catalogo por Categoria ║ +║ ────────────────────────── ║ +║ 6. Admin: Partes ║ +║ 7. Admin: Fabricantes ║ +║ 8. Admin: Referencias Cruzadas ║ +║ 9. Import/Export ║ +║ ────────────────────────── ║ +║ S. Estadisticas del Sistema ║ +║ 0. Salir ║ +╚══════════════════════════════════════╝ +``` + +## 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, --mode vt220|modern +├── 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 # Modo VT220 (curses) +│ └── textual_renderer.py # Modo moderno (Rich) +│ +├── 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 diff --git a/docs/plans/2026-02-14-pick-console-design.md b/docs/plans/2026-02-14-pick-console-design.md new file mode 100644 index 0000000..24b9f88 --- /dev/null +++ b/docs/plans/2026-02-14-pick-console-design.md @@ -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. diff --git a/docs/plans/2026-02-14-pick-console-plan.md b/docs/plans/2026-02-14-pick-console-plan.md new file mode 100644 index 0000000..9be36db --- /dev/null +++ b/docs/plans/2026-02-14-pick-console-plan.md @@ -0,0 +1,1982 @@ +# Pick-Style Console System - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a keyboard-driven Pick/VT220-inspired console application for the autoparts catalog with two rendering modes (curses and textual). + +**Architecture:** Three-layer architecture — DB layer (SQLite, swappable to PostgreSQL), business logic screens, and pluggable renderers (curses for VT220 aesthetic, textual for modern TUI). All screens define data/actions abstractly; renderers handle drawing. + +**Tech Stack:** Python 3, curses (stdlib), textual/rich (pip), sqlite3 (stdlib), requests (for NHTSA VIN API) + +--- + +## Task 1: Project Scaffold and Config + +**Files:** +- Create: `console/__init__.py` +- Create: `console/main.py` +- Create: `console/config.py` +- Create: `console/core/__init__.py` +- Create: `console/screens/__init__.py` +- Create: `console/renderers/__init__.py` +- Create: `console/utils/__init__.py` + +**Step 1: Create directory structure** + +```bash +mkdir -p /home/Autopartes/console/{core,screens,renderers,utils} +touch /home/Autopartes/console/__init__.py +touch /home/Autopartes/console/core/__init__.py +touch /home/Autopartes/console/screens/__init__.py +touch /home/Autopartes/console/renderers/__init__.py +touch /home/Autopartes/console/utils/__init__.py +``` + +**Step 2: Write config.py** + +```python +# console/config.py +import os + +VERSION = "1.0.0" +APP_NAME = "AUTOPARTES" +APP_SUBTITLE = "Sistema de Catálogo de Autopartes" + +# Database +DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vehicle_database', 'vehicle_database.db') + +# VIN API +NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin" +VIN_CACHE_DAYS = 30 + +# Display +DEFAULT_MODE = "vt220" # "vt220" or "modern" +PAGE_SIZE = 15 # rows per page in lists/tables + +# Color schemes for curses (name: (fg, bg)) +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'), +} +``` + +**Step 3: Write main.py entry point** + +```python +# console/main.py +import argparse +import sys +from console.config import VERSION, DEFAULT_MODE + + +def parse_args(): + parser = argparse.ArgumentParser(description='Autopartes Console - Pick-Style Catalog System') + parser.add_argument('--mode', choices=['vt220', 'modern'], default=DEFAULT_MODE, + help='Rendering mode: vt220 (curses) or modern (textual)') + parser.add_argument('--version', action='version', version=f'Autopartes Console v{VERSION}') + return parser.parse_args() + + +def main(): + args = parse_args() + + if args.mode == 'vt220': + from console.renderers.curses_renderer import CursesRenderer + renderer = CursesRenderer() + else: + from console.renderers.textual_renderer import TextualRenderer + renderer = TextualRenderer() + + from console.db import Database + from console.core.app import App + + db = Database() + app = App(renderer, db) + app.run() + + +if __name__ == '__main__': + main() +``` + +**Step 4: Commit** + +```bash +git add console/ +git commit -m "feat(console): scaffold project structure and config" +``` + +--- + +## Task 2: Database Abstraction Layer + +**Files:** +- Create: `console/db.py` +- Create: `console/tests/__init__.py` +- Create: `console/tests/test_db.py` + +**Step 1: Write test for DB layer** + +```python +# console/tests/test_db.py +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) +from console.db import Database + + +@pytest.fixture +def db(): + return Database() + + +def test_get_brands(db): + brands = db.get_brands() + assert isinstance(brands, list) + assert len(brands) > 0 + assert 'name' in brands[0] + + +def test_get_models_no_filter(db): + models = db.get_models() + assert isinstance(models, list) + assert len(models) > 0 + + +def test_get_models_by_brand(db): + models = db.get_models(brand='TOYOTA') + assert isinstance(models, list) + assert len(models) > 0 + + +def test_get_categories(db): + cats = db.get_categories() + assert isinstance(cats, list) + assert len(cats) == 12 + + +def test_get_groups(db): + groups = db.get_groups(category_id=2) + assert isinstance(groups, list) + assert len(groups) > 0 + assert 'name' in groups[0] + + +def test_get_parts_by_group(db): + parts = db.get_parts(group_id=17) + assert isinstance(parts, list) + + +def test_get_part_detail(db): + part = db.get_part(1) + assert part is not None + assert 'oem_part_number' in part + + +def test_get_alternatives(db): + alts = db.get_alternatives(1) + assert isinstance(alts, list) + + +def test_get_cross_references(db): + refs = db.get_cross_references(1) + assert isinstance(refs, list) + + +def test_search_parts_fts(db): + results = db.search_parts('brake') + assert isinstance(results, list) + assert len(results) > 0 + + +def test_search_part_number(db): + results = db.search_part_number('04465') + assert isinstance(results, list) + + +def test_get_stats(db): + stats = db.get_stats() + assert 'brands' in stats + assert 'models' in stats + assert 'parts' in stats + assert stats['brands'] > 0 + + +def test_get_years(db): + years = db.get_years(brand='TOYOTA', model='CAMRY') + assert isinstance(years, list) + + +def test_get_engines(db): + engines = db.get_engines(brand='TOYOTA', model='CAMRY') + assert isinstance(engines, list) +``` + +**Step 2: Run test to verify it fails** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_db.py -v +``` + +Expected: FAIL — `ModuleNotFoundError: No module named 'console.db'` + +**Step 3: Implement db.py** + +```python +# console/db.py +import sqlite3 +import os +from console.config import DB_PATH + + +class Database: + def __init__(self, db_path=None): + self.db_path = db_path or DB_PATH + + def _connect(self): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _query(self, sql, params=(), one=False): + conn = self._connect() + cursor = conn.cursor() + cursor.execute(sql, params) + rows = cursor.fetchall() + conn.close() + if one: + return dict(rows[0]) if rows else None + return [dict(r) for r in rows] + + def _execute(self, sql, params=()): + conn = self._connect() + cursor = conn.cursor() + cursor.execute(sql, params) + conn.commit() + last_id = cursor.lastrowid + conn.close() + return last_id + + # === Vehicle Navigation === + + def get_brands(self): + return self._query(""" + SELECT id, name, country FROM brands ORDER BY name + """) + + def get_models(self, brand=None): + if brand: + return self._query(""" + SELECT DISTINCT m.id, m.name + FROM models m + JOIN brands b ON m.brand_id = b.id + WHERE UPPER(b.name) = UPPER(?) + ORDER BY m.name + """, (brand,)) + return self._query("SELECT id, name FROM models ORDER BY name") + + def get_years(self, brand=None, model=None): + sql = """ + SELECT DISTINCT y.id, y.year + FROM years y + JOIN model_year_engine mye ON mye.year_id = y.id + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + WHERE 1=1 + """ + params = [] + if brand: + sql += " AND UPPER(b.name) = UPPER(?)" + params.append(brand) + if model: + sql += " AND UPPER(m.name) = UPPER(?)" + params.append(model) + sql += " ORDER BY y.year DESC" + return self._query(sql, params) + + def get_engines(self, brand=None, model=None, year=None): + sql = """ + SELECT DISTINCT e.id, e.name, e.displacement_cc, e.cylinders, + e.fuel_type, e.power_hp, e.torque_nm + FROM engines e + JOIN model_year_engine mye ON mye.engine_id = e.id + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + WHERE 1=1 + """ + params = [] + if brand: + sql += " AND UPPER(b.name) = UPPER(?)" + params.append(brand) + if model: + sql += " AND UPPER(m.name) = UPPER(?)" + params.append(model) + if year: + sql += " AND mye.year_id IN (SELECT id FROM years WHERE year = ?)" + params.append(int(year)) + sql += " ORDER BY e.name" + return self._query(sql, params) + + def get_model_year_engine(self, brand, model, year, engine_id): + return self._query(""" + SELECT mye.id, mye.trim_level, mye.drivetrain, mye.transmission + FROM model_year_engine mye + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + WHERE UPPER(b.name) = UPPER(?) + AND UPPER(m.name) = UPPER(?) + AND y.year = ? + AND mye.engine_id = ? + """, (brand, model, int(year), engine_id)) + + # === Parts Catalog === + + def get_categories(self): + return self._query(""" + SELECT id, name, name_es, icon_name, slug, display_order + FROM part_categories ORDER BY display_order + """) + + def get_groups(self, category_id): + return self._query(""" + SELECT id, name, name_es, slug, display_order + FROM part_groups WHERE category_id = ? ORDER BY display_order + """, (category_id,)) + + def get_parts(self, group_id=None, mye_id=None, page=1, per_page=15): + offset = (page - 1) * per_page + if mye_id and group_id: + return self._query(""" + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pg.name AS group_name, vp.quantity_required, vp.position, + (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count + FROM parts p + JOIN vehicle_parts vp ON vp.part_id = p.id + JOIN part_groups pg ON p.group_id = pg.id + WHERE vp.model_year_engine_id = ? AND p.group_id = ? + ORDER BY p.name + LIMIT ? OFFSET ? + """, (mye_id, group_id, per_page, offset)) + elif mye_id: + return self._query(""" + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pg.name AS group_name, vp.quantity_required, vp.position, + (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count + FROM parts p + JOIN vehicle_parts vp ON vp.part_id = p.id + JOIN part_groups pg ON p.group_id = pg.id + WHERE vp.model_year_engine_id = ? + ORDER BY pg.display_order, p.name + LIMIT ? OFFSET ? + """, (mye_id, per_page, offset)) + elif group_id: + return self._query(""" + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pg.name AS group_name, + (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id + WHERE p.group_id = ? + ORDER BY p.name + LIMIT ? OFFSET ? + """, (group_id, per_page, offset)) + return self._query(""" + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pg.name AS group_name, + (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id + ORDER BY p.name + LIMIT ? OFFSET ? + """, (per_page, offset)) + + def get_part(self, part_id): + return self._query(""" + SELECT p.*, pg.name AS group_name, pg.category_id, + pc.name AS category_name, pc.name_es AS category_name_es + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE p.id = ? + """, (part_id,), one=True) + + def get_alternatives(self, part_id): + return self._query(""" + SELECT ap.*, m.name AS manufacturer_name, m.country AS manufacturer_country + FROM aftermarket_parts ap + JOIN manufacturers m ON ap.manufacturer_id = m.id + WHERE ap.oem_part_id = ? + ORDER BY + CASE ap.quality_tier + WHEN 'premium' THEN 1 + WHEN 'standard' THEN 2 + WHEN 'economy' THEN 3 + ELSE 4 + END + """, (part_id,)) + + def get_cross_references(self, part_id): + return self._query(""" + SELECT * FROM part_cross_references + WHERE part_id = ? ORDER BY reference_type + """, (part_id,)) + + # === Search === + + def search_parts(self, query, page=1, per_page=15): + offset = (page - 1) * per_page + return self._query(""" + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pg.name AS group_name, pc.name AS category_name + FROM parts_fts fts + JOIN parts p ON fts.rowid = p.id + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE parts_fts MATCH ? + ORDER BY rank + LIMIT ? OFFSET ? + """, (query, per_page, offset)) + + def search_part_number(self, number): + like = f'%{number}%' + results = [] + # OEM + oem = self._query(""" + SELECT p.id, p.oem_part_number AS part_number, p.name, p.name_es, 'OEM' AS type, + (SELECT b.name FROM brands b JOIN models m ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id + WHERE vp.part_id = p.id LIMIT 1) AS source + FROM parts p WHERE p.oem_part_number LIKE ? + """, (like,)) + results.extend(oem) + # Aftermarket + after = self._query(""" + SELECT ap.id, ap.part_number, ap.name, ap.name_es, 'AFTERMARKET' AS type, + m.name AS source + FROM aftermarket_parts ap + JOIN manufacturers m ON ap.manufacturer_id = m.id + WHERE ap.part_number LIKE ? + """, (like,)) + results.extend(after) + # Cross-references + xref = self._query(""" + SELECT pcr.id, pcr.cross_reference_number AS part_number, + p.name, p.name_es, + 'CROSS-REF (' || pcr.reference_type || ')' AS type, + pcr.source + FROM part_cross_references pcr + JOIN parts p ON pcr.part_id = p.id + WHERE pcr.cross_reference_number LIKE ? + """, (like,)) + results.extend(xref) + return results + + # === VIN === + + def get_vin_cache(self, vin): + return self._query(""" + SELECT * FROM vin_cache + WHERE vin = ? AND expires_at > datetime('now') + """, (vin,), one=True) + + def save_vin_cache(self, vin, data, make, model, year, engine_info, body_class, drive_type): + self._execute(""" + INSERT OR REPLACE INTO vin_cache + (vin, decoded_data, make, model, year, engine_info, body_class, drive_type, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+30 days')) + """, (vin, data, make, model, year, engine_info, body_class, drive_type)) + + # === Stats === + + def get_stats(self): + stats = {} + tables = ['brands', 'models', 'years', 'engines', 'model_year_engine', + 'part_categories', 'part_groups', 'parts', 'vehicle_parts', + 'manufacturers', 'aftermarket_parts', 'part_cross_references', + 'diagrams', 'diagram_hotspots', 'vin_cache'] + conn = self._connect() + cursor = conn.cursor() + for table in tables: + cursor.execute(f"SELECT COUNT(*) FROM [{table}]") + stats[table] = cursor.fetchone()[0] + + # Top brands with parts + cursor.execute(""" + SELECT b.name, COUNT(vp.id) AS cnt + FROM brands b + JOIN models m ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id + GROUP BY b.name ORDER BY cnt DESC LIMIT 5 + """) + stats['top_brands'] = [dict(r) for r in cursor.fetchall()] + conn.close() + return stats + + # === Admin CRUD === + + def create_part(self, data): + return self._execute(""" + INSERT INTO parts (oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'], + data.get('description'), data.get('description_es'), data.get('weight_kg'), data.get('material'))) + + def update_part(self, part_id, data): + self._execute(""" + UPDATE parts SET oem_part_number=?, name=?, name_es=?, group_id=?, + description=?, description_es=?, weight_kg=?, material=?, is_discontinued=? + WHERE id=? + """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'], + data.get('description'), data.get('description_es'), data.get('weight_kg'), + data.get('material'), data.get('is_discontinued', 0), part_id)) + + def delete_part(self, part_id): + self._execute("DELETE FROM parts WHERE id=?", (part_id,)) + + def create_manufacturer(self, data): + return self._execute(""" + INSERT INTO manufacturers (name, type, quality_tier, country, website) + VALUES (?, ?, ?, ?, ?) + """, (data['name'], data.get('type', 'aftermarket'), data.get('quality_tier', 'standard'), + data.get('country'), data.get('website'))) + + def update_manufacturer(self, mfr_id, data): + self._execute(""" + UPDATE manufacturers SET name=?, type=?, quality_tier=?, country=?, website=? + WHERE id=? + """, (data['name'], data.get('type'), data.get('quality_tier'), + data.get('country'), data.get('website'), mfr_id)) + + def delete_manufacturer(self, mfr_id): + self._execute("DELETE FROM manufacturers WHERE id=?", (mfr_id,)) + + def get_manufacturers(self): + return self._query("SELECT * FROM manufacturers ORDER BY name") + + def create_crossref(self, data): + return self._execute(""" + INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source, notes) + VALUES (?, ?, ?, ?, ?) + """, (data['part_id'], data['cross_reference_number'], data.get('reference_type', 'interchange'), + data.get('source'), data.get('notes'))) + + def update_crossref(self, xref_id, data): + self._execute(""" + UPDATE part_cross_references + SET part_id=?, cross_reference_number=?, reference_type=?, source=?, notes=? + WHERE id=? + """, (data['part_id'], data['cross_reference_number'], data.get('reference_type'), + data.get('source'), data.get('notes'), xref_id)) + + def delete_crossref(self, xref_id): + self._execute("DELETE FROM part_cross_references WHERE id=?", (xref_id,)) + + def get_crossrefs_paginated(self, page=1, per_page=15): + offset = (page - 1) * per_page + return self._query(""" + SELECT pcr.*, p.oem_part_number, p.name AS part_name + FROM part_cross_references pcr + JOIN parts p ON pcr.part_id = p.id + ORDER BY pcr.id + LIMIT ? OFFSET ? + """, (per_page, offset)) + + def get_vehicles_for_part(self, part_id): + return self._query(""" + SELECT b.name AS brand, m.name AS model, y.year, e.name AS engine + FROM vehicle_parts vp + JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id + JOIN models m ON mye.model_id = m.id + JOIN brands b ON m.brand_id = b.id + JOIN years y ON mye.year_id = y.id + JOIN engines e ON mye.engine_id = e.id + WHERE vp.part_id = ? + ORDER BY b.name, m.name, y.year + """, (part_id,)) +``` + +**Step 4: Run tests** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_db.py -v +``` + +Expected: All PASS + +**Step 5: Commit** + +```bash +git add console/db.py console/tests/ +git commit -m "feat(console): add database abstraction layer with tests" +``` + +--- + +## Task 3: Core Framework — Key Bindings and Navigation + +**Files:** +- Create: `console/core/keybindings.py` +- Create: `console/core/navigation.py` +- Create: `console/core/screens.py` +- Create: `console/tests/test_core.py` + +**Step 1: Write tests** + +```python +# console/tests/test_core.py +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from console.core.keybindings import Key, KeyBindings +from console.core.navigation import Navigation +from console.core.screens import Screen + + +def test_key_constants(): + assert Key.ESCAPE == 27 + assert Key.ENTER == 10 + assert Key.F1 is not None + + +def test_keybindings_register(): + kb = KeyBindings() + called = [] + kb.bind(Key.F1, lambda: called.append('help')) + kb.handle(Key.F1) + assert called == ['help'] + + +def test_keybindings_unbound_key(): + kb = KeyBindings() + result = kb.handle(999) + assert result is False + + +def test_navigation_push_pop(): + nav = Navigation() + nav.push('menu', {}) + nav.push('brands', {'filter': 'T'}) + assert nav.current() == ('brands', {'filter': 'T'}) + nav.pop() + assert nav.current() == ('menu', {}) + + +def test_navigation_breadcrumb(): + nav = Navigation() + nav.push('menu', {}, label='Menu') + nav.push('brands', {}, label='Marcas') + nav.push('models', {}, label='TOYOTA') + bc = nav.breadcrumb() + assert bc == ['Menu', 'Marcas', 'TOYOTA'] + + +def test_navigation_empty(): + nav = Navigation() + assert nav.current() is None + nav.pop() # should not crash + + +def test_screen_base(): + s = Screen('test', 'Test Screen') + assert s.name == 'test' + assert s.title == 'Test Screen' +``` + +**Step 2: Run test to verify failure** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_core.py -v +``` + +**Step 3: Implement keybindings.py** + +```python +# console/core/keybindings.py +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 of key-to-action bindings.""" + + def __init__(self): + self._bindings = {} + + def bind(self, key, action): + self._bindings[key] = action + + def handle(self, key): + if key in self._bindings: + self._bindings[key]() + return True + return False + + def get_footer_labels(self): + """Return list of (key_label, description) for footer display.""" + return self._footer_labels if hasattr(self, '_footer_labels') else [] + + def set_footer(self, labels): + """Set footer labels: [('F1', 'Ayuda'), ('ESC', 'Atrás'), ...]""" + self._footer_labels = labels +``` + +**Step 4: Implement navigation.py** + +```python +# console/core/navigation.py + + +class Navigation: + """Screen navigation stack with breadcrumb support.""" + + def __init__(self): + self._stack = [] # list of (screen_name, context, label) + + def push(self, screen_name, context=None, label=None): + self._stack.append((screen_name, context or {}, label or screen_name)) + + def pop(self): + if self._stack: + return self._stack.pop() + return None + + def current(self): + if self._stack: + entry = self._stack[-1] + return (entry[0], entry[1]) + return None + + def breadcrumb(self): + return [entry[2] for entry in self._stack] + + def clear(self): + self._stack.clear() + + def depth(self): + return len(self._stack) +``` + +**Step 5: Implement screens.py** + +```python +# console/core/screens.py + + +class Screen: + """Base class for all application screens.""" + + def __init__(self, name, title): + self.name = name + self.title = title + + def on_enter(self, context, db, renderer): + """Called when screen becomes active. Override in subclasses.""" + pass + + def on_key(self, key, context, db, renderer, nav): + """Handle key press. Return screen_name to navigate, None to stay, 'back' to pop.""" + return None + + def render(self, context, db, renderer): + """Draw the screen. Override in subclasses.""" + pass +``` + +**Step 6: Run tests** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_core.py -v +``` + +Expected: All PASS + +**Step 7: Commit** + +```bash +git add console/core/ console/tests/test_core.py +git commit -m "feat(console): add core framework - keybindings, navigation, screen base" +``` + +--- + +## Task 4: Utilities — Formatting and VIN API + +**Files:** +- Create: `console/utils/formatting.py` +- Create: `console/utils/vin_api.py` +- Create: `console/tests/test_utils.py` + +**Step 1: Write tests** + +```python +# console/tests/test_utils.py +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from console.utils.formatting import format_currency, format_number, truncate, pad_right, format_table_row + + +def test_format_currency(): + assert format_currency(45.99) == '$45.99' + assert format_currency(None) == '──' + assert format_currency(0) == '$0.00' + + +def test_format_number(): + assert format_number(13685) == '13,685' + assert format_number(0) == '0' + + +def test_truncate(): + assert truncate('Hello World', 8) == 'Hello...' + assert truncate('Short', 10) == 'Short' + + +def test_pad_right(): + assert pad_right('Hi', 5) == 'Hi ' + assert pad_right('Hello World', 5) == 'Hello' + + +def test_format_table_row(): + row = format_table_row(['ID', 'Name', 'Price'], [4, 20, 8]) + assert len(row) > 0 + assert 'ID' in row +``` + +**Step 2: Run test to verify failure** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v +``` + +**Step 3: Implement formatting.py** + +```python +# console/utils/formatting.py + + +def format_currency(value): + if value is None: + return '──' + return f'${value:,.2f}' + + +def format_number(value): + if value is None: + return '0' + return f'{int(value):,}' + + +def truncate(text, max_len): + if text is None: + return '' + text = str(text) + if len(text) <= max_len: + return text + return text[:max_len - 3] + '...' + + +def pad_right(text, width): + if text is None: + text = '' + text = str(text) + if len(text) > width: + return text[:width] + return text.ljust(width) + + +def format_table_row(values, widths, separator=' │ '): + """Format a row with fixed column widths.""" + parts = [] + for val, width in zip(values, widths): + parts.append(pad_right(str(val) if val is not None else '', width)) + return separator.join(parts) + + +def quality_bar(tier): + """Return visual quality bar for quality tier.""" + bars = { + 'premium': '██████████', + 'oem': '███████████', + 'standard': '███████░░░', + 'economy': '█████░░░░░', + } + return bars.get(tier, '░░░░░░░░░░') +``` + +**Step 4: Implement vin_api.py** + +```python +# console/utils/vin_api.py +import json +import requests +from console.config import NHTSA_API_URL + + +def decode_vin_nhtsa(vin): + """Decode a VIN using NHTSA API. Returns dict with vehicle info.""" + try: + response = requests.get( + NHTSA_API_URL, + params={'format': 'json'}, + timeout=10 + ) + # NHTSA uses path-based VIN + response = requests.get( + f"{NHTSA_API_URL}/{vin}", + params={'format': 'json'}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + results = data.get('Results', []) + info = {} + for item in results: + var = item.get('Variable', '') + val = item.get('Value') + if val and val.strip(): + if var == 'Make': + info['make'] = val.strip() + elif var == 'Model': + info['model'] = val.strip() + elif var == 'Model Year': + info['year'] = int(val.strip()) + elif var == 'Body Class': + info['body_class'] = val.strip() + elif var == 'Drive Type': + info['drive_type'] = val.strip() + elif var == 'Displacement (L)': + info.setdefault('engine_info', {})['displacement_l'] = val.strip() + elif var == 'Engine Number of Cylinders': + info.setdefault('engine_info', {})['cylinders'] = val.strip() + elif var == 'Fuel Type - Primary': + info.setdefault('engine_info', {})['fuel_type'] = val.strip() + elif var == 'Engine Brake (hp) From': + info.setdefault('engine_info', {})['power_hp'] = val.strip() + + engine = info.get('engine_info', {}) + if engine: + parts = [] + if engine.get('displacement_l'): + parts.append(f"{engine['displacement_l']}L") + if engine.get('cylinders'): + parts.append(f"{engine['cylinders']} cil.") + if engine.get('fuel_type'): + parts.append(engine['fuel_type']) + engine['raw'] = ' '.join(parts) + + return info + except Exception as e: + return {'error': str(e)} +``` + +**Step 5: Run tests** + +```bash +cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v +``` + +Expected: All PASS + +**Step 6: Commit** + +```bash +git add console/utils/ console/tests/test_utils.py +git commit -m "feat(console): add formatting utils and VIN API client" +``` + +--- + +## Task 5: Curses Renderer (VT220 Mode) + +**Files:** +- Create: `console/renderers/curses_renderer.py` +- Create: `console/renderers/base.py` + +**Step 1: Write base renderer interface** + +```python +# console/renderers/base.py + + +class BaseRenderer: + """Abstract renderer interface. All renderers must implement these methods.""" + + def init_screen(self): + raise NotImplementedError + + def cleanup(self): + raise NotImplementedError + + def get_size(self): + """Return (height, width) of terminal.""" + raise NotImplementedError + + def clear(self): + raise NotImplementedError + + def refresh(self): + raise NotImplementedError + + def get_key(self): + """Block and return key code.""" + raise NotImplementedError + + def draw_header(self, title, subtitle=''): + raise NotImplementedError + + def draw_footer(self, key_labels): + """key_labels: list of (key, description) tuples.""" + raise NotImplementedError + + def draw_menu(self, items, selected_index=0, title=''): + """items: list of (number, label) tuples.""" + raise NotImplementedError + + def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): + raise NotImplementedError + + def draw_detail(self, fields, title=''): + """fields: list of (label, value) tuples.""" + raise NotImplementedError + + def draw_form(self, fields, focused_index=0, title=''): + """fields: list of {label, value, width, type} dicts.""" + raise NotImplementedError + + def draw_filter_list(self, items, filter_text, selected_index, title=''): + raise NotImplementedError + + def draw_comparison(self, columns, title=''): + """columns: list of {header, rows} dicts for side-by-side.""" + raise NotImplementedError + + def draw_text(self, row, col, text, style='normal'): + raise NotImplementedError + + def draw_box(self, top, left, height, width, title=''): + raise NotImplementedError + + def show_message(self, text, msg_type='info'): + """msg_type: 'info', 'error', 'confirm'. Returns True/False for confirm.""" + raise NotImplementedError + + def show_input(self, prompt, max_len=40): + """Show input prompt and return typed string.""" + raise NotImplementedError +``` + +**Step 2: Implement curses_renderer.py** + +This is the largest single file. It implements the full VT220 aesthetic with green-on-black, box drawing characters, and Pick-style screen layout. + +```python +# console/renderers/curses_renderer.py +import curses +from console.renderers.base import BaseRenderer +from console.config import COLORS_VT220 +from console.utils.formatting import pad_right, truncate + + +class CursesRenderer(BaseRenderer): + + def __init__(self): + self.stdscr = None + self.colors = {} + + def init_screen(self): + self.stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + curses.curs_set(0) + self.stdscr.keypad(True) + + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + self._init_colors() + + def _init_colors(self): + color_map = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3, + 'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7} + for i, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), 1): + if i < curses.COLOR_PAIRS: + curses.init_pair(i, color_map.get(fg, 7), color_map.get(bg, 0)) + self.colors[name] = curses.color_pair(i) + # Ensure defaults + self.colors.setdefault('normal', curses.color_pair(0)) + + def _style(self, name): + return self.colors.get(name, self.colors.get('normal', 0)) + + def cleanup(self): + if self.stdscr: + curses.nocbreak() + self.stdscr.keypad(False) + curses.echo() + curses.endwin() + + def get_size(self): + h, w = self.stdscr.getmaxyx() + return h, w + + def clear(self): + self.stdscr.clear() + + def refresh(self): + self.stdscr.refresh() + + def get_key(self): + return self.stdscr.getch() + + def draw_header(self, title, subtitle=''): + h, w = self.get_size() + header_text = f' {title}' + if subtitle: + space = w - len(title) - len(subtitle) - 4 + header_text = f' {title}{" " * max(space, 2)}{subtitle} ' + header_text = header_text[:w].ljust(w) + try: + self.stdscr.addstr(0, 0, header_text, self._style('header') | curses.A_BOLD) + except curses.error: + pass + # Separator line + try: + self.stdscr.addstr(1, 0, '─' * w, self._style('border')) + except curses.error: + pass + + def draw_footer(self, key_labels): + h, w = self.get_size() + # Separator + try: + self.stdscr.addstr(h - 2, 0, '─' * w, self._style('border')) + except curses.error: + pass + # Key labels + footer = ' ' + ' '.join(f'{k}={v}' for k, v in key_labels) + ' ' + footer = footer[:w].ljust(w) + try: + self.stdscr.addstr(h - 1, 0, footer[:w-1], self._style('footer')) + except curses.error: + pass + + def draw_menu(self, items, selected_index=0, title=''): + h, w = self.get_size() + start_row = 3 + if title: + try: + self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) + except curses.error: + pass + start_row += 2 + + for i, (num, label) in enumerate(items): + row = start_row + i + if row >= h - 3: + break + style = self._style('highlight') if i == selected_index else self._style('normal') + prefix = '>' if i == selected_index else ' ' + text = f' {prefix} {num}. {label}' + try: + self.stdscr.addstr(row, 0, pad_right(text, w), style) + except curses.error: + pass + + def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): + h, w = self.get_size() + start_row = 3 + + # Header row + header_line = ' # │ ' + for hdr, width in zip(headers, widths): + header_line += pad_right(hdr, width) + ' │ ' + try: + self.stdscr.addstr(start_row, 0, header_line[:w], self._style('title') | curses.A_BOLD) + self.stdscr.addstr(start_row + 1, 0, '─' * w, self._style('border')) + except curses.error: + pass + + # Data rows + for i, row in enumerate(rows): + y = start_row + 2 + i + if y >= h - 4: + break + style = self._style('highlight') if i == selected_row else self._style('normal') + line = f' {i + 1:<3}│ ' + for val, width in zip(row, widths): + line += pad_right(truncate(str(val) if val is not None else '', width), width) + ' │ ' + try: + self.stdscr.addstr(y, 0, line[:w], style) + except curses.error: + pass + + # Page info + if page_info: + info_row = h - 3 + info_text = f' Página {page_info.get("page", 1)}/{page_info.get("total_pages", 1)}' \ + f' {page_info.get("showing", "")} de {page_info.get("total", "")} registros' + try: + self.stdscr.addstr(info_row, 0, info_text, self._style('info')) + except curses.error: + pass + + def draw_detail(self, fields, title=''): + h, w = self.get_size() + start_row = 3 + if title: + try: + self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) + except curses.error: + pass + start_row += 2 + + max_label = max(len(f[0]) for f in fields) if fields else 10 + for i, (label, value) in enumerate(fields): + row = start_row + i + if row >= h - 3: + break + dots = '.' * (max_label - len(label) + 2) + line = f' {label}{dots}: {value}' + try: + self.stdscr.addstr(row, 2, label, self._style('field_label')) + self.stdscr.addstr(row, 2 + len(label), dots + ': ', self._style('border')) + self.stdscr.addstr(row, 2 + max_label + 4, str(value)[:w - max_label - 8], + self._style('field_value')) + except curses.error: + pass + + def draw_form(self, fields, focused_index=0, title=''): + h, w = self.get_size() + start_row = 3 + if title: + try: + self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) + except curses.error: + pass + start_row += 2 + + max_label = max(len(f['label']) for f in fields) if fields else 10 + for i, field in enumerate(fields): + row = start_row + i + if row >= h - 3: + break + label = field['label'] + value = field.get('value', '') + field_width = field.get('width', 30) + dots = '.' * (max_label - len(label) + 2) + is_focused = (i == focused_index) + + try: + self.stdscr.addstr(row, 2, f'{i + 1}.', self._style('info')) + self.stdscr.addstr(row, 5, label, self._style('field_label')) + self.stdscr.addstr(row, 5 + len(label), dots + ': ', self._style('border')) + + val_col = 5 + max_label + 4 + display_val = f'[{pad_right(str(value), field_width)}]' + style = self._style('field_active') if is_focused else self._style('field_value') + self.stdscr.addstr(row, val_col, display_val[:w - val_col - 2], style) + + hint = field.get('hint', '') + if hint: + hint_col = val_col + field_width + 3 + if hint_col < w - 2: + self.stdscr.addstr(row, hint_col, hint[:w - hint_col - 1], self._style('info')) + except curses.error: + pass + + def draw_filter_list(self, items, filter_text, selected_index, title=''): + h, w = self.get_size() + start_row = 3 + if title: + try: + self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) + except curses.error: + pass + start_row += 1 + self.stdscr.addstr(start_row, 0, '─' * w, self._style('border')) + start_row += 1 + + # Filter input + try: + self.stdscr.addstr(start_row, 2, 'Filtro: ', self._style('field_label')) + self.stdscr.addstr(start_row, 10, filter_text + '_', self._style('field_active')) + except curses.error: + pass + start_row += 1 + try: + self.stdscr.addstr(start_row, 0, '─' * w, self._style('border')) + except curses.error: + pass + start_row += 1 + + # Items + visible_items = h - start_row - 4 + scroll_offset = max(0, selected_index - visible_items + 1) + for i, item in enumerate(items[scroll_offset:scroll_offset + visible_items]): + row = start_row + i + actual_idx = scroll_offset + i + is_selected = actual_idx == selected_index + prefix = '> ' if is_selected else ' ' + num = f'{actual_idx + 1}.' + style = self._style('highlight') if is_selected else self._style('normal') + text = f' {prefix}{num:>4} {item}' + try: + self.stdscr.addstr(row, 0, pad_right(text, w), style) + except curses.error: + pass + + # Count + count_text = f' {len(items)} coincidencias' + try: + self.stdscr.addstr(h - 3, w - len(count_text) - 2, count_text, self._style('info')) + except curses.error: + pass + + def draw_comparison(self, columns, title=''): + h, w = self.get_size() + start_row = 3 + if title: + try: + self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) + except curses.error: + pass + start_row += 2 + + n_cols = len(columns) + if n_cols == 0: + return + label_w = 16 + col_w = max(12, (w - label_w - 4) // n_cols - 3) + + # Column headers + header_line = pad_right('', label_w) + ' │ ' + for col in columns: + header_line += pad_right(truncate(col['header'], col_w), col_w) + ' │ ' + try: + self.stdscr.addstr(start_row, 2, header_line[:w - 4], self._style('title') | curses.A_BOLD) + self.stdscr.addstr(start_row + 1, 2, '─' * (w - 4), self._style('border')) + except curses.error: + pass + start_row += 2 + + # Find max rows + max_rows = max(len(col.get('rows', [])) for col in columns) if columns else 0 + for r in range(max_rows): + row = start_row + r + if row >= h - 4: + break + # Row label from first column + row_label = columns[0]['rows'][r][0] if r < len(columns[0].get('rows', [])) else '' + line = pad_right(row_label, label_w) + ' │ ' + for col in columns: + val = col['rows'][r][1] if r < len(col.get('rows', [])) else '' + line += pad_right(truncate(str(val), col_w), col_w) + ' │ ' + try: + self.stdscr.addstr(row, 2, line[:w - 4], self._style('normal')) + except curses.error: + pass + + def draw_text(self, row, col, text, style='normal'): + h, w = self.get_size() + if row < h and col < w: + try: + self.stdscr.addstr(row, col, text[:w - col], self._style(style)) + except curses.error: + pass + + def draw_box(self, top, left, height, width, title=''): + try: + # Top border + self.stdscr.addstr(top, left, '┌' + '─' * (width - 2) + '┐', self._style('border')) + if title: + self.stdscr.addstr(top, left + 2, f' {title} ', self._style('title') | curses.A_BOLD) + # Sides + for i in range(1, height - 1): + self.stdscr.addstr(top + i, left, '│', self._style('border')) + self.stdscr.addstr(top + i, left + width - 1, '│', self._style('border')) + # Bottom border + self.stdscr.addstr(top + height - 1, left, '└' + '─' * (width - 2) + '┘', self._style('border')) + except curses.error: + pass + + def show_message(self, text, msg_type='info'): + h, w = self.get_size() + box_w = min(len(text) + 6, w - 4) + box_h = 5 + top = h // 2 - 2 + left = (w - box_w) // 2 + + style = self._style('error') if msg_type == 'error' else self._style('info') + self.draw_box(top, left, box_h, box_w) + + try: + self.stdscr.addstr(top + 2, left + 3, truncate(text, box_w - 6), style) + if msg_type == 'confirm': + self.stdscr.addstr(top + 3, left + 3, '(S/N)', self._style('field_label')) + else: + self.stdscr.addstr(top + 3, left + 3, 'Presione cualquier tecla...', self._style('field_label')) + except curses.error: + pass + self.refresh() + + if msg_type == 'confirm': + while True: + key = self.get_key() + if key in (ord('s'), ord('S'), ord('y'), ord('Y')): + return True + if key in (ord('n'), ord('N'), 27): + 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, 40) + box_h = 5 + top = h // 2 - 2 + left = (w - box_w) // 2 + + self.draw_box(top, left, box_h, box_w, prompt) + + value = '' + curses.curs_set(1) + while True: + try: + display = f'[{pad_right(value, max_len)}]' + self.stdscr.addstr(top + 2, left + 3, display, self._style('field_active')) + self.stdscr.move(top + 2, left + 4 + len(value)) + except curses.error: + pass + self.refresh() + + key = self.get_key() + if key == 10: # ENTER + break + elif key == 27: # ESC + curses.curs_set(0) + return None + elif key in (127, curses.KEY_BACKSPACE, 8): + value = value[:-1] + elif 32 <= key <= 126 and len(value) < max_len: + value += chr(key) + + curses.curs_set(0) + return value +``` + +**Step 3: Commit** + +```bash +git add console/renderers/ +git commit -m "feat(console): add curses VT220 renderer with full widget set" +``` + +--- + +## Task 6: App Controller and Main Menu Screen + +**Files:** +- Create: `console/core/app.py` +- Create: `console/screens/menu_principal.py` +- Create: `console/screens/estadisticas.py` + +**Step 1: Implement app.py** + +```python +# console/core/app.py +from console.core.navigation import Navigation +from console.core.keybindings import Key + + +class App: + """Main application controller. Manages screen lifecycle and navigation.""" + + def __init__(self, renderer, db): + self.renderer = renderer + self.db = db + self.nav = Navigation() + self.screens = {} + self.running = False + self._register_screens() + + def _register_screens(self): + from console.screens.menu_principal import MenuPrincipal + from console.screens.estadisticas import EstadisticasScreen + from console.screens.vehiculo_nav import VehiculoNavScreen + from console.screens.buscar_parte import BuscarParteScreen + from console.screens.buscar_texto import BuscarTextoScreen + from console.screens.vin_decoder import VinDecoderScreen + from console.screens.catalogo import CatalogoScreen + from console.screens.parte_detalle import ParteDetalleScreen + from console.screens.comparador import ComparadorScreen + from console.screens.admin_partes import AdminPartesScreen + from console.screens.admin_fabricantes import AdminFabricantesScreen + from console.screens.admin_crossref import AdminCrossrefScreen + from console.screens.admin_import import AdminImportScreen + + self.screens = { + 'menu': MenuPrincipal(), + 'estadisticas': EstadisticasScreen(), + 'vehiculo_nav': VehiculoNavScreen(), + 'buscar_parte': BuscarParteScreen(), + 'buscar_texto': BuscarTextoScreen(), + 'vin_decoder': VinDecoderScreen(), + 'catalogo': CatalogoScreen(), + 'parte_detalle': ParteDetalleScreen(), + 'comparador': ComparadorScreen(), + 'admin_partes': AdminPartesScreen(), + 'admin_fabricantes': AdminFabricantesScreen(), + 'admin_crossref': AdminCrossrefScreen(), + 'admin_import': AdminImportScreen(), + } + + def run(self): + self.renderer.init_screen() + self.running = True + self.nav.push('menu', {}, label='Menú') + + 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 encontrada', 'error') + self.nav.pop() + continue + + # Render + self.renderer.clear() + screen.render(context, self.db, self.renderer) + self.renderer.refresh() + + # Get input + key = self.renderer.get_key() + + # Global keys + if key == Key.F10: + self.nav.clear() + self.nav.push('menu', {}, label='Menú') + continue + + # Screen-specific 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 result and isinstance(result, tuple): + # (screen_name, context, label) + name, ctx, label = result + self.nav.push(name, ctx, label=label) + elif result and isinstance(result, str): + self.nav.push(result, {}, label=result) + + except KeyboardInterrupt: + pass + finally: + self.renderer.cleanup() +``` + +**Step 2: Implement menu_principal.py** + +```python +# console/screens/menu_principal.py +from console.core.screens import Screen +from console.core.keybindings import Key +from console.config import APP_NAME, APP_SUBTITLE, VERSION + + +class MenuPrincipal(Screen): + + def __init__(self): + super().__init__('menu', 'Menú Principal') + self.selected = 0 + self.items = [ + ('1', 'Consulta por Vehículo'), + ('2', 'Búsqueda por Número de Parte'), + ('3', 'Búsqueda por Descripción'), + ('4', 'Decodificador VIN'), + ('5', 'Catálogo de Categorías'), + ('─', '─────────────────────────'), + ('6', 'Administración de Partes'), + ('7', 'Administración de Fabricantes'), + ('8', 'Cross-References'), + ('9', 'Importar / Exportar Datos'), + ('─', '─────────────────────────'), + ('0', 'Estadísticas del Sistema'), + ] + self.selectable = [i for i, (num, _) in enumerate(self.items) if num != '─'] + self.actions = { + '1': ('vehiculo_nav', {}, 'Vehículo'), + '2': ('buscar_parte', {}, 'Buscar Parte'), + '3': ('buscar_texto', {}, 'Buscar Texto'), + '4': ('vin_decoder', {}, 'VIN Decoder'), + '5': ('catalogo', {}, 'Catálogo'), + '6': ('admin_partes', {}, 'Admin Partes'), + '7': ('admin_fabricantes', {}, 'Admin Fabricantes'), + '8': ('admin_crossref', {}, 'Admin Cross-Ref'), + '9': ('admin_import', {}, 'Importar/Exportar'), + '0': ('estadisticas', {}, 'Estadísticas'), + } + + def render(self, context, db, renderer): + renderer.draw_header(f'{APP_NAME} v{VERSION}', APP_SUBTITLE) + renderer.draw_menu(self.items, self.selected) + renderer.draw_footer([ + ('F1', 'Ayuda'), ('F3', 'Buscar'), ('F10', 'Menú'), ('ESC', 'Salir') + ]) + + def on_key(self, key, context, db, renderer, nav): + if key == Key.ESCAPE: + if renderer.show_message('¿Desea salir del sistema?', 'confirm'): + return 'quit' + return None + + # Number keys + if 48 <= key <= 57: # 0-9 + num = chr(key) + if num in self.actions: + return self.actions[num] + + # Arrow navigation + if key == Key.DOWN: + idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0 + idx = min(idx + 1, len(self.selectable) - 1) + self.selected = self.selectable[idx] + elif key == Key.UP: + idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0 + idx = max(idx - 1, 0) + self.selected = self.selectable[idx] + elif key == Key.ENTER: + num = self.items[self.selected][0] + if num in self.actions: + return self.actions[num] + + return None +``` + +**Step 3: Implement estadisticas.py** + +```python +# console/screens/estadisticas.py +from console.core.screens import Screen +from console.core.keybindings import Key +from console.utils.formatting import format_number + + +class EstadisticasScreen(Screen): + + def __init__(self): + super().__init__('estadisticas', 'Estadísticas del Sistema') + + def render(self, context, db, renderer): + import datetime + now = datetime.datetime.now().strftime('%d/%b/%Y %H:%M') + renderer.draw_header('ESTADÍSTICAS DEL SISTEMA', now) + + stats = db.get_stats() + + fields = [ + ('', '── BASE DE DATOS ─────────────────────────────────'), + ('Marcas', format_number(stats.get('brands', 0))), + ('Modelos', format_number(stats.get('models', 0))), + ('Motores', format_number(stats.get('engines', 0))), + ('Configuraciones', format_number(stats.get('model_year_engine', 0))), + ('Fabricantes', format_number(stats.get('manufacturers', 0))), + ('Categorías', format_number(stats.get('part_categories', 0))), + ('Grupos', format_number(stats.get('part_groups', 0))), + ('Partes OEM', format_number(stats.get('parts', 0))), + ('Aftermarket', format_number(stats.get('aftermarket_parts', 0))), + ('Cross-Refs', format_number(stats.get('part_cross_references', 0))), + ('Diagramas', format_number(stats.get('diagrams', 0))), + ('', '── COBERTURA ─────────────────────────────────────'), + ('Fitments', format_number(stats.get('vehicle_parts', 0))), + ('VINs en caché', format_number(stats.get('vin_cache', 0))), + ] + + # Add top brands + top_brands = stats.get('top_brands', []) + if top_brands: + brands_str = ' '.join(f"{b['name']}({format_number(b['cnt'])})" for b in top_brands) + fields.append(('Top marcas', brands_str)) + + renderer.draw_detail(fields, 'ESTADÍSTICAS DEL SISTEMA') + renderer.draw_footer([('F5', 'Actualizar'), ('ESC', 'Menú')]) + + def on_key(self, key, context, db, renderer, nav): + if key == Key.ESCAPE: + return 'back' + if key == Key.F5: + return None # Re-render + return None +``` + +**Step 4: Commit** + +```bash +git add console/core/app.py console/screens/menu_principal.py console/screens/estadisticas.py +git commit -m "feat(console): add app controller, main menu and statistics screen" +``` + +--- + +## Task 7: Vehicle Navigation Screen (Drill-Down) + +**Files:** +- Create: `console/screens/vehiculo_nav.py` + +**Step 1: Implement drill-down navigation** + +The vehicle navigation screen handles the full drill-down flow: Brand → Model → Year → Engine → Categories → Groups → Parts, each as a filterable list. It uses the renderer's `draw_filter_list` method and maintains state for each drill-down level within its context dict. + +**Key behaviors:** +- Typing characters filters the list in real time +- Arrow keys move selection cursor +- ENTER advances to next drill-down level +- ESC goes back one level (or exits to menu if at brand level) +- After selecting engine, transitions to catalogo screen with the vehicle's mye_id + +**Step 2: Commit** + +```bash +git add console/screens/vehiculo_nav.py +git commit -m "feat(console): add vehicle drill-down navigation screen" +``` + +--- + +## Task 8: Catalog, Search, and VIN Screens + +**Files:** +- Create: `console/screens/catalogo.py` +- Create: `console/screens/buscar_parte.py` +- Create: `console/screens/buscar_texto.py` +- Create: `console/screens/vin_decoder.py` + +**Step 1: Implement catalogo.py** + +Categories → Groups → Parts drill-down, similar structure to vehiculo_nav but for the parts hierarchy. Uses `draw_filter_list` for categories/groups and `draw_table` for parts list. + +**Step 2: Implement buscar_parte.py** + +Single input field (via `show_input`), calls `db.search_part_number()`, displays results in `draw_table`. Selecting a result navigates to `parte_detalle`. + +**Step 3: Implement buscar_texto.py** + +Same pattern as buscar_parte but uses `db.search_parts()` (FTS5) with a text query. Paginated results via PgUp/PgDn. + +**Step 4: Implement vin_decoder.py** + +Input 17-char VIN, check `db.get_vin_cache()` first, else call `decode_vin_nhtsa()` and cache result. Display decoded info via `draw_detail`. Option to navigate to vehicle parts. + +**Step 5: Commit** + +```bash +git add console/screens/catalogo.py console/screens/buscar_parte.py console/screens/buscar_texto.py console/screens/vin_decoder.py +git commit -m "feat(console): add catalog, search, and VIN decoder screens" +``` + +--- + +## Task 9: Part Detail and Comparator Screens + +**Files:** +- Create: `console/screens/parte_detalle.py` +- Create: `console/screens/comparador.py` + +**Step 1: Implement parte_detalle.py** + +Top section: `draw_detail` with part fields (OEM number, name, group, category, description, material, weight). Bottom section: `draw_table` with aftermarket alternatives. F4 navigates to cross-references view. F6 shows vehicles that use this part. Number key selects an alternative to compare. + +**Step 2: Implement comparador.py** + +Uses `draw_comparison` to show OEM part and selected aftermarket alternatives side by side. Shows quality bars, prices, savings percentages, warranty info. + +**Step 3: Commit** + +```bash +git add console/screens/parte_detalle.py console/screens/comparador.py +git commit -m "feat(console): add part detail and comparator screens" +``` + +--- + +## Task 10: Admin CRUD Screens + +**Files:** +- Create: `console/screens/admin_partes.py` +- Create: `console/screens/admin_fabricantes.py` +- Create: `console/screens/admin_crossref.py` +- Create: `console/screens/admin_import.py` + +**Step 1: Implement admin_partes.py** + +List view with `draw_table` (paginated). F2 or ENTER to edit selected part via `draw_form`. F3 to add new part. F9 saves, ESC cancels. Delete with confirmation dialog. + +**Step 2: Implement admin_fabricantes.py** + +Same CRUD pattern as admin_partes but for manufacturers table. + +**Step 3: Implement admin_crossref.py** + +Same CRUD pattern for cross-references. F1 on part_id field opens lookup list of parts. + +**Step 4: Implement admin_import.py** + +Menu to select data type (categories, groups, parts, manufacturers, aftermarket, crossref, fitment). File path input via `show_input`. Reads CSV and calls appropriate db methods. Shows progress/results. + +**Step 5: Commit** + +```bash +git add console/screens/admin_*.py +git commit -m "feat(console): add admin CRUD screens for parts, manufacturers, crossref, import" +``` + +--- + +## Task 11: Textual/Rich Modern Renderer + +**Files:** +- Create: `console/renderers/textual_renderer.py` + +**Step 1: Install textual dependency** + +```bash +pip install textual rich +``` + +**Step 2: Implement textual_renderer.py** + +Implements the same `BaseRenderer` interface but using textual/rich for a modern TUI experience. Uses Rich tables, panels, and styled text instead of curses box drawing. The same screen logic drives both renderers. + +Note: textual uses an async event loop, so this renderer wraps textual's App in a synchronous interface matching BaseRenderer. An alternative is a thin adapter that translates `draw_*` calls into Rich console output for simpler integration. + +**Step 3: Commit** + +```bash +git add console/renderers/textual_renderer.py +git commit -m "feat(console): add textual/rich modern renderer" +``` + +--- + +## Task 12: Integration Testing and Polish + +**Files:** +- Create: `console/tests/test_integration.py` +- Modify: `console/main.py` + +**Step 1: Write integration tests** + +Test that the app can be instantiated, screens registered, and basic navigation works (using a mock renderer that records draw calls instead of painting to screen). + +**Step 2: Add error handling to main.py** + +Wrap `app.run()` in try/except to always clean up the terminal on crash. Add `--db` flag to override database path. + +**Step 3: Test full app manually** + +```bash +cd /home/Autopartes && python -m console.main --mode vt220 +cd /home/Autopartes && python -m console.main --mode modern +``` + +**Step 4: Commit** + +```bash +git add console/ +git commit -m "feat(console): add integration tests and polish error handling" +``` + +--- + +## Task 13: Documentation and Final Commit + +**Files:** +- Create: `console/README.md` +- Modify: `README.md` (root) + +**Step 1: Write console README** + +Usage instructions, keybindings reference, screenshots placeholder, mode descriptions. + +**Step 2: Update root README** + +Add section about the console system with quickstart command. + +**Step 3: Final commit** + +```bash +git add console/README.md README.md +git commit -m "docs: add console system documentation" +``` + +--- + +## Summary + +| Task | Description | Est. Steps | +|------|-------------|------------| +| 1 | Project scaffold + config | 4 | +| 2 | Database abstraction layer | 5 | +| 3 | Core framework (keys, nav, screens) | 7 | +| 4 | Utilities (formatting, VIN API) | 6 | +| 5 | Curses VT220 renderer | 3 | +| 6 | App controller + main menu + stats | 4 | +| 7 | Vehicle drill-down navigation | 2 | +| 8 | Catalog, search, VIN screens | 5 | +| 9 | Part detail + comparator | 3 | +| 10 | Admin CRUD screens | 5 | +| 11 | Textual modern renderer | 3 | +| 12 | Integration tests + polish | 4 | +| 13 | Documentation | 3 | +| **Total** | | **54 steps** | + +**Dependencies:** Tasks 1-5 must be completed first (foundation). Tasks 6-10 can be done in any order after foundation. Task 11 is independent of 6-10. Tasks 12-13 are final.