diff --git a/application/app_prefs.py b/application/app_prefs.py new file mode 100644 index 0000000..a623b1c --- /dev/null +++ b/application/app_prefs.py @@ -0,0 +1,54 @@ +"""Persistencia simple de preferencias de la GUI.""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Dict + +from .config import DEFAULT_ALLOWED_DOCUMENT_TYPES + + +logger = logging.getLogger(__name__) +PREFS_FILE = Path(__file__).resolve().parent.parent / "app_prefs.json" + + +def load_app_prefs() -> Dict[str, Any]: + """Carga preferencias desde disco si existen.""" + try: + if not PREFS_FILE.exists(): + return {} + raw = PREFS_FILE.read_text(encoding="utf-8") + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + logger.exception("No se pudieron cargar las preferencias de la app") + return {} + + +def save_app_prefs(data: Dict[str, Any]) -> None: + """Guarda preferencias de la app en JSON.""" + try: + PREFS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + logger.exception("No se pudieron guardar las preferencias de la app") + + +def normalize_document_types(values: Any) -> list[str]: + """Normaliza una lista editable de tipos documentales.""" + raw_values = values if isinstance(values, list) else [] + normalized: list[str] = [] + seen: set[str] = set() + for item in raw_values: + text = str(item or "").strip().upper() + if not text or text == "NIT" or text in seen: + continue + seen.add(text) + normalized.append(text) + return normalized or list(DEFAULT_ALLOWED_DOCUMENT_TYPES) + + +def get_allowed_document_types() -> tuple[str, ...]: + """Devuelve la lista persistida de tipos documentales válidos.""" + prefs = load_app_prefs() + return tuple(normalize_document_types(prefs.get("allowed_document_types"))) diff --git a/application/batch_processor.py b/application/batch_processor.py index 9813438..fe84f60 100644 --- a/application/batch_processor.py +++ b/application/batch_processor.py @@ -18,11 +18,23 @@ from application.costs import compute_cost_from_usage, accumulate_usage from application.file_discovery import get_sorted_radicados, get_files_as_radicados from application.csv_manager import CSVManager from application.config import settings +from application.app_prefs import get_allowed_document_types from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat from core.csv_utils import rows_from_data logger = logging.getLogger(__name__) +DOCUMENT_FORCE_TARGETS = { + "tipo de documento", + "documento_forzate", +} + +DOCUMENT_TYPE_HINT_ALIASES = { + "PT", + "PAS", + "PASS", +} + class BatchProcessor: def __init__(self, service: 'InvoiceProcessingService'): """ @@ -51,6 +63,8 @@ class BatchProcessor: alias_map: Optional[Dict[str, str]] = None, tiff_page_range: Optional[Tuple[int, int]] = None, structure_mode: str = "radicados", + retry_on_empty: bool = False, + radicado_filter: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Procesa radicados con selección de campos y CSV incremental. @@ -77,7 +91,9 @@ class BatchProcessor: on_progress=on_progress, on_usage=on_usage, on_error_retry=on_error_retry, - structure_mode=structure_mode + structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, )) # 3. Procesamiento Single-doc (Retrocompatibilidad) @@ -94,7 +110,9 @@ class BatchProcessor: tiff_page_range=tiff_page_range, on_progress=on_progress, on_usage=on_usage, - structure_mode=structure_mode + structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) else: # Si hay fields, es procesamiento masivo -> ASYNC @@ -110,7 +128,9 @@ class BatchProcessor: on_progress=on_progress, on_usage=on_usage, on_error_retry=on_error_retry, - structure_mode=structure_mode + structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, )) def _prepare_multidoc_schemas(self, root_path, doc_types, instruction, tiff_page_range, structure_mode): @@ -131,9 +151,11 @@ class BatchProcessor: async def _process_multidoc_async( self, root_path, doc_types, fields, csv_output_path, csv_header_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, - structure_mode="radicados" + structure_mode="radicados", retry_on_empty=False, radicado_filter=None ): - csv_header = self._prepare_csv_header(fields, alias_map, csv_header_override) + effective_fields = self._expand_document_force_fields(fields or [], alias_map) + effective_alias_map = self._expand_document_force_alias_map(alias_map, effective_fields) + csv_header = self._prepare_csv_header(effective_fields, effective_alias_map, csv_header_override) if structure_mode == "general": radicados = get_files_as_radicados(root_path) @@ -143,6 +165,15 @@ class BatchProcessor: if not radicados and not os.path.isdir(root_path): raise ValueError(f"La ruta raíz no es un directorio: {root_path}") + # Aplicar filtro de radicados si está activo + if radicado_filter: + filter_set = {r.strip() for r in radicado_filter if r.strip()} + if structure_mode == "general": + radicados = [r for r in radicados if os.path.splitext(r)[0] in filter_set] + else: + radicados = [r for r in radicados if r in filter_set] + logger.info(f"Filtro activo: {len(radicados)} radicados seleccionados de {len(filter_set)} indicados") + total = len(radicados) prof = get_profile('invoice') @@ -161,7 +192,10 @@ class BatchProcessor: async def process_radicado(idx, rad): nonlocal completed_count async with sem: + attempts = 0 while True: # Bucle para permitir reintentos tras pausa + attempts += 1 + should_retry_empty = False await self._pause_event.wait() if self._stop_requested: return {"radicado": rad, "status": "stopped"} @@ -209,7 +243,7 @@ class BatchProcessor: continue # Extraer - selected_paths_for_type = [p.split('/', 1)[1] for p in (fields or []) if p.startswith(f"{tname}/")] + selected_paths_for_type = [p.split('/', 1)[1] for p in effective_fields if p.startswith(f"{tname}/")] try: prompt_t = PromptBuilder.build_for_type(tname, selected_paths_for_type, {}, profile_name=prof.get('name', 'invoice')) except Exception: @@ -220,6 +254,7 @@ class BatchProcessor: self._accumulate_usage(agg_extract_usage, usage_ext) # Normalizar (CPU bound, mover a thread) + raw_data = data if isinstance(data, dict) else {"data": data} if isinstance(data, dict): data['radicado'] = rad_id else: @@ -238,12 +273,28 @@ class BatchProcessor: if isinstance(norm, dict): n_usage = norm.get("_usage") normalized_obj = norm.get("normalized") or data + + if isinstance(normalized_obj, dict): + normalized_obj = self._inject_document_force_fields_from_raw( + normalized_obj, + raw_data, + selected_paths_for_type, + ) self._accumulate_usage(agg_normalize_usage, n_usage) fused_data[tname] = normalized_obj or {} fused_data["_source_pdf"] = match + if retry_on_empty and self._is_effectively_empty_output(fused_data, effective_fields, effective_alias_map): + if attempts < 2: + logger.info(f"[{rad_id}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": rad_id}) + should_retry_empty = True + else: + logger.info(f"[{rad_id}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": rad_id}) + # Report usage + if should_retry_empty: + continue self._report_usage(on_usage, rad_id, "extract", agg_extract_usage) self._report_usage(on_usage, rad_id, "normalize", agg_normalize_usage) @@ -252,8 +303,8 @@ class BatchProcessor: async with csv_lock: await asyncio.to_thread( self._write_csv_multidoc, - csv_output_path, fused_data, fields, doc_types, - prof, csv_header, alias_map, rad_id + csv_output_path, fused_data, effective_fields, doc_types, + prof, csv_header, effective_alias_map, rad_id ) return fused_data @@ -280,9 +331,11 @@ class BatchProcessor: return self._handle_error_async(rad, e, csv_output_path, csv_header) finally: async with progress_lock: - completed_count += 1 - self._notify_progress(on_progress, rad, completed_count, total) - break # Salir del bucle True si no hubo RateLimitError o se manejó + if not should_retry_empty: + completed_count += 1 + self._notify_progress(on_progress, rad, completed_count, total) + if not should_retry_empty: + break # Salir del bucle True si no hubo RateLimitError o se manejó tasks = [process_radicado(i, r) for i, r in enumerate(radicados, start=1)] resultados = await asyncio.gather(*tasks) @@ -291,12 +344,18 @@ class BatchProcessor: def _process_singledoc( self, root_path, keywords, fields, csv_output_path, csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, - structure_mode="radicados" + structure_mode="radicados", retry_on_empty=False, radicado_filter=None ): if structure_mode == "general": entries = collect_documents_flat(root_path, keywords) else: entries = collect_radicado_docs(root_path, keywords or []) + + # Aplicar filtro de radicados si está activo + if radicado_filter: + filter_set = {r.strip() for r in radicado_filter if r.strip()} + entries = [e for e in entries if e.get("radicado") in filter_set] + logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas") if not entries: logger.info("No se encontraron radicados con PDFs que coincidan.") @@ -321,27 +380,35 @@ class BatchProcessor: # Fallback a procesamiento secuencial/async si se llama directamente return asyncio.run(self._process_singledoc_async( root_path, keywords, fields, csv_output_path, csv_header_override, - instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode + instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry, structure_mode, retry_on_empty, + radicado_filter=radicado_filter, )) async def _process_singledoc_async( self, root_path, keywords, fields, csv_output_path, csv_header_override, instruction_override, alias_map, tiff_page_range, on_progress, on_usage, on_error_retry=None, - structure_mode="radicados" + structure_mode="radicados", retry_on_empty=False, radicado_filter=None ): # Recolección de archivos (IO bloqueante) if structure_mode == "general": entries = await asyncio.to_thread(collect_documents_flat, root_path, keywords) else: entries = await asyncio.to_thread(collect_radicado_docs, root_path, keywords or []) + + # Aplicar filtro de radicados si está activo + if radicado_filter: + filter_set = {r.strip() for r in radicado_filter if r.strip()} + entries = [e for e in entries if e.get("radicado") in filter_set] + logger.info(f"Filtro activo: {len(entries)} entradas seleccionadas de {len(filter_set)} indicadas") if not entries: logger.info("No se encontraron radicados con PDFs que coincidan.") return [] - selected_fields = fields or [] + selected_fields = self._expand_document_force_fields(fields or [], alias_map) + effective_alias_map = self._expand_document_force_alias_map(alias_map, selected_fields) prompt = PromptBuilder.build(selected_fields, {}) - csv_header = self._prepare_csv_header(selected_fields, alias_map, csv_header_override) + csv_header = self._prepare_csv_header(selected_fields, effective_alias_map, csv_header_override) prof = get_profile('invoice') total = len(entries) @@ -358,7 +425,10 @@ class BatchProcessor: async def process_entry(idx, entry): nonlocal completed_count async with sem: + attempts = 0 while True: # Bucle para permitir reintentos tras pausa + attempts += 1 + should_retry_empty = False await self._pause_event.wait() if self._stop_requested: return {"radicado": entry['radicado'], "status": "stopped"} @@ -370,6 +440,7 @@ class BatchProcessor: data, usage_ext = await self.service.extract_full_from_pdf_async(entry["path"], instruction=prompt, tiff_page_range=tiff_page_range) self._report_usage(on_usage, entry['radicado'], "extract", usage_ext) + raw_data = data if isinstance(data, dict) else {"data": data} if isinstance(data, dict): data['radicado'] = entry['radicado'] else: @@ -388,15 +459,28 @@ class BatchProcessor: normalized_obj = norm.get("normalized") if isinstance(normalized_obj, dict): - data = normalized_obj + data = self._inject_document_force_fields_from_raw( + normalized_obj, + raw_data, + selected_fields, + ) data['radicado'] = entry['radicado'] data["_source_pdf"] = entry["path"] + + if retry_on_empty and self._is_effectively_empty_output(data, selected_fields, effective_alias_map): + if attempts < 2: + logger.info(f"[{entry['radicado']}] Resultado vacío; reintentando extracción ({attempts + 1}/2)", extra={"radicado": entry['radicado'], "pdf": entry['path']}) + should_retry_empty = True + else: + logger.info(f"[{entry['radicado']}] Resultado vacío tras 2 intentos; se conserva el resultado.", extra={"radicado": entry['radicado'], "pdf": entry['path']}) + if should_retry_empty: + continue if csv_output_path: async with csv_lock: await asyncio.to_thread( self._write_csv_singledoc, - csv_output_path, data, selected_fields, prof, csv_header, alias_map, entry['radicado'] + csv_output_path, data, selected_fields, prof, csv_header, effective_alias_map, entry['radicado'] ) return data @@ -423,9 +507,11 @@ class BatchProcessor: return self._handle_error_async(entry['radicado'], e, csv_output_path, csv_header, entry['path']) finally: async with progress_lock: - completed_count += 1 - self._notify_progress(on_progress, entry['radicado'], completed_count, total) - break # Salir del bucle si no hubo RateLimitError o se manejó + if not should_retry_empty: + completed_count += 1 + self._notify_progress(on_progress, entry['radicado'], completed_count, total) + if not should_retry_empty: + break # Salir del bucle si no hubo RateLimitError o se manejó tasks = [process_entry(i, e) for i, e in enumerate(entries, start=1)] resultados = await asyncio.gather(*tasks) @@ -443,6 +529,65 @@ class BatchProcessor: expanded[sub] = alias_value return expanded + def _detect_document_force_target(self, alias_map: Optional[Dict[str, str]]) -> Optional[str]: + if not alias_map: + return None + for alias_value in alias_map.values(): + target = str(alias_value or "").strip() + if target.lower() in DOCUMENT_FORCE_TARGETS: + return target + return None + + def _expand_document_force_fields(self, fields: List[str], alias_map: Optional[Dict[str, str]]) -> List[str]: + """Si existe `documento_forzate`, incluye todos los tipos documentales válidos del mismo nivel.""" + target = self._detect_document_force_target(alias_map) + if not target: + return list(fields or []) + + allowed_types = list(get_allowed_document_types()) + expanded = list(fields or []) + seen = set(expanded) + + for orig_key, alias_value in (alias_map or {}).items(): + if str(alias_value or "").strip().lower() not in DOCUMENT_FORCE_TARGETS: + continue + + base = str(orig_key or "").strip() + if not base: + continue + + if "/" in base: + prefix = base.rsplit("/", 1)[0] + candidates = [f"{prefix}/{doc_type}" for doc_type in allowed_types] + else: + candidates = allowed_types + + for candidate in candidates: + if candidate not in seen: + expanded.append(candidate) + seen.add(candidate) + + return expanded + + def _expand_document_force_alias_map(self, alias_map: Optional[Dict[str, str]], fields: List[str]) -> Dict[str, str]: + """Mapea todos los tipos documentales válidos al mismo alias especial.""" + expanded = dict(alias_map or {}) + target = self._detect_document_force_target(expanded) + if not target: + return expanded + + allowed_types = list(get_allowed_document_types()) + for field in fields or []: + field_str = str(field or "").strip() + if not field_str: + continue + key_tail = field_str.split("/")[-1].upper() + if key_tail not in allowed_types: + continue + expanded[field_str] = target + + return expanded + def _prepare_csv_header(self, fields, alias_map, override): if override: return override @@ -460,6 +605,62 @@ class BatchProcessor: seen.add(h) return final_header + def _is_effectively_empty_output(self, data: Dict[str, Any], fields: List[str], alias_map: Optional[Dict[str, str]]) -> bool: + """Verdadero cuando la salida final solo conserva el radicado y no trae datos útiles.""" + try: + rows, _header = rows_from_data(data, allowed_paths=fields) + final_rows = self._apply_alias_to_rows(rows, alias_map) + except Exception: + return False + + if not final_rows: + return True + + for row in final_rows: + if not isinstance(row, dict): + continue + for key, value in row.items(): + if str(key).strip().lower() == "radicado": + continue + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + return False + return True + + def _inject_document_force_fields_from_raw(self, normalized_obj: Dict[str, Any], raw_obj: Dict[str, Any], selected_fields: List[str]) -> Dict[str, Any]: + """Inyecta campos documentales desde la extracción cruda si el normalizador los omitió.""" + result = dict(normalized_obj or {}) + selected_keys = {str(p or "").split("/")[-1].strip().upper() for p in (selected_fields or [])} + candidates = self._collect_document_force_candidates(raw_obj) + + for dtype, value in candidates.items(): + if selected_keys and dtype not in selected_keys: + continue + if dtype not in result or result.get(dtype) in (None, ""): + result[dtype] = value + return result + + def _collect_document_force_candidates(self, obj: Any) -> Dict[str, Any]: + """Recorre el JSON crudo buscando claves tipo CC/TI/EPT/PT con sus valores.""" + found: Dict[str, Any] = {} + valid_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES + + def walk(node: Any) -> None: + if isinstance(node, dict): + for key, value in node.items(): + key_tail = str(key or "").split("/")[-1].strip().upper() + if key_tail in valid_keys and value not in (None, ""): + found[key_tail] = value + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(obj) + return found + def _notify_progress(self, callback, rad, idx, total): if callback: try: @@ -529,6 +730,7 @@ class BatchProcessor: new_r = {} for k, v in r.items(): new_k = alias_map2.get(k, k) + v = self._format_document_type_value(k, new_k, v) if new_k in new_r: # Lógica de fusión solicitada: @@ -556,6 +758,27 @@ class BatchProcessor: aliased_rows.append(r) return aliased_rows + def _format_document_type_value(self, original_key, aliased_key, value): + """Compone 'CC 123456' cuando una clave de tipo documental se exporta como 'tipo de documento'.""" + if value is None: + return value + + document_type_keys = set(get_allowed_document_types()) + key_tail = str(original_key or "").split("/")[-1].strip().upper() + target = str(aliased_key or "").strip().lower() + if key_tail not in document_type_keys or target not in DOCUMENT_FORCE_TARGETS: + return value + + text = str(value).strip() + if not text: + return value + + # Si ya viene prefijado con el mismo tipo, no duplicarlo. + if text.upper().startswith(f"{key_tail} "): + return text + + return f"{key_tail} {text}" + def _handle_error_async(self, rad, e, csv_path, header, pdf_path=None): error_obj = { "radicado": rad, diff --git a/application/config.py b/application/config.py index 97625db..59e4622 100644 --- a/application/config.py +++ b/application/config.py @@ -26,7 +26,7 @@ class ApiSettings(BaseSettings): """Parámetros del backend compatible con OpenAI/Ollama (usando os.getenv).""" base_url: str = os.getenv("EXTRACTOR_BASE_URL", "http://10.8.0.4:11434/v1") api_key: str = os.getenv("EXTRACTOR_API_KEY", "ollama") - model: str = os.getenv("EXTRACTOR_MODEL", "gpt-4.1") + model: str = os.getenv("EXTRACTOR_MODEL", "gpt-5.4") request_timeout_s: int = int(os.getenv("EXTRACTOR_TIMEOUT_S", 120)) model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -65,3 +65,20 @@ class AppSettings(BaseSettings): settings = AppSettings() + +# Tipos documentales válidos por defecto para el campo especial `documento_forzate`. +# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal. +DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = ( + "CC", + "TI", + "CE", + "RC", + "PA", + "PPT", + "PEP", + "EPT", + "NUIP", +) + +# Alias de compatibilidad. +ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = DEFAULT_ALLOWED_DOCUMENT_TYPES diff --git a/application/costs.py b/application/costs.py index ebfb298..1e115de 100644 --- a/application/costs.py +++ b/application/costs.py @@ -7,36 +7,36 @@ from typing import Dict, Any, Optional # Precios actualizados por 1M tokens (USD) - Enero 2026 MODEL_PRICING = { - "gpt-4.1": { - "input": 2.00, - "cached_input": 0.50, - "output": 8.00, - }, "gpt-4.1-mini": { "input": 0.40, "cached_input": 0.10, "output": 1.60, }, - "gpt-4.1-nano": { - "input": 0.10, - "cached_input": 0.03, - "output": 0.40, - }, - "gpt-5.1": { + "gpt-5.2": { "input": 1.25, - "cached_input": 0.13, + "cached_input": 0.125, "output": 10.00, }, - "gpt-5.1-codex-mini": { - "input": 0.25, - "cached_input": 0.025, - "output": 2.00, + "gpt-5.4": { + "input": 2.50, + "cached_input": 0.25, + "output": 15.00, + }, + "gpt-5.4-mini": { + "input": 0.75, + "cached_input": 0.075, + "output": 4.50, + }, + "gpt-5.4-nano": { + "input": 0.20, + "cached_input": 0.02, + "output": 0.80, }, } # Legacy mapping o fase por defecto (si no se especifica modelo) PRICING = { - "extract": MODEL_PRICING["gpt-4.1"], + "extract": MODEL_PRICING["gpt-5.4"], "normalize": MODEL_PRICING["gpt-4.1-mini"], } diff --git a/application/prompts.py b/application/prompts.py index 7c30db8..d333b66 100644 --- a/application/prompts.py +++ b/application/prompts.py @@ -11,15 +11,22 @@ import json from typing import Dict, List, Any from .profiles import get_profile +from .app_prefs import get_allowed_document_types -_INSTRUCCIONES = [ - "Extrae únicamente los campos solicitados respetando jerarquía y listas", - "Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional", - "No inventes claves nuevas ni cambies nombres existentes", - "Si un campo no existe, usa null o [] según corresponda", - "Ten especial cuidado en no confundir el número de autorización con el código", -] +def _build_instructions() -> List[str]: + tipos_documento = ", ".join(get_allowed_document_types()) + return [ + "Extrae únicamente los campos solicitados respetando jerarquía y listas", + "Devuelve EXCLUSIVAMENTE un JSON válido y bien formado, sin texto adicional", + "No inventes claves nuevas ni cambies nombres existentes", + "Si un campo no existe, usa null o [] según corresponda", + "Ten especial cuidado en no confundir el número de autorización con el código", + f"Los únicos tipos de documento válidos para 'documento_forzate' son: {tipos_documento}. NIT no es válido para ese campo.", + "Si entre los valores visibles aparece uno o varios tipos de documento válidos seguidos por su número (por ejemplo: 'CC 123456789' o 'EPT 123456789'), aunque no estén escritos con formato 'clave: valor', y existe el campo solicitado 'documento_forzate', guarda cada valor completo con su sigla exacta.", + "Respeta la sigla exacta del documento encontrado. Ejemplo: si el documento dice 'EPT', devuelve 'EPT 123456789' y no lo conviertas a 'PT'.", + "Si ves un campo llamado observacion o parecido tambien incluye ese valor en el JSON" + ] _REGLAS_NOMENCLATURA = [ "Usa EXACTAMENTE los nombres listados en 'campos_requeridos'", @@ -49,7 +56,7 @@ class PromptBuilder: meta.update(extra_meta) return { "meta": meta, - "instrucciones": _INSTRUCCIONES, + "instrucciones": _build_instructions(), "campos_requeridos": fields, "schema_referencia": {k: schema[k] for k in fields if k in schema}, "reglas_estrictas_nomenclatura": _REGLAS_NOMENCLATURA, diff --git a/application/service.py b/application/service.py index d1bd8f1..dd373d3 100644 --- a/application/service.py +++ b/application/service.py @@ -347,6 +347,8 @@ class InvoiceProcessingService: alias_map: Optional[Dict[str, str]] = None, tiff_page_range: Optional[Tuple[int, int]] = None, structure_mode: str = "radicados", + retry_on_empty: bool = False, + radicado_filter: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Procesa radicados con selección de campos y CSV incremental. (Delegado a BatchProcessor)""" return self.batch_processor.process_root( @@ -356,7 +358,9 @@ class InvoiceProcessingService: instruction_override=instruction_override, on_usage=on_usage, on_error_retry=on_error_retry, alias_map=alias_map, tiff_page_range=tiff_page_range, - structure_mode=structure_mode + structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) def estimate_tokens_and_cost( diff --git a/build_installer.bat b/build_installer.bat new file mode 100644 index 0000000..e95103f --- /dev/null +++ b/build_installer.bat @@ -0,0 +1,97 @@ +@echo off +REM ============================================================================ +REM Build script para IPSExtractor +REM Genera el ejecutable con PyInstaller y opcionalmente el instalador con +REM Inno Setup. +REM +REM Uso: +REM build_installer.bat (solo PyInstaller) +REM build_installer.bat --inno (PyInstaller + Inno Setup) +REM ============================================================================ + +setlocal + +set "ROOT=%~dp0" +cd /d "%ROOT%" + +echo. +echo ======================================== +echo IPSExtractor - Build +echo ======================================== +echo. + +REM -- Verificar Python --- +python --version >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python no encontrado en PATH. + pause + exit /b 1 +) + +REM -- Verificar PyInstaller --- +python -c "import PyInstaller" >nul 2>&1 +if errorlevel 1 ( + echo Instalando PyInstaller... + pip install pyinstaller +) + +REM -- Limpiar builds anteriores --- +echo Limpiando builds anteriores... +if exist "build" rmdir /s /q "build" +if exist "dist\IPSExtractor" rmdir /s /q "dist\IPSExtractor" + +REM -- Ejecutar PyInstaller --- +echo. +echo Ejecutando PyInstaller... +echo. +pyinstaller ipsextractor.spec --noconfirm + +if errorlevel 1 ( + echo. + echo ERROR: PyInstaller fallo. Revise los errores arriba. + pause + exit /b 1 +) + +echo. +echo ======================================== +echo Build exitoso! +echo Output: dist\IPSExtractor\ +echo ======================================== +echo. + +REM -- Inno Setup (opcional) --- +if /i "%~1"=="--inno" ( + echo Buscando Inno Setup... + + set "ISCC=" + if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ( + set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + ) + if exist "C:\Program Files\Inno Setup 6\ISCC.exe" ( + set "ISCC=C:\Program Files\Inno Setup 6\ISCC.exe" + ) + + if not defined ISCC ( + echo AVISO: Inno Setup no encontrado. Instale Inno Setup 6 para generar el instalador. + echo Descargue en: https://jrsoftware.org/isdl.php + pause + exit /b 0 + ) + + echo Generando instalador con Inno Setup... + "%ISCC%" "%ROOT%installer.iss" + + if errorlevel 1 ( + echo ERROR: Inno Setup fallo. + pause + exit /b 1 + ) + + echo. + echo ======================================== + echo Instalador generado en: dist\ + echo ======================================== +) + +pause diff --git a/gui/controller.py b/gui/controller.py index b1392b7..8ffc4d8 100644 --- a/gui/controller.py +++ b/gui/controller.py @@ -13,6 +13,7 @@ import os from core.utils import parse_comma_separated, collect_radicado_docs, collect_documents_flat from application.logging_config import configure_logging +from application.app_prefs import load_app_prefs, save_app_prefs, normalize_document_types from gui.field_selector import FieldSelectorDialog from application.preclassification import PreclassificationService from application.strategies import OllamaExtractor @@ -111,11 +112,11 @@ class AppController: # Mapeo de nombres de UI a IDs de modelo (y viceversa) self.ui_to_model = { - "5.1": "gpt-5.1", - "4.1": "gpt-4.1", - "4.1 mini": "gpt-4.1-mini", - "4.1 nano": "gpt-4.1-nano", - "5.1 Codex mini": "gpt-5.1-codex-mini", + "5.4": "gpt-5.4", + "5.4-mini": "gpt-5.4-mini", + "5.4-nano": "gpt-5.4-nano", + "5.2": "gpt-5.2", + "4.1-mini": "gpt-4.1-mini", } self.model_to_ui = {v: k for k, v in self.ui_to_model.items()} @@ -136,6 +137,12 @@ class AppController: except Exception: pass + self._load_persisted_preferences() + try: + self.view.protocol("WM_DELETE_WINDOW", self.on_app_close) + except Exception: + pass + def _get_model_pricing_data(self): """Obtiene las tarifas por 1M de tokens para cada modelo.""" results = [] @@ -173,6 +180,8 @@ class AppController: # Actualizar settings globales settings.processing.concurrency_limit = concurrency + self.view.set_allowed_document_types_hint(self.view.get_allowed_document_types()) + self._save_preferences() mb.showinfo("Configuración", f"Configuración actualizada.\nModelo: {selected_name}\nConcurrencia: {concurrency}") self.view.append_output_safe(f"Configuración actualizada: Modelo={real_model_id}, Concurrencia={concurrency}") @@ -190,6 +199,7 @@ class AppController: if not selected: return self.view.set_root_path(selected) + self._save_preferences() # Detectar si el primer documento a procesar es TIFF para esta carpeta try: @@ -468,6 +478,97 @@ class AppController: selected = fd.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Todos", "*.*")]) if selected: self.view.set_csv_path(selected) + self._save_preferences() + + def _collect_preferences(self) -> dict: + """Toma una foto de la configuración visible de la app.""" + try: + doc_types = self.view.get_doc_types() + except Exception: + doc_types = [] + + return { + "selected_model": self.view.get_selected_model(), + "concurrency_limit": self.view.get_concurrency_limit(), + "allowed_document_types": self.view.get_allowed_document_types(), + "retry_on_empty": self.view.get_retry_empty_enabled(), + "process_mode": self.view.get_process_mode(), + "root_path": self.view.get_root_path(), + "csv_path": self.view.get_csv_path(), + "doc_types": doc_types, + } + + def _save_preferences(self) -> None: + """Guarda preferencias de la GUI en disco.""" + save_app_prefs(self._collect_preferences()) + + def _load_persisted_preferences(self) -> None: + """Carga preferencias persistidas y las aplica a la vista/servicio.""" + prefs = load_app_prefs() + if not prefs: + return + + try: + selected_model = str(prefs.get("selected_model") or "").strip() + if selected_model: + self.view.set_selected_model(selected_model) + real_model_id = self.ui_to_model.get(selected_model, selected_model) + if hasattr(self.service.extractor, "update_config"): + self.service.extractor.update_config(model=real_model_id) + except Exception: + logger.exception("No se pudo restaurar el modelo persistido") + + try: + concurrency = int(prefs.get("concurrency_limit") or settings.processing.concurrency_limit) + self.view.set_concurrency_limit(concurrency) + settings.processing.concurrency_limit = concurrency + except Exception: + pass + + try: + allowed_document_types = normalize_document_types(prefs.get("allowed_document_types")) + self.view.set_allowed_document_types(allowed_document_types) + self.view.set_allowed_document_types_hint(allowed_document_types) + except Exception: + pass + + try: + self.view.set_retry_empty_enabled(bool(prefs.get("retry_on_empty", False))) + except Exception: + pass + + try: + self.view.set_process_mode(str(prefs.get("process_mode") or "radicados")) + except Exception: + pass + + try: + root_path = str(prefs.get("root_path") or "").strip() + if root_path: + self.view.set_root_path(root_path) + except Exception: + pass + + try: + csv_path = str(prefs.get("csv_path") or "").strip() + if csv_path: + self.view.set_csv_path(csv_path) + except Exception: + pass + + try: + doc_types = prefs.get("doc_types") + if isinstance(doc_types, list): + self.view.set_doc_types(doc_types) + except Exception: + pass + + def on_app_close(self): + """Guarda preferencias antes de cerrar la ventana.""" + try: + self._save_preferences() + finally: + self.view.destroy() def on_process(self): """Inicia el flujo de procesamiento por radicado (solo facturas) en un hilo de fondo. @@ -475,6 +576,7 @@ class AppController: - Si el servicio requiere selección de campos, abre `FieldSelectorDialog` y relanza. - Emite resultados en el área de salida y opcionalmente escribe CSV incremental. """ + self._save_preferences() root = self.view.get_root_path() # Tipos de Documento (multi-doc) doc_types = [] @@ -493,7 +595,16 @@ class AppController: keywords_str = self.view.get_keywords() fields_str = self.view.get_fields() structure_mode = self.view.get_process_mode() - + retry_on_empty = self.view.get_retry_empty_enabled() + + # Filtro de radicados + radicado_filter = None + try: + if self.view.get_radicado_filter_enabled(): + radicado_filter = self.view.get_radicado_filter_list() or None + except Exception: + radicado_filter = None + if structure_mode == "general": # En modo general, si está vacío o es el default, procesar todo (None) # Si el usuario escribió algo específico, usarlo. @@ -607,6 +718,8 @@ class AppController: csv_output_path=csv_path, tiff_page_range=tiff_page_range, structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) else: resultados = self.service.process_root_with_field_selection( @@ -617,6 +730,8 @@ class AppController: csv_output_path=csv_path, tiff_page_range=tiff_page_range, structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) # Si requiere selección de campos abrir diálogo; de lo contrario, mostrar resultados @@ -801,6 +916,8 @@ class AppController: alias_map=alias_map, tiff_page_range=tiff_page_range, structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) else: res = self.service.process_root_with_field_selection( @@ -814,6 +931,8 @@ class AppController: alias_map=alias_map, tiff_page_range=tiff_page_range, structure_mode=structure_mode, + retry_on_empty=retry_on_empty, + radicado_filter=radicado_filter, ) self.view.append_output_safe(json.dumps(res, ensure_ascii=False, indent=2)) except Exception as e2: diff --git a/gui/field_selector.py b/gui/field_selector.py index c5b540b..3a12011 100644 --- a/gui/field_selector.py +++ b/gui/field_selector.py @@ -11,6 +11,12 @@ from typing import Dict, List, Callable, Optional import customtkinter as ctk import threading import tkinter.messagebox as mb +from application.app_prefs import get_allowed_document_types + +DOCUMENT_TYPE_HINT_ALIASES = { + "CC", "TI", "CE", "RC", "PA", "PPT", "PEP", "EPT", "NUIP", + "PT", "PAS", "PASS", +} class FieldSelectorDialog(ctk.CTkToplevel): @@ -99,6 +105,7 @@ class FieldSelectorDialog(ctk.CTkToplevel): except Exception: pass alias_entry.grid(row=0, column=1, padx=(12, 0)) + self._add_document_type_hint(row, path) try: alias_entry.bind("", lambda _e: self._validate_aliases()) except Exception: @@ -332,6 +339,7 @@ class FieldSelectorDialog(ctk.CTkToplevel): except Exception: pass alias_entry.grid(row=0, column=1, padx=(12, 0)) + self._add_document_type_hint(row, path) try: alias_entry.bind("", lambda _e: self._validate_aliases()) except Exception: @@ -361,3 +369,18 @@ class FieldSelectorDialog(ctk.CTkToplevel): except Exception: pass + def _add_document_type_hint(self, row: ctk.CTkFrame, path: str) -> None: + """Muestra una ayuda breve bajo claves tipo CC, TI, CE, PAS, etc.""" + key_tail = str(path or "").split("/")[-1].strip().upper() + valid_hint_keys = set(get_allowed_document_types()) | DOCUMENT_TYPE_HINT_ALIASES + if key_tail not in valid_hint_keys: + return + + hint = ctk.CTkLabel( + row, + text="Si a este campo le pones el alias 'documento_forzate', se guardará con la sigla exacta detectada, por ejemplo 'CC 123456789' o 'EPT 123456789'.", + anchor="w", + justify="left", + ) + hint.grid(row=1, column=0, columnspan=2, sticky="w", padx=(28, 0), pady=(2, 0)) + diff --git a/gui/tabs/main_tab.py b/gui/tabs/main_tab.py index 27c4b1a..6e1730c 100644 --- a/gui/tabs/main_tab.py +++ b/gui/tabs/main_tab.py @@ -1,6 +1,7 @@ """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: @@ -112,7 +113,47 @@ class MainProcessingTab: 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) @@ -139,6 +180,15 @@ class MainProcessingTab: 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, @@ -147,7 +197,7 @@ class MainProcessingTab: width=120, height=28 ) - self.add_doc_type_btn.grid(row=0, column=1, sticky="e") + 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 @@ -161,6 +211,16 @@ class MainProcessingTab: # 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, @@ -323,6 +383,16 @@ class MainProcessingTab: 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.""" @@ -347,6 +417,50 @@ class MainProcessingTab: 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.""" @@ -398,6 +512,11 @@ class MainProcessingTab: 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.""" @@ -445,6 +564,9 @@ class MainProcessingTab: 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) diff --git a/gui/tabs/settings_tab.py b/gui/tabs/settings_tab.py index 1c807d8..e62cb79 100644 --- a/gui/tabs/settings_tab.py +++ b/gui/tabs/settings_tab.py @@ -1,6 +1,7 @@ """Pestaña de Configuración de la aplicación GUI.""" import customtkinter as ctk from gui.styles import AppStyles +from application.app_prefs import get_allowed_document_types, normalize_document_types class SettingsTab: @@ -20,6 +21,7 @@ class SettingsTab: self._setup_layout() self._create_api_section() self._create_processing_section() + self._create_document_types_section() self._create_save_button() def _setup_layout(self): @@ -55,18 +57,18 @@ class SettingsTab: self.combo_model = ctk.CTkComboBox( self.frame_api, - values=["5.1", "4.1", "4.1 mini", "4.1 nano"], + values=["5.4", "5.4-mini", "5.4-nano", "5.2", "4.1-mini"], width=250, font=self.fonts["ui"] ) self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w") - self.combo_model.set("4.1") + self.combo_model.set("5.4") # Texto de ayuda help_text = ( - "ℹ 4.1 mini: Muy rápido (recomendado)\n" - "ℹ 4.1: Estándar (balanceado)\n" - "ℹ 5.1: Lento pero mayor razonamiento" + "ℹ 5.4: Modelo predeterminado\n" + "ℹ 5.4-mini / 4.1-mini: opciones más rápidas\n" + "ℹ 5.4-nano: opción más económica" ) lbl_help = ctk.CTkLabel( self.frame_api, @@ -119,6 +121,36 @@ class SettingsTab: text_color="orange" ) lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w") + + def _create_document_types_section(self): + """Crea la sección editable de tipos documentales permitidos.""" + self.frame_doc_types = ctk.CTkFrame(self.settings_container) + self.frame_doc_types.grid(row=2, column=0, sticky="ew", padx=5, pady=5) + self.frame_doc_types.grid_columnconfigure(0, weight=1) + + lbl_title = ctk.CTkLabel( + self.frame_doc_types, + text="Tipos de Documento Permitidos", + font=self.fonts["section"] + ) + lbl_title.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 4)) + + lbl_help = ctk.CTkLabel( + self.frame_doc_types, + text="Escríbelos separados por comas. Se usarán en documento_forzate. NIT se ignorará automáticamente.", + font=self.fonts["italic_small"], + text_color=self.colors["gray_light"], + justify="left" + ) + lbl_help.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 6)) + + self.allowed_doc_types_entry = ctk.CTkEntry( + self.frame_doc_types, + font=self.fonts["ui"], + placeholder_text="CC, TI, CE, RC, PA, PPT, PEP, EPT, NUIP" + ) + self.allowed_doc_types_entry.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 10)) + self.set_allowed_document_types(list(get_allowed_document_types())) def _create_save_button(self): """Crea el botón de guardar configuración.""" @@ -128,7 +160,7 @@ class SettingsTab: font=self.fonts["ui"], height=35 ) - self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew") + self.btn_save_settings.grid(row=3, column=0, padx=15, pady=15, sticky="ew") # --- API Pública --- @@ -154,10 +186,22 @@ class SettingsTab: def set_concurrency_limit(self, limit: int) -> None: """Establece el límite de concurrencia.""" self.concurrency_var.set(str(limit)) + + def get_allowed_document_types(self) -> list[str]: + """Devuelve la lista editable de tipos documentales válidos.""" + raw = self.allowed_doc_types_entry.get().strip() + values = [part.strip() for part in raw.split(",")] + return normalize_document_types(values) + + def set_allowed_document_types(self, values: list[str]) -> None: + """Establece la lista de tipos documentales válidos.""" + self.allowed_doc_types_entry.delete(0, "end") + self.allowed_doc_types_entry.insert(0, ", ".join(normalize_document_types(values))) def enable_controls(self, enabled: bool): """Habilita o deshabilita los controles.""" state = "normal" if enabled else "disabled" self.combo_model.configure(state=state) self.concurrency_entry.configure(state=state) + self.allowed_doc_types_entry.configure(state=state) self.btn_save_settings.configure(state=state) diff --git a/gui/view.py b/gui/view.py index 768ba2b..9c8eaac 100644 --- a/gui/view.py +++ b/gui/view.py @@ -109,6 +109,12 @@ class InvoiceAppView(ctk.CTk): def set_concurrency_limit(self, limit: int) -> None: self.settings_tab.set_concurrency_limit(limit) + def get_allowed_document_types(self) -> list[str]: + return self.settings_tab.get_allowed_document_types() + + def set_allowed_document_types(self, values: list[str]) -> None: + self.settings_tab.set_allowed_document_types(values) + def set_on_run_preclassification(self, callback): self.preclass_tab.set_on_run(callback) @@ -177,6 +183,9 @@ class InvoiceAppView(ctk.CTk): """Devuelve el modo de procesamiento: 'radicados' o 'general'.""" return self.main_tab.get_process_mode() + def set_process_mode(self, mode: str) -> None: + self.main_tab.set_process_mode(mode) + def get_preclass_root_path(self) -> str: return self.preclass_tab.get_root_path() @@ -201,6 +210,18 @@ class InvoiceAppView(ctk.CTk): def get_csv_path(self) -> str: return self.main_tab.get_csv_path() + def get_retry_empty_enabled(self) -> bool: + return self.main_tab.get_retry_empty_enabled() + + def set_retry_empty_enabled(self, enabled: bool) -> None: + self.main_tab.set_retry_empty_enabled(enabled) + + def get_radicado_filter_enabled(self) -> bool: + return self.main_tab.get_radicado_filter_enabled() + + def get_radicado_filter_list(self) -> list[str]: + return self.main_tab.get_radicado_filter_list() + def show_tiff_range_controls(self, show: bool) -> None: self.main_tab.show_tiff_range_controls(show) @@ -327,5 +348,8 @@ class InvoiceAppView(ctk.CTk): def set_doc_types(self, doc_types: list[dict]) -> None: self.main_tab.set_doc_types(doc_types) + def set_allowed_document_types_hint(self, allowed_types: list[str]) -> None: + self.main_tab.set_allowed_document_types_hint(allowed_types) + def validate_doc_types(self) -> tuple[bool, str | None]: return self.main_tab.validate_doc_types() diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..6c6d0ff --- /dev/null +++ b/installer.iss @@ -0,0 +1,49 @@ +; ============================================================================ +; Inno Setup Script para IPSExtractor +; +; Prerequisito: ejecutar build_installer.bat primero para generar dist\IPSExtractor +; Luego: build_installer.bat --inno o compilar este .iss directamente +; +; Descargar Inno Setup: https://jrsoftware.org/isdl.php +; ============================================================================ + +#define MyAppName "IPSExtractor" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "IPSExtractor" +#define MyAppExeName "IPSExtractor.exe" + +[Setup] +AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputDir=dist +OutputBaseFilename=IPSExtractor_Setup_{#MyAppVersion} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +SetupLogging=yes + +[Languages] +Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" + +[Tasks] +Name: "desktopicon"; Description: "Crear acceso directo en el Escritorio"; GroupDescription: "Accesos directos:" + +[Files] +; Todos los archivos de dist\IPSExtractor +Source: "dist\IPSExtractor\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\Desinstalar {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "Ejecutar {#MyAppName}"; Flags: nowait postinstall skipifsilent