diff --git a/console/screens/buscar_parte.py b/console/screens/buscar_parte.py new file mode 100644 index 0000000..782a5b3 --- /dev/null +++ b/console/screens/buscar_parte.py @@ -0,0 +1,153 @@ +""" +Part number search screen for the AUTOPARTES 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 diff --git a/console/screens/buscar_texto.py b/console/screens/buscar_texto.py new file mode 100644 index 0000000..181c104 --- /dev/null +++ b/console/screens/buscar_texto.py @@ -0,0 +1,174 @@ +""" +Full-text search screen for the AUTOPARTES 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 diff --git a/console/screens/catalogo.py b/console/screens/catalogo.py new file mode 100644 index 0000000..93fa923 --- /dev/null +++ b/console/screens/catalogo.py @@ -0,0 +1,354 @@ +""" +Catalog navigation screen for the AUTOPARTES 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 diff --git a/console/screens/vin_decoder.py b/console/screens/vin_decoder.py new file mode 100644 index 0000000..3fe882a --- /dev/null +++ b/console/screens/vin_decoder.py @@ -0,0 +1,259 @@ +""" +VIN decoder screen for the AUTOPARTES 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