feat: Enhance search functionality in the database manager to support relaxed criteria for result retrieval, improving user experience by allowing broader searches when no results are found. Update GUI components to reflect these changes, including new filter notes and options for relaxing search criteria.

This commit is contained in:
erik
2026-04-26 19:49:20 -05:00
parent 6b3eccdc19
commit 37d32a5a96
6 changed files with 1048 additions and 123 deletions
+16 -9
View File
@@ -310,7 +310,9 @@ class AppController:
# Mostrar resumen rápido
# Usar tabla results (1 fila = 1 documento), no el join con matches
docs = self.db_manager.search_results(run_id=run_id, status="Todos", text=None)
docs, _ = self.db_manager.search_results(
run_id=run_id, status="Todos", text=None, relax_if_empty=False
)
match_count = sum(1 for r in docs if r.get("status") == "MATCH")
self.view.append_preclass_output_safe(f"Total procesados: {len(docs)}")
self.view.append_preclass_output_safe(f"Coincidencias (MATCH): {match_count}")
@@ -427,20 +429,24 @@ class AppController:
def on_res_search(self):
"""Busca resultados con los filtros actuales."""
self.view.clear_res_filter_note()
filters = self.view.get_res_filters()
run_id = None
if filters["run_id"] != "Todos":
try:
run_id = int(filters["run_id"])
except: pass
try:
rows = self.db_manager.search_results(
rows, relax_note = self.db_manager.search_results(
run_id=run_id,
status=filters["status"],
text=filters["text"]
text=filters["text"],
relax_if_empty=bool(filters.get("relax_on_empty", False)),
)
self.view.update_res_table(rows)
if relax_note:
self.view.set_res_filter_note(relax_note)
except Exception as e:
logger.error(f"Error buscando resultados: {e}")
mb.showerror("Error", f"Error en búsqueda: {e}")
@@ -462,13 +468,14 @@ class AppController:
if filters["run_id"] != "Todos":
try: run_id = int(filters["run_id"])
except: pass
rows = self.db_manager.search_results(
rows, _ = self.db_manager.search_results(
run_id=run_id,
status=filters["status"],
text=filters["text"]
text=filters["text"],
relax_if_empty=bool(filters.get("relax_on_empty", False)),
)
if not rows:
mb.showwarning("Exportar", "No hay datos para exportar.")
return
@@ -1292,7 +1299,7 @@ class AppController:
if path.lower().endswith(".xlsx"):
self._cleaning_result_df.to_excel(path, index=False)
else:
self._cleaning_result_df.to_csv(path, index=False)
self._cleaning_result_df.to_csv(path, index=False, sep="|")
mb.showinfo("Guardado", f"Archivo guardado correctamente:\n{path}")
except Exception as e:
logger.exception("Error guardando CSV limpio")
+155 -25
View File
@@ -12,7 +12,11 @@ from typing import Optional
import customtkinter as ctk
import pandas as pd
from application.csv_cleaning import CLEANING_RULES, ColumnSummary
from application.csv_cleaning import (
CLEANING_RULES,
ColumnSummary,
applicable_rule_ids_for_type,
)
from gui.styles import AppStyles
@@ -43,14 +47,24 @@ _TYPE_COLORS: dict[str, tuple[str, str]] = {
"empty": ("#6B7280", "#6B7280"),
}
# Orden fijo en el desplegable de anulación manual
_USER_EDITABLE_TYPE_IDS: tuple[str, ...] = (
"integer", "decimal", "date", "boolean", "categorical", "text", "mixed", "empty",
)
_TYPE_MENU_VALUES: list[str] = [
_TYPE_LABELS.get(t, t) for t in _USER_EDITABLE_TYPE_IDS
]
_RULE_SHORT_LABELS: dict[str, str] = {
"strip_special_spaces": "Sin especiales/espacios",
"strip_special_keep_date_seps": "Sin especiales (mantiene / - espacio)",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, espacios",
"strip_spaces": "Sin espacios",
"to_integer": "A entero",
"date_numeric_to_ddmmyyyy": "Fecha numérica → DD/MM/AAAA",
"date_alpha_to_ddmmyyyy": "Fecha alfanumérica → DD/MM/AAAA",
"date_messy_text_to_ddmmyyyy": "Fecha con ruido (texto) → DD/MM/AAAA",
"round_decimals_custom": "Aprox. decimales (>0.5 sube)",
"doc_id_split_tipo_numero": "Dividir doc (tipo + cifras)",
}
@@ -73,7 +87,10 @@ class _ColumnUI:
card: ctk.CTkFrame
body: ctk.CTkFrame
toggle_btn: ctk.CTkButton
type_menu: ctk.CTkOptionMenu
count_label: ctk.CTkLabel
stats_frame: ctk.CTkFrame
rules_frame: ctk.CTkFrame
rule_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]]
summary: ColumnSummary
expanded: bool = True
@@ -90,6 +107,8 @@ class CleaningTab:
self._column_uis: dict[str, _ColumnUI] = {}
self._search_var: Optional[ctk.StringVar] = None
# Tipo de dato forzado por el usuario (id interno) por columna; vacío = inferido
self._type_overrides: dict[str, str] = {}
self._setup_layout()
self._create_file_section()
@@ -275,13 +294,14 @@ class CleaningTab:
)
self.bulk_type_menu.pack(side="left", padx=(0, 6))
rule_options = [r["name"] for r in CLEANING_RULES]
self._bulk_rule_var = ctk.StringVar(value=rule_options[0])
self._bulk_rule_var = ctk.StringVar(value=CLEANING_RULES[0]["name"])
self.bulk_rule_menu = ctk.CTkOptionMenu(
bulk_row, values=rule_options, variable=self._bulk_rule_var,
bulk_row, values=[r["name"] for r in CLEANING_RULES], variable=self._bulk_rule_var,
width=380, font=self.fonts["ui"],
)
self.bulk_rule_menu.pack(side="left", padx=(0, 6))
self._sync_bulk_rule_menu()
self._bulk_type_var.trace_add("write", lambda *_: self._sync_bulk_rule_menu())
self.btn_bulk_apply = ctk.CTkButton(
bulk_row, text="Aplicar a todas", width=140, height=28,
@@ -314,8 +334,9 @@ class CleaningTab:
)
self.lbl_global_count.pack(side="right")
self.columns_container = ctk.CTkScrollableFrame(
frame, fg_color="transparent", height=420,
# Frame plano: el scroll único es el de `self.scroll` (evita doble rueda del ratón)
self.columns_container = ctk.CTkFrame(
frame, fg_color="transparent",
)
self.columns_container.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
self.columns_container.grid_columnconfigure(0, weight=1)
@@ -460,11 +481,33 @@ class CleaningTab:
self.btn_expand_all.configure(state=state)
self.btn_collapse_all.configure(state=state)
def _sync_bulk_rule_menu(self) -> None:
"""Acota las reglas del desplegable según el tipo (p. ej. entero: sin a entero/approx)."""
if not hasattr(self, "bulk_rule_menu") or not hasattr(
self, "_bulk_type_var",
):
return
type_label = self._bulk_type_var.get()
type_id = next(
(k for k, v in _TYPE_LABELS.items() if v == type_label), "text"
)
want = set(applicable_rule_ids_for_type(type_id))
names = [r["name"] for r in CLEANING_RULES if r["id"] in want]
if not names:
return
cur = self._bulk_rule_var.get()
self.bulk_rule_menu.configure(values=names)
if cur in names:
self._bulk_rule_var.set(cur)
else:
self._bulk_rule_var.set(names[0])
def populate_columns(self, summaries: list[ColumnSummary]):
"""Renderiza la lista de columnas con sus reglas aplicables."""
for child in list(self.columns_container.winfo_children()):
child.destroy()
self._column_uis.clear()
self._type_overrides.clear()
if self._search_var is not None:
try:
self._search_var.set("")
@@ -523,15 +566,28 @@ class CleaningTab:
)
toggle_btn.grid(row=0, column=0, padx=(0, 6), sticky="w")
type_color = _TYPE_COLORS.get(summary.inferred_type, ("gray60", "gray40"))
type_label = _TYPE_LABELS.get(summary.inferred_type, summary.inferred_type)
badge = ctk.CTkLabel(
header, text=type_label.upper(),
font=ctk.CTkFont(size=10, weight="bold"),
fg_color=type_color, text_color="white",
corner_radius=10, padx=10, pady=2,
eff0 = self._type_overrides.get(summary.name) or summary.inferred_type
if eff0 not in _TYPE_LABELS:
eff0 = "text"
tcol = _TYPE_COLORS.get(eff0, ("#64748B", "#64748B"))
tmenu = ctk.CTkOptionMenu(
header,
values=_TYPE_MENU_VALUES,
width=150,
height=28,
font=self.fonts["ui"],
corner_radius=10,
button_color=tcol[0] if tcol[0] else tcol[1],
button_hover_color=tcol[0],
fg_color=tcol[0] if tcol[0] else tcol[1],
)
tmenu.set(_TYPE_LABELS.get(eff0, _TYPE_LABELS["text"]))
tmenu.grid(row=0, column=1, padx=(0, 6), sticky="w")
tmenu.configure(
command=lambda v, c=summary.name, m=tmenu: self._on_user_column_type_change(
c, m, v
)
)
badge.grid(row=0, column=1, padx=(0, 10), sticky="w")
name_lbl = ctk.CTkLabel(
header, text=summary.name, font=self.fonts["bold_large"], anchor="w",
@@ -559,7 +615,8 @@ class CleaningTab:
)
lbl_example.grid(row=0, column=0, sticky="ew", padx=4, pady=(0, 4))
self._build_stats_chips(body, summary).grid(
stats_frame = self._build_stats_chips(body, summary, eff0)
stats_frame.grid(
row=1, column=0, sticky="ew", padx=4, pady=(0, 4),
)
@@ -572,8 +629,11 @@ class CleaningTab:
rules_frame.grid(row=3, column=0, sticky="ew", padx=0, pady=(2, 0))
rules_frame.grid_columnconfigure((0, 1), weight=1, uniform="rules")
applicable_ids = summary.applicable_rules or [r["id"] for r in CLEANING_RULES]
ordered_ids = [r["id"] for r in CLEANING_RULES if r["id"] in applicable_ids]
eff_rules = self._type_overrides.get(summary.name) or summary.inferred_type
if eff_rules not in _TYPE_LABELS:
eff_rules = "text"
applic = set(applicable_rule_ids_for_type(eff_rules))
ordered_ids = [r["id"] for r in CLEANING_RULES if r["id"] in applic]
vars_list: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = []
col_in_row = 0
@@ -597,8 +657,15 @@ class CleaningTab:
row_in_rules += 1
ui = _ColumnUI(
card=card, body=body, toggle_btn=toggle_btn,
count_label=count_lbl, rule_vars=vars_list, summary=summary,
card=card,
body=body,
toggle_btn=toggle_btn,
type_menu=tmenu,
count_label=count_lbl,
stats_frame=stats_frame,
rules_frame=rules_frame,
rule_vars=vars_list,
summary=summary,
)
toggle_btn.configure(command=lambda c=summary.name: self._toggle_column(c))
return ui
@@ -607,9 +674,14 @@ class CleaningTab:
# Stats visuales por columna
# ===========================================================================
def _build_stats_chips(self, parent, summary: ColumnSummary) -> ctk.CTkFrame:
def _build_stats_chips(
self, parent, summary: ColumnSummary, display_type: Optional[str] = None
) -> ctk.CTkFrame:
"""Construye una fila de chips con la información clave de la columna."""
row = ctk.CTkFrame(parent, fg_color="transparent")
dtype = (display_type or summary.inferred_type) or "text"
if dtype not in _TYPE_LABELS:
dtype = "text"
# Sin datos
nulls_color = (
@@ -638,7 +710,7 @@ class CleaningTab:
).pack(side="left", padx=(0, 6), pady=2)
# Stats por tipo
if summary.inferred_type in ("integer", "decimal"):
if dtype in ("integer", "decimal"):
if summary.min is not None:
self._make_stat_chip(
row, label="Mín", value=_fmt_number(summary.min),
@@ -654,7 +726,7 @@ class CleaningTab:
row, label="Promedio", value=_fmt_number(summary.mean),
bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"),
).pack(side="left", padx=(0, 6), pady=2)
elif summary.inferred_type == "date":
elif dtype == "date":
if summary.detected_date_format:
self._make_stat_chip(
row, label="Formato detectado",
@@ -662,7 +734,7 @@ class CleaningTab:
bg=("#FFEDD5", "#7C2D12"),
text_color=("#9A3412", "#FED7AA"),
).pack(side="left", padx=(0, 6), pady=2)
elif summary.inferred_type in ("text", "categorical"):
elif dtype in ("text", "categorical"):
if summary.avg_length is not None:
self._make_stat_chip(
row, label="Longitud promedio",
@@ -736,6 +808,64 @@ class CleaningTab:
# Lógica interna
# ===========================================================================
def _effective_inferred_type(self, ui: _ColumnUI) -> str:
"""Tipo mostrado para la columna: anulación del usuario o inferido."""
t = self._type_overrides.get(ui.summary.name) or ui.summary.inferred_type
return t if t in _TYPE_LABELS else "text"
def _on_user_column_type_change(
self, col_name: str, type_menu: ctk.CTkOptionMenu, spanish_label: str
) -> None:
type_id = next(
(k for k, v in _TYPE_LABELS.items() if v == spanish_label), "text"
)
ui = self._column_uis.get(col_name)
if ui is None:
return
s = ui.summary
if type_id == s.inferred_type:
self._type_overrides.pop(col_name, None)
else:
self._type_overrides[col_name] = type_id
eff = self._effective_inferred_type(ui)
tcol = _TYPE_COLORS.get(eff, ("#64748B", "#64748B"))
type_menu.configure(
button_color=tcol[0] if tcol[0] else tcol[1],
button_hover_color=tcol[0],
fg_color=tcol[0] if tcol[0] else tcol[1],
)
ui.stats_frame.destroy()
new_stats = self._build_stats_chips(ui.body, s, eff)
new_stats.grid(row=1, column=0, sticky="ew", padx=4, pady=(0, 4))
ui.stats_frame = new_stats
for w in list(ui.rules_frame.winfo_children()):
w.destroy()
applic = set(applicable_rule_ids_for_type(eff))
new_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = []
col_in_row = 0
row_in_rules = 0
rmap = {r["id"]: r for r in CLEANING_RULES}
for rid in (r["id"] for r in CLEANING_RULES if r["id"] in applic):
rule = rmap.get(rid)
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add("write", lambda *_a, c=col_name: self._on_rule_toggle(c))
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
ui.rules_frame, text=label, variable=var,
font=self.fonts["ui"], checkbox_width=18, checkbox_height=18,
)
chk.grid(row=row_in_rules, column=col_in_row, sticky="w", padx=10, pady=4)
new_vars.append((rid, chk, var))
col_in_row += 1
if col_in_row >= 2:
col_in_row = 0
row_in_rules += 1
ui.rule_vars = new_vars
self.set_status(f"Columna «{col_name}»: tipo «{_TYPE_LABELS.get(eff, eff)}».")
self._on_rule_toggle(col_name)
def _toggle_column(self, col_name: str):
ui = self._column_uis.get(col_name)
if ui is None:
@@ -804,7 +934,7 @@ class CleaningTab:
matched = 0
for ui in self._column_uis.values():
if ui.summary.inferred_type != type_id:
if self._effective_inferred_type(ui) != type_id:
continue
for rid, _chk, var in ui.rule_vars:
if rid == rule_id:
+47 -1
View File
@@ -1,5 +1,6 @@
"""Pestaña de Resultados de la aplicación GUI."""
import os
import tkinter as tk
import customtkinter as ctk
import tkinter.ttk as ttk
from gui.styles import AppStyles
@@ -88,6 +89,43 @@ class ResultsTab:
self.btn_res_search = ctk.CTkButton(self.frame_res_filters, text="Buscar", width=100)
self.btn_res_search.grid(row=0, column=5, padx=10, pady=5)
self.lbl_res_priority = ctk.CTkLabel(
self.frame_res_filters,
text=(
"Filtros estrictos: se combinan (AND) con importancia: "
"Run > estado (MATCH, etc.) > criterio de búsqueda. "
"Puede ampliar la búsqueda (sin tocar el run) abajo."
),
text_color=("#5C6370", "#9CA3AF"),
font=ctk.CTkFont(size=12),
anchor="w",
wraplength=920,
)
self.lbl_res_priority.grid(
row=1, column=0, columnspan=6, padx=5, pady=(4, 0), sticky="w"
)
self._res_relax_var = tk.BooleanVar(value=False)
self.chk_res_relax = ctk.CTkCheckBox(
self.frame_res_filters,
text="Si 0 resultados, ampliar: quitar criterio de búsqueda, luego estado (el run fijado no se descarta).",
font=ctk.CTkFont(size=12),
variable=self._res_relax_var,
)
self.chk_res_relax.grid(
row=2, column=0, columnspan=6, padx=5, pady=4, sticky="w"
)
self.lbl_res_filter_note = ctk.CTkLabel(
self.frame_res_filters,
text="",
text_color=("#1D4ED8", "#60A5FA"),
font=ctk.CTkFont(size=12),
anchor="w",
wraplength=920,
)
self.lbl_res_filter_note.grid(
row=3, column=0, columnspan=6, padx=5, pady=(0, 4), sticky="w"
)
def _create_tables_section(self):
"""Crea la sección con las tablas (principal y de detalle)."""
@@ -238,9 +276,17 @@ class ResultsTab:
return {
"run_id": self.combo_res_run.get(),
"status": self.combo_res_status.get(),
"text": self.entry_res_search.get()
"text": self.entry_res_search.get(),
"relax_on_empty": bool(self._res_relax_var.get()),
}
def set_filter_note(self, text: str) -> None:
"""Mensaje bajo ampliar (p. ej. qué criterio se relajó). Texto vacío la limpia."""
self.lbl_res_filter_note.configure(text=(text or "").strip())
def clear_filter_note(self) -> None:
self.set_filter_note("")
def update_results_table(self, rows):
"""Actualiza la tabla de resultados con los datos proporcionados."""
for item in self.tree_results.get_children():
+18 -14
View File
@@ -39,12 +39,12 @@ class InvoiceAppView(ctk.CTk):
self.tabs.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
self.tab_main = self.tabs.add("Procesar Documentos")
self.tab_cleaning = self.tabs.add("Proceso de Limpieza")
self.tab_preclass = self.tabs.add("Preclasificación")
self.tab_results = self.tabs.add("Resultados")
self.tab_settings = self.tabs.add("Configuración")
self.tab_usage = self.tabs.add("Dashboard & Métricas")
self.tab_postprocess = self.tabs.add("Post-Procesamiento")
self.tab_cleaning = self.tabs.add("Proceso de Limpieza")
# =========================================================================
# --- TAB: PROCESAR DOCUMENTOS ---
@@ -52,21 +52,24 @@ class InvoiceAppView(ctk.CTk):
self.main_tab = MainProcessingTab(self.tab_main)
# =========================================================================
# --- TAB: RESULTADOS ---
# --- TAB: PROCESO DE LIMPIEZA ---
# =========================================================================
self.results_tab = ResultsTab(self.tab_results)
# =========================================================================
# --- TAB: CONFIGURACIÓN ---
# =========================================================================
self.settings_tab = SettingsTab(self.tab_settings)
self.cleaning_tab = CleaningTab(self.tab_cleaning)
# =========================================================================
# --- TAB: PRECLASIFICACIÓN ---
# =========================================================================
self.preclass_tab = PreclassificationTab(self.tab_preclass)
# =========================================================================
# --- TAB: RESULTADOS ---
# =========================================================================
self.results_tab = ResultsTab(self.tab_results)
# =========================================================================
# --- TAB: CONFIGURACIÓN ---
# =========================================================================
self.settings_tab = SettingsTab(self.tab_settings)
# =========================================================================
# --- TAB: DASHBOARD & MÉTRICAS ---
@@ -78,11 +81,6 @@ class InvoiceAppView(ctk.CTk):
# =========================================================================
self.postprocess_tab = PostProcessTab(self.tab_postprocess)
# =========================================================================
# --- TAB: PROCESO DE LIMPIEZA ---
# =========================================================================
self.cleaning_tab = CleaningTab(self.tab_cleaning)
# ---------------------------------------------------------------------------
# MÉTODOS PÚBLICOS (API COMPATIBLE)
@@ -161,6 +159,12 @@ class InvoiceAppView(ctk.CTk):
def get_res_filters(self):
return self.results_tab.get_filters()
def set_res_filter_note(self, text: str) -> None:
self.results_tab.set_filter_note(text)
def clear_res_filter_note(self) -> None:
self.results_tab.clear_filter_note()
def update_res_table(self, rows):
self.results_tab.update_results_table(rows)