diff --git a/README.md b/README.md index fc89e70..12d70ff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Arquitectura de microservicios: cada servicio tiene su propia API, dependencias, | `api_gateway` | **8189** | 8000 | Python/FastAPI | Única puerta de entrada expuesta al host | | `extraction` | — | 8001 | Python/FastAPI | Extracción LLM; solo red Docker | | `cleaning` | — | 8002 | Python/FastAPI | Limpieza; solo red Docker | +| `poblacion_apta` | — | 8003 | Python/FastAPI | Población apta (Verificador ST); solo red Docker | | `count-api` | — | 8080 | Go | Conteo OCR+ONNX; solo red Docker | | `database` | — | 5432 | PostgreSQL 16 | Solo red Docker | | `redis` | — | 6379 | Redis 7 | Solo red Docker | @@ -122,6 +123,7 @@ services/ api_gateway/ — proxy HTTP (Python) → README extraction/ — LLM + jobs + conteo BD (Python) → README cleaning/ — limpieza de datos (Python) → README + poblacion_apta/ — población apta / Verificador ST → README bot_descarga/ — API control remoto de bots → README count-api/ — OCR + ONNX stateless (Go) → README db/ — migraciones Alembic → README @@ -138,12 +140,14 @@ docker-compose.yml — stack completo ``` jobs — contenedor de un trabajo del usuario - tasks — módulo ejecutado (counting / extraction / cleaning) + tasks — módulo ejecutado (counting / extraction / cleaning / poblacion_apta) count_job_files — resultado de conteo por PDF extraction_batches — lote de extracción extraction_rows — fila extraída por documento cleaning_jobs — job de limpieza cleaned_rows — fila transformada + poblacion_apta_jobs — consolidado validado (PASO_BOT) + poblacion_apta_rows ``` Esquema completo: [`docs/database.dbml`](docs/database.dbml) @@ -156,6 +160,7 @@ Esquema completo: [`docs/database.dbml`](docs/database.dbml) - [`services/api_gateway/README.md`](services/api_gateway/README.md) - [`services/extraction/README.md`](services/extraction/README.md) - [`services/cleaning/README.md`](services/cleaning/README.md) +- [`services/poblacion_apta/README.md`](services/poblacion_apta/README.md) - [`services/services-count-facturas-medicas-serving/README.md`](services/services-count-facturas-medicas-serving/README.md) - [`services/db/README.md`](services/db/README.md) - [`services/desktop_app/README.md`](services/desktop_app/README.md) diff --git a/Verificador_ST.py b/Verificador_ST.py new file mode 100644 index 0000000..9a499f6 --- /dev/null +++ b/Verificador_ST.py @@ -0,0 +1,1847 @@ +# -*- coding: utf-8 -*- +from email.mime import base +import shutil +import os +import re +import csv +import threading +import traceback +from pathlib import Path +from datetime import datetime +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +import pandas as pd + + +# ============================================================ +# CONFIGURACIÓN GENERAL +# ============================================================ + +COLUMNAS_FINAL_IT = [ + "radicado", + "total_cargos", + "autorizacion", + "ingreso", + "tipo_documento", + "documento", + "tipo_servicio", + "copago", + "cuota_moderadora", +] + +CSV_FINAL_IT_HEADERS = [ + "RADICADO", + "BRUTO", + "AUTORIZACION", + "FECHA", + "TIPO_ID", + "NUMERO", + "SERVICIO", + "COPAGO", + "MODERADORA", +] + +CSV_FINAL_IT_RENAME = { + "radicado": "RADICADO", + "total_cargos": "BRUTO", + "autorizacion": "AUTORIZACION", + "ingreso": "FECHA", + "tipo_documento": "TIPO_ID", + "documento": "NUMERO", + "tipo_servicio": "SERVICIO", + "copago": "COPAGO", + "cuota_moderadora": "MODERADORA", +} + +# Tabla de cuotas moderadoras por año. +TABLA_MODERADORA = { + 2023: {4100, 16400, 43000}, + 2024: {4500, 18200, 47700}, + 2025: {4700, 19200, 50300}, + 2026: {5000, 20100, 52800}, +} + +# Longitudes según tabla entregada por el usuario. +# Los tipos marcados como alfanuméricos permiten letras y números. +REGLAS_DOCUMENTO = { + "CC": {"min": 3, "max": 10, "tipo": "num"}, + "CE": {"min": 3, "max": 7, "tipo": "num"}, + "TI": {"min": 10, "max": 10, "tipo": "num"}, + "RC": {"min": 10, "max": 10, "tipo": "num"}, + "CN": {"min": 9, "max": 14, "tipo": "num"}, + "PT": {"min": 7, "max": 8, "tipo": "num"}, + "PE": {"min": 15, "max": 15, "tipo": "num"}, + "PA": {"min": 3, "max": 16, "tipo": "alnum"}, + "PS": {"min": 3, "max": 16, "tipo": "alnum"}, + "AS": {"min": 3, "max": 16, "tipo": "alnum"}, + "MS": {"min": 3, "max": 16, "tipo": "alnum"}, +} + +HOMOLOGACION_TIPO_DOC = { + "CC": "CC", "C.C": "CC", "C.C.": "CC", "CEDULA": "CC", "CEDULA CIUDADANIA": "CC", + "CÉDULA": "CC", "CÉDULA CIUDADANÍA": "CC", "CEDULA DE CIUDADANIA": "CC", "Cédula Ciudadanía": "CC", "Cédula_Ciudadanía": "CC", + "TI": "TI", "T.I": "TI", "T.I.": "TI", "TARJETA IDENTIDAD": "TI", "TARJETA DE IDENTIDAD": "TI", "tarjeta identidad": "TI", "tarjeta_identidad": "TI", + "RC": "RC", "R.C": "RC", "R.C.": "RC", "REGISTRO CIVIL": "RC", "REGISTRO_CIVIL": "RC", "registro civil": "RC", "registro_civil": "RC", + "CE": "CE", "C.E": "CE", "C.E.": "CE", "CEDULA EXTRANJERIA": "CE", "CÉDULA EXTRANJERÍA": "CE", "CEDULA DE EXTRANJERIA": "CE", "Cédula Extranjería": "CE", "Cédula_Extranjería": "CE", + "PA": "PA", "PASAPORTE": "PA", + "PS": "PS", "PERMISO ESPECIAL": "PS", + "PT": "PT", "PERMISO PROTECCION TEMPORAL": "PT", "PERMISO POR PROTECCION TEMPORAL": "PT", "PERMISO PROTECCION TEMPORAL": "PT", "Permiso Protección Temporal": "PT", "Permiso_Protección_Temporal": "PT", + "PE": "PE", "PERMISO ESPECIAL": "PE", "PERMISO ESPECIAL DE PERMANENCIA": "PE", "Permiso Especial de Permanencia": "PE", "Permiso_Especial_de_Permanencia": "PE", + "CN": "CN", "CERTIFICADO NACIDO VIVO": "CN", "NACIDO VIVO": "CN", "CERTIFICADO NACIDO": "CN", "Certificado Nacido Vivo": "CN", "Certificado_Nacido_Vivo": "CN", + "AS": "AS", "ADULTO SIN IDENTIFICACION": "AS", "ADULTO_SIN_IDENTIFICACION": "AS", + "MS": "MS", "MENOR SIN IDENTIFICACION": "MS", "MENOR_SIN_IDENTIFICACION": "MS", "MENOR SIN IDENTIFICACIÓN": "MS", "Menor Sin Identificación": "MS", "Menor_Sin_Identificación": "MS", +} + +# Diccionario inicial basado en los ejemplos que compartiste. +# Se puede ampliar fácilmente. +DICCIONARIO_COLUMNAS = { + "radicado": [ + "radicado", "radicacion", "radicación", "numero radicado", "numero_radicado", + "numero radicacion", "numero_radicacion", "numeroradicacion", "numero de radicacion", + "no radicado", "nro radicado", "num radicado", "id radicado" + ], + "autorizacion": [ + "autorizacion", "autorización", "autorrizacion", "autoriz", "autoriza", + "numero autorizacion", "num autorizacion", "no autorizacion", "nro autorizacion", + "aut", "numaut", "no_aut", "autoritiponumero" + ], + "ingreso": [ + "ingreso", "fecha ingreso", "fec ingreso", "fec.ingreso", "fecha_ingreso", + "fecha de ingreso", "fecha servicio", "fec servicio", "fecha_servicio", + "fecha", "ingeso" + ], + "tipo_documento": [ + "tipo_documento", "tipo documento", "tipo de documento", "tipo doc", "tipodoc", + "tipo", "td", "doc tipo", "documenoto tipo" + ], + "documento": [ + "documento", "docuemnto", "numero documento", "número documento", "no documento", + "no. documento", "num documento", "identificacion", "identificación", + "cedula", "cédula", "id paciente", "paciente id", "id" + ], + "tipo_documento_documento": [ + "tipo numero", "tiponumero", "tiponum", "tipnumero", "tipnum", "tipnumebr", "tipnumingraut", + "tipo / documento", "tipo/documento", "tipo muner", "documento tipo", "autoritiponumero", + "tipo y documento", "tipo-documento", "tipo doc documento", + "tipo identificacion", "tipo identificación", "tipo id", + "identificacion paciente", "identificación paciente", "identipo", + "tipo num doc", "tipo nro doc", "tipo no doc" + ], + "total_cargos": [ + "total_cargos", "total cargos", "bruto", "brutos", "brruto", "valor bruto", + "valorbruto", "valor factura", "valor_factura", "total factura", + "totalfactura", "valor facturado", "valor_facturado" + ], + "neto": [ + "neto", "valor neto", "valorneto", "netos", "valor pagado", "valor_pagado" + ], + "copago": [ + "copago", "copagos", "cop", "valor copago", "valor_copago" + ], + "cuota_moderadora": [ + "moderadora", "cuota moderadora", "cuota_moderadora", "cm", + "valor moderadora", "valor_moderadora", "moderradora" + ], + "cop_mod": [ + "cop/mod", "copago/moderadora", "copago cm", "copago/cm", "copago cuota moderadora", + "copago/CM", "copago cm", "valor usuario", "valor_usuario", "abono paciente", + "cuota usuario", "pago usuario", "pagousuario", "copago/CM" + ], + "tipo_servicio": [ + "tipo_servicio", "tipo servicio", "servicio", "tipo de servicio", + "origen_servicio", "origen servicio", "ambito", "ámbito" + ], +} + + +# ============================================================ +# UTILIDADES DE LECTURA +# ============================================================ + +def normalizar_nombre_columna(nombre): + if nombre is None: + return "" + s = str(nombre).strip().lower() + s = s.replace("\ufeff", "") + s = s.replace("_", " ") + s = re.sub(r"\s+", " ", s) + # quitar tildes básicas sin depender de librerías externas + trans = str.maketrans("áéíóúüñ", "aeiouun") + s = s.translate(trans) + return s + + +def detectar_delimitador(path): + try: + with open(path, "r", encoding="utf-8-sig", errors="ignore") as f: + muestra = f.read(4096) + delimitadores = ["|", ";", ",", "\t"] + conteos = {d: muestra.count(d) for d in delimitadores} + mejor = max(conteos, key=conteos.get) + return mejor if conteos[mejor] > 0 else "," + except Exception: + return "," + + +def leer_csv_flexible(path): + sep = detectar_delimitador(path) + intentos = [ + {"sep": sep, "encoding": "utf-8-sig"}, + {"sep": sep, "encoding": "latin1"}, + {"sep": ";", "encoding": "utf-8-sig"}, + {"sep": ",", "encoding": "utf-8-sig"}, + {"sep": "|", "encoding": "utf-8-sig"}, + ] + + ultimo_error = None + for opt in intentos: + try: + df = pd.read_csv( + path, + sep=opt["sep"], + encoding=opt["encoding"], + dtype=str, + engine="python", + keep_default_na=False, + on_bad_lines="skip", + ) + df.columns = [str(c).strip() for c in df.columns] + return df + except Exception as e: + ultimo_error = e + + raise RuntimeError(f"No se pudo leer CSV {path.name}: {ultimo_error}") + + +def encontrar_columna(df, campo): + cols_norm = {normalizar_nombre_columna(c): c for c in df.columns} + equivalencias = DICCIONARIO_COLUMNAS.get(campo, []) + + for eq in equivalencias: + eq_norm = normalizar_nombre_columna(eq) + if eq_norm in cols_norm: + return cols_norm[eq_norm] + + # búsqueda por contiene, útil para encabezados largos + for eq in equivalencias: + eq_norm = normalizar_nombre_columna(eq) + for c_norm, c_original in cols_norm.items(): + if eq_norm and (eq_norm in c_norm or c_norm in eq_norm): + return c_original + + return None + + +# ============================================================ +# LIMPIEZA Y NORMALIZACIÓN +# ============================================================ + +def limpiar_texto(valor): + if valor is None: + return "" + s = str(valor).strip() + if s.lower() in {"nan", "none", "null"}: + return "" + return s + + +def limpiar_numero_entero(valor, default=""): + """ + Limpia valores monetarios sin inflar decimales. + + Ejemplos: + - $ 150.000,00 -> 150000 + - 150000,00 -> 150000 + - 150000.00 -> 150000 + - 1.250.000,00 -> 1250000 + - 1,250,000.00 -> 1250000 + - 150.000 -> 150000 + - 150,000 -> 150000 + """ + s = limpiar_texto(valor) + if not s: + return default + + s = s.replace("$", "").replace("COP", "").replace("cop", "").strip() + s = re.sub(r"[^\d,.\-]", "", s) + + if not s: + return default + + # Para este proceso esperamos valores positivos. + s = s.replace("-", "") + + # Caso con coma y punto: el último separador define el decimal. + if "," in s and "." in s: + last_comma = s.rfind(",") + last_dot = s.rfind(".") + + if last_comma > last_dot: + # Formato colombiano: 1.250.000,00 + entero = s[:last_comma] + decimal = s[last_comma + 1:] + if len(decimal) <= 2: + s = entero.replace(".", "").replace(",", "") + else: + s = s.replace(".", "").replace(",", "") + else: + # Formato inglés: 1,250,000.00 + entero = s[:last_dot] + decimal = s[last_dot + 1:] + if len(decimal) <= 2: + s = entero.replace(",", "").replace(".", "") + else: + s = s.replace(",", "").replace(".", "") + + elif "," in s: + partes = s.split(",") + ultima = partes[-1] + + # 150000,00 -> decimal. Se elimina la parte decimal. + if len(ultima) in (1, 2): + s = "".join(partes[:-1]) + else: + # 150,000 -> miles + s = "".join(partes) + + elif "." in s: + partes = s.split(".") + ultima = partes[-1] + + # 150000.00 -> decimal. + # 150.000 -> miles porque la última parte tiene 3 dígitos. + if len(ultima) in (1, 2): + s = "".join(partes[:-1]) + else: + s = "".join(partes) + + s = re.sub(r"\D", "", s) + return s if s else default + + +def to_int(valor, default=0): + s = limpiar_numero_entero(valor, default="") + if not s: + return default + try: + return int(s) + except Exception: + return default + + +def homologar_tipo_documento(valor): + """ + Homologa tipo de documento aunque venga con texto adicional. + + Ejemplos: + - C.C. -> CC + - CÉDULA CIUDADANIA -> CC + - CEDULA CIUDAD 1021676637 -> CC + - CC 1012333072 -> CC + - RC 1188975098 -> RC + """ + s = limpiar_texto(valor).upper() + if not s: + return "" + + trans = str.maketrans("ÁÉÍÓÚÜÑ", "AEIOUUN") + s = s.translate(trans) + + s = s.replace(".", "").replace("-", " ").replace("_", " ") + s = re.sub(r"\s+", " ", s).strip() + + # Casos textuales de cédula + if "CEDULA" in s and ("CIUDAD" in s or "CIUDADANIA" in s): + return "CC" + + # Si empieza por tipo válido y luego trae documento/nombre + m = re.match(r"^(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\b", s) + if m: + return m.group(1) + + return HOMOLOGACION_TIPO_DOC.get(s, s) + + +def limpiar_documento(valor, tipo_doc): + s = limpiar_texto(valor).upper() + if tipo_doc in {"PA", "PS", "AS", "MS"}: + s = re.sub(r"[^A-Z0-9]", "", s) + else: + s = re.sub(r"\D", "", s) + return s + + +def validar_documento(tipo_doc, documento): + tipo_doc = homologar_tipo_documento(tipo_doc) + documento = limpiar_documento(documento, tipo_doc) + + # Si el tipo está vacío, pero el documento numérico es usable, pasa porque el bot procesa el número. + if not tipo_doc: + if documento and documento.isdigit() and 3 <= len(documento) <= 16: + return True, "" + return False, "Tipo de documento vacío y documento inválido" + + if tipo_doc not in REGLAS_DOCUMENTO: + return False, f"Tipo de documento no parametrizado: {tipo_doc}" + + regla = REGLAS_DOCUMENTO[tipo_doc] + largo = len(documento) + + if largo < regla["min"] or largo > regla["max"]: + if regla["min"] == regla["max"]: + return False, f"Longitud inválida para {tipo_doc}. Debe tener {regla['min']} dígitos." + return False, f"Longitud inválida para {tipo_doc}. Debe tener entre {regla['min']} y {regla['max']} dígitos." + + if regla["tipo"] == "num" and not documento.isdigit(): + return False, f"Documento inválido para {tipo_doc}. Debe ser numérico." + + if regla["tipo"] == "alnum" and not re.match(r"^[A-Z0-9]+$", documento): + return False, f"Documento inválido para {tipo_doc}. Debe ser alfanumérico." + + return True, "" + + +def normalizar_fecha(valor): + """ + Normaliza ingreso a DD/MM/YYYY. + + Soporta: + - 2026-05-13 00:00 + - 13/05/2026 + - 13-05-2026 + - 29 abr. 2026 06:52 p. m. + - ATENCION 07/05/2026. 8/05/2026 -> toma la primera fecha válida + """ + s = limpiar_texto(valor) + if not s: + return "", "fecha ingreso vacia" + + s_original = s + s = s.strip() + s_upper = s.upper() + if any(marker in s_upper for marker in ["SIN_FECHA", "SIN FECHA", "NO FECHA", "SINFECHA"]): + return "", "sin fecha" + + trans = str.maketrans("ÁÉÍÓÚÜÑáéíóúüñ", "AEIOUUNaeiouun") + s_norm = s.translate(trans) + s_norm = re.sub(r"\s+", " ", s_norm).strip() + s_norm = s_norm.upper() + s_norm = re.sub(r"[^A-Z0-9/\-.:\s]", " ", s_norm) + + meses = { + "ENE": "01", "ENERO": "01", + "FEB": "02", "FEBRERO": "02", + "MAR": "03", "MARZO": "03", + "ABR": "04", "ABRIL": "04", + "MAY": "05", "MAYO": "05", + "JUN": "06", "JUNIO": "06", + "JUL": "07", "JULIO": "07", + "AGO": "08", "AGOSTO": "08", + "SEP": "09", "SEPT": "09", "SEPTIEMBRE": "09", + "OCT": "10", "OCTUBRE": "10", + "NOV": "11", "NOVIEMBRE": "11", + "DIC": "12", "DICIEMBRE": "12", + } + + def reemplazar_mes(match): + token = re.sub(r"[^A-Z]", "", match.group(0).upper()) + if token in meses: + return meses[token] + if token.endswith("BR"): + return meses["ABR"] + return match.group(0) + + s_mes = re.sub(r"\b[\w\.]{3,12}\b", reemplazar_mes, s_norm) + s_mes = re.sub(r"\bP\s*\.\s*M\b", "PM", s_mes) + s_mes = re.sub(r"\bA\s*\.\s*M\b", "AM", s_mes) + s_mes = re.sub(r"\bP\s*M\b", "PM", s_mes) + s_mes = re.sub(r"\bA\s*M\b", "AM", s_mes) + s_mes = re.sub(r"\s+", " ", s_mes).strip() + + # Buscar primero fechas numéricas dentro del texto y tomar la primera válida. + fechas_en_texto = re.findall(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b", s_mes) + for f in fechas_en_texto: + try: + fecha = pd.to_datetime(f, dayfirst=True, errors="raise") + if 1900 <= fecha.year <= 2100: + return fecha.strftime("%d/%m/%Y"), "" + except Exception: + pass + + # Intentar parsear después de reemplazar meses en español. + for dayfirst in [True, False]: + try: + fecha = pd.to_datetime(s_mes, dayfirst=dayfirst, errors="raise") + if pd.isna(fecha): + continue + if 1900 <= fecha.year <= 2100: + return fecha.strftime("%d/%m/%Y"), "" + except Exception: + pass + + # Serial de Excel aproximado + if re.fullmatch(r"\d{5}", s_mes): + try: + fecha = pd.to_datetime(float(s_mes), unit="D", origin="1899-12-30") + return fecha.strftime("%d/%m/%Y"), "" + except Exception: + pass + + # Formato compacto YYYYMMDD o DDMMYYYY + solo_digitos = re.sub(r"\D", "", s_mes) + if len(solo_digitos) == 8: + candidatos = [ + solo_digitos[:4] + "-" + solo_digitos[4:6] + "-" + solo_digitos[6:], + solo_digitos[:2] + "/" + solo_digitos[2:4] + "/" + solo_digitos[4:], + ] + for c in candidatos: + try: + fecha = pd.to_datetime(c, dayfirst=("/" in c), errors="raise") + if 1900 <= fecha.year <= 2100: + return fecha.strftime("%d/%m/%Y"), "" + except Exception: + pass + + # ISO claro + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?(?:[-+]\d{2}:?\d{2})?)?$", s_mes): + try: + fecha = pd.to_datetime(s_mes, dayfirst=False, errors="raise") + if 1900 <= fecha.year <= 2100: + return fecha.strftime("%d/%m/%Y"), "" + except Exception: + pass + + return "", f"Fecha ingreso inválida: {s_original}" + + +def obtener_anio_fecha(fecha_ddmmyyyy): + try: + return int(fecha_ddmmyyyy[-4:]) + except Exception: + return None + + +def normalizar_autorizacion(valor): + """ + Normaliza autorización. + + Reglas: + - Si viene vacía o con marcador de ausencia -> observación. + - Si viene con guion -> se conserva. + - Si viene pegada sin guion -> se agrega guion después de los primeros 5 caracteres. + Ejemplo: + 128242624857057 -> 12824-2624857057 + - Después de normalizar debe quedar con largo 16 incluyendo el guion. + """ + s_raw = limpiar_texto(valor) + s = s_raw.upper() + if not s: + return "", "autorizacion vacia" + + if any(marker in s for marker in ["SIN_AUTORIZACION", "SIN AUTORIZACION", "NO AUTORIZACION", "NO AUTORIZACIÓN", "NO AUTORIZACION"]): + return "", "sin autorizacion" + + s = s.replace("AUTORIZACION", "").replace("AUTORIZACIÓN", "") + s = s.replace(":", " ").strip() + s = re.sub(r"[^A-Z0-9\-]", "", s) + + if not s: + return "", "autorizacion vacia" + + if s.isdigit() and len(s) == 10: + return s, "autorizacion incompleta" + + # Si viene sin guion y con caracteres suficientes, agregar guion después del 5.º carácter. + if "-" not in s and len(s) > 5: + s = s[:5] + "-" + s[5:] + + if len(s) == 16: + return s, "" + + return s, "autorizacion invalida" + + +def separar_copago_moderadora(valor, anio): + """ + Si el valor coincide con tabla moderadora del año => cuota_moderadora. + Si no coincide => copago. + """ + monto = to_int(valor, default=0) + if monto == 0: + return 0, 0 + + if anio in TABLA_MODERADORA and monto in TABLA_MODERADORA[anio]: + return 0, monto + + return monto, 0 + + +def normalizar_tipo_servicio(valor): + s = limpiar_texto(valor).upper() + s = re.sub(r"\s+", " ", s).strip() + # Se respeta porque el usuario indicó que ya lo manejan bien. + return s + + +# ============================================================ +# DETECCIÓN Y EXTRACCIÓN ESPECIAL +# ============================================================ + +def es_archivo_especial(df): + """ + Heurística: + - Alguna columna tiene textos largos. + - Y contiene palabras clave típicas del archivo especial: + AUTORIZACION, PACIENTE, INGRESO, ABONO. + """ + if df.empty: + return False + + palabras = ["AUTORIZACION", "AUTORIZACIÓN", "PACIENTE", "INGRESO", "ABONO"] + for col in df.columns: + serie = df[col].astype(str).head(50) + textos = " ".join(serie.tolist()).upper() + largo_promedio = serie.map(lambda x: len(str(x))).mean() + hits = sum(1 for p in palabras if p in textos) + + if largo_promedio >= 45 and hits >= 2: + return True + + return False + + +def extraer_tipo_doc_y_documento_desde_texto(texto): + """ + Extrae tipo y número de documento cuando vienen mezclados. + + Soporta: + - CC 1065138068 + - CC-1065138068 + - C.C. 1065138068 + - CÉDULA CIUDAD 1021676637 NOMBRE PACIENTE + - CEDULA CIUDADANIA 1021676637 NOMBRE PACIENTE + - PACIENTE: RC-1253158282-NOMBRE + - RC 1.067.645.729 + - TI 1.066.894.756 + - solo número: 1023876746 => tipo vacío, documento 1023876746 + """ + t = limpiar_texto(texto).upper() + if not t: + return "", "" + + trans = str.maketrans("ÁÉÍÓÚÜÑ", "AEIOUUN") + t_norm = t.translate(trans) + t_norm = t_norm.replace("_", " ") + + t_norm = re.sub(r"\bC\s*\.?\s*C\.?\b", "CC", t_norm) + t_norm = re.sub(r"\bT\s*\.?\s*I\.?\b", "TI", t_norm) + t_norm = re.sub(r"\bR\s*\.?\s*C\.?\b", "RC", t_norm) + t_norm = re.sub(r"\bC\s*\.?\s*E\.?\b", "CE", t_norm) + + def limpiar_doc_texto(valor): + return re.sub(r"[^A-Z0-9]", "", valor.upper()) + + m = re.search(r"\bCEDULA(?:\s+DE)?\s+CIUDAD(?:ANIA)?\s+([0-9]{3,16})\b", t_norm) + if m: + return "CC", m.group(1) + + m = re.search(r"\bCEDULA\s+([0-9]{3,16})\b", t_norm) + if m: + return "CC", m.group(1) + + m = re.match(r"^([A-Z ]+?)\s+([0-9\.\s]+)$", t_norm) + if m: + tipo_candidato = homologar_tipo_documento(m.group(1)) + if tipo_candidato in REGLAS_DOCUMENTO: + return tipo_candidato, limpiar_doc_texto(m.group(2)) + + m = re.search(r"PACIENTE\s*:\s*(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\s*[-:/ ]+\s*([0-9\.\s]+)", t_norm) + if m: + return homologar_tipo_documento(m.group(1)), limpiar_doc_texto(m.group(2)) + + m = re.search( + r"(?:TIPO\s*/?\s*DOCUMENTO|TIPO\s*/?\s*NUMERO|TIPO\s+NUMERO|DOCUMENTO)\s*[:\- ]+\s*" + r"(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\s*[-:/ ]+\s*([0-9\.\s]+)", + t_norm + ) + if m: + return homologar_tipo_documento(m.group(1)), limpiar_doc_texto(m.group(2)) + + m = re.search(r"\b(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\s*[-:/ ]+\s*([0-9\.\s]+)\b", t_norm) + if m: + return homologar_tipo_documento(m.group(1)), limpiar_doc_texto(m.group(2)) + + m = re.search(r"\b([0-9\.\s]+)\s*[-:/ ]+\s*(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\b", t_norm) + if m: + return homologar_tipo_documento(m.group(2)), limpiar_doc_texto(m.group(1)) + + # Solo número: se permite tipo vacío si el documento es usable para el bot. + # Evita extraer números desde textos largos con nombres/observaciones. + if re.fullmatch(r"[0-9\s.,;:/\-]+", t_norm): + cleaned = limpiar_doc_texto(t_norm) + if 6 <= len(cleaned) <= 16: + return "", cleaned + + numeros = re.findall(r"\d{5,20}", cleaned) + if numeros: + candidatos = [n for n in numeros if 6 <= len(n) <= 10] + if candidatos: + candidatos = sorted(candidatos, key=len) + return "", candidatos[0] + return "", numeros[-1] + + # Si el texto contiene solo tipo y número con puntos, limpiar y reintentar. + m = re.search(r"\b(CC|TI|RC|CE|PA|PS|PT|PE|CN|AS|MS)\s+([0-9\.\s]+)\b", t_norm) + if m: + return homologar_tipo_documento(m.group(1)), limpiar_doc_texto(m.group(2)) + + return "", "" + + +def extraer_autorizacion_desde_texto(texto): + t = limpiar_texto(texto).upper() + m = re.search(r"AUTORIZACI[ÓO]N\s*:\s*([A-Z0-9\-]+)", t) + if m: + return m.group(1) + return "" + + +def extraer_ingreso_desde_texto(texto): + t = limpiar_texto(texto).upper() + # Caso especial mencionado: INGRESO: 9 + m = re.search(r"INGRESO\s*:\s*([0-9/\-.]+)", t) + if m: + return m.group(1) + return "" + + +def extraer_abono_desde_texto(texto): + t = limpiar_texto(texto).upper() + m = re.search(r"ABONO\s+PACIENTE\s*:\s*([0-9.,]+)", t) + if m: + return m.group(1) + return "" + + +def obtener_serie_columna_especial(df): + """ + Devuelve la columna que parece contener el texto mezclado. + """ + mejor_col = None + mejor_score = -1 + palabras = ["AUTORIZACION", "AUTORIZACIÓN", "PACIENTE", "INGRESO", "ABONO"] + + for col in df.columns: + serie = df[col].astype(str) + textos = " ".join(serie.head(50).tolist()).upper() + score = sum(textos.count(p) for p in palabras) + int(serie.map(lambda x: len(str(x))).mean()) + if score > mejor_score: + mejor_score = score + mejor_col = col + + return mejor_col + + +def construir_base_normal(df, archivo_nombre): + """ + Construye dataframe base con columnas estándar a partir de archivo normal. + """ + base = pd.DataFrame() + for campo in COLUMNAS_FINAL_IT: + base[campo] = "" + + # columnas origen + col_radicado = encontrar_columna(df, "radicado") + col_tipo_doc = encontrar_columna(df, "tipo_documento") + col_doc = encontrar_columna(df, "documento") + col_tipo_doc_doc = encontrar_columna(df, "tipo_documento_documento") + col_ingreso = encontrar_columna(df, "ingreso") + col_total = encontrar_columna(df, "total_cargos") + col_aut = encontrar_columna(df, "autorizacion") + col_serv = encontrar_columna(df, "tipo_servicio") + col_copago = encontrar_columna(df, "copago") + col_mod = encontrar_columna(df, "cuota_moderadora") + col_cop_mod = encontrar_columna(df, "cop_mod") + col_neto = encontrar_columna(df, "neto") + + n = len(df) + base = pd.DataFrame(index=df.index) + base["archivo_origen"] = archivo_nombre + base["tipo_archivo"] = "NORMAL" + + base["radicado"] = df[col_radicado] if col_radicado else "" + base["tipo_documento"] = df[col_tipo_doc] if col_tipo_doc else "" + base["documento"] = df[col_doc] if col_doc else "" + + # Si viene tipo y documento mezclado en una misma columna, intentar partir. + if col_tipo_doc_doc: + columnas_para_extraer = [col_tipo_doc_doc] + else: + # Fallback: revisar columnas probables sin incluir radicado ni montos. + columnas_para_extraer = [ + c for c in df.columns + if c not in { + col_radicado, col_ingreso, col_total, col_aut, + col_serv, col_copago, col_mod, col_cop_mod, col_neto + } + ] + + for col_mix in columnas_para_extraer: + for i, val in df[col_mix].items(): + tipo, doc = extraer_tipo_doc_y_documento_desde_texto(val) + current_tipo = limpiar_texto(base.at[i, "tipo_documento"]) + current_doc = limpiar_texto(base.at[i, "documento"]) + raw_val = limpiar_texto(val) + + if col_mix == col_tipo_doc_doc and (current_tipo == raw_val or current_doc == raw_val): + if tipo: + base.at[i, "tipo_documento"] = tipo + if doc: + base.at[i, "documento"] = doc + continue + + if col_mix != col_tipo_doc_doc and current_tipo and current_doc: + continue + + if tipo and not current_tipo: + base.at[i, "tipo_documento"] = tipo + if doc and not current_doc: + base.at[i, "documento"] = doc + + # Si no se detectó columna radicado explícita, intentar inferir radicado desde cualquier columna + # buscando secuencias largas de dígitos (heurística). No sobrescribe documento ya detectado. + for col in df.columns: + for i, val in df[col].items(): + if limpiar_texto(base.at[i, "radicado"]): + continue + s = limpiar_texto(val) + if not s: + continue + # Buscar secuencia de al menos 7 dígitos hasta 13 + m = re.search(r"(\d{7,13})", str(s)) + if not m: + continue + candidate = m.group(1) + # Evitar tomar si coincide con documento ya detectado + if limpiar_texto(base.at[i, "documento"]) == candidate: + continue + # Evitar sobrescribir si ya hay tipo_documento (probablemente documento legítimo) + if limpiar_texto(base.at[i, "tipo_documento"]): + continue + base.at[i, "radicado"] = candidate + + base["ingreso"] = df[col_ingreso] if col_ingreso else "" + base["total_cargos"] = df[col_total] if col_total else "" + base["autorizacion"] = df[col_aut] if col_aut else "" + base["tipo_servicio"] = df[col_serv] if col_serv else "" + base["copago_origen"] = df[col_copago] if col_copago else "" + base["moderadora_origen"] = df[col_mod] if col_mod else "" + base["cop_mod_origen"] = df[col_cop_mod] if col_cop_mod else "" + base["neto_origen"] = df[col_neto] if col_neto else "" + + return base + + +def construir_base_especial(df, archivo_nombre): + """ + Reconstruye columnas estándar desde texto mezclado. + """ + col_texto = obtener_serie_columna_especial(df) + col_radicado = encontrar_columna(df, "radicado") + col_total = encontrar_columna(df, "total_cargos") + col_serv = encontrar_columna(df, "tipo_servicio") + + base = pd.DataFrame(index=df.index) + base["archivo_origen"] = archivo_nombre + base["tipo_archivo"] = "ESPECIAL" + + base["radicado"] = df[col_radicado] if col_radicado else "" + base["tipo_documento"] = "" + base["documento"] = "" + base["ingreso"] = "" + base["total_cargos"] = df[col_total] if col_total else "" + base["autorizacion"] = "" + base["tipo_servicio"] = df[col_serv] if col_serv else "" + base["copago_origen"] = "" + base["moderadora_origen"] = "" + base["cop_mod_origen"] = "" + base["neto_origen"] = "" + + if col_texto: + for i, texto in df[col_texto].items(): + tipo, doc = extraer_tipo_doc_y_documento_desde_texto(texto) + aut = extraer_autorizacion_desde_texto(texto) + ingreso = extraer_ingreso_desde_texto(texto) + abono = extraer_abono_desde_texto(texto) + + base.at[i, "tipo_documento"] = tipo + base.at[i, "documento"] = doc + base.at[i, "autorizacion"] = aut + base.at[i, "ingreso"] = ingreso + base.at[i, "cop_mod_origen"] = abono + + return base + + +# ============================================================ +# NORMALIZACIÓN FINAL Y VALIDACIONES +# ============================================================ + + +def resolver_tipo_y_documento(tipo_doc_raw, documento_raw): + """ + Resuelve casos donde el tipo y documento vienen mezclados en cualquiera de las dos columnas. + + Regla: + - Si tipo_documento trae "CC 1012333072", separa. + - Si tipo_documento trae "CÉDULA CIUDADANIA" y documento viene aparte, homologa a CC. + - Si documento trae "RC 1188975098", separa. + - Si solo viene número, permite tipo vacío y documento numérico. + """ + tipo_raw = limpiar_texto(tipo_doc_raw) + doc_raw = limpiar_texto(documento_raw) + + # 1. Intentar extraer desde la columna tipo_documento si viene mezclada. + tipo_mix, doc_mix = extraer_tipo_doc_y_documento_desde_texto(tipo_raw) + if tipo_mix or doc_mix: + tipo_final = homologar_tipo_documento(tipo_mix) + doc_final = limpiar_documento(doc_mix, tipo_final) + + # Si solo encontró tipo, usar documento de la otra columna. + if tipo_final and not doc_final and doc_raw: + doc_final = limpiar_documento(doc_raw, tipo_final) + + if tipo_final or doc_final: + return tipo_final, doc_final + + # 2. Intentar extraer desde la columna documento si viene mezclada. + tipo_mix, doc_mix = extraer_tipo_doc_y_documento_desde_texto(doc_raw) + if tipo_mix or doc_mix: + tipo_final = homologar_tipo_documento(tipo_mix) + doc_final = limpiar_documento(doc_mix, tipo_final) + + # Si el tipo no salió en documento, usar tipo_raw homologado. + if not tipo_final: + tipo_final = homologar_tipo_documento(tipo_raw) + + if tipo_final or doc_final: + return tipo_final, doc_final + + # 3. Caso normal. + tipo_final = homologar_tipo_documento(tipo_raw) + doc_final = limpiar_documento(doc_raw, tipo_final) + return tipo_final, doc_final + + +def normalizar_registros(base): + registros = [] + errores = [] + + for idx, row in base.iterrows(): + obs = [] + + archivo = limpiar_texto(row.get("archivo_origen", "")) + tipo_archivo = limpiar_texto(row.get("tipo_archivo", "")) + + radicado = limpiar_texto(row.get("radicado", "")) + + # IMPORTANTE: + # Primero separar tipo/documento si vienen mezclados, luego homologar y validar. + tipo_doc, documento = resolver_tipo_y_documento( + row.get("tipo_documento", ""), + row.get("documento", "") + ) + + ingreso, err_fecha = normalizar_fecha(row.get("ingreso", "")) + # No detener el proceso si la fecha no se reconoce. + if err_fecha: + ingreso = "" + obs.append(err_fecha) + + anio = obtener_anio_fecha(ingreso) + + total_cargos = limpiar_numero_entero(row.get("total_cargos", ""), default="") + neto_origen = limpiar_numero_entero(row.get("neto_origen", ""), default="") + if not total_cargos and neto_origen: + total_cargos = neto_origen + + if not total_cargos: + obs.append("Total cargos / bruto vacío o inválido") + + autorizacion, err_aut = normalizar_autorizacion(row.get("autorizacion", "")) + # No detener el proceso si la autorización no se reconoce. + if err_aut: + autorizacion = autorizacion or "" + obs.append(err_aut) + + tipo_servicio = normalizar_tipo_servicio(row.get("tipo_servicio", "")) + + ok_doc, err_doc = validar_documento(tipo_doc, documento) + if not ok_doc: + obs.append(err_doc) + + # Copago / moderadora: + copago = to_int(row.get("copago_origen", ""), default=0) + moderadora = to_int(row.get("moderadora_origen", ""), default=0) + + cop_mod = to_int(row.get("cop_mod_origen", ""), default=0) + + # Si hay cop_mod mezclado, clasificar por tabla. + if cop_mod > 0: + c, m = separar_copago_moderadora(cop_mod, anio) + copago += c + moderadora += m + + # Si hay neto y bruto, usar diferencia para inferir cop/mod. + neto = to_int(row.get("neto_origen", ""), default=0) + bruto = to_int(total_cargos, default=0) + if bruto > 0 and neto > 0 and bruto >= neto: + diferencia = bruto - neto + if diferencia > 0 and copago == 0 and moderadora == 0: + c, m = separar_copago_moderadora(diferencia, anio) + copago = c + moderadora = m + + final = { + "archivo_origen": archivo, + "tipo_archivo": tipo_archivo, + "radicado": radicado, + "tipo_documento": tipo_doc, + "documento": documento, + "ingreso": ingreso, + "total_cargos": total_cargos, + "autorizacion": autorizacion, + "tipo_servicio": tipo_servicio, + "copago": str(copago), + "cuota_moderadora": str(moderadora), + "observacion": " | ".join(obs), + } + + if obs: + errores.append(final) + else: + registros.append(final) + + return pd.DataFrame(registros), pd.DataFrame(errores) + + +def separar_duplicados(df_ok): + """ + Lógica tipo CONTAR.SI del usuario: + + Si un radicado aparece 2 o más veces: + → TODOS esos registros van a DUPLICADOS.xlsx + → NINGUNO pasa al CSV_FINAL_IT + """ + if df_ok.empty: + return df_ok, pd.DataFrame() + + # Limpiar radicado temporalmente + temp = df_ok.copy() + temp["_radicado_clean"] = temp["radicado"].astype(str).str.strip() + + # Conteo tipo CONTAR.SI + conteo = temp.groupby("_radicado_clean")["_radicado_clean"].transform("count") + + # Duplicado = aparece 2 o más veces + dup_mask = (conteo >= 2) & temp["_radicado_clean"].ne("") + + duplicados = temp[dup_mask].copy() + unicos = temp[~dup_mask].copy() + + if not duplicados.empty: + duplicados["cantidad_duplicados"] = conteo[dup_mask] + duplicados["observacion"] = ( + "Radicado duplicado detectado por CONTAR.SI. " + "Cantidad: " + duplicados["cantidad_duplicados"].astype(str) + ) + + # Quitar columnas temporales + for df_ in [duplicados, unicos]: + if "_radicado_clean" in df_.columns: + df_.drop(columns=["_radicado_clean"], inplace=True, errors="ignore") + + return unicos, duplicados + + + +def clasificar_errores_por_hoja(df_errores): + """ + Separa los errores en hojas por categoría. + Un mismo registro puede aparecer en varias hojas si tiene varios errores. + """ + if df_errores.empty or "observacion" not in df_errores.columns: + return {} + + categorias = { + "Autorizaciones": [ + "autorización", "autorizacion" + ], + "Fechas": [ + "fecha ingreso", "fecha" + ], + "Documentos": [ + "documento inválido", "documento invalido", "longitud inválida", + "longitud invalida", "tipo de documento" + ], + "Total_Bruto": [ + "total cargos", "bruto" + ], + "Tipo_Documento": [ + "tipo de documento vacío", "tipo de documento vacio", + "tipo de documento no parametrizado" + ], + "Otros": [] + } + + hojas = {} + obs_norm = df_errores["observacion"].astype(str).str.lower() + + usados = pd.Series(False, index=df_errores.index) + + for hoja, palabras in categorias.items(): + if hoja == "Otros": + continue + + mask = pd.Series(False, index=df_errores.index) + for palabra in palabras: + mask = mask | obs_norm.str.contains(palabra, na=False, regex=False) + + if mask.any(): + hojas[hoja] = df_errores[mask].copy() + usados = usados | mask + + otros = df_errores[~usados].copy() + if not otros.empty: + hojas["Otros"] = otros + + return hojas + + +def escribir_errores_por_hojas(writer, df_err_total): + """ + Escribe ERRORES_NORMALIZACION.xlsx con: + - Hoja Todos + - Hojas por categoría: Autorizaciones, Fechas, Documentos, Total_Bruto, Tipo_Documento, Otros + """ + columnas_base = [ + "archivo_origen", "tipo_archivo", + *COLUMNAS_FINAL_IT, + "observacion" + ] + + if df_err_total.empty: + pd.DataFrame(columns=columnas_base).to_excel(writer, index=False, sheet_name="Todos") + return + + df_err_total.to_excel(writer, index=False, sheet_name="Todos") + + hojas = clasificar_errores_por_hoja(df_err_total) + for nombre_hoja, df_hoja in hojas.items(): + # Excel permite máximo 31 caracteres en nombre de hoja + nombre_seguro = str(nombre_hoja)[:31] + df_hoja.to_excel(writer, index=False, sheet_name=nombre_seguro) + + + + + +def leer_archivo_flexible(path): + """Lee CSV o Excel manteniendo todo como texto.""" + path = Path(path) + ext = path.suffix.lower() + if ext == ".csv": + return leer_csv_flexible(path) + if ext in {".xlsx", ".xlsm", ".xls"}: + return pd.read_excel(path, dtype=str, keep_default_na=False) + raise RuntimeError(f"Formato no soportado: {path.name}. Usa CSV o Excel.") + + +def listar_archivos_entrada(carpeta): + carpeta = Path(carpeta) + archivos = [] + for patron in ("*.csv", "*.xlsx", "*.xlsm", "*.xls"): + archivos.extend(carpeta.glob(patron)) + + # Evitar leer salidas anteriores o temporales. + archivos_limpios = [] + for a in sorted(archivos): + nombre = a.name.upper() + if a.parent.name.upper() == "SALIDA_NORMALIZADOR_IT": + continue + if nombre.startswith("CONSOLIDADO_BOT_IT"): + continue + if "ORIGEN_SERVICIO" in nombre: + continue + if nombre.startswith("~$"): + continue + archivos_limpios.append(a) + return archivos_limpios + + +def normalizar_clave_radicado(valor): + """ + Normaliza el radicado para poder cruzarlo contra Origen Servicio. + Evita fallos por espacios, guiones, .0 de Excel o separadores. + """ + s = limpiar_texto(valor).upper() + if not s: + return "" + + # Cuando Excel deja 100000069634188.0 + s = re.sub(r"\.0$", "", s) + + # Quitar espacios y separadores comunes + s = re.sub(r"[\s\-_.:,;/\\]+", "", s) + + # Si quedó algo tipo RAD100000..., conservar letras y números + s = re.sub(r"[^A-Z0-9]", "", s) + return s + +def cargar_origen_servicio(ruta_archivo): + """ + Carga el Excel/CSV seleccionado por el usuario y arma un diccionario: + radicado -> tipo_servicio. + + Soporta encabezados como: + - radicado / NumeroRadicacion / numero radicacion + - tipo_servicio / servicio / Origen_Servicio + """ + if not ruta_archivo: + return {} + + archivo_servicio = Path(ruta_archivo) + + if not archivo_servicio.exists(): + return {} + + df_serv = leer_archivo_flexible(archivo_servicio) + df_serv.columns = [str(c).strip() for c in df_serv.columns] + + col_rad = encontrar_columna(df_serv, "radicado") + col_serv = encontrar_columna(df_serv, "tipo_servicio") + + # Refuerzo por si el encabezado viene pegado tipo NumeroRadicacion + if not col_rad: + for c in df_serv.columns: + c_norm = normalizar_nombre_columna(c).replace(" ", "") + if "radic" in c_norm: + col_rad = c + break + + # Refuerzo por si el encabezado viene pegado tipo OrigenServicio + if not col_serv: + for c in df_serv.columns: + c_norm = normalizar_nombre_columna(c).replace(" ", "") + if "servicio" in c_norm or "origenservicio" in c_norm: + col_serv = c + break + + if not col_rad or not col_serv: + return {} + + mapa = {} + for _, row in df_serv.iterrows(): + rad_original = limpiar_texto(row.get(col_rad, "")) + serv = limpiar_texto(row.get(col_serv, "")) + + if rad_original and serv: + # Guarda varias llaves para que cruce aunque Excel venga con espacios, guiones o .0 + mapa[rad_original] = serv + mapa[normalizar_clave_radicado(rad_original)] = serv + + solo_digitos = re.sub(r"\D", "", rad_original) + if solo_digitos: + mapa[solo_digitos] = serv + + return mapa + +def simplificar_observacion(obs): + """Convierte observaciones largas a códigos rápidos para filtrar.""" + obs = limpiar_texto(obs) + if not obs: + return "" + + partes_salida = [] + obs_norm = normalizar_nombre_columna(obs) + + reglas = [ + ("SIN_AUTORIZACION", ["autorizacion vacia", "sin autorizacion"]), + ("AUTORIZACION_INCOMPLETA", ["autorizacion incompleta"]), + ("AUTORIZACION_INVALIDA", ["autorizacion invalida"]), + ("SIN_FECHA", ["sin fecha"]), + ("FECHA_INVALIDA", ["fecha ingreso vacia", "fecha ingreso invalida", "fecha"]), + ("DOCUMENTO_INVALIDO", ["documento invalido", "longitud invalida", "tipo de documento vacio", "tipo de documento no parametrizado"]), + ("VALOR_INVALIDO", ["total cargos", "bruto"]), + ] + + for codigo, palabras in reglas: + if any(p in obs_norm for p in palabras): + partes_salida.append(codigo) + + if not partes_salida: + partes_salida.append(obs.upper()) + + # Quitar repetidos conservando orden. + vistos = set() + final = [] + for p in partes_salida: + if p not in vistos: + vistos.add(p) + final.append(p) + return " | ".join(final) + + +def preparar_consolidado(df_ok_total, df_err_total): + columnas = [*COLUMNAS_FINAL_IT, "OBSERVACION", "archivo_origen", "tipo_archivo"] + + partes = [] + + if df_ok_total is not None and not df_ok_total.empty: + ok = df_ok_total.copy() + ok["OBSERVACION"] = "" + partes.append(ok) + + if df_err_total is not None and not df_err_total.empty: + err = df_err_total.copy() + err["OBSERVACION"] = err.get("observacion", "").apply(simplificar_observacion) + partes.append(err) + + if partes: + cons = pd.concat(partes, ignore_index=True) + else: + cons = pd.DataFrame(columns=columnas) + + for c in COLUMNAS_FINAL_IT: + if c not in cons.columns: + cons[c] = "" + + # Detectar duplicado sobre TODO el consolidado, así esté bueno o malo. + if not cons.empty and "radicado" in cons.columns: + rad = cons["radicado"].astype(str).str.strip() + conteo = rad.map(rad.value_counts()) + mask_dup = rad.ne("") & conteo.ge(2) + cons.loc[mask_dup, "OBSERVACION"] = cons.loc[mask_dup, "OBSERVACION"].apply( + lambda x: "RADICADO_DUPLICADO" if not limpiar_texto(x) else f"{x} | RADICADO_DUPLICADO" + ) + + cons["OBSERVACION"] = cons["OBSERVACION"].apply(lambda x: "PASO_BOT" if not limpiar_texto(x) else x) + + # Limpieza de columnas auxiliares. + for c in ["observacion", "cantidad_duplicados"]: + if c in cons.columns: + cons.drop(columns=[c], inplace=True, errors="ignore") + + for c in columnas: + if c not in cons.columns: + cons[c] = "" + + return cons[columnas] + + +def preparar_resumen(df_consolidado, resumen_archivos): + resumen_obs = pd.DataFrame(columns=["OBSERVACION", "CANTIDAD"]) + if df_consolidado is not None and not df_consolidado.empty: + resumen_obs = ( + df_consolidado["OBSERVACION"] + .fillna("") + .astype(str) + .str.split(" | ") + .explode() + .str.strip() + .replace("", pd.NA) + .dropna() + .value_counts() + .rename_axis("OBSERVACION") + .reset_index(name="CANTIDAD") + ) + total = pd.DataFrame([{"OBSERVACION": "TOTAL", "CANTIDAD": len(df_consolidado)}]) + resumen_obs = pd.concat([resumen_obs, total], ignore_index=True) + + resumen_arch = pd.DataFrame(resumen_archivos) + return resumen_obs, resumen_arch + + +def autoajustar_excel(writer, hojas): + try: + from openpyxl.styles import Font, PatternFill, Alignment + for nombre_hoja in hojas: + ws = writer.book[nombre_hoja] + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + for cell in ws[1]: + cell.font = Font(bold=True) + cell.fill = PatternFill("solid", fgColor="D9EAF7") + cell.alignment = Alignment(horizontal="center") + for col in ws.columns: + max_len = 0 + letter = col[0].column_letter + for cell in col[:200]: + max_len = max(max_len, len(str(cell.value or ""))) + ws.column_dimensions[letter].width = min(max(max_len + 2, 12), 45) + except Exception: + pass + + +def escribir_excel_consolidado(path_salida, df_original, df_consolidado, resumen_obs, resumen_archivos): + with pd.ExcelWriter(path_salida, engine="openpyxl") as writer: + df_original.to_excel(writer, index=False, sheet_name="Original") + df_consolidado.to_excel(writer, index=False, sheet_name="Consolidado") + resumen_obs.to_excel(writer, index=False, sheet_name="Resumen", startrow=0) + + # Debajo del resumen por observación se deja el detalle por archivo. + start = len(resumen_obs) + 3 + pd.DataFrame([{"": "DETALLE POR ARCHIVO"}]).to_excel( + writer, index=False, header=False, sheet_name="Resumen", startrow=start + ) + resumen_archivos.to_excel(writer, index=False, sheet_name="Resumen", startrow=start + 2) + + autoajustar_excel(writer, ["Original", "Consolidado", "Resumen"]) + + +def procesar_archivo_unico(archivo_entrada, log_callback=None, ruta_origen_servicio=None): + archivo_entrada = Path(archivo_entrada) + carpeta_temp = archivo_entrada.parent / "__TEMP_ARCHIVO_UNICO__" + carpeta_temp.mkdir(exist_ok=True) + + copia = carpeta_temp / archivo_entrada.name + shutil.copy2(archivo_entrada, copia) + + return procesar_carpeta_incremental(carpeta_temp, log_callback=log_callback, ruta_origen_servicio=ruta_origen_servicio) + + +def procesar_carpeta_incremental(carpeta, log_callback=None, ruta_origen_servicio=None): + """ + Genera un Excel único con 3 hojas: + 1. Original + 2. Consolidado + 3. Resumen + + También conserva CSV_FINAL_IT.csv solo con registros PASO_BOT para el bot. + """ + carpeta = Path(carpeta) + archivos = listar_archivos_entrada(carpeta) + + if not archivos: + raise RuntimeError("No se encontraron archivos CSV o Excel en la carpeta seleccionada.") + + salida = carpeta / "SALIDA_NORMALIZADOR_IT" + salida.mkdir(exist_ok=True) + + excel_consolidado = salida / "CONSOLIDADO_BOT_IT.xlsx" + csv_final = salida / "CSV_FINAL_IT.csv" + + todos_original = [] + todos_ok = [] + todos_errores = [] + resumen = [] + + def log(msg): + if log_callback: + log_callback(msg) + + log("Procesando archivos y generando Excel único: Original | Consolidado | Resumen...") + mapa_servicios = cargar_origen_servicio(ruta_origen_servicio) + + if mapa_servicios: + log(f"Origen_Servicio cargado: {len(mapa_servicios)} radicados con servicio.") + else: + log("No se seleccionó Origen Servicio o no tenía datos válidos.") + + for idx_archivo, path in enumerate(archivos, start=1): + try: + log(f"[{idx_archivo}/{len(archivos)}] Leyendo: {path.name}") + df = leer_archivo_flexible(path) + df.columns = [str(c).strip() for c in df.columns] + filas_leidas = len(df) + + original = df.copy() + original.insert(0, "archivo_origen", path.name) + todos_original.append(original) + + if df.empty: + resumen.append({ + "archivo": path.name, + "estado": "SIN_DATOS", + "tipo": "", + "filas_leidas": 0, + "paso_bot": 0, + "con_error": 0, + "duplicados": 0, + "detalle": "Archivo vacío", + }) + continue + + especial = es_archivo_especial(df) + if especial: + log(" → Detectado como ESPECIAL") + base = construir_base_especial(df, path.name) + else: + log(" → Detectado como NORMAL") + base = construir_base_normal(df, path.name) + # Llenar tipo_servicio desde el Excel/CSV Origen Servicio usando el radicado + if mapa_servicios and "radicado" in base.columns: + encontrados_servicio = 0 + sin_cruce_servicio = 0 + + for i, row in base.iterrows(): + rad_original = limpiar_texto(row.get("radicado", "")) + rad_norm = normalizar_clave_radicado(rad_original) + rad_digitos = re.sub(r"\D", "", rad_original) + + servicio_excel = ( + mapa_servicios.get(rad_original, "") + or mapa_servicios.get(rad_norm, "") + or mapa_servicios.get(rad_digitos, "") + ) + + if servicio_excel: + base.at[i, "tipo_servicio"] = servicio_excel + encontrados_servicio += 1 + else: + sin_cruce_servicio += 1 + + log(f" → Origen Servicio aplicado: {encontrados_servicio} filas con servicio / {sin_cruce_servicio} sin cruce.") + + ok, err = normalizar_registros(base) + todos_ok.append(ok) + todos_errores.append(err) + + resumen.append({ + "archivo": path.name, + "estado": "PROCESADO", + "tipo": "ESPECIAL" if especial else "NORMAL", + "filas_leidas": filas_leidas, + "paso_bot": 0, # se actualiza después + "con_error": len(err), + "duplicados": 0, # se actualiza después + "detalle": "", + }) + + log(f" → Leídas: {filas_leidas} | OK preliminar: {len(ok)} | Con error: {len(err)}") + + except Exception as e: + detalle = f"{type(e).__name__}: {e}" + log(f" [ERROR] {path.name}: {detalle}") + resumen.append({ + "archivo": path.name, + "estado": "ERROR_LECTURA", + "tipo": "", + "filas_leidas": 0, + "paso_bot": 0, + "con_error": 0, + "duplicados": 0, + "detalle": detalle, + }) + + df_original = pd.concat(todos_original, ignore_index=True, sort=False) if todos_original else pd.DataFrame() + df_ok_total = pd.concat(todos_ok, ignore_index=True) if todos_ok else pd.DataFrame() + df_err_total = pd.concat(todos_errores, ignore_index=True) if todos_errores else pd.DataFrame() + + df_consolidado = preparar_consolidado(df_ok_total, df_err_total) + + # CSV para el bot: solo PASO_BOT. + df_final_bot = df_consolidado[df_consolidado["OBSERVACION"].eq("PASO_BOT")].copy() + if df_final_bot.empty: + pd.DataFrame(columns=CSV_FINAL_IT_HEADERS).to_csv(csv_final, sep="|", index=False, encoding="utf-8-sig") + else: + df_out = df_final_bot.reindex(columns=COLUMNAS_FINAL_IT).fillna("") + df_out = df_out.rename(columns=CSV_FINAL_IT_RENAME) + df_out = df_out.reindex(columns=CSV_FINAL_IT_HEADERS) + df_out.to_csv(csv_final, sep="|", index=False, encoding="utf-8-sig") + + # Actualizar resumen por archivo desde el consolidado real. + if not df_consolidado.empty and "archivo_origen" in df_consolidado.columns: + paso_por_archivo = df_consolidado[df_consolidado["OBSERVACION"].eq("PASO_BOT")].groupby("archivo_origen").size().to_dict() + dup_por_archivo = df_consolidado[df_consolidado["OBSERVACION"].str.contains("RADICADO_DUPLICADO", na=False)].groupby("archivo_origen").size().to_dict() + error_por_archivo = df_consolidado[~df_consolidado["OBSERVACION"].eq("PASO_BOT")].groupby("archivo_origen").size().to_dict() + for r in resumen: + a = r.get("archivo", "") + r["paso_bot"] = int(paso_por_archivo.get(a, 0)) + r["duplicados"] = int(dup_por_archivo.get(a, 0)) + r["con_error"] = int(error_por_archivo.get(a, 0)) + + resumen_obs, resumen_arch = preparar_resumen(df_consolidado, resumen) + escribir_excel_consolidado(excel_consolidado, df_original, df_consolidado, resumen_obs, resumen_arch) + + total_duplicados = int(df_consolidado["OBSERVACION"].str.contains("RADICADO_DUPLICADO", na=False).sum()) if not df_consolidado.empty else 0 + total_errores = int((~df_consolidado["OBSERVACION"].eq("PASO_BOT")).sum()) if not df_consolidado.empty else 0 + + return { + "carpeta_salida": str(salida), + "excel_consolidado": str(excel_consolidado), + "csv_final": str(csv_final), + "total_final": len(df_final_bot), + "total_errores": total_errores, + "total_duplicados": total_duplicados, + "archivos": len(archivos), + "modo": "excel_consolidado", + } + + +def procesar_carpeta(carpeta, log_callback=None, ruta_origen_servicio=None): + # Se deja como alias para que el modo masivo también genere el nuevo formato. + return procesar_carpeta_incremental(carpeta, log_callback=log_callback, ruta_origen_servicio=ruta_origen_servicio) + + +# ============================================================ +# INTERFAZ GRÁFICA +# ============================================================ + +class NormalizadorITApp: + def __init__(self, root): + self.root = root + self.root.title("Normalizador CSV IT - Salud Total") + self.root.geometry("900x600") + self.root.minsize(850, 550) + + self.carpeta_var = tk.StringVar() + self.archivo_var = tk.StringVar() + self.ruta_origen_servicio = tk.StringVar() + + self.modo_entrada = tk.StringVar(value="carpeta") + self.modo_proceso = tk.StringVar(value="incremental") + + self._build_ui() + + def _build_ui(self): + frame = ttk.Frame(self.root, padding=15) + frame.pack(fill="both", expand=True) + + title = ttk.Label( + frame, + text="Normalizador CSV para Bot IT", + font=("Segoe UI", 16, "bold") + ) + title.pack(anchor="w") + + subtitle = ttk.Label( + frame, + text="Genera Excel único: Original, Consolidado y Resumen; además crea CSV_FINAL_IT.csv", + font=("Segoe UI", 10) + ) + subtitle.pack(anchor="w", pady=(0, 15)) + + entrada_frame = ttk.LabelFrame(frame, text="Tipo de entrada") + entrada_frame.pack(fill="x", pady=5) + + ttk.Radiobutton( + entrada_frame, + text="Procesar carpeta completa", + variable=self.modo_entrada, + value="carpeta" + ).pack(anchor="w", padx=8, pady=2) + + ttk.Radiobutton( + entrada_frame, + text="Procesar un solo archivo CSV", + variable=self.modo_entrada, + value="archivo" + ).pack(anchor="w", padx=8, pady=2) + + row = ttk.Frame(frame) + row.pack(fill="x", pady=5) + + ttk.Label(row, text="Ruta:").pack(side="left") + + self.entry_ruta = ttk.Entry(row, textvariable=self.carpeta_var) + self.entry_ruta.pack(side="left", fill="x", expand=True, padx=8) + + ttk.Button(row, text="Seleccionar", command=self.seleccionar_entrada).pack(side="left") + + row_servicio = ttk.Frame(frame) + row_servicio.pack(fill="x", pady=5) + + ttk.Label(row_servicio, text="Origen Servicio:").pack(side="left") + + self.entry_origen_servicio = ttk.Entry(row_servicio, textvariable=self.ruta_origen_servicio) + self.entry_origen_servicio.pack(side="left", fill="x", expand=True, padx=8) + + ttk.Button( + row_servicio, + text="Seleccionar Excel", + command=self.seleccionar_origen_servicio + ).pack(side="left") + + ttk.Button( + row_servicio, + text="Limpiar", + command=lambda: self.ruta_origen_servicio.set("") + ).pack(side="left", padx=(8, 0)) + + modo_frame = ttk.LabelFrame(frame, text="Modo de procesamiento") + modo_frame.pack(fill="x", pady=8) + + ttk.Radiobutton( + modo_frame, + text="Procesar uno por uno y consolidar (recomendado)", + variable=self.modo_proceso, + value="incremental" + ).pack(anchor="w", padx=8, pady=2) + + ttk.Radiobutton( + modo_frame, + text="Procesar todo junto (modo anterior)", + variable=self.modo_proceso, + value="masivo" + ).pack(anchor="w", padx=8, pady=2) + + buttons = ttk.Frame(frame) + buttons.pack(fill="x", pady=10) + + self.btn_procesar = ttk.Button(buttons, text="Procesar carpeta", command=self.iniciar_proceso) + self.btn_procesar.pack(side="left") + + ttk.Button(buttons, text="Abrir salida", command=self.abrir_salida).pack(side="left", padx=8) + + self.progress = ttk.Progressbar(frame, mode="indeterminate") + self.progress.pack(fill="x", pady=8) + + ttk.Label(frame, text="Log del proceso:").pack(anchor="w") + + self.txt_log = tk.Text(frame, height=22, wrap="word") + self.txt_log.pack(fill="both", expand=True) + + scrollbar = ttk.Scrollbar(self.txt_log, command=self.txt_log.yview) + self.txt_log.configure(yscrollcommand=scrollbar.set) + + footer = ttk.Label( + frame, + text="Salidas: CONSOLIDADO_BOT_IT.xlsx (Original, Consolidado, Resumen) | CSV_FINAL_IT.csv", + font=("Segoe UI", 9) + ) + footer.pack(anchor="w", pady=(8, 0)) + + def log(self, msg): + self.txt_log.insert("end", f"{datetime.now().strftime('%H:%M:%S')} {msg}\n") + self.txt_log.see("end") + self.root.update_idletasks() + + def seleccionar_entrada(self): + modo = self.modo_entrada.get() + + if modo == "archivo": + archivo = filedialog.askopenfilename( + title="Selecciona un archivo CSV o Excel", + filetypes=[("CSV o Excel", "*.csv *.xlsx *.xlsm *.xls"), ("CSV", "*.csv"), ("Excel", "*.xlsx *.xlsm *.xls")] + ) + + if archivo: + self.archivo_var.set(archivo) + self.carpeta_var.set(archivo) + + else: + carpeta = filedialog.askdirectory( + title="Selecciona carpeta con CSV o Excel" + ) + + if carpeta: + self.carpeta_var.set(carpeta) + + def seleccionar_origen_servicio(self): + archivo = filedialog.askopenfilename( + title="Seleccionar Excel/CSV de Origen Servicio", + filetypes=[ + ("Excel o CSV", "*.xlsx *.xlsm *.xls *.csv"), + ("Excel", "*.xlsx *.xlsm *.xls"), + ("CSV", "*.csv"), + ("Todos", "*.*"), + ] + ) + + if archivo: + self.ruta_origen_servicio.set(archivo) + self.log(f"Origen Servicio seleccionado: {archivo}") + + def abrir_salida(self): + carpeta = self.carpeta_var.get().strip() + if not carpeta: + messagebox.showwarning("Sin carpeta", "Selecciona primero una carpeta.") + return + + salida = Path(carpeta) / "SALIDA_NORMALIZADOR_IT" + if not salida.exists(): + messagebox.showinfo("Sin salida", "Todavía no existe la carpeta de salida.") + return + + try: + os.startfile(str(salida)) + except Exception as e: + messagebox.showerror("Error", f"No se pudo abrir la salida:\n{e}") + + def iniciar_proceso(self): + ruta = self.carpeta_var.get().strip() + + if not ruta: + messagebox.showwarning("Sin ruta", "Selecciona carpeta o archivo.") + return + + if not Path(ruta).exists(): + messagebox.showerror("Error", "La ruta seleccionada no existe.") + return + + self.btn_procesar.config(state="disabled") + self.progress.start(10) + self.txt_log.delete("1.0", "end") + self.log("Iniciando normalización...") + if self.ruta_origen_servicio.get().strip(): + self.log(f"Usando Origen Servicio: {self.ruta_origen_servicio.get().strip()}") + else: + self.log("Origen Servicio: no seleccionado (se usará el servicio del archivo original si existe).") + + hilo = threading.Thread(target=self._procesar_thread, args=(ruta,), daemon=True) + hilo.start() + + def _procesar_thread(self, ruta): + try: + modo_entrada = self.modo_entrada.get() + modo_proceso = self.modo_proceso.get() + + if modo_entrada == "archivo": + resultado = procesar_archivo_unico(ruta, log_callback=self.log, ruta_origen_servicio=self.ruta_origen_servicio.get().strip()) + + else: + if modo_proceso == "incremental": + resultado = procesar_carpeta_incremental(ruta, log_callback=self.log, ruta_origen_servicio=self.ruta_origen_servicio.get().strip()) + else: + resultado = procesar_carpeta(ruta, log_callback=self.log, ruta_origen_servicio=self.ruta_origen_servicio.get().strip()) + + self.log("") + self.log("Proceso terminado correctamente.") + self.log(f"Archivos leídos: {resultado['archivos']}") + self.log(f"Registros finales IT: {resultado['total_final']}") + self.log(f"Errores normalización: {resultado['total_errores']}") + self.log(f"Duplicados: {resultado['total_duplicados']}") + self.log(f"Salida: {resultado['carpeta_salida']}") + if resultado.get("excel_consolidado"): + self.log(f"Excel consolidado: {resultado['excel_consolidado']}") + + messagebox.showinfo( + "Proceso terminado", + "Normalización finalizada.\n\n" + f"Registros finales IT: {resultado['total_final']}\n" + f"Errores: {resultado['total_errores']}\n" + f"Duplicados: {resultado['total_duplicados']}\n\n" + f"Salida:\n{resultado['carpeta_salida']}" + ) + + except Exception as e: + self.log("[ERROR GENERAL]") + self.log(str(e)) + self.log(traceback.format_exc()) + messagebox.showerror("Error", f"Ocurrió un error:\n{e}") + + finally: + self.progress.stop() + self.btn_procesar.config(state="normal") + + +def main(): + root = tk.Tk() + app = NormalizadorITApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index a7280eb..0c3b2ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,19 @@ services: environment: DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor + poblacion_apta: + build: + context: ./services/poblacion_apta + dockerfile: Dockerfile + container_name: ipsextractor-poblacion-apta + depends_on: + database: + condition: service_healthy + db-migrations: + condition: service_completed_successfully + environment: + DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor + count-api: build: context: ./services/services-count-facturas-medicas-serving @@ -196,6 +209,8 @@ services: condition: service_healthy cleaning: condition: service_started + poblacion_apta: + condition: service_started count-api: condition: service_healthy bot-descarga: @@ -203,6 +218,7 @@ services: environment: EXTRACTION_URL: http://extraction:8001 CLEANING_URL: http://cleaning:8002 + POBLACION_APTA_URL: http://poblacion_apta:8003 COUNT_URL: http://count-api:8080 BOT_DESCARGA_URL: http://bot-descarga:8200 ports: diff --git a/docs/database.dbml b/docs/database.dbml index 263d3ba..7dee13a 100644 --- a/docs/database.dbml +++ b/docs/database.dbml @@ -29,6 +29,8 @@ TableGroup pipeline { extraction_rows cleaning_jobs cleaned_rows + poblacion_apta_jobs + poblacion_apta_rows } TableGroup counting { @@ -274,3 +276,38 @@ Table cleaned_rows { Note: 'Salida del servicio de limpieza.' } + +Table poblacion_apta_jobs { + id varchar(36) [pk, note: 'UUID v4'] + job_id varchar(36) [null, ref: > jobs.id, note: 'Trabajo Desktop al que pertenece'] + status varchar(32) [not null, default: 'pending', note: 'pending | running | completed | failed'] + request jsonb [not null, default: '{}', note: 'cleaning_job_ids, job_id'] + log jsonb [not null, default: '[]'] + summary jsonb [not null, default: '{}', note: 'totales paso_bot / errores / por fuente'] + error text + created_at timestamptz [not null] + updated_at timestamptz [not null] + + indexes { + job_id + status + } + + Note: 'Consolidación y validación Verificador ST sobre una o más limpiezas.' +} + +Table poblacion_apta_rows { + id integer [pk, increment] + poblacion_apta_job_id varchar(36) [not null, ref: > poblacion_apta_jobs.id] + cleaning_job_id varchar(36) [null, note: 'Limpieza de origen de la fila'] + row_index integer [not null, default: 0] + row_json jsonb [not null, default: '{}', note: 'Schema FINAL_IT + OBSERVACION'] + created_at timestamptz [not null] + + indexes { + poblacion_apta_job_id + cleaning_job_id + } + + Note: 'Filas del consolidado Población apta (PASO_BOT y observaciones).' +} diff --git a/services/api_gateway/api/main.py b/services/api_gateway/api/main.py index 3bbcce0..4e5cef5 100644 --- a/services/api_gateway/api/main.py +++ b/services/api_gateway/api/main.py @@ -17,6 +17,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/") CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/") +POBLACION_APTA_URL = os.getenv("POBLACION_APTA_URL", "http://poblacion_apta:8003").rstrip("/") COUNT_URL = os.getenv("COUNT_URL", "http://count-api:8080").rstrip("/") BOT_DESCARGA_URL = os.getenv("BOT_DESCARGA_URL", "http://bot-descarga:8200").rstrip("/") @@ -35,9 +36,10 @@ async def lifespan(app: FastAPI): limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), ) _log.info( - "Gateway iniciado — extraction=%s cleaning=%s count=%s bot_descarga=%s", + "Gateway iniciado — extraction=%s cleaning=%s poblacion_apta=%s count=%s bot_descarga=%s", EXTRACTION_URL, CLEANING_URL, + POBLACION_APTA_URL, COUNT_URL, BOT_DESCARGA_URL, ) @@ -79,6 +81,8 @@ def _is_upload_path(path: str) -> bool: or "/upload" in path or path.startswith("/cleaning/analyze-csv") or path.startswith("/cleaning/jobs/from-csv") + or path.startswith("/poblacion-apta/jobs/from-csv") + or path.startswith("/poblacion-apta/jobs/from-cleaning") or path.startswith("/bot-descarga/jobs") ) @@ -98,13 +102,15 @@ async def health() -> dict[str, Any]: client = _client() extraction = await _safe_health(client, EXTRACTION_URL) cleaning = await _safe_health(client, CLEANING_URL) + poblacion_apta = await _safe_health(client, POBLACION_APTA_URL) counting = await _safe_health(client, COUNT_URL) bot_descarga = await _safe_health(client, BOT_DESCARGA_URL) return { - "status": "ok" if extraction["ok"] and cleaning["ok"] else "degraded", + "status": "ok" if extraction["ok"] and cleaning["ok"] and poblacion_apta["ok"] else "degraded", "services": { "extraction": extraction, "cleaning": cleaning, + "poblacion_apta": poblacion_apta, "counting": counting, "bot_descarga": bot_descarga, }, @@ -116,6 +122,11 @@ async def models() -> Response: return await _proxy("GET", f"{EXTRACTION_URL}/models") +@app.get("/openai/balance") +async def openai_balance() -> Response: + return await _proxy("GET", f"{EXTRACTION_URL}/openai/balance") + + @app.get("/config") async def config() -> dict[str, Any]: client = _client() @@ -147,6 +158,11 @@ async def cleaning_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{CLEANING_URL}/{path}") +@app.api_route("/poblacion-apta/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def poblacion_apta_proxy(path: str, request: Request) -> Response: + return await _proxy_request(request, f"{POBLACION_APTA_URL}/{path}") + + @app.api_route("/count/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def count_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{COUNT_URL}/{path}") diff --git a/services/cleaning/api/main.py b/services/cleaning/api/main.py index dc7e365..eadceaf 100644 --- a/services/cleaning/api/main.py +++ b/services/cleaning/api/main.py @@ -48,6 +48,8 @@ class CleaningRequest(BaseModel): job_id: str | None = None rules_per_column: dict[str, list[str]] = Field(default_factory=dict) cop_mod_fecha_column: dict[str, str] = Field(default_factory=dict) + cop_mod_bruto_column: dict[str, str] = Field(default_factory=dict) + cop_mod_neto_column: dict[str, str] = Field(default_factory=dict) cop_mod_config: dict[str, Any] | None = None @@ -56,6 +58,8 @@ class PreviewRequest(BaseModel): extraction_batch_id: str | None = None rules_per_column: dict[str, list[str]] = Field(default_factory=dict) cop_mod_fecha_column: dict[str, str] = Field(default_factory=dict) + cop_mod_bruto_column: dict[str, str] = Field(default_factory=dict) + cop_mod_neto_column: dict[str, str] = Field(default_factory=dict) cop_mod_config: dict[str, Any] | None = None sample_size: int = 8 @@ -65,6 +69,12 @@ def dataframe_to_records(df: pd.DataFrame) -> list[dict[str, Any]]: def serialize_job(job: CleaningJob) -> dict[str, Any]: + # started/finished derivados: created_at = inicio; updated_at = fin si ya terminó + finished = ( + job.updated_at.isoformat() + if job.status in {"completed", "failed"} + else None + ) return { "id": job.id, "extraction_batch_id": job.extraction_batch_id, @@ -76,6 +86,8 @@ def serialize_job(job: CleaningJob) -> dict[str, Any]: "error": job.error, "created_at": job.created_at.isoformat(), "updated_at": job.updated_at.isoformat(), + "started_at": job.created_at.isoformat(), + "finished_at": finished, } @@ -96,6 +108,7 @@ def _create_cleaning_task( request: dict[str, Any], error: str | None = None, title: str | None = None, + started_at: Any | None = None, ) -> str | None: """Crea Task(type=cleaning) vinculada al job Desktop. Devuelve task.id o None.""" if not job_id: @@ -109,7 +122,7 @@ def _create_cleaning_task( request=request, result={"cleaning_job_id": cleaning_job_id}, error=error, - started_at=now, + started_at=started_at or now, finished_at=now, updated_at=now, ) @@ -142,12 +155,16 @@ def _run_rules_on_dataframe( rules_per_column: dict[str, list[str]], cop_mod_fecha_column: dict[str, str], cop_mod_config: dict[str, Any] | None, + cop_mod_bruto_column: dict[str, str] | None = None, + cop_mod_neto_column: dict[str, str] | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: result_df, log = apply_rules( df, rules_per_column, cop_mod_fecha_column=cop_mod_fecha_column, cop_mod_config=cop_mod_config, + cop_mod_bruto_column=cop_mod_bruto_column, + cop_mod_neto_column=cop_mod_neto_column, ) return dataframe_to_records(result_df), log @@ -186,6 +203,8 @@ def _preview_from_dataframe( rules_per_column: dict[str, list[str]], cop_mod_fecha_column: dict[str, str], cop_mod_config: dict[str, Any] | None, + cop_mod_bruto_column: dict[str, str] | None = None, + cop_mod_neto_column: dict[str, str] | None = None, ) -> dict[str, Any]: rows_before = dataframe_to_records(df) result_df, _log = apply_rules( @@ -193,6 +212,8 @@ def _preview_from_dataframe( rules_per_column, cop_mod_fecha_column=cop_mod_fecha_column, cop_mod_config=cop_mod_config, + cop_mod_bruto_column=cop_mod_bruto_column, + cop_mod_neto_column=cop_mod_neto_column, ) rows_after = dataframe_to_records(result_df) @@ -299,6 +320,8 @@ def preview_cleaning(request: PreviewRequest) -> dict[str, Any]: request.rules_per_column, request.cop_mod_fecha_column, request.cop_mod_config, + request.cop_mod_bruto_column, + request.cop_mod_neto_column, ) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -349,6 +372,8 @@ def run_cleaning(request: CleaningRequest) -> dict[str, Any]: request.rules_per_column, request.cop_mod_fecha_column, request.cop_mod_config, + request.cop_mod_bruto_column, + request.cop_mod_neto_column, ) with session_scope() as session: @@ -372,6 +397,7 @@ def run_cleaning(request: CleaningRequest) -> dict[str, Any]: status="completed", request=request_dump, title="Limpieza desde extracción", + started_at=job.created_at, ) session.flush() return serialize_job(job) @@ -380,6 +406,7 @@ def run_cleaning(request: CleaningRequest) -> dict[str, Any]: except Exception as exc: with session_scope() as session: job = session.get(CleaningJob, job_id) + started = job.created_at if job is not None else None if job is not None: job.status = "failed" job.error = str(exc) @@ -393,6 +420,7 @@ def run_cleaning(request: CleaningRequest) -> dict[str, Any]: request=request_dump, error=str(exc), title="Limpieza desde extracción", + started_at=started, ) raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -403,6 +431,8 @@ async def run_cleaning_from_csv( job_id: str | None = Form(None), rules_per_column: str | None = Form(None), cop_mod_fecha_column: str | None = Form(None), + cop_mod_bruto_column: str | None = Form(None), + cop_mod_neto_column: str | None = Form(None), cop_mod_config: str | None = Form(None), ) -> dict[str, Any]: content = await file.read() @@ -411,11 +441,17 @@ async def run_cleaning_from_csv( rules = _parse_json_form(rules_per_column, {}) fecha_map = _parse_json_form(cop_mod_fecha_column, {}) + bruto_map = _parse_json_form(cop_mod_bruto_column, {}) + neto_map = _parse_json_form(cop_mod_neto_column, {}) cop_cfg = _parse_json_form(cop_mod_config, None) if not isinstance(rules, dict): raise HTTPException(status_code=400, detail="rules_per_column debe ser un objeto JSON") if not isinstance(fecha_map, dict): raise HTTPException(status_code=400, detail="cop_mod_fecha_column debe ser un objeto JSON") + if not isinstance(bruto_map, dict): + raise HTTPException(status_code=400, detail="cop_mod_bruto_column debe ser un objeto JSON") + if not isinstance(neto_map, dict): + raise HTTPException(status_code=400, detail="cop_mod_neto_column debe ser un objeto JSON") try: df, delimiter = load_csv_bytes(content, file.filename or "upload.csv") @@ -431,6 +467,8 @@ async def run_cleaning_from_csv( "job_id": job_id, "rules_per_column": rules, "cop_mod_fecha_column": fecha_map, + "cop_mod_bruto_column": bruto_map, + "cop_mod_neto_column": neto_map, "cop_mod_config": cop_cfg, } @@ -450,7 +488,9 @@ async def run_cleaning_from_csv( desktop_job_id = job_id try: - output_rows, log = _run_rules_on_dataframe(df, rules, fecha_map, cop_cfg) + output_rows, log = _run_rules_on_dataframe( + df, rules, fecha_map, cop_cfg, bruto_map, neto_map + ) with session_scope() as session: _persist_cleaned_rows( @@ -473,6 +513,7 @@ async def run_cleaning_from_csv( status="completed", request=request_dump, title=f"Limpieza CSV — {file.filename or 'upload.csv'}", + started_at=job.created_at, ) session.flush() return serialize_job(job) @@ -481,6 +522,7 @@ async def run_cleaning_from_csv( except Exception as exc: with session_scope() as session: job = session.get(CleaningJob, cleaning_job_id) + started = job.created_at if job is not None else None if job is not None: job.status = "failed" job.error = str(exc) @@ -494,6 +536,7 @@ async def run_cleaning_from_csv( request=request_dump, error=str(exc), title=f"Limpieza CSV — {file.filename or 'upload.csv'}", + started_at=started, ) raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/services/cleaning/domain/csv_cleaning/apply.py b/services/cleaning/domain/csv_cleaning/apply.py index 72d96dd..2c1f861 100644 --- a/services/cleaning/domain/csv_cleaning/apply.py +++ b/services/cleaning/domain/csv_cleaning/apply.py @@ -6,7 +6,11 @@ from typing import Any, Optional import pandas as pd -from .cop_mod import _apply_cop_mod_split, merge_moderadora_config +from .cop_mod import ( + _apply_cop_mod_bruto_neto, + _apply_cop_mod_split, + merge_moderadora_config, +) from .documents import _apply_doc_id_split_columns from .notas import _apply_nota_extract_columns from .rules import get_rule_fn, get_rule_name @@ -20,20 +24,24 @@ def apply_rules( *, cop_mod_fecha_column: Optional[dict[str, str]] = None, cop_mod_config: Optional[dict[str, Any]] = None, + cop_mod_bruto_column: Optional[dict[str, str]] = None, + cop_mod_neto_column: Optional[dict[str, str]] = None, ) -> tuple[pd.DataFrame, list[str]]: """Aplica las reglas seleccionadas a cada columna. `rules_per_column` es un dict de `columna -> lista de IDs de reglas en el - orden a aplicar`. La regla `cop_mod_split` se difiere a una segunda pasada - tras el resto, para que la columna de fecha pueda normalizarse antes. + orden a aplicar`. Las reglas `cop_mod_split` y `cop_mod_bruto_neto` se + difieren a una segunda pasada tras el resto. - `cop_mod_fecha_column`: mapa columna de importe -> nombre de columna Fecha Servicio. - `cop_mod_config`: opcional; puede incluir ``moderadora_by_year`` (dict año → lista). + `cop_mod_fecha_column`: mapa columna ancla -> Fecha Servicio (para split). + `cop_mod_bruto_column` / `cop_mod_neto_column`: mapas ancla -> bruto / neto. + `cop_mod_config`: opcional; puede incluir ``moderadora_by_year``. """ result = df.copy() log: list[str] = [] moderadora_years = merge_moderadora_config(cop_mod_config) + deferred = {"cop_mod_split", "cop_mod_bruto_neto"} def _run_rules_for_column(col: str, rule_ids: list[str]) -> None: if not rule_ids: @@ -74,12 +82,12 @@ def apply_rules( logger.exception("Error aplicando regla %s a %s", rule_id, col) log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}") - # Fase 1: todas las reglas excepto cop_mod_split + # Fase 1: todas las reglas excepto COP/MOD diferidas for col, rule_ids in rules_per_column.items(): - filtered = [r for r in rule_ids if r != "cop_mod_split"] + filtered = [r for r in rule_ids if r not in deferred] _run_rules_for_column(col, filtered) - # Fase 2: Separa COP/MOD sobre el DataFrame ya transformado + # Fase 2a: Separa COP/MOD (cuotas por año) fecha_map = cop_mod_fecha_column or {} for col, rule_ids in rules_per_column.items(): if "cop_mod_split" not in rule_ids: @@ -102,4 +110,23 @@ def apply_rules( logger.exception("Error aplicando COP/MOD a %s", col) log.append(f"[ERROR] '{col}' regla 'cop_mod_split': {exc}") + # Fase 2b: COP/MOD = bruto − neto + bruto_map = cop_mod_bruto_column or {} + neto_map = cop_mod_neto_column or {} + for col, rule_ids in rules_per_column.items(): + if "cop_mod_bruto_neto" not in rule_ids: + continue + bruto_col = (bruto_map.get(col) or col).strip() + neto_col = (neto_map.get(col) or "").strip() + if not neto_col: + log.append( + f"[WARN] COP/MOD (bruto−neto) en '{col}': indique la columna «Valor neto»; se omite." + ) + continue + try: + _apply_cop_mod_bruto_neto(result, bruto_col, neto_col, log) + except Exception as exc: # noqa: BLE001 + logger.exception("Error aplicando COP/MOD bruto−neto a %s", col) + log.append(f"[ERROR] '{col}' regla 'cop_mod_bruto_neto': {exc}") + return result, log diff --git a/services/cleaning/domain/csv_cleaning/cop_mod.py b/services/cleaning/domain/csv_cleaning/cop_mod.py index 0ffae04..7aa075c 100644 --- a/services/cleaning/domain/csv_cleaning/cop_mod.py +++ b/services/cleaning/domain/csv_cleaning/cop_mod.py @@ -160,3 +160,59 @@ def _apply_cop_mod_split( f"[OK] '{amount_col}' -> '{ccol}', '{mcol}' <- Separa COP/MOD " f"(fecha: '{fecha_column}')" ) + + +def _alloc_cop_mod_diff_column_name(existing: set[str]) -> str: + """Nombre único para la columna resultado (preferido: cop/mod).""" + preferred = "cop/mod" + if preferred not in existing: + return preferred + for i in range(2, 200): + name = f"cop/mod_{i}" + if name not in existing: + return name + raise ValueError("No se pudo asignar nombre de columna único para cop/mod.") + + +def _apply_cop_mod_bruto_neto( + result: pd.DataFrame, + bruto_col: str, + neto_col: str, + log: list[str], +) -> None: + """Crea columna ``cop/mod`` = valor bruto − valor neto.""" + if bruto_col not in result.columns: + log.append( + f"[WARN] COP/MOD (bruto−neto): columna bruto '{bruto_col}' no existe; se omite." + ) + return + if neto_col not in result.columns: + log.append( + f"[WARN] COP/MOD (bruto−neto): columna neto '{neto_col}' no existe; se omite." + ) + return + if bruto_col == neto_col: + log.append( + f"[WARN] COP/MOD (bruto−neto): bruto y neto son la misma columna '{bruto_col}'; se omite." + ) + return + + bruto_ser = result[bruto_col] + neto_ser = result[neto_col] + bruto_hint = _infer_decimal_hint_from_column(bruto_ser.astype(str)) + neto_hint = _infer_decimal_hint_from_column(neto_ser.astype(str)) + + out_col = _alloc_cop_mod_diff_column_name(set(result.columns)) + values: list[Any] = [] + for i in range(len(result)): + bruto_i = _amount_cell_to_optional_int(bruto_ser.iloc[i], bruto_hint) + neto_i = _amount_cell_to_optional_int(neto_ser.iloc[i], neto_hint) + if bruto_i is None or neto_i is None: + values.append("") + continue + values.append(bruto_i - neto_i) + + result[out_col] = values + log.append( + f"[OK] '{out_col}' = '{bruto_col}' − '{neto_col}' <- COP/MOD (bruto − neto)" + ) diff --git a/services/cleaning/domain/csv_cleaning/rules/builtins.py b/services/cleaning/domain/csv_cleaning/rules/builtins.py index 2be0016..8e0bbd9 100644 --- a/services/cleaning/domain/csv_cleaning/rules/builtins.py +++ b/services/cleaning/domain/csv_cleaning/rules/builtins.py @@ -121,6 +121,15 @@ register_rule( ), fn=special._rule_passthrough_series, ) +register_rule( + "cop_mod_bruto_neto", + name="COP/MOD (bruto − neto)", + description=( + "Calcula la columna «cop/mod» como Valor bruto − Valor neto. " + "Elija ambas columnas en la tarjeta (se detectan automáticamente si existen)." + ), + fn=special._rule_passthrough_series, +) register_rule( "nota_paciente_autorizacion", name="Extraer datos de notas (documento y autorización)", diff --git a/services/cleaning/domain/csv_cleaning/type_inference.py b/services/cleaning/domain/csv_cleaning/type_inference.py index d85d219..4cfdaa6 100644 --- a/services/cleaning/domain/csv_cleaning/type_inference.py +++ b/services/cleaning/domain/csv_cleaning/type_inference.py @@ -101,7 +101,7 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]: """Identificadores de reglas a mostrar según el tipo inferido (sin redundancias).""" base = ["strip_special_spaces", "strip_special_keep_date_seps", "strip_spaces"] if inferred_type == TYPE_INTEGER: - return base + ["cop_mod_split"] + return base + ["cop_mod_split", "cop_mod_bruto_neto"] if inferred_type == TYPE_DECIMAL: return base + [ "to_integer", @@ -109,6 +109,7 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]: "doc_id_split_tipo_numero", "doc_type_homologate", "cop_mod_split", + "cop_mod_bruto_neto", ] if inferred_type == TYPE_DATE: return base + [ @@ -125,6 +126,7 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]: "doc_type_homologate", "date_to_ddmmyyyy", "cop_mod_split", + "cop_mod_bruto_neto", "nota_paciente_autorizacion", ] # Texto / categórico / boolean / empty → todas las de limpieza de texto + las @@ -136,6 +138,7 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]: "doc_type_homologate", "date_to_ddmmyyyy", "cop_mod_split", + "cop_mod_bruto_neto", "nota_paciente_autorizacion", ] diff --git a/services/db/alembic/versions/0006_poblacion_apta.py b/services/db/alembic/versions/0006_poblacion_apta.py new file mode 100644 index 0000000..f184f4a --- /dev/null +++ b/services/db/alembic/versions/0006_poblacion_apta.py @@ -0,0 +1,82 @@ +"""Add poblacion_apta_jobs and poblacion_apta_rows tables. + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-07-28 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006" +down_revision: Union[str, None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + existing = set(inspector.get_table_names()) + + if "poblacion_apta_jobs" not in existing: + op.create_table( + "poblacion_apta_jobs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("job_id", sa.String(36), sa.ForeignKey("jobs.id"), nullable=True), + sa.Column("status", sa.String(32), nullable=False, server_default="pending"), + sa.Column("request", sa.JSON, nullable=False, server_default="{}"), + sa.Column("log", sa.JSON, nullable=False, server_default="[]"), + sa.Column("summary", sa.JSON, nullable=False, server_default="{}"), + sa.Column("error", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_poblacion_apta_jobs_job_id", "poblacion_apta_jobs", ["job_id"]) + op.create_index("ix_poblacion_apta_jobs_status", "poblacion_apta_jobs", ["status"]) + + if "poblacion_apta_rows" not in existing: + op.create_table( + "poblacion_apta_rows", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "poblacion_apta_job_id", + sa.String(36), + sa.ForeignKey("poblacion_apta_jobs.id"), + nullable=False, + ), + sa.Column("cleaning_job_id", sa.String(36), nullable=True), + sa.Column("row_index", sa.Integer, nullable=False, server_default="0"), + sa.Column("row_json", sa.JSON, nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index( + "ix_poblacion_apta_rows_poblacion_apta_job_id", + "poblacion_apta_rows", + ["poblacion_apta_job_id"], + ) + op.create_index( + "ix_poblacion_apta_rows_cleaning_job_id", + "poblacion_apta_rows", + ["cleaning_job_id"], + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + existing = set(inspector.get_table_names()) + if "poblacion_apta_rows" in existing: + op.drop_index("ix_poblacion_apta_rows_cleaning_job_id", table_name="poblacion_apta_rows") + op.drop_index( + "ix_poblacion_apta_rows_poblacion_apta_job_id", table_name="poblacion_apta_rows" + ) + op.drop_table("poblacion_apta_rows") + if "poblacion_apta_jobs" in existing: + op.drop_index("ix_poblacion_apta_jobs_status", table_name="poblacion_apta_jobs") + op.drop_index("ix_poblacion_apta_jobs_job_id", table_name="poblacion_apta_jobs") + op.drop_table("poblacion_apta_jobs") diff --git a/services/desktop_app/electron/main.cjs b/services/desktop_app/electron/main.cjs index ce85062..b0ee697 100644 --- a/services/desktop_app/electron/main.cjs +++ b/services/desktop_app/electron/main.cjs @@ -65,6 +65,25 @@ ipcMain.handle("updater:install", async (_event, manifest) => { return autoUpdate.downloadAndInstall(manifest, sendUpdateProgress); }); +ipcMain.handle("net:download-url", async (event, urlStr) => { + const raw = String(urlStr || "").trim(); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error("URL de descarga inválida"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Solo se permiten descargas http(s)"); + } + const win = BrowserWindow.fromWebContents(event.sender) || mainWindow; + if (!win || win.isDestroyed()) { + throw new Error("Ventana no disponible para iniciar la descarga"); + } + win.webContents.downloadURL(parsed.toString()); + return { ok: true }; +}); + app.whenReady().then(() => { if (process.platform === "win32") { app.setAppUserModelId("com.ipsextractor.desktop"); diff --git a/services/desktop_app/electron/preload.cjs b/services/desktop_app/electron/preload.cjs index 08ae111..c2604d9 100644 --- a/services/desktop_app/electron/preload.cjs +++ b/services/desktop_app/electron/preload.cjs @@ -21,4 +21,5 @@ contextBridge.exposeInMainWorld("desktopApi", { ipcRenderer.on("updater:progress", handler); return () => ipcRenderer.removeListener("updater:progress", handler); }, + downloadUrl: (url) => ipcRenderer.invoke("net:download-url", url), }); diff --git a/services/desktop_app/package.json b/services/desktop_app/package.json index 8dbd0b5..19b8d87 100644 --- a/services/desktop_app/package.json +++ b/services/desktop_app/package.json @@ -1,6 +1,6 @@ { "name": "ipsextractor-desktop", - "version": "1.1.1", + "version": "1.2.0", "private": true, "description": "Desktop extraction client for IPSExtractor microservices.", "main": "electron/main.cjs", diff --git a/services/desktop_app/scripts/publish-version.mjs b/services/desktop_app/scripts/publish-version.mjs index 8e74b3b..e14fc44 100644 --- a/services/desktop_app/scripts/publish-version.mjs +++ b/services/desktop_app/scripts/publish-version.mjs @@ -103,14 +103,32 @@ async function sha256File(filePath) { } async function upload(client, key, body, contentType) { - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: body, - ContentType: contentType, - }), - ); + const payload = Buffer.isBuffer(body) + ? body + : typeof body === "string" + ? Buffer.from(body, "utf8") + : body; + + const params = { + Bucket: bucket, + Key: key, + Body: payload, + ContentType: contentType, + }; + + // MinIO exige Content-Length; con Body string/stream el SDK a veces no lo envía. + if (Buffer.isBuffer(payload)) { + params.ContentLength = payload.byteLength; + } else if (typeof payload === "object" && payload && "path" in payload) { + // ReadStream desde createReadStream(path) + try { + params.ContentLength = statSync(payload.path).size; + } catch { + /* ignore — el stream del .exe suele ir bien */ + } + } + + await client.send(new PutObjectCommand(params)); console.log(` ↑ s3://${bucket}/${key}`); } @@ -201,7 +219,13 @@ async function main() { console.log(`Publicando v${version} → ${endpoint}/${bucket}/${prefix}/`); await upload(client, objectKeyExe, createReadStream(setupPath), "application/octet-stream"); - await upload(client, objectKeyData, `${releaseDateDisplay}\n`, "text/plain; charset=utf-8"); + // Re-upload metadata even if exe already exists (reintento tras fallo parcial). + await upload( + client, + objectKeyData, + Buffer.from(`${releaseDateDisplay}\n`, "utf8"), + "text/plain; charset=utf-8", + ); await upload( client, objectKeyLatest, diff --git a/services/desktop_app/src/renderer/App.tsx b/services/desktop_app/src/renderer/App.tsx index 69ab238..01d9cef 100644 --- a/services/desktop_app/src/renderer/App.tsx +++ b/services/desktop_app/src/renderer/App.tsx @@ -1,12 +1,16 @@ import { BarChart2, Briefcase, + ChevronDown, Cpu, Download, Hash, Link2, + Package, Settings, Sparkles, + Users, + Wallet, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { Job } from "../shared/types"; @@ -29,12 +33,49 @@ import { UpdatePrompt, CheckUpdatesButton, useAutoUpdateCheck } from "./componen import "./modules/counting/CountingModule"; import "./modules/extraction/ExtractionModule"; import "./modules/cleaning/CleaningModule"; +import "./modules/poblacion_apta/PoblacionAptaModule"; import "./modules/downloads/DownloadsModule"; +const BOT_PACKAGE_URL = + "http://s3.builker.xyz/apps-versions/BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip"; + type Status = "idle" | "ok" | "error" | "busy"; type View = "home" | "job" | "settings"; +type OpenAiBalanceState = { + status: Status; + available: number | null; + currency: string; + error: string | null; +}; + export type { JobSection } from "./types/jobSection"; +function formatUsd(amount: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +function formatOpenaiBalanceError(error: string | null): string { + const msg = (error || "").toLowerCase(); + if (!msg) return "No disponible"; + if (msg.includes("mock-key") || msg.includes("incorrect api key")) return "Key inválida"; + if (msg.includes("no configurada") || msg.includes("api key de openai no")) return "Sin API key"; + if ( + msg.includes("session key") || + msg.includes("navegador") || + msg.includes("panel web") || + msg.includes("no permite consultar") + ) { + return "Solo en web"; + } + if (msg.includes("sin conexión") || msg.includes("connection refused")) return "Sin red"; + return "No disponible"; +} + function App() { const [view, setView] = useState("home"); const [settingsReturnView, setSettingsReturnView] = useState<"home" | "job">("home"); @@ -48,8 +89,17 @@ function App() { const [healthJson, setHealthJson] = useState(null); const [configJson, setConfigJson] = useState(null); const [modelsJson, setModelsJson] = useState(null); + const [openaiBalance, setOpenaiBalance] = useState({ + status: "idle", + available: null, + currency: "USD", + error: null, + }); const [busy, setBusy] = useState(false); const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE); + const [downloadsMenuOpen, setDownloadsMenuOpen] = useState(true); + const [botDownloadBusy, setBotDownloadBusy] = useState(false); + const [botDownloadError, setBotDownloadError] = useState(null); const update = useAutoUpdateCheck(); useEffect(() => { @@ -80,6 +130,35 @@ function App() { localStorage.setItem("apiBaseUrl", apiBaseUrl); }, [apiBaseUrl]); + const refreshOpenaiBalance = useCallback(async () => { + setOpenaiBalance((prev) => ({ ...prev, status: "busy", error: null })); + try { + const balance = await client.openaiBalance(); + if (balance.ok && typeof balance.available === "number") { + setOpenaiBalance({ + status: "ok", + available: balance.available, + currency: balance.currency || "USD", + error: null, + }); + } else { + setOpenaiBalance({ + status: "error", + available: null, + currency: balance.currency || "USD", + error: balance.error || "No se pudo obtener el crédito de OpenAI.", + }); + } + } catch (err) { + setOpenaiBalance({ + status: "error", + available: null, + currency: "USD", + error: err instanceof Error ? err.message : "No se pudo obtener el crédito de OpenAI.", + }); + } + }, [client]); + const verifyConnection = useCallback(async () => { setConnectionStatus("busy"); setConnectionMessage("Comprobando gateway y servicios…"); @@ -95,22 +174,38 @@ function App() { setModelsJson(models); setConnectionStatus("ok"); setConnectionMessage("Gateway respondió correctamente."); + void refreshOpenaiBalance(); } catch (err) { setHealthJson(null); setConnectionStatus("error"); setConnectionMessage(err instanceof Error ? err.message : "No se pudo conectar"); + setOpenaiBalance({ + status: "error", + available: null, + currency: "USD", + error: "Sin conexión al gateway.", + }); } finally { setBusy(false); } - }, [client]); + }, [client, refreshOpenaiBalance]); useEffect(() => { void verifyConnection(); }, [verifyConnection]); + useEffect(() => { + if (connectionStatus !== "ok") return; + const timer = window.setInterval(() => { + void refreshOpenaiBalance(); + }, 5 * 60_000); + return () => window.clearInterval(timer); + }, [connectionStatus, refreshOpenaiBalance]); + function openJob(job: Job) { setActiveJob(job); setJobSection("downloads"); + setDownloadsMenuOpen(true); setJobSectionContext({}); setView("job"); } @@ -118,6 +213,31 @@ function App() { function handleActivateSection(section: JobSection, context: Record = {}) { setJobSection(section); setJobSectionContext(context); + setDownloadsMenuOpen(section === "downloads"); + } + + async function downloadBotPackage() { + setBotDownloadBusy(true); + setBotDownloadError(null); + try { + const api = window.desktopApi; + if (typeof api?.downloadUrl === "function") { + await api.downloadUrl(BOT_PACKAGE_URL); + return; + } + const a = document.createElement("a"); + a.href = BOT_PACKAGE_URL; + a.download = "bot_descarga.zip"; + a.rel = "noopener noreferrer"; + a.target = "_blank"; + document.body.appendChild(a); + a.click(); + a.remove(); + } catch (err) { + setBotDownloadError(err instanceof Error ? err.message : "No se pudo iniciar la descarga del bot."); + } finally { + setBotDownloadBusy(false); + } } function openSettings(section: SettingsSection = "connection") { @@ -137,6 +257,20 @@ function App() { const isJobOpen = view === "job" && activeJob; const isSettingsOpen = view === "settings"; + const openaiBalanceLabel = + openaiBalance.status === "busy" + ? "Consultando…" + : openaiBalance.status === "ok" && openaiBalance.available != null + ? formatUsd(openaiBalance.available) + : openaiBalance.status === "error" + ? formatOpenaiBalanceError(openaiBalance.error) + : "—"; + + const openaiBalanceTitle = + openaiBalance.status === "ok" && openaiBalance.available != null + ? `Crédito OpenAI disponible: ${formatUsd(openaiBalance.available)}` + : openaiBalance.error || "Crédito disponible de la API de OpenAI"; + return (