feat(console): add catalog, search, and VIN decoder screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
console/screens/buscar_parte.py
Normal file
153
console/screens/buscar_parte.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Part number search screen for the 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
|
||||||
174
console/screens/buscar_texto.py
Normal file
174
console/screens/buscar_texto.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Full-text search screen for the 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
354
console/screens/catalogo.py
Normal 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
|
||||||
259
console/screens/vin_decoder.py
Normal file
259
console/screens/vin_decoder.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
VIN decoder screen for the 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
|
||||||
Reference in New Issue
Block a user