feat(console): add catalog, search, and VIN decoder screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:49:15 +00:00
parent 69fb26723d
commit b042853408
4 changed files with 940 additions and 0 deletions

View File

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

View File

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

354
console/screens/catalogo.py Normal file
View File

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

View File

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