Files
IPSExtractor/gui/tabs/main_tab.py
T

575 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pestaña Procesar Documentos de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
from application.app_prefs import get_allowed_document_types
class MainProcessingTab:
"""Encapsula la UI de la pestaña Procesar Documentos."""
def __init__(self, parent_tab):
"""Inicializa la pestaña de Procesar Documentos.
Args:
parent_tab: El CTkFrame que representa la pestaña.
"""
self.tab = parent_tab
self.fonts = AppStyles.get_fonts()
self.colors = AppStyles.get_colors()
self.spacing = AppStyles.get_spacing()
# Variables internas
self._doc_type_rows = []
self._setup_layout()
self._create_paths_section()
self._create_config_section()
self._create_process_button()
self._create_output_section()
# Conectar el trace después de que todos los widgets existen
self.mode_var.trace_add("write", lambda *_: self.update_ui_for_mode())
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.scroll_container = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.scroll_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.scroll_container.grid_columnconfigure(0, weight=1)
def _create_paths_section(self):
"""Crea la sección de rutas y archivos."""
self.frame_paths = ctk.CTkFrame(self.scroll_container)
self.frame_paths.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 2))
self.frame_paths.grid_columnconfigure(1, weight=1)
# Título
lbl_paths = ctk.CTkLabel(
self.frame_paths,
text="Rutas y Archivos",
font=self.fonts["section"]
)
lbl_paths.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(5, 2))
# Carpeta Raíz
self.root_label = ctk.CTkLabel(self.frame_paths, text="Carpeta Raíz:", font=self.fonts["ui"])
self.root_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.root_entry = ctk.CTkEntry(
self.frame_paths,
placeholder_text="Selecciona la carpeta con los documentos...",
font=self.fonts["ui"]
)
self.root_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
self.root_button = ctk.CTkButton(self.frame_paths, text="Explorar...", font=self.fonts["ui"], width=100)
self.root_button.grid(row=1, column=2, padx=15, pady=2)
# CSV Salida
self.csv_label = ctk.CTkLabel(self.frame_paths, text="Archivo CSV:", font=self.fonts["ui"])
self.csv_label.grid(row=2, column=0, padx=15, pady=2, sticky="w")
self.csv_entry = ctk.CTkEntry(
self.frame_paths,
placeholder_text="Ruta para guardar el reporte CSV...",
font=self.fonts["ui"]
)
self.csv_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
self.csv_button = ctk.CTkButton(
self.frame_paths,
text="Guardar en...",
font=self.fonts["ui"],
width=100,
fg_color="transparent",
border_width=1
)
self.csv_button.grid(row=2, column=2, padx=15, pady=2)
# Selector de Modo (Radicados vs General)
self.mode_var = ctk.StringVar(value="radicados")
lbl_mode = ctk.CTkLabel(self.frame_paths, text="Estructura:", font=self.fonts["ui"])
lbl_mode.grid(row=3, column=0, padx=15, pady=2, sticky="w")
self.frame_radios_main = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
self.frame_radios_main.grid(row=3, column=1, columnspan=2, sticky="w", padx=5, pady=2)
self.radio_radicados_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Por Radicados (Subcarpetas)",
variable=self.mode_var,
value="radicados",
font=self.fonts["ui"]
)
self.radio_radicados_main.pack(side="left", padx=(0, 20))
self.radio_general_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Carpeta General (Archivos Planos)",
variable=self.mode_var,
value="general",
font=self.fonts["ui"]
)
self.radio_general_main.pack(side="left")
# Filtro por Radicados
self.filter_enabled_var = ctk.BooleanVar(value=False)
self.filter_check = ctk.CTkCheckBox(
self.frame_paths,
text="Procesar solo radicados seleccionados",
variable=self.filter_enabled_var,
font=self.fonts["ui"],
command=self._on_filter_toggle
)
self.filter_check.grid(row=4, column=0, columnspan=3, padx=15, pady=(4, 0), sticky="w")
self.filter_frame = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
self.filter_frame.grid(row=5, column=0, columnspan=3, padx=15, pady=(2, 6), sticky="ew")
self.filter_frame.grid_columnconfigure(0, weight=1)
filter_hint = ctk.CTkLabel(
self.filter_frame,
text="Ingresa los radicados a procesar (uno por línea o separados por coma):",
font=self.fonts["ui"],
text_color=self.colors["text_muted"]
)
filter_hint.grid(row=0, column=0, sticky="w", pady=(0, 2))
self.filter_textbox = ctk.CTkTextbox(
self.filter_frame,
height=80,
font=self.fonts["mono"],
)
self.filter_textbox.grid(row=1, column=0, sticky="ew")
# Ocultar hasta que se active el checkbox
self.filter_frame.grid_remove()
def _on_filter_toggle(self):
"""Muestra u oculta el área de texto del filtro."""
if self.filter_enabled_var.get():
self.filter_frame.grid()
else:
self.filter_frame.grid_remove()
def _create_config_section(self):
"""Crea la sección de configuración de tipos de documento y TIFF."""
self.frame_config = ctk.CTkFrame(self.scroll_container)
self.frame_config.grid(row=1, column=0, sticky="ew", padx=10, pady=2)
self.frame_config.grid_columnconfigure(0, weight=1)
# Keywords oculto (legacy)
self.kw_label = ctk.CTkLabel(self.frame_config, text="Keywords:")
self.kw_entry = ctk.CTkEntry(self.frame_config)
self.kw_entry.insert(0, "Factura_O_Documento_Equivalente,factura")
# Tipos de Documento
self.doc_types_container = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
self.doc_types_container.grid_columnconfigure(0, weight=1)
self.doc_types_header = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_header.grid(row=0, column=0, sticky="ew", pady=(0, 2))
self.doc_types_header.grid_columnconfigure(0, weight=1)
self.doc_types_label = ctk.CTkLabel(
self.doc_types_header,
text="Definición de Tipos de Documento",
font=self.fonts["section"]
)
self.doc_types_label.grid(row=0, column=0, sticky="w")
allowed_types_text = ", ".join(get_allowed_document_types())
self.doc_types_hint = ctk.CTkLabel(
self.doc_types_header,
text=f"Tipos válidos para documento_forzate: {allowed_types_text}. NIT no aplica.",
font=self.fonts["ui"],
text_color=self.colors["text_muted"],
)
self.doc_types_hint.grid(row=1, column=0, sticky="w", pady=(2, 0))
self.add_doc_type_btn = ctk.CTkButton(
self.doc_types_header,
text="+ Añadir Tipo",
font=self.fonts["ui"],
width=120,
height=28
)
self.add_doc_type_btn.grid(row=0, column=1, rowspan=2, sticky="e")
self.add_doc_type_btn.configure(command=self._add_doc_type_row)
# Lista de Tipos
self.doc_types_list = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_list.grid(row=1, column=0, sticky="ew", pady=(2, 0))
self.doc_types_list.grid_columnconfigure(0, weight=1)
# Fila por defecto
self._add_doc_type_row(name="Factura", keyword="Factura_O_Documento_Equivalente")
# Configuración Avanzada (TIFF Range)
self.frame_advanced = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.frame_advanced.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 2))
self.frame_advanced.grid_columnconfigure(0, weight=1)
self.retry_empty_var = ctk.BooleanVar(value=False)
self.retry_empty_check = ctk.CTkCheckBox(
self.frame_advanced,
text="Reintentar si la fila sale vacía (máx. 2 intentos)",
variable=self.retry_empty_var,
font=self.fonts["ui"]
)
self.retry_empty_check.grid(row=0, column=0, columnspan=4, padx=5, pady=(0, 6), sticky="w")
self.tiff_start_label = ctk.CTkLabel(
self.frame_advanced,
text="Pág. Inicio TIFF:",
font=self.fonts["ui"]
)
self.tiff_start_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_start_entry.insert(0, "0")
self.tiff_end_label = ctk.CTkLabel(
self.frame_advanced,
text="Pág. Fin TIFF:",
font=self.fonts["ui"]
)
self.tiff_end_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_end_entry.insert(0, "0")
# Ocultar inicialmente
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
# Campos legacy ocultos
self.fields_entry = ctk.CTkEntry(self.tab)
self.fields_label = ctk.CTkLabel(self.tab, text="")
def _create_process_button(self):
"""Crea el botón de acción principal."""
self.process_button = ctk.CTkButton(
self.scroll_container,
text="INICIAR PROCESAMIENTO",
font=self.fonts["bold_large"],
height=35,
fg_color=self.colors["primary_button"],
hover_color=self.colors["primary_hover"]
)
self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew")
def _create_output_section(self):
"""Crea la sección de logs/salida."""
self.log_frame = ctk.CTkFrame(self.scroll_container)
self.log_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(0, 15))
self.log_frame.grid_columnconfigure(0, weight=1)
lbl_logs = ctk.CTkLabel(
self.log_frame,
text="Registro de Actividad:",
font=self.fonts["ui"],
text_color=self.colors["text_muted"]
)
lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
self.output_text = ctk.CTkTextbox(
self.log_frame,
font=self.fonts["mono"],
activate_scrollbars=True,
height=150
)
self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None:
"""Añade una fila de tipo de documento."""
row = ctk.CTkFrame(self.doc_types_list, fg_color="transparent")
row.pack(fill="x", pady=2)
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
grid_inner.pack(fill="x", padx=5, pady=2)
grid_inner.grid_columnconfigure(0, weight=1)
grid_inner.grid_columnconfigure(1, weight=1)
name_entry = ctk.CTkEntry(
grid_inner,
placeholder_text="Nombre Lógico",
height=28,
font=self.fonts["ui"]
)
if name:
name_entry.insert(0, name)
name_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
kw_entry = ctk.CTkEntry(
grid_inner,
placeholder_text="Patrón / Keyword",
height=28,
font=self.fonts["ui"]
)
if keyword:
kw_entry.insert(0, keyword)
kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew")
def _remove():
# Buscar y eliminar de la lista interna
for i, r_data in enumerate(self._doc_type_rows):
if r_data["frame"] == row:
self._doc_type_rows.pop(i)
break
row.destroy()
remove_btn = ctk.CTkButton(
grid_inner,
text="×",
width=30,
height=28,
fg_color=self.colors["danger"],
hover_color=self.colors["danger_hover"],
command=_remove
)
remove_btn.grid(row=0, column=2, sticky="e")
self._doc_type_rows.append({
"frame": row,
"name_entry": name_entry,
"kw_entry": kw_entry,
"remove_btn": remove_btn
})
# --- API Pública ---
def set_on_select_root(self, callback):
"""Vincula callback al botón de selección de carpeta raíz."""
self.root_button.configure(command=callback)
def set_on_select_csv(self, callback):
"""Vincula callback al botón de selección de CSV."""
self.csv_button.configure(command=callback)
def set_on_process(self, callback):
"""Vincula callback al botón de procesar."""
self.process_button.configure(command=callback)
def set_on_mode_change(self, callback):
"""Vincula callback al cambio de modo."""
self.mode_var.trace("w", lambda *args: callback())
def get_root_path(self) -> str:
"""Devuelve la ruta raíz seleccionada."""
return self.root_entry.get().strip()
def set_root_path(self, path: str) -> None:
"""Establece la ruta raíz."""
self.root_entry.configure(state="normal")
self.root_entry.delete(0, "end")
self.root_entry.insert(0, path)
def get_csv_path(self) -> str:
"""Devuelve la ruta del CSV."""
return self.csv_entry.get().strip()
def set_csv_path(self, path: str) -> None:
"""Establece la ruta del CSV."""
self.csv_entry.configure(state="normal")
self.csv_entry.delete(0, "end")
self.csv_entry.insert(0, path)
def get_process_mode(self) -> str:
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
try:
v = (self.mode_var.get() or "").strip().lower()
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def set_process_mode(self, mode: str) -> None:
"""Establece el modo de procesamiento y refresca la UI."""
try:
normalized = (mode or "").strip().lower()
self.mode_var.set(normalized if normalized in ("radicados", "general") else "radicados")
self.update_ui_for_mode()
except Exception:
self.mode_var.set("radicados")
self.update_ui_for_mode()
def get_keywords(self) -> str:
"""Devuelve los keywords."""
return self.kw_entry.get().strip()
def get_fields(self) -> str:
"""Devuelve los fields (legacy)."""
return self.fields_entry.get().strip()
def get_tiff_start_page(self) -> int:
"""Devuelve la página de inicio TIFF."""
try:
value = self.tiff_start_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def get_tiff_end_page(self) -> int:
"""Devuelve la página final TIFF."""
try:
value = self.tiff_end_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def get_retry_empty_enabled(self) -> bool:
"""Indica si se debe reintentar cuando la fila solo trae el radicado."""
try:
return bool(self.retry_empty_var.get())
except Exception:
return False
def set_retry_empty_enabled(self, enabled: bool) -> None:
"""Establece el reintento automático por fila vacía."""
self.retry_empty_var.set(bool(enabled))
def get_radicado_filter_enabled(self) -> bool:
"""Indica si el filtro de radicados está activo."""
try:
return bool(self.filter_enabled_var.get())
except Exception:
return False
def get_radicado_filter_list(self) -> list[str]:
"""Devuelve la lista de radicados del filtro (sin blancos ni duplicados)."""
try:
raw = self.filter_textbox.get("1.0", "end")
items = []
for part in raw.replace(",", "\n").splitlines():
s = part.strip()
if s:
items.append(s)
return list(dict.fromkeys(items)) # preserva orden, elimina duplicados
except Exception:
return []
def set_radicado_filter_enabled(self, enabled: bool) -> None:
"""Activa o desactiva el filtro y refresca la UI."""
self.filter_enabled_var.set(bool(enabled))
self._on_filter_toggle()
def set_radicado_filter_list(self, radicados: list[str]) -> None:
"""Carga una lista de radicados en el área de texto del filtro."""
try:
self.filter_textbox.delete("1.0", "end")
self.filter_textbox.insert("1.0", "\n".join(radicados))
except Exception:
pass
def show_tiff_range_controls(self, show: bool) -> None:
"""Muestra u oculta los controles de rango TIFF."""
if show:
self.tiff_start_label.grid(row=0, column=0, padx=5, pady=5)
self.tiff_start_entry.grid(row=0, column=1, padx=5, pady=5)
self.tiff_end_label.grid(row=0, column=2, padx=5, pady=5)
self.tiff_end_entry.grid(row=0, column=3, padx=5, pady=5)
else:
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
def get_doc_types(self) -> list[dict]:
"""Devuelve la lista de tipos de documento."""
out = []
for it in self._doc_type_rows:
try:
name = it["name_entry"].get().strip()
kw = it["kw_entry"].get().strip()
if name and kw:
out.append({"name": name, "keyword": kw})
except:
pass
return out
def set_doc_types(self, doc_types: list[dict]) -> None:
"""Establece los tipos de documento."""
for it in self._doc_type_rows:
it["frame"].destroy()
self._doc_type_rows = []
if not doc_types:
self._add_doc_type_row("Factura", "Factura_O_Documento_Equivalente")
return
for dt in doc_types:
self._add_doc_type_row(str(dt.get("name", "")), str(dt.get("keyword", "")))
def validate_doc_types(self) -> tuple[bool, str | None]:
"""Valida los tipos de documento."""
seen = set()
for dt in self.get_doc_types():
name = dt.get("name")
if name in seen:
return False, f"Nombre duplicado: {name}"
seen.add(name)
if not seen:
return False, "Debe haber al menos un tipo de documento."
return True, None
def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None:
"""Actualiza el texto de ayuda de tipos válidos."""
text = ", ".join(allowed_types)
self.doc_types_hint.configure(text=f"Tipos válidos para documento_forzate: {text}. NIT no aplica.")
def update_ui_for_mode(self):
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
mode = self.get_process_mode()
if mode == "general":
# Ocultar Tipos de Documento
self.doc_types_container.grid_forget()
# Mostrar Keywords (opcional, para filtrar)
self.kw_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.kw_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
# Limpiar valor por defecto si existe
curr = self.kw_entry.get()
if "Factura_O_Documento_Equivalente" in curr:
self.kw_entry.delete(0, "end")
else:
# Mostrar Tipos de Documento
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
# Ocultar Keywords
self.kw_label.grid_forget()
self.kw_entry.grid_forget()
def append_output(self, text: str) -> None:
"""Añade texto al área de salida."""
self.output_text.insert("end", text + "\n")
self.output_text.see("end")
def append_output_safe(self, text: str) -> None:
"""Añade texto al área de salida de forma thread-safe."""
# Nota: se espera que sea llamado con self.after(0, ...)
self.append_output(text)
def enable_controls(self, enabled: bool):
"""Habilita o deshabilita los controles."""
state = "normal" if enabled else "disabled"
self.process_button.configure(state=state, text="PROCESANDO..." if not enabled else "INICIAR PROCESAMIENTO")
if not enabled:
self.process_button.configure(fg_color="gray")
else:
self.process_button.configure(fg_color=self.colors["primary_button"])
self.root_button.configure(state=state)
self.root_entry.configure(state=state)
self.csv_button.configure(state=state)
self.csv_entry.configure(state=state)
self.add_doc_type_btn.configure(state=state)
self.retry_empty_check.configure(state=state)
self.filter_check.configure(state=state)
self.filter_textbox.configure(state=state)
for row in self._doc_type_rows:
row["name_entry"].configure(state=state)
row["kw_entry"].configure(state=state)
row["remove_btn"].configure(state=state)