feat: Enhance CSV cleaning and management functionalities by adding new date normalization rules, improving document type homologation, and implementing robust sanitization for CSV cell values. Update GUI components to support loading indicators and status messages during CSV processing, ensuring a smoother user experience.

This commit is contained in:
erik
2026-05-04 01:54:06 -05:00
parent 37d32a5a96
commit e2536380d4
7 changed files with 864 additions and 72 deletions
+52 -16
View File
@@ -1201,35 +1201,71 @@ class AppController:
if not filepath:
return
try:
df, delimiter = csv_cleaning.load_csv(filepath)
except Exception as e:
logger.exception("Error leyendo CSV para limpieza")
mb.showerror("Error", f"No se pudo leer el CSV:\n{e}")
return
self._cleaning_df = df
self._cleaning_result_df = None
self.view.set_cleaning_csv_path(filepath)
self.view.enable_cleaning_controls(False)
self.view.enable_cleaning_save(False)
self.view.set_cleaning_result(None)
self.view.set_cleaning_preview(None)
self.view.set_cleaning_status("Cargando CSV...")
try:
self.view.set_cleaning_loading(True, "Leyendo archivo CSV...")
except Exception:
pass
def worker():
try:
df, delimiter = csv_cleaning.load_csv(filepath)
self.view.after(
0,
lambda d=df, delim=delimiter: self._on_cleaning_csv_loaded(
d, delim
),
)
summaries = csv_cleaning.analyze_dataframe(df)
self._cleaning_df = df
self.view.after(
0,
lambda d=df, s=summaries: self._finish_cleaning_csv_load(d, s),
)
except Exception as e:
logger.exception("Error leyendo o analizando CSV para limpieza")
err = str(e)
self.view.after(0, lambda m=err: self._fail_cleaning_csv_load(m))
threading.Thread(target=worker, daemon=True).start()
def _on_cleaning_csv_loaded(self, df: pd.DataFrame, delimiter: str):
self.view.set_cleaning_status(
f"CSV cargado: {len(df):,} filas x {len(df.columns)} columnas."
f"CSV leído: {len(df):,} filas x {len(df.columns)} columnas. Analizando columnas..."
)
try:
self.view.set_cleaning_loading(True, "Analizando columnas...")
self.view.set_cleaning_delimiter_chip(delimiter)
except Exception:
pass
try:
summaries = csv_cleaning.analyze_dataframe(df)
except Exception as e:
logger.exception("Error analizando CSV para limpieza")
mb.showerror("Error", f"No se pudo analizar el CSV:\n{e}")
return
def _finish_cleaning_csv_load(self, df: pd.DataFrame, summaries):
self.view.populate_cleaning_columns(summaries)
self.view.set_cleaning_preview(df)
self.view.set_cleaning_status(
f"CSV cargado: {len(df):,} filas x {len(df.columns)} columnas."
)
try:
self.view.set_cleaning_loading(False)
except Exception:
pass
self.view.enable_cleaning_controls(True)
def _fail_cleaning_csv_load(self, message: str):
self._cleaning_df = None
self.view.set_cleaning_status("Error al cargar CSV")
try:
self.view.set_cleaning_loading(False)
except Exception:
pass
self.view.enable_cleaning_controls(True)
mb.showerror("Error", f"No se pudo cargar o analizar el CSV:\n{message}")
def on_cleaning_apply(self):
"""Aplica las reglas seleccionadas en un hilo y actualiza el resultado."""
+72 -5
View File
@@ -56,15 +56,17 @@ _TYPE_MENU_VALUES: list[str] = [
]
_RULE_SHORT_LABELS: dict[str, str] = {
"strip_special_spaces": "Sin especiales/espacios",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, espacios",
"strip_special_spaces": "Sin especiales/espacios (no antes de fecha+hora)",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, :, espacios",
"strip_spaces": "Sin espacios",
"to_integer": "A entero",
"date_to_ddmmyyyy": "Normalizar fecha → DD/MM/AAAA",
"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)",
"doc_type_homologate": "Homologar tipo doc",
}
@@ -109,6 +111,7 @@ class CleaningTab:
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._syncing_rule_vars = False
self._setup_layout()
self._create_file_section()
@@ -163,6 +166,27 @@ class CleaningTab:
self.chip_delim = self._make_chip(chips_frame, "Delim: —")
self.chip_delim.pack(side="left")
self.loading_frame = ctk.CTkFrame(frame, fg_color="transparent")
self.loading_frame.grid(
row=2, column=0, columnspan=4, sticky="ew", padx=15, pady=(0, 12)
)
self.loading_frame.grid_columnconfigure(1, weight=1)
self.loading_label = ctk.CTkLabel(
self.loading_frame,
text="",
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
anchor="w",
)
self.loading_label.grid(row=0, column=0, sticky="w", padx=(0, 10))
self.loading_progress = ctk.CTkProgressBar(
self.loading_frame,
mode="indeterminate",
height=8,
)
self.loading_progress.grid(row=0, column=1, sticky="ew")
self.loading_frame.grid_remove()
def _make_chip(self, parent, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(
parent, text=text, font=self.fonts["italic_small"],
@@ -429,6 +453,23 @@ class CleaningTab:
def set_status(self, text: str):
self.lbl_status.configure(text=text)
def set_loading(self, loading: bool, text: str = ""):
"""Muestra u oculta una barra indeterminada para operaciones de CSV."""
if loading:
self.loading_label.configure(text=text or "Procesando CSV...")
self.loading_frame.grid()
try:
self.loading_progress.start()
except Exception:
pass
else:
try:
self.loading_progress.stop()
except Exception:
pass
self.loading_label.configure(text="")
self.loading_frame.grid_remove()
def set_preview(self, df: Optional[pd.DataFrame]):
"""Renderiza el DataFrame original en la tabla de vista previa."""
if df is None or len(df) == 0:
@@ -643,7 +684,10 @@ class CleaningTab:
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add("write", lambda *_a, c=summary.name: self._on_rule_toggle(c))
var.trace_add(
"write",
lambda *_a, c=summary.name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
rules_frame, text=label, variable=var,
@@ -850,7 +894,10 @@ class CleaningTab:
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add("write", lambda *_a, c=col_name: self._on_rule_toggle(c))
var.trace_add(
"write",
lambda *_a, c=col_name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
ui.rules_frame, text=label, variable=var,
@@ -883,10 +930,30 @@ class CleaningTab:
if ui.expanded != expanded:
self._toggle_column(col_name)
def _on_rule_toggle(self, col_name: str):
def _on_rule_toggle(self, col_name: str, changed_rule_id: Optional[str] = None):
ui = self._column_uis.get(col_name)
if ui is None:
return
if (
changed_rule_id == "doc_id_split_tipo_numero"
and not self._syncing_rule_vars
):
split_selected = any(
rid == "doc_id_split_tipo_numero" and bool(var.get())
for rid, _chk, var in ui.rule_vars
)
if split_selected:
for rid, _chk, var in ui.rule_vars:
if rid == "doc_type_homologate" and not bool(var.get()):
self._syncing_rule_vars = True
try:
var.set(True)
finally:
self._syncing_rule_vars = False
self.set_status(
f"Columna «{col_name}»: se activó homologación de tipo de documento."
)
break
selected = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get()))
text = f"{selected} regla{'s' if selected != 1 else ''}"
color = self.colors["success"] if selected else self.colors["gray_light"]
+3
View File
@@ -399,5 +399,8 @@ class InvoiceAppView(ctk.CTk):
def set_cleaning_status(self, text: str):
self.cleaning_tab.set_status(text)
def set_cleaning_loading(self, loading: bool, text: str = ""):
self.cleaning_tab.set_loading(loading, text)
def set_cleaning_delimiter_chip(self, delimiter: str):
self.cleaning_tab.set_delimiter_chip(delimiter)