From e73e425e4bf1d880e5f0a3ccf13a3ca7760f6146 Mon Sep 17 00:00:00 2001
From: erik
Date: Tue, 28 Jul 2026 04:29:14 -0500
Subject: [PATCH] feat: Add poblacion_apta service to docker-compose, enhance
API gateway for new service integration, and update README with service
details. Introduce new database tables for poblacion_apta_jobs and
poblacion_apta_rows, and implement functionality for handling cop/mod
calculations in the cleaning service.
---
README.md | 7 +-
Verificador_ST.py | 1847 +++++++++++++++++
docker-compose.yml | 16 +
docs/database.dbml | 37 +
services/api_gateway/api/main.py | 20 +-
services/cleaning/api/main.py | 47 +-
.../cleaning/domain/csv_cleaning/apply.py | 43 +-
.../cleaning/domain/csv_cleaning/cop_mod.py | 56 +
.../domain/csv_cleaning/rules/builtins.py | 9 +
.../domain/csv_cleaning/type_inference.py | 5 +-
.../alembic/versions/0006_poblacion_apta.py | 82 +
services/desktop_app/electron/main.cjs | 19 +
services/desktop_app/electron/preload.cjs | 1 +
services/desktop_app/package.json | 2 +-
.../desktop_app/scripts/publish-version.mjs | 42 +-
services/desktop_app/src/renderer/App.tsx | 200 +-
.../components/BackgroundProgressBanner.tsx | 1 +
.../renderer/components/CountingWizard.tsx | 27 +-
.../components/PoblacionAptaResultsTable.tsx | 232 +++
.../desktop_app/src/renderer/lib/apiClient.ts | 68 +
services/desktop_app/src/renderer/lib/time.ts | 76 +
.../modules/cleaning/CleaningWizard.tsx | 163 +-
.../poblacion_apta/PoblacionAptaModule.ts | 18 +
.../poblacion_apta/PoblacionAptaResults.tsx | 134 ++
.../poblacion_apta/PoblacionAptaWizard.tsx | 523 +++++
.../PoblacionAptaWizardModule.tsx | 25 +
services/desktop_app/src/renderer/styles.css | 241 +++
.../desktop_app/src/renderer/test/setup.ts | 1 +
.../src/renderer/types/jobSection.ts | 9 +-
.../src/renderer/views/JobResultsView.tsx | 253 ++-
.../src/renderer/views/JobView.tsx | 19 +-
services/desktop_app/src/shared/types.ts | 46 +-
services/extraction/api/main.py | 28 +-
services/extraction/domain/openai_balance.py | 244 +++
services/extraction/requirements.txt | 1 +
services/poblacion_apta/Dockerfile | 17 +
services/poblacion_apta/README.md | 45 +
services/poblacion_apta/api/__init__.py | 0
services/poblacion_apta/api/main.py | 517 +++++
services/poblacion_apta/domain/__init__.py | 1 +
services/poblacion_apta/domain/csv_io.py | 89 +
.../domain/verificador_st/__init__.py | 14 +
.../domain/verificador_st/core.py | 1448 +++++++++++++
.../domain/verificador_st/pipeline.py | 225 ++
.../poblacion_apta/infrastructure/__init__.py | 0
.../poblacion_apta/infrastructure/database.py | 155 ++
services/poblacion_apta/requirements.txt | 9 +
47 files changed, 6978 insertions(+), 84 deletions(-)
create mode 100644 Verificador_ST.py
create mode 100644 services/db/alembic/versions/0006_poblacion_apta.py
create mode 100644 services/desktop_app/src/renderer/components/PoblacionAptaResultsTable.tsx
create mode 100644 services/desktop_app/src/renderer/lib/time.ts
create mode 100644 services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaModule.ts
create mode 100644 services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaResults.tsx
create mode 100644 services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizard.tsx
create mode 100644 services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizardModule.tsx
create mode 100644 services/extraction/domain/openai_balance.py
create mode 100644 services/poblacion_apta/Dockerfile
create mode 100644 services/poblacion_apta/README.md
create mode 100644 services/poblacion_apta/api/__init__.py
create mode 100644 services/poblacion_apta/api/main.py
create mode 100644 services/poblacion_apta/domain/__init__.py
create mode 100644 services/poblacion_apta/domain/csv_io.py
create mode 100644 services/poblacion_apta/domain/verificador_st/__init__.py
create mode 100644 services/poblacion_apta/domain/verificador_st/core.py
create mode 100644 services/poblacion_apta/domain/verificador_st/pipeline.py
create mode 100644 services/poblacion_apta/infrastructure/__init__.py
create mode 100644 services/poblacion_apta/infrastructure/database.py
create mode 100644 services/poblacion_apta/requirements.txt
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 (
@@ -202,14 +336,41 @@ function App() {
- handleActivateSection("downloads")}
- >
-
- Bot de descarga
-
+
+
{
+ if (jobSection === "downloads") {
+ setDownloadsMenuOpen((open) => !open);
+ } else {
+ handleActivateSection("downloads");
+ }
+ }}
+ >
+
+ Bot de descarga
+
+
+ {downloadsMenuOpen && (
+
+
void downloadBotPackage()}
+ >
+
+ {botDownloadBusy ? "Descargando…" : "Descargar bot"}
+
+
+ )}
+
Limpieza
+ handleActivateSection("poblacion_apta")}
+ >
+
+ Población apta
+
+ void refreshOpenaiBalance()}
+ disabled={openaiBalance.status === "busy"}
+ >
+
+
+ OpenAI
+ {openaiBalanceLabel}
+
+
diff --git a/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx b/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx
index e91df2e..5cdefb8 100644
--- a/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx
+++ b/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx
@@ -16,6 +16,7 @@ export function BackgroundProgressBanner({ progress, compact = false }: Props) {
const moduleLabel =
progress.module === "counting" ? "Conteo"
: progress.module === "cleaning" ? "Limpieza"
+ : progress.module === "poblacion_apta" ? "Población apta"
: "Extracción";
return (
diff --git a/services/desktop_app/src/renderer/components/CountingWizard.tsx b/services/desktop_app/src/renderer/components/CountingWizard.tsx
index a5cf1f8..01f0be5 100644
--- a/services/desktop_app/src/renderer/components/CountingWizard.tsx
+++ b/services/desktop_app/src/renderer/components/CountingWizard.tsx
@@ -27,6 +27,7 @@ import type {
import { ApiClient } from "../lib/apiClient";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv";
import { countFlatFolders, downloadFilesToManifest } from "../lib/downloadManifest";
+import { taskTimingLabel, timingDuration } from "../lib/time";
import { CountResultsTable } from "./CountResultsTable";
import { CountingStepper, type CountingStep } from "./CountingStepper";
import {
@@ -425,6 +426,7 @@ export function CountingWizard({
job_id: job?.id,
download_job_id: downloadJobIdRef.current ?? null,
source: fromDownload ? "from-download" : "desktop",
+ started_at: new Date(startedAt).toISOString(),
results: finalResults.map((r, i) => {
const entry = manifest[i];
return {
@@ -727,15 +729,22 @@ export function CountingWizard({
{savedTask ? (
- setForm((f) => ({ ...f, validCounts: v }))}
- autoAdjustedFilenames={autoAdjustedFilenames}
- onToggleValid={handleToggleValid}
- onExportCsv={handleExportCsv}
- loading={running}
- />
+ <>
+
+ Duración: {timingDuration(savedTask)}
+ {" · "}
+ {taskTimingLabel(savedTask)}
+
+ setForm((f) => ({ ...f, validCounts: v }))}
+ autoAdjustedFilenames={autoAdjustedFilenames}
+ onToggleValid={handleToggleValid}
+ onExportCsv={handleExportCsv}
+ loading={running}
+ />
+ >
) : (
!error && (
diff --git a/services/desktop_app/src/renderer/components/PoblacionAptaResultsTable.tsx b/services/desktop_app/src/renderer/components/PoblacionAptaResultsTable.tsx
new file mode 100644
index 0000000..f8d843c
--- /dev/null
+++ b/services/desktop_app/src/renderer/components/PoblacionAptaResultsTable.tsx
@@ -0,0 +1,232 @@
+import { Search } from "lucide-react";
+import { useMemo, useState } from "react";
+
+type Row = Record
;
+type FilterKey = "all" | "aptos" | "no_aptos";
+
+interface Props {
+ rows: Row[];
+ /** Resumen opcional del backend; si falta se calcula desde filas. */
+ summary?: {
+ total?: number;
+ paso_bot?: number;
+ con_error?: number;
+ duplicados?: number;
+ };
+}
+
+const PREFERRED_COLUMNS = [
+ "radicado",
+ "total_cargos",
+ "autorizacion",
+ "ingreso",
+ "tipo_documento",
+ "documento",
+ "tipo_servicio",
+ "copago",
+ "cuota_moderadora",
+ "OBSERVACION",
+ "archivo_origen",
+];
+
+const COLUMN_LABELS: Record = {
+ radicado: "Radicado",
+ total_cargos: "Bruto",
+ autorizacion: "Autorización",
+ ingreso: "Fecha",
+ tipo_documento: "Tipo ID",
+ documento: "Documento",
+ tipo_servicio: "Servicio",
+ copago: "Copago",
+ cuota_moderadora: "Moderadora",
+ OBSERVACION: "Observación",
+ archivo_origen: "Origen",
+ tipo_archivo: "Tipo archivo",
+};
+
+const HIDDEN_COLUMNS = new Set(["cleaning_job_id", "tipo_archivo"]);
+
+function cellValue(row: Row, col: string): string {
+ const v = row[col];
+ if (v === null || v === undefined) return "";
+ return String(v);
+}
+
+function observacionOf(row: Row): string {
+ return cellValue(row, "OBSERVACION").trim();
+}
+
+function isApto(row: Row): boolean {
+ return observacionOf(row) === "PASO_BOT";
+}
+
+function isDuplicado(row: Row): boolean {
+ return observacionOf(row).includes("RADICADO_DUPLICADO");
+}
+
+function orderColumns(rows: Row[]): string[] {
+ const present = new Set(rows.flatMap((r) => Object.keys(r)));
+ const ordered: string[] = [];
+ for (const c of PREFERRED_COLUMNS) {
+ if (present.has(c) && !HIDDEN_COLUMNS.has(c)) ordered.push(c);
+ }
+ for (const c of present) {
+ if (!ordered.includes(c) && !HIDDEN_COLUMNS.has(c)) ordered.push(c);
+ }
+ return ordered;
+}
+
+function ObsBadge({ value }: { value: string }) {
+ if (!value) return — ;
+ const apto = value === "PASO_BOT";
+ const dup = value.includes("RADICADO_DUPLICADO");
+ const cls = apto
+ ? "pa-obs-badge pa-obs-badge--apto"
+ : dup
+ ? "pa-obs-badge pa-obs-badge--dup"
+ : "pa-obs-badge pa-obs-badge--error";
+ return {value} ;
+}
+
+export function PoblacionAptaResultsTable({ rows, summary: summaryProp }: Props) {
+ const [filter, setFilter] = useState("all");
+ const [search, setSearch] = useState("");
+
+ const columns = useMemo(() => orderColumns(rows), [rows]);
+
+ const summary = useMemo(() => {
+ const aptos = rows.filter(isApto).length;
+ const dups = rows.filter(isDuplicado).length;
+ return {
+ total: summaryProp?.total ?? rows.length,
+ aptos: summaryProp?.paso_bot ?? aptos,
+ noAptos: summaryProp?.con_error ?? rows.length - aptos,
+ duplicados: summaryProp?.duplicados ?? dups,
+ };
+ }, [rows, summaryProp]);
+
+ const filtered = useMemo(() => {
+ let list = rows;
+ if (filter === "aptos") list = list.filter(isApto);
+ else if (filter === "no_aptos") list = list.filter((r) => !isApto(r));
+
+ const q = search.trim().toLowerCase();
+ if (q) {
+ list = list.filter((r) =>
+ columns.some((c) => cellValue(r, c).toLowerCase().includes(q)),
+ );
+ }
+ return list;
+ }, [rows, columns, filter, search]);
+
+ const FILTERS: { key: FilterKey; label: string; count: number }[] = [
+ { key: "all", label: "Todos", count: summary.total },
+ { key: "aptos", label: "Aptos", count: summary.aptos },
+ { key: "no_aptos", label: "No aptos", count: summary.noAptos },
+ ];
+
+ return (
+
+
+
+ {summary.total}
+ Total
+
+
+ {summary.aptos}
+ Aptos
+
+
+ {summary.noAptos}
+ No aptos
+
+ {summary.duplicados > 0 && (
+
+ {summary.duplicados}
+ Duplicados
+
+ )}
+
+
+
+
+ {FILTERS.map(({ key, label, count }) => (
+ setFilter(key)}
+ >
+ {label}
+ {count}
+
+ ))}
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+ {columns.map((c) => (
+
+ {COLUMN_LABELS[c] ?? c}
+
+ ))}
+
+
+
+ {filtered.length === 0 ? (
+
+
+ {search
+ ? "No hay filas que coincidan con la búsqueda."
+ : "No hay filas en esta categoría."}
+
+
+ ) : (
+ filtered.map((row, i) => {
+ const apto = isApto(row);
+ return (
+
+ {columns.map((c) => (
+
+ {c === "OBSERVACION" ? (
+
+ ) : cellValue(row, c).trim() === "" ? (
+ —
+ ) : (
+ cellValue(row, c)
+ )}
+
+ ))}
+
+ );
+ })
+ )}
+
+
+
+
+ {filtered.length !== rows.length && (
+
+ Mostrando {filtered.length} de {rows.length} filas
+
+ )}
+
+ );
+}
diff --git a/services/desktop_app/src/renderer/lib/apiClient.ts b/services/desktop_app/src/renderer/lib/apiClient.ts
index ee3d689..2743e97 100644
--- a/services/desktop_app/src/renderer/lib/apiClient.ts
+++ b/services/desktop_app/src/renderer/lib/apiClient.ts
@@ -16,8 +16,11 @@ import type {
ExtractionBatch,
ExtractionFromCountPayload,
Job,
+ PoblacionAptaJob,
+ PoblacionAptaRowsResponse,
RowsResponse,
RunCleaningPayload,
+ RunPoblacionAptaPayload,
SchemaResponse,
Task,
UpdateJobPayload,
@@ -82,6 +85,8 @@ export class ApiClient {
path.includes("/upload") ||
path.startsWith("/cleaning/analyze-csv") ||
path.startsWith("/cleaning/jobs/from-csv") ||
+ path.startsWith("/poblacion-apta/jobs/from-csv") ||
+ path.startsWith("/poblacion-apta/jobs/from-cleaning") ||
path === "/bot-descarga/jobs"
);
}
@@ -129,6 +134,18 @@ export class ApiClient {
return this.getJson("/models");
}
+ async openaiBalance(): Promise<{
+ ok: boolean;
+ available: number | null;
+ total_granted?: number | null;
+ total_used?: number | null;
+ currency?: string;
+ source?: string | null;
+ error?: string | null;
+ }> {
+ return this.getJson("/openai/balance");
+ }
+
async discoverSchema(payload: Record): Promise {
return this.postJson("/extraction/schemas", payload);
}
@@ -380,6 +397,57 @@ export class ApiClient {
);
}
+ async runPoblacionApta(payload: RunPoblacionAptaPayload): Promise {
+ return this.postJson("/poblacion-apta/jobs", payload);
+ }
+
+ async runPoblacionAptaFromCleaning(form: FormData): Promise {
+ return this.postForm("/poblacion-apta/jobs/from-cleaning", form);
+ }
+
+ async runPoblacionAptaFromCsv(form: FormData): Promise {
+ return this.postForm("/poblacion-apta/jobs/from-csv", form);
+ }
+
+ async poblacionAptaJobs(jobId?: string): Promise {
+ const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
+ return this.getJson(`/poblacion-apta/jobs${qs}`);
+ }
+
+ async poblacionAptaJob(jobId: string): Promise {
+ return this.getJson(`/poblacion-apta/jobs/${encodeURIComponent(jobId)}`);
+ }
+
+ async poblacionAptaJobRows(
+ jobId: string,
+ limit = 500,
+ offset = 0,
+ observacion?: string,
+ ): Promise {
+ const params = new URLSearchParams({
+ limit: String(limit),
+ offset: String(offset),
+ });
+ if (observacion) params.set("observacion", observacion);
+ return this.getJson(
+ `/poblacion-apta/jobs/${encodeURIComponent(jobId)}/rows?${params}`,
+ );
+ }
+
+ async poblacionAptaExportCsv(
+ jobId: string,
+ subset: "aptos" | "no_aptos" = "aptos",
+ ): Promise {
+ const params = new URLSearchParams({ subset });
+ const path = `/poblacion-apta/jobs/${encodeURIComponent(jobId)}/export.csv?${params}`;
+ const response = await fetchWithTimeout(this.url(path), {}, this.timeoutFor(path));
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || `Error ${response.status}`);
+ }
+ return response.blob();
+ }
+
private async handle(response: Response): Promise {
const text = await response.text();
let data: unknown = null;
diff --git a/services/desktop_app/src/renderer/lib/time.ts b/services/desktop_app/src/renderer/lib/time.ts
new file mode 100644
index 0000000..c8f3676
--- /dev/null
+++ b/services/desktop_app/src/renderer/lib/time.ts
@@ -0,0 +1,76 @@
+/** Formato de fechas y duraciones para tareas/procesos. */
+
+export function formatDateTime(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ try {
+ return new Date(iso).toLocaleString("es-CO", {
+ dateStyle: "short",
+ timeStyle: "short",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+/** Duración legible entre dos ISO (o ms). */
+export function formatDuration(
+ started: string | number | null | undefined,
+ finished?: string | number | null,
+): string {
+ if (started == null) return "—";
+ const startMs = typeof started === "number" ? started : Date.parse(started);
+ if (Number.isNaN(startMs)) return "—";
+ const endMs =
+ finished == null
+ ? Date.now()
+ : typeof finished === "number"
+ ? finished
+ : Date.parse(finished);
+ if (Number.isNaN(endMs) || endMs < startMs) return "—";
+
+ let secs = Math.round((endMs - startMs) / 1000);
+ if (secs < 1) return "<1s";
+ const hours = Math.floor(secs / 3600);
+ secs %= 3600;
+ const mins = Math.floor(secs / 60);
+ secs %= 60;
+ if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
+ if (mins > 0) return `${mins}m ${secs}s`;
+ return `${secs}s`;
+}
+
+export interface TimingFields {
+ started_at?: string | null;
+ finished_at?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ status?: string;
+}
+
+/** Inicio · fin · duración (o “en curso”). */
+export function taskTimingLabel(task: TimingFields): string {
+ const start = task.started_at || task.created_at || null;
+ const end =
+ task.finished_at ||
+ (task.status && ["completed", "failed"].includes(task.status)
+ ? task.updated_at
+ : null) ||
+ null;
+ if (!start) return "—";
+ if (!end) {
+ return `${formatDateTime(start)} · en curso (${formatDuration(start)})`;
+ }
+ return `${formatDateTime(start)} · ${formatDuration(start, end)}`;
+}
+
+/** Solo la duración, con respaldo created/updated. */
+export function timingDuration(task: TimingFields): string {
+ const start = task.started_at || task.created_at || null;
+ const end =
+ task.finished_at ||
+ (task.status && ["completed", "failed"].includes(task.status)
+ ? task.updated_at
+ : null) ||
+ null;
+ return formatDuration(start, end);
+}
diff --git a/services/desktop_app/src/renderer/modules/cleaning/CleaningWizard.tsx b/services/desktop_app/src/renderer/modules/cleaning/CleaningWizard.tsx
index fb20cb0..fe3129c 100644
--- a/services/desktop_app/src/renderer/modules/cleaning/CleaningWizard.tsx
+++ b/services/desktop_app/src/renderer/modules/cleaning/CleaningWizard.tsx
@@ -27,6 +27,7 @@ import { ExtractionResultsTable } from "../../components/ExtractionResultsTable"
import type { ApiClient } from "../../lib/apiClient";
import { loadCopModConfig } from "../../lib/cleaningSettings";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv";
+import { taskTimingLabel, timingDuration } from "../../lib/time";
type SourceMode = "extraction" | "csv";
@@ -43,20 +44,6 @@ function batchIdFromTask(task: Task): string {
return String((task.result as Record)?.extraction_batch_id ?? "");
}
-function formatTaskDate(iso: string): string {
- try {
- return new Date(iso).toLocaleString("es-CO", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
- } catch {
- return iso;
- }
-}
-
export function CleaningWizard({
client,
job,
@@ -78,6 +65,8 @@ export function CleaningWizard({
const [analysis, setAnalysis] = useState(null);
const [rulesPerColumn, setRulesPerColumn] = useState>({});
const [copModFechaColumn, setCopModFechaColumn] = useState>({});
+ const [copModBrutoColumn, setCopModBrutoColumn] = useState>({});
+ const [copModNetoColumn, setCopModNetoColumn] = useState>({});
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState(null);
@@ -179,6 +168,26 @@ export function CleaningWizard({
return preferred?.name ?? "";
}
+ function guessBrutoColumn(columns: CleaningColumnAnalysis[], preferCol?: string): string {
+ if (preferCol && /bruto|total[_\s]?cargos|valor[_\s]?bruto|valor[_\s]?factura/i.test(preferCol)) {
+ return preferCol;
+ }
+ const preferred =
+ columns.find((c) => /valor[_\s]?bruto|^bruto$|total[_\s]?cargos/i.test(c.name)) ??
+ columns.find((c) => /bruto/i.test(c.name)) ??
+ (preferCol ? columns.find((c) => c.name === preferCol) : undefined);
+ return preferred?.name ?? preferCol ?? "";
+ }
+
+ function guessNetoColumn(columns: CleaningColumnAnalysis[], brutoCol: string): string {
+ const preferred =
+ columns.find(
+ (c) => c.name !== brutoCol && /valor[_\s]?neto|^neto$|valor[_\s]?pagado/i.test(c.name),
+ ) ??
+ columns.find((c) => c.name !== brutoCol && /neto/i.test(c.name));
+ return preferred?.name ?? "";
+ }
+
function toggleRule(column: string, ruleId: string) {
setRulesPerColumn((prev) => {
const current = prev[column] ?? [];
@@ -202,6 +211,27 @@ export function CleaningWizard({
return guess ? { ...prev, [column]: guess } : prev;
});
}
+ if (ruleId === "cop_mod_bruto_neto") {
+ const current = rulesPerColumn[column] ?? [];
+ const turningOn = !current.includes(ruleId);
+ if (!turningOn) {
+ setCopModBrutoColumn((prev) => {
+ const next = { ...prev };
+ delete next[column];
+ return next;
+ });
+ setCopModNetoColumn((prev) => {
+ const next = { ...prev };
+ delete next[column];
+ return next;
+ });
+ } else if (analysis) {
+ const bruto = guessBrutoColumn(analysis.columns, column);
+ const neto = guessNetoColumn(analysis.columns, bruto || column);
+ setCopModBrutoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: bruto || column }));
+ setCopModNetoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: neto }));
+ }
+ }
setSampleMode("after");
}
@@ -213,20 +243,38 @@ export function CleaningWizard({
}
setRulesPerColumn(suggested);
setCopModFechaColumn({});
+ setCopModBrutoColumn({});
+ setCopModNetoColumn({});
}
function cleaningExtraPayload(): {
cop_mod_fecha_column?: Record;
+ cop_mod_bruto_column?: Record;
+ cop_mod_neto_column?: Record;
cop_mod_config: ReturnType;
} {
const fechaEntries = Object.entries(copModFechaColumn).filter(
([col, fecha]) =>
Boolean(fecha) && (rulesPerColumn[col] ?? []).includes("cop_mod_split"),
);
+ const brutoEntries = Object.entries(copModBrutoColumn).filter(
+ ([col, bruto]) =>
+ Boolean(bruto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
+ );
+ const netoEntries = Object.entries(copModNetoColumn).filter(
+ ([col, neto]) =>
+ Boolean(neto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
+ );
return {
...(fechaEntries.length > 0
? { cop_mod_fecha_column: Object.fromEntries(fechaEntries) }
: {}),
+ ...(brutoEntries.length > 0
+ ? { cop_mod_bruto_column: Object.fromEntries(brutoEntries) }
+ : {}),
+ ...(netoEntries.length > 0
+ ? { cop_mod_neto_column: Object.fromEntries(netoEntries) }
+ : {}),
cop_mod_config: loadCopModConfig(),
};
}
@@ -241,17 +289,24 @@ export function CleaningWizard({
return [];
}
- /** Columnas nuevas respecto al análisis (p. ej. *_doc_tipo / *_doc_numeros). */
+ /** Columnas nuevas respecto al análisis (p. ej. *_doc_tipo / *_doc_numeros / cop/mod). */
function derivedColumnNames(sourceCol: string): string[] {
if (!preview) return [];
const original = new Set(analysis?.columns.map((c) => c.name) ?? []);
+ const selected = rulesPerColumn[sourceCol] ?? [];
return preview.columns
.map((c) => c.name)
- .filter(
- (name) =>
- !original.has(name) &&
- name.toLowerCase().startsWith(`${sourceCol.toLowerCase()}_`),
- );
+ .filter((name) => {
+ if (original.has(name)) return false;
+ if (name.toLowerCase().startsWith(`${sourceCol.toLowerCase()}_`)) return true;
+ if (
+ selected.includes("cop_mod_bruto_neto") &&
+ (name === "cop/mod" || name.startsWith("cop/mod_"))
+ ) {
+ return true;
+ }
+ return false;
+ });
}
/** Orden: columna activa → columnas derivadas → resto (sin cortar). */
@@ -309,7 +364,7 @@ export function CleaningWizard({
return () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
};
- }, [step, analysis, rulesPerColumn, copModFechaColumn, batchId, client]);
+ }, [step, analysis, rulesPerColumn, copModFechaColumn, copModBrutoColumn, copModNetoColumn, batchId, client]);
async function loadAnalysis(mode: SourceMode, batch?: string, file?: File | null) {
setLoading(true);
@@ -393,6 +448,12 @@ export function CleaningWizard({
if (extra.cop_mod_fecha_column) {
form.append("cop_mod_fecha_column", JSON.stringify(extra.cop_mod_fecha_column));
}
+ if (extra.cop_mod_bruto_column) {
+ form.append("cop_mod_bruto_column", JSON.stringify(extra.cop_mod_bruto_column));
+ }
+ if (extra.cop_mod_neto_column) {
+ form.append("cop_mod_neto_column", JSON.stringify(extra.cop_mod_neto_column));
+ }
form.append("cop_mod_config", JSON.stringify(extra.cop_mod_config));
cleaningJob = await client.runCleaningFromCsv(form);
}
@@ -528,7 +589,7 @@ export function CleaningWizard({
{task.title ?? `Extracción ${extractionTasks.length - index}`}
- {formatTaskDate(task.created_at)}
+ {taskTimingLabel(task)}
{index === 0 ? " · más reciente" : ""}
@@ -791,6 +852,63 @@ export function CleaningWizard({
)}
+ {selected.includes("cop_mod_bruto_neto") && (
+
+
+ Valor bruto
+
+
+ setCopModBrutoColumn((prev) => ({
+ ...prev,
+ [col.name]: e.target.value,
+ }))
+ }
+ >
+ {analysis.columns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ Valor neto
+
+
+ setCopModNetoColumn((prev) => ({
+ ...prev,
+ [col.name]: e.target.value,
+ }))
+ }
+ >
+ Selecciona columna neto…
+ {analysis.columns
+ .filter(
+ (c) =>
+ c.name !== (copModBrutoColumn[col.name] ?? col.name),
+ )
+ .map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ Se crea la columna cop/mod = bruto − neto.
+
+
+ )}
>
);
@@ -911,6 +1029,7 @@ export function CleaningWizard({
Limpieza completada
{resultJob.log?.length ? ` · ${resultJob.log.length} operaciones` : null}
{resultRows.length > 0 ? ` · ${resultRows.length} filas` : null}
+ {` · duración ${timingDuration(resultJob)}`}
diff --git a/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaModule.ts b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaModule.ts
new file mode 100644
index 0000000..4663f70
--- /dev/null
+++ b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaModule.ts
@@ -0,0 +1,18 @@
+import { PoblacionAptaWizardModule } from "./PoblacionAptaWizardModule";
+import { PoblacionAptaResults } from "./PoblacionAptaResults";
+import type { ModuleDescriptor } from "../registry";
+import { registerModule } from "../registry";
+import type { Task } from "../../../shared/types";
+
+const PoblacionAptaModule: ModuleDescriptor = {
+ type: "poblacion_apta",
+ label: "Población apta",
+ iconName: "Users",
+ hasTask: (tasks: Task[]) => tasks.some((t) => t.type === "poblacion_apta"),
+ Wizard: PoblacionAptaWizardModule,
+ Results: PoblacionAptaResults,
+};
+
+registerModule(PoblacionAptaModule);
+
+export { PoblacionAptaModule };
diff --git a/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaResults.tsx b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaResults.tsx
new file mode 100644
index 0000000..380c24c
--- /dev/null
+++ b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaResults.tsx
@@ -0,0 +1,134 @@
+import { Download, Loader2, RefreshCw, Users } from "lucide-react";
+import { useEffect, useState } from "react";
+import type { PoblacionAptaJob } from "../../../shared/types";
+import { PoblacionAptaResultsTable } from "../../components/PoblacionAptaResultsTable";
+import { buildExportCsvFilename, downloadCsv } from "../../lib/csv";
+import type { ModuleResultsProps } from "../registry";
+
+type Row = Record;
+
+export function PoblacionAptaResults({ client, task, job }: ModuleResultsProps) {
+ const [paJob, setPaJob] = useState(null);
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ async function load() {
+ try {
+ const paJobId = String(
+ (task.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) {
+ setError("La tarea no tiene poblacion_apta_job_id en result");
+ return;
+ }
+ const jobData = await client.poblacionAptaJob(paJobId);
+ setPaJob(jobData);
+ if (jobData.status === "completed") {
+ const response = await client.poblacionAptaJobRows(paJobId, 500, 0);
+ setRows(response.rows ?? []);
+ }
+ } catch (e) {
+ setError(String(e instanceof Error ? e.message : e));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void load();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [task.id]);
+
+ async function exportAptos() {
+ const paJobId = String(
+ (task.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) return;
+ const blob = await client.poblacionAptaExportCsv(paJobId, "aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_aptos", job.title), text);
+ }
+
+ async function exportNoAptos() {
+ const paJobId = String(
+ (task.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) return;
+ const blob = await client.poblacionAptaExportCsv(paJobId, "no_aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
+ }
+
+ if (loading) {
+ return (
+
+
+ Cargando población apta…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+ {
+ setError(null);
+ setLoading(true);
+ void load();
+ }}
+ >
+ Reintentar
+
+
+ );
+ }
+
+ const summary = paJob?.summary ?? {};
+
+ return (
+
+
+
+
+
+ {task.title ?? "Población apta"}
+
+
+ {paJob?.status ?? task.status}
+
+
+
+ void exportAptos()}>
+ CSV aptos ({summary.paso_bot ?? 0})
+
+ void exportNoAptos()}>
+ CSV no aptos ({summary.con_error ?? 0})
+
+
+
+
+ {paJob?.log && paJob.log.length > 0 && (
+
+ Log ({paJob.log.length})
+
+ {paJob.log.map((line, i) => (
+ {line}
+ ))}
+
+
+ )}
+
+ {rows.length === 0 ? (
+
+
No se encontraron filas.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizard.tsx b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizard.tsx
new file mode 100644
index 0000000..3063eb8
--- /dev/null
+++ b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizard.tsx
@@ -0,0 +1,523 @@
+import {
+ AlertCircle,
+ CheckSquare,
+ Download,
+ FileSpreadsheet,
+ Loader2,
+ Square,
+ Upload,
+ Users,
+ X,
+} from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { BackgroundProgress, Job, PoblacionAptaJob, Task } from "../../../shared/types";
+import { PoblacionAptaResultsTable } from "../../components/PoblacionAptaResultsTable";
+import type { ApiClient } from "../../lib/apiClient";
+import { buildExportCsvFilename, downloadCsv } from "../../lib/csv";
+import { taskTimingLabel, timingDuration } from "../../lib/time";
+
+interface Props {
+ client: ApiClient;
+ job: Job;
+ onTaskCreated: (task: Task) => void;
+ onProgress?: (p: BackgroundProgress) => void;
+ onActivateModule?: (type: string, context?: Record) => void;
+}
+
+type SourceMode = "cleaning" | "csv";
+
+function cleaningJobIdFromTask(task: Task): string {
+ return String((task.result as Record)?.cleaning_job_id ?? "");
+}
+
+function formatBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function PoblacionAptaWizard({
+ client,
+ job,
+ onTaskCreated,
+ onProgress,
+ onActivateModule,
+}: Props) {
+ const [source, setSource] = useState("cleaning");
+ const [cleaningTasks, setCleaningTasks] = useState([]);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [csvFiles, setCsvFiles] = useState([]);
+ const [origenServicio, setOrigenServicio] = useState(null);
+ const fileInputRef = useRef(null);
+ const origenInputRef = useRef(null);
+ const [loadingTasks, setLoadingTasks] = useState(true);
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState(null);
+ const [resultJob, setResultJob] = useState(null);
+ const [resultRows, setResultRows] = useState[]>([]);
+ const [done, setDone] = useState(false);
+
+ const loadCleaningTasks = useCallback(async () => {
+ setLoadingTasks(true);
+ try {
+ const tasks = await client.jobTasks(job.id);
+ const cleaning = tasks
+ .filter((t) => t.type === "cleaning" && t.status === "completed" && cleaningJobIdFromTask(t))
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ setCleaningTasks(cleaning);
+ } catch {
+ setCleaningTasks([]);
+ } finally {
+ setLoadingTasks(false);
+ }
+ }, [client, job.id]);
+
+ useEffect(() => {
+ void loadCleaningTasks();
+ }, [loadCleaningTasks]);
+
+ function toggle(taskId: string) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(taskId)) next.delete(taskId);
+ else next.add(taskId);
+ return next;
+ });
+ }
+
+ function toggleAll() {
+ if (selectedIds.size === cleaningTasks.length) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(cleaningTasks.map((t) => t.id)));
+ }
+ }
+
+ function onCsvPicked(list: FileList | null) {
+ if (!list || list.length === 0) return;
+ const next = Array.from(list).filter((f) =>
+ /\.csv$/i.test(f.name) || f.type === "text/csv" || f.type === "application/vnd.ms-excel",
+ );
+ setCsvFiles((prev) => {
+ const names = new Set(prev.map((f) => `${f.name}:${f.size}`));
+ const merged = [...prev];
+ for (const f of next) {
+ const key = `${f.name}:${f.size}`;
+ if (!names.has(key)) {
+ merged.push(f);
+ names.add(key);
+ }
+ }
+ return merged;
+ });
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+
+ function removeCsv(index: number) {
+ setCsvFiles((prev) => prev.filter((_, i) => i !== index));
+ }
+
+ function onOrigenPicked(list: FileList | null) {
+ if (!list || list.length === 0) return;
+ const file = list[0];
+ const ok =
+ /\.(csv|xlsx|xlsm|xls)$/i.test(file.name) ||
+ file.type === "text/csv" ||
+ file.type.includes("spreadsheet") ||
+ file.type.includes("excel");
+ if (!ok) {
+ setError("Origen Servicio debe ser CSV o Excel (.xlsx / .xls)");
+ return;
+ }
+ setOrigenServicio(file);
+ setError(null);
+ if (origenInputRef.current) origenInputRef.current.value = "";
+ }
+
+ const canRun =
+ source === "cleaning" ? selectedIds.size > 0 : csvFiles.length > 0;
+
+ async function handleRun() {
+ setRunning(true);
+ setError(null);
+ setResultJob(null);
+ setResultRows([]);
+
+ const total =
+ source === "cleaning" ? selectedIds.size : csvFiles.length;
+ const label =
+ source === "cleaning"
+ ? `${selectedIds.size} limpiezas`
+ : `${csvFiles.length} CSV`;
+
+ onProgress?.({
+ module: "poblacion_apta",
+ total,
+ processed: 0,
+ failed: 0,
+ currentFile: label,
+ running: true,
+ label: "Validando población apta…",
+ });
+
+ try {
+ let paJob: PoblacionAptaJob;
+ if (source === "cleaning") {
+ const cleaningJobIds = cleaningTasks
+ .filter((t) => selectedIds.has(t.id))
+ .map(cleaningJobIdFromTask)
+ .filter(Boolean);
+ if (cleaningJobIds.length === 0) {
+ throw new Error("Selecciona al menos una limpieza completada");
+ }
+ const form = new FormData();
+ form.append("job_id", job.id);
+ form.append("cleaning_job_ids", JSON.stringify(cleaningJobIds));
+ if (origenServicio) {
+ form.append("origen_servicio", origenServicio, origenServicio.name);
+ }
+ paJob = await client.runPoblacionAptaFromCleaning(form);
+ } else {
+ if (csvFiles.length === 0) {
+ throw new Error("Selecciona al menos un archivo CSV");
+ }
+ const form = new FormData();
+ form.append("job_id", job.id);
+ for (const file of csvFiles) {
+ form.append("files", file, file.name);
+ }
+ if (origenServicio) {
+ form.append("origen_servicio", origenServicio, origenServicio.name);
+ }
+ paJob = await client.runPoblacionAptaFromCsv(form);
+ }
+
+ setResultJob(paJob);
+ if (paJob.status === "completed") {
+ const resp = await client.poblacionAptaJobRows(paJob.id, 500, 0);
+ setResultRows(resp.rows ?? []);
+ }
+ try {
+ const tasks = await client.jobTasks(job.id);
+ const latest = tasks
+ .filter((t) => t.type === "poblacion_apta")
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
+ if (latest) onTaskCreated(latest);
+ } catch { /* silent */ }
+ setDone(true);
+ } catch (e) {
+ setError(String(e instanceof Error ? e.message : e));
+ } finally {
+ setRunning(false);
+ onProgress?.({
+ module: "poblacion_apta",
+ total,
+ processed: total,
+ failed: 0,
+ currentFile: "",
+ running: false,
+ label: "",
+ });
+ }
+ }
+
+ async function exportAptos() {
+ if (!resultJob) return;
+ const blob = await client.poblacionAptaExportCsv(resultJob.id, "aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_aptos", job.title), text);
+ }
+
+ async function exportNoAptos() {
+ if (!resultJob) return;
+ const blob = await client.poblacionAptaExportCsv(resultJob.id, "no_aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
+ }
+
+ function reset() {
+ setDone(false);
+ setResultJob(null);
+ setResultRows([]);
+ setError(null);
+ setSelectedIds(new Set());
+ setCsvFiles([]);
+ setOrigenServicio(null);
+ void loadCleaningTasks();
+ }
+
+ if (done && resultJob) {
+ const summary = resultJob.summary ?? {};
+ return (
+
+
+
+
+
+ Población apta
+
+
+ Consolidado validado: aptos para bot (PASO_BOT) y registros con observación.
+ {" · "}Duración: {timingDuration(resultJob)}
+ {resultJob.started_at || resultJob.created_at
+ ? ` (${taskTimingLabel(resultJob)})`
+ : ""}
+
+
+
+ void exportAptos()}>
+ CSV aptos ({summary.paso_bot ?? 0})
+
+ void exportNoAptos()}>
+ CSV no aptos ({summary.con_error ?? 0})
+
+
+ Nueva corrida
+
+
+
+
+ {resultJob.log?.length > 0 && (
+
+ Log ({resultJob.log.length})
+
+ {resultJob.log.map((line, i) => (
+ {line}
+ ))}
+
+
+ )}
+
+ {resultRows.length === 0 ? (
+
No hay filas en el consolidado.
+ ) : (
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+ Población apta
+
+
+ Elige limpiezas completadas o sube uno o varios CSV. Opcionalmente adjunta Origen Servicio
+ (Excel/CSV) para cruzar radicado → tipo de servicio. Se valida y consolida en un único
+ resultado (PASO_BOT / observaciones).
+
+
+
+ {error && (
+
+ )}
+
+
+
Origen
+
+ setSource("cleaning")}
+ >
+ Desde limpiezas
+
+ setSource("csv")}
+ >
+ Subir CSV
+
+
+
+
+ {source === "cleaning" ? (
+
+
+ Limpiezas de este trabajo
+ {cleaningTasks.length > 0 && (
+
+ {selectedIds.size === cleaningTasks.length ? (
+ <> Quitar todas>
+ ) : (
+ <> Seleccionar todas>
+ )}
+
+ )}
+
+
+ {loadingTasks ? (
+
+ Cargando limpiezas…
+
+ ) : cleaningTasks.length === 0 ? (
+
+
No hay limpiezas completadas en este trabajo.
+
+ onActivateModule?.("cleaning")}
+ >
+ Ir a Limpieza
+
+ setSource("csv")}>
+ Subir CSV
+
+
+
+ ) : (
+
+ {cleaningTasks.map((task, index) => {
+ const selected = selectedIds.has(task.id);
+ return (
+
toggle(task.id)}
+ >
+
+ {selected ?
:
}
+
+
+ {task.title ?? `Limpieza ${cleaningTasks.length - index}`}
+
+
+ {taskTimingLabel(task)}
+ {index === 0 ? " · más reciente" : ""}
+
+
+
+ {task.status}
+
+ );
+ })}
+
+ )}
+ {selectedIds.size > 0 && (
+
+ Seleccionadas: {selectedIds.size}
+
+ )}
+
+ ) : (
+
+
Archivos CSV
+
onCsvPicked(e.target.files)}
+ />
+
+
+ {csvFiles.length === 0
+ ? "Ningún archivo seleccionado"
+ : `${csvFiles.length} archivo${csvFiles.length === 1 ? "" : "s"}`}
+
+
fileInputRef.current?.click()}
+ >
+ Elegir CSV…
+
+
+ {csvFiles.length > 0 && (
+
+ {csvFiles.map((file, index) => (
+
+
+
+
+
{file.name}
+
{formatBytes(file.size)}
+
+
+ removeCsv(index)}
+ >
+
+
+
+ ))}
+
+ )}
+
+ Puedes seleccionar varios CSV a la vez; cada uno se valida y se consolida en un solo resultado.
+
+
+ )}
+
+
+
Origen Servicio (opcional)
+
onOrigenPicked(e.target.files)}
+ />
+
+
+ {origenServicio
+ ? `${origenServicio.name} (${formatBytes(origenServicio.size)})`
+ : "Sin archivo — se usa tipo_servicio de la fuente si existe"}
+
+
+ origenInputRef.current?.click()}
+ >
+ Elegir…
+
+ {origenServicio && (
+ setOrigenServicio(null)}
+ >
+
+
+ )}
+
+
+
+ Archivo Excel o CSV con el número de radicado y el tipo de servicio (por ejemplo
+ ambulatorio u hospitalario). Se usa para completar el servicio de cada registro.
+
+
+
+
+ void handleRun()}
+ >
+ {running ? (
+ <> Procesando…>
+ ) : (
+ <> Generar población apta>
+ )}
+
+
+
+ );
+}
diff --git a/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizardModule.tsx b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizardModule.tsx
new file mode 100644
index 0000000..50782a6
--- /dev/null
+++ b/services/desktop_app/src/renderer/modules/poblacion_apta/PoblacionAptaWizardModule.tsx
@@ -0,0 +1,25 @@
+import type { BackgroundProgress } from "../../../shared/types";
+import type { ModuleWizardProps } from "../registry";
+import { PoblacionAptaWizard } from "./PoblacionAptaWizard";
+
+interface Props extends ModuleWizardProps {
+ onProgress?: (p: BackgroundProgress) => void;
+}
+
+export function PoblacionAptaWizardModule({
+ client,
+ job,
+ onTaskCreated,
+ onProgress,
+ onActivateModule,
+}: Props) {
+ return (
+
+ );
+}
diff --git a/services/desktop_app/src/renderer/styles.css b/services/desktop_app/src/renderer/styles.css
index 0b1f51b..7a5e364 100644
--- a/services/desktop_app/src/renderer/styles.css
+++ b/services/desktop_app/src/renderer/styles.css
@@ -148,6 +148,42 @@ button:disabled {
position: relative;
}
+.side-nav-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.side-nav-group > button .side-nav-chevron {
+ flex-shrink: 0;
+ margin-left: auto;
+ opacity: 0.65;
+ transition: transform 0.15s ease;
+}
+
+.side-nav-group > button .side-nav-chevron--open {
+ transform: rotate(180deg);
+}
+
+.side-nav-sub {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin: 0 0 2px;
+ padding: 0 0 0 12px;
+}
+
+.side-nav-sub button {
+ font-size: 12.5px;
+ min-height: 32px;
+ padding-left: 14px;
+}
+
+.side-nav-sub button:disabled {
+ cursor: wait;
+ opacity: 0.65;
+}
+
.sidebar-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
@@ -155,6 +191,75 @@ button:disabled {
padding: 14px 8px 0;
}
+.sidebar-openai {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 6px 4px;
+ text-align: left;
+ width: 100%;
+}
+
+.sidebar-openai:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.sidebar-openai:disabled {
+ cursor: default;
+ opacity: 0.85;
+}
+
+.sidebar-openai svg {
+ color: #6b7f96;
+ flex-shrink: 0;
+}
+
+.sidebar-openai.ok svg {
+ color: #34d399;
+}
+
+.sidebar-openai.error svg {
+ color: #f87171;
+}
+
+.sidebar-openai.busy svg {
+ color: #f59e0b;
+}
+
+.sidebar-openai-text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.sidebar-openai-label {
+ color: #6b7f96;
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.sidebar-openai-value {
+ color: #c7d2e0;
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.sidebar-openai.error .sidebar-openai-value {
+ color: #f87171;
+ font-size: 12px;
+ font-weight: 500;
+}
+
.sidebar-gateway {
align-items: center;
display: flex;
@@ -4267,6 +4372,13 @@ th {
gap: 8px;
}
+.results-timing {
+ color: var(--muted, #64748b);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
.results-section-actions {
align-items: center;
display: flex;
@@ -5111,3 +5223,132 @@ th {
font-weight: 600;
padding: 1px 6px;
}
+
+/* ── Población apta results ── */
+.wiz-shell {
+ box-sizing: border-box;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+ overflow: auto;
+ padding: 20px 28px 28px;
+}
+
+.pa-results {
+ padding: 8px 0 16px;
+}
+
+.pa-stats-row {
+ margin-bottom: 0;
+}
+
+.pa-stat--dup .wiz-stat-value {
+ color: #b45309;
+}
+
+.pa-obs-badge {
+ border-radius: 6px;
+ display: inline-block;
+ font-size: 11.5px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ line-height: 1.3;
+ max-width: 280px;
+ overflow: hidden;
+ padding: 3px 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pa-obs-badge--apto {
+ background: #dcfce7;
+ color: #15803d;
+}
+
+.pa-obs-badge--error {
+ background: #fff7ed;
+ color: #c2410c;
+}
+
+.pa-obs-badge--dup {
+ background: #fef3c7;
+ color: #a16207;
+}
+
+.pa-row--apto td {
+ background: transparent;
+}
+
+.pa-row--no-apto td {
+ background: #fffbeb;
+}
+
+.pa-th-obs,
+.pa-td-obs {
+ min-width: 140px;
+}
+
+.pa-table th {
+ text-transform: none;
+ letter-spacing: 0.01em;
+}
+
+.pa-results-header {
+ align-items: flex-start;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 16px;
+ justify-content: space-between;
+ margin-bottom: 4px;
+ padding: 0;
+}
+
+.pa-results-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pa-results-log {
+ background: var(--surface, #f8fafc);
+ border: 1px solid var(--border, #e2e8f0);
+ border-radius: 8px;
+ font-size: 12px;
+ margin: 0 0 4px;
+ padding: 10px 14px;
+}
+
+.pa-results-log summary {
+ cursor: pointer;
+ font-weight: 550;
+}
+
+.pa-results-log ul {
+ color: var(--text-muted);
+ margin: 8px 0 0;
+ padding-left: 18px;
+}
+
+/* Módulo / pestaña Resultados: mismo aire lateral */
+.module-results:has(.pa-results),
+.module-results:has(.pa-results-header) {
+ box-sizing: border-box;
+ padding: 16px 28px 24px;
+}
+
+.results-section:has(.pa-results) .results-section-header {
+ padding-left: 28px;
+ padding-right: 28px;
+}
+
+.results-section:has(.pa-results) .pa-results {
+ padding-left: 28px;
+ padding-right: 28px;
+}
+
+.results-section:has(.pa-results) .wiz-toast {
+ margin-left: 28px !important;
+ margin-right: 28px !important;
+}
diff --git a/services/desktop_app/src/renderer/test/setup.ts b/services/desktop_app/src/renderer/test/setup.ts
index dfc1311..3a46fb1 100644
--- a/services/desktop_app/src/renderer/test/setup.ts
+++ b/services/desktop_app/src/renderer/test/setup.ts
@@ -24,5 +24,6 @@ beforeEach(() => {
listVersions: vi.fn().mockResolvedValue({ currentVersion: "1.0.0", versions: [] }),
installUpdate: vi.fn().mockResolvedValue({ ok: true }),
onUpdateProgress: vi.fn().mockReturnValue(() => undefined),
+ downloadUrl: vi.fn().mockResolvedValue({ ok: true }),
};
});
diff --git a/services/desktop_app/src/renderer/types/jobSection.ts b/services/desktop_app/src/renderer/types/jobSection.ts
index 46f4888..2116738 100644
--- a/services/desktop_app/src/renderer/types/jobSection.ts
+++ b/services/desktop_app/src/renderer/types/jobSection.ts
@@ -1,2 +1,7 @@
-export type JobSection = "counting" | "extraction" | "cleaning" | "downloads" | "results";
-
+export type JobSection =
+ | "counting"
+ | "extraction"
+ | "cleaning"
+ | "poblacion_apta"
+ | "downloads"
+ | "results";
diff --git a/services/desktop_app/src/renderer/views/JobResultsView.tsx b/services/desktop_app/src/renderer/views/JobResultsView.tsx
index e61e722..d93516c 100644
--- a/services/desktop_app/src/renderer/views/JobResultsView.tsx
+++ b/services/desktop_app/src/renderer/views/JobResultsView.tsx
@@ -9,17 +9,28 @@ import {
Plus,
RefreshCw,
Sparkles,
+ Users,
} from "lucide-react";
import { useEffect, useState } from "react";
-import type { BackgroundProgress, CleaningJob, CountJobFile, DownloadJobFile, Job, Task } from "../../shared/types";
+import type {
+ BackgroundProgress,
+ CleaningJob,
+ CountJobFile,
+ DownloadJobFile,
+ Job,
+ PoblacionAptaJob,
+ Task,
+} from "../../shared/types";
import { CountResultsTable } from "../components/CountResultsTable";
import { ExtractionResultsTable } from "../components/ExtractionResultsTable";
+import { PoblacionAptaResultsTable } from "../components/PoblacionAptaResultsTable";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
import type { ApiClient } from "../lib/apiClient";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv";
+import { taskTimingLabel, timingDuration } from "../lib/time";
import { DownloadsResultsPanel } from "../modules/downloads/DownloadsResultsPanel";
-type ResultTab = "counting" | "extraction" | "cleaning" | "downloads";
+type ResultTab = "counting" | "extraction" | "cleaning" | "poblacion_apta" | "downloads";
interface Props {
client: ApiClient;
@@ -28,10 +39,12 @@ interface Props {
countingProgress?: BackgroundProgress | null;
extractionProgress?: BackgroundProgress | null;
cleaningProgress?: BackgroundProgress | null;
+ poblacionAptaProgress?: BackgroundProgress | null;
onRefresh: () => Promise;
onStartCounting: () => void;
onStartExtraction: (ctx?: Record) => void;
onStartCleaning: (ctx?: Record) => void;
+ onStartPoblacionApta: () => void;
onStartDownload: () => void;
onContinueToCounting?: (ctx: {
downloadJobId: string;
@@ -47,10 +60,12 @@ export function JobResultsView({
countingProgress,
extractionProgress,
cleaningProgress,
+ poblacionAptaProgress,
onRefresh,
onStartCounting,
onStartExtraction,
onStartCleaning,
+ onStartPoblacionApta,
onStartDownload,
onContinueToCounting,
}: Props) {
@@ -63,6 +78,8 @@ export function JobResultsView({
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const cleaningTasks = tasks.filter((t) => t.type === "cleaning")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ const poblacionAptaTasks = tasks.filter((t) => t.type === "poblacion_apta")
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const downloadTasks = tasks.filter((t) => t.type === "downloads")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
@@ -121,6 +138,21 @@ export function JobResultsView({
)}
+ setActiveTab("poblacion_apta")}
+ >
+
+ Población apta
+ {(poblacionAptaTasks.length > 0 || poblacionAptaProgress?.running) && (
+
+ {poblacionAptaProgress?.running
+ ? `${poblacionAptaProgress.processed}/${poblacionAptaProgress.total}`
+ : poblacionAptaTasks.length}
+
+ )}
+
onStartCleaning()}
/>
)}
+ {activeTab === "poblacion_apta" && (
+
+ )}
{activeTab === "downloads" && (
{tasks.map((t, i) => (
- {t.title ?? `Conteo ${tasks.length - i}`} — {new Date(t.created_at).toLocaleDateString("es-CO")}
+ {t.title ?? `Conteo ${tasks.length - i}`} — {taskTimingLabel(t)}
))}
)}
{selectedTask && (
- {selectedTask.status}
+ <>
+ {selectedTask.status}
+
+ {timingDuration(selectedTask)}
+
+ >
)}
@@ -471,16 +517,21 @@ function ExtractionResultsSection({
>
{tasks.map((t, i) => (
- {t.title ?? `Extracción ${tasks.length - i}`} — {new Date(t.created_at).toLocaleDateString("es-CO")}
+ {t.title ?? `Extracción ${tasks.length - i}`} — {taskTimingLabel(t)}
))}
)}
{selectedTask && (
-
- {isRunning && }
- {taskStatus}
-
+ <>
+
+ {isRunning && }
+ {taskStatus}
+
+
+ {timingDuration(selectedTask)}
+
+ >
)}
@@ -672,15 +723,20 @@ function CleaningResultsSection({
>
{tasks.map((t, i) => (
- {t.title ?? `Limpieza ${tasks.length - i}`} — {new Date(t.created_at).toLocaleDateString("es-CO")}
+ {t.title ?? `Limpieza ${tasks.length - i}`} — {taskTimingLabel(t)}
))}
)}
{selectedTask && (
-
- {cleaningJob?.status ?? selectedTask.status}
-
+ <>
+
+ {cleaningJob?.status ?? selectedTask.status}
+
+
+ {timingDuration(cleaningJob ?? selectedTask)}
+
+ >
)}
@@ -714,3 +770,174 @@ function CleaningResultsSection({
);
}
+
+/* ══════════════════════════════════════════
+ Sección de resultados de población apta
+══════════════════════════════════════════ */
+function PoblacionAptaResultsSection({
+ client,
+ job,
+ tasks,
+ liveProgress,
+ onStartPoblacionApta,
+}: {
+ client: ApiClient;
+ job: Job;
+ tasks: Task[];
+ liveProgress?: BackgroundProgress | null;
+ onStartPoblacionApta: () => void;
+}) {
+ const [selectedTask, setSelectedTask] = useState(tasks[0] ?? null);
+ const [paJob, setPaJob] = useState(null);
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setSelectedTask(tasks[0] ?? null);
+ }, [tasks.map((t) => t.id).join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (!selectedTask) {
+ setRows([]);
+ setPaJob(null);
+ return;
+ }
+ let cancelled = false;
+ async function load() {
+ setLoading(true);
+ setError(null);
+ try {
+ const paJobId = String(
+ (selectedTask!.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) {
+ setError("La tarea no tiene poblacion_apta_job_id");
+ return;
+ }
+ const jobData = await client.poblacionAptaJob(paJobId);
+ if (cancelled) return;
+ setPaJob(jobData);
+ if (jobData.status === "completed") {
+ const resp = await client.poblacionAptaJobRows(paJobId, 500, 0);
+ if (cancelled) return;
+ setRows(resp.rows ?? []);
+ }
+ } catch (e) {
+ if (!cancelled) setError(String(e instanceof Error ? e.message : e));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+ void load();
+ return () => { cancelled = true; };
+ }, [selectedTask?.id, client]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function exportAptos() {
+ const paJobId = String(
+ (selectedTask?.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) return;
+ const blob = await client.poblacionAptaExportCsv(paJobId, "aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_aptos", job.title), text);
+ }
+
+ async function exportNoAptos() {
+ const paJobId = String(
+ (selectedTask?.result as Record)?.poblacion_apta_job_id ?? "",
+ );
+ if (!paJobId) return;
+ const blob = await client.poblacionAptaExportCsv(paJobId, "no_aptos");
+ const text = await blob.text();
+ downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
+ }
+
+ if (tasks.length === 0 && !liveProgress?.running) {
+ return (
+
+
+
No hay resultados de población apta todavía.
+
+ Generar población apta
+
+
+ );
+ }
+
+ if (tasks.length === 0 && liveProgress?.running) {
+ return (
+
+
+
+ );
+ }
+
+ const summary = paJob?.summary ?? {};
+
+ return (
+
+ {liveProgress?.running && (
+
+
+
+ )}
+
+
+ {tasks.length > 1 && (
+ setSelectedTask(tasks.find((t) => t.id === e.target.value) ?? null)}
+ >
+ {tasks.map((t, i) => (
+
+ {t.title ?? `Población apta ${tasks.length - i}`} — {taskTimingLabel(t)}
+
+ ))}
+
+ )}
+ {selectedTask && (
+ <>
+
+ {paJob?.status ?? selectedTask.status}
+
+
+ {timingDuration(paJob ?? selectedTask)}
+
+ >
+ )}
+
+
+
+
void exportAptos()}>
+ CSV aptos ({summary.paso_bot ?? 0})
+
+
void exportNoAptos()}>
+ CSV no aptos ({summary.con_error ?? 0})
+
+
+ Nueva corrida
+
+
+
+
+ {error && (
+
+ )}
+
+ {loading && rows.length === 0 ? (
+
+ ) : rows.length === 0 ? (
+
+
No hay filas en el consolidado.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/services/desktop_app/src/renderer/views/JobView.tsx b/services/desktop_app/src/renderer/views/JobView.tsx
index 0fa4401..44a9fb6 100644
--- a/services/desktop_app/src/renderer/views/JobView.tsx
+++ b/services/desktop_app/src/renderer/views/JobView.tsx
@@ -6,6 +6,7 @@ import type { JobSection } from "../types/jobSection";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
import { CountingWizardModule } from "../modules/counting/CountingWizardModule";
import { CleaningWizardModule } from "../modules/cleaning/CleaningWizardModule";
+import { PoblacionAptaWizardModule } from "../modules/poblacion_apta/PoblacionAptaWizardModule";
import { JobExtractionWizard } from "../modules/extraction/JobExtractionWizard";
import { DownloadsWizardModule } from "../modules/downloads/DownloadsWizardModule";
import { JobResultsView } from "./JobResultsView";
@@ -27,12 +28,14 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
const [countingProgress, setCountingProgress] = useState(null);
const [extractionProgress, setExtractionProgress] = useState(null);
const [cleaningProgress, setCleaningProgress] = useState(null);
+ const [poblacionAptaProgress, setPoblacionAptaProgress] = useState(null);
const pollingRef = useRef | null>(null);
const backgroundRunning =
countingProgress?.running === true ||
extractionProgress?.running === true ||
- cleaningProgress?.running === true;
+ cleaningProgress?.running === true ||
+ poblacionAptaProgress?.running === true;
const loadTasks = useCallback(async () => {
try {
@@ -80,6 +83,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
countingProgress?.running ? countingProgress
: extractionProgress?.running ? extractionProgress
: cleaningProgress?.running ? cleaningProgress
+ : poblacionAptaProgress?.running ? poblacionAptaProgress
: null;
return (
@@ -121,6 +125,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
{section === "counting" && "Conteo de servicios"}
{section === "extraction" && "Extracción de datos"}
{section === "cleaning" && "Limpieza de datos"}
+ {section === "poblacion_apta" && "Población apta"}
{section === "downloads" && "Bot de descarga"}
{section === "results" && "Resultados del trabajo"}
@@ -182,6 +187,16 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
/>
+
+
setPoblacionAptaProgress(p.running ? p : null)}
+ onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
+ />
+
+
@@ -194,10 +209,12 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
countingProgress={countingProgress}
extractionProgress={extractionProgress}
cleaningProgress={cleaningProgress}
+ poblacionAptaProgress={poblacionAptaProgress}
onRefresh={loadTasks}
onStartCounting={() => onActivateSection("counting")}
onStartExtraction={(ctx?: Record) => onActivateSection("extraction", ctx)}
onStartCleaning={(ctx?: Record) => onActivateSection("cleaning", ctx)}
+ onStartPoblacionApta={() => onActivateSection("poblacion_apta")}
onStartDownload={() => onActivateSection("downloads")}
onContinueToCounting={(ctx) => onActivateSection("counting", ctx)}
/>
diff --git a/services/desktop_app/src/shared/types.ts b/services/desktop_app/src/shared/types.ts
index 7478525..3c1fdef 100644
--- a/services/desktop_app/src/shared/types.ts
+++ b/services/desktop_app/src/shared/types.ts
@@ -144,6 +144,8 @@ export interface CreateCountJobPayload {
results: CountJobFileCreateEntry[];
download_job_id?: string | null;
source?: string | null;
+ /** ISO del inicio real del conteo en el cliente */
+ started_at?: string | null;
}
export interface ExtractionFromCountPayload {
@@ -219,6 +221,8 @@ export interface CleaningPreviewPayload {
extraction_batch_id?: string;
rules_per_column: Record;
cop_mod_fecha_column?: Record;
+ cop_mod_bruto_column?: Record;
+ cop_mod_neto_column?: Record;
cop_mod_config?: Record | null;
sample_size?: number;
}
@@ -234,6 +238,8 @@ export interface CleaningJob {
error: string | null;
created_at: string;
updated_at: string;
+ started_at?: string | null;
+ finished_at?: string | null;
}
export interface RunCleaningPayload {
@@ -241,6 +247,8 @@ export interface RunCleaningPayload {
job_id?: string;
rules_per_column: Record;
cop_mod_fecha_column?: Record;
+ cop_mod_bruto_column?: Record;
+ cop_mod_neto_column?: Record;
cop_mod_config?: Record | null;
}
@@ -251,6 +259,40 @@ export interface CleaningRowsResponse {
offset: number;
}
+export interface PoblacionAptaJob {
+ id: string;
+ job_id: string | null;
+ status: "pending" | "running" | "completed" | "failed" | string;
+ request: Record;
+ log: string[];
+ summary: {
+ total?: number;
+ paso_bot?: number;
+ con_error?: number;
+ duplicados?: number;
+ por_fuente?: Array>;
+ por_observacion?: Array>;
+ };
+ error: string | null;
+ created_at: string;
+ updated_at: string;
+ started_at?: string | null;
+ finished_at?: string | null;
+}
+
+export interface RunPoblacionAptaPayload {
+ job_id?: string;
+ cleaning_job_ids: string[];
+}
+
+export interface PoblacionAptaRowsResponse {
+ job_id: string;
+ rows: Array>;
+ total: number;
+ limit: number;
+ offset: number;
+}
+
export interface CountingFormState {
localRoot: string;
containerRoot: string;
@@ -261,7 +303,7 @@ export interface CountingFormState {
/** Estado de progreso de un módulo ejecutándose en segundo plano. */
export interface BackgroundProgress {
- module: "counting" | "extraction" | "cleaning";
+ module: "counting" | "extraction" | "cleaning" | "poblacion_apta";
total: number;
processed: number;
failed: number;
@@ -396,4 +438,6 @@ export interface DesktopBridge {
listVersions(): Promise;
installUpdate(manifest: UpdateManifest): Promise<{ ok: boolean; destPath?: string }>;
onUpdateProgress(callback: (progress: UpdateProgress) => void): () => void;
+ /** Inicia una descarga del sistema (Electron) desde una URL http(s). */
+ downloadUrl(url: string): Promise<{ ok: boolean }>;
}
diff --git a/services/extraction/api/main.py b/services/extraction/api/main.py
index 7629c88..af678e4 100644
--- a/services/extraction/api/main.py
+++ b/services/extraction/api/main.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
import uuid
from contextlib import asynccontextmanager
+from datetime import datetime, timezone
from typing import Any
try:
@@ -25,6 +26,7 @@ from domain.batch_processor import BatchProcessor
from domain.config import extractor_llm_config, normalizer_llm_config, settings
from domain.config import get_allowed_document_types
from domain.estimator import Estimator
+from domain.openai_balance import fetch_openai_balance
from domain.service import InvoiceProcessingService
from domain.strategies import OpenAICompatibleExtractor
from infrastructure.database import (
@@ -177,6 +179,7 @@ class ExtractionBatchRead(BaseModel):
class TaskRead(BaseModel):
id: str
+ job_id: str | None = None
type: str
status: str
title: str | None
@@ -205,6 +208,7 @@ def serialize_batch(batch: ExtractionBatch) -> dict[str, Any]:
def serialize_task(task: Task) -> dict[str, Any]:
return {
"id": task.id,
+ "job_id": task.job_id,
"type": task.type,
"status": task.status,
"title": task.title,
@@ -378,6 +382,12 @@ def list_models() -> dict[str, Any]:
}
+@app.get("/openai/balance")
+def openai_balance() -> dict[str, Any]:
+ """Crédito disponible en la cuenta OpenAI (o proveedor compatible)."""
+ return fetch_openai_balance()
+
+
@app.post("/schemas")
def build_schema(request: SchemaRequest) -> dict[str, Any]:
"""Descubre campos disponibles para que el cliente API elija `fields`."""
@@ -1117,6 +1127,7 @@ class CreateCountJobRequest(BaseModel):
results: list[CountJobFileCreate]
download_job_id: str | None = None
source: str | None = None
+ started_at: str | None = None # ISO del cliente (inicio real del conteo)
class CountJobFileRead(BaseModel):
@@ -1179,6 +1190,17 @@ def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]:
"""Persiste los resultados del servicio de conteo de facturas en la BD compartida."""
total = len(request.results)
default_title = request.title or f"Conteo ({total} archivos)"
+ finished = utcnow()
+ started = finished
+ if request.started_at:
+ try:
+ parsed = datetime.fromisoformat(request.started_at.replace("Z", "+00:00"))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ started = parsed
+ except ValueError:
+ started = finished
+
with session_scope() as session:
if request.job_id:
job = session.get(Job, request.job_id)
@@ -1212,9 +1234,9 @@ def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]:
"failed": sum(1 for r in request.results if r.status != "ok"),
"nits": sorted({r.nit for r in request.results if r.nit}),
},
- started_at=utcnow(),
- finished_at=utcnow(),
- updated_at=utcnow(),
+ started_at=started,
+ finished_at=finished,
+ updated_at=finished,
)
session.add(task)
session.flush()
diff --git a/services/extraction/domain/openai_balance.py b/services/extraction/domain/openai_balance.py
new file mode 100644
index 0000000..601caec
--- /dev/null
+++ b/services/extraction/domain/openai_balance.py
@@ -0,0 +1,244 @@
+"""Consulta de crédito / saldo disponible vía la API de OpenAI (y compatibles).
+
+OpenAI no documenta un endpoint público de saldo con secret key; el endpoint
+histórico de credit_grants sigue siendo el más usado por herramientas de
+monitorización y por APIs compatibles. Se consulta con la key del servidor
+y se normaliza la respuesta para la UI.
+
+Si EXTRACTOR_BASE_URL apunta a un mock/local, también se prueba la API oficial
+de OpenAI con la misma key.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import httpx
+
+from .config import extractor_llm_config
+
+_log = logging.getLogger("domain.openai_balance")
+
+_TIMEOUT = httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
+_OPENAI_OFFICIAL = "https://api.openai.com/v1"
+
+
+def _is_openai_host(base_url: str) -> bool:
+ host = (urlparse(base_url).hostname or "").lower()
+ return host in {"api.openai.com", "openai.com"}
+
+
+def _is_local_or_mock(base_url: str) -> bool:
+ host = (urlparse(base_url).hostname or "").lower()
+ return host in {
+ "localhost",
+ "127.0.0.1",
+ "host.docker.internal",
+ "0.0.0.0",
+ } or host.endswith(".local")
+
+
+def _candidate_roots(base_url: str) -> list[str]:
+ cleaned = (base_url or _OPENAI_OFFICIAL).rstrip("/") + "/"
+ roots = [cleaned]
+ if cleaned.rstrip("/").endswith("/v1"):
+ roots.append(cleaned.rstrip("/").removesuffix("/v1").rstrip("/") + "/")
+ else:
+ roots.append(urljoin(cleaned, "v1/"))
+ seen: set[str] = set()
+ out: list[str] = []
+ for root in roots:
+ if root not in seen:
+ seen.add(root)
+ out.append(root)
+ return out
+
+
+def _candidate_urls(base_url: str) -> list[str]:
+ """URLs a probar: primero el proveedor configurado, luego OpenAI oficial si aplica."""
+ paths = (
+ "dashboard/billing/credit_grants",
+ "v1/dashboard/billing/credit_grants",
+ "credits", # OpenRouter-style
+ "user/balance", # DeepSeek-style
+ )
+ urls: list[str] = []
+ seen: set[str] = set()
+
+ bases = [base_url or _OPENAI_OFFICIAL]
+ # Con mock/local u otro proveedor, el saldo de cuenta OpenAI sigue en api.openai.com
+ if not _is_openai_host(bases[0]):
+ bases.append(_OPENAI_OFFICIAL)
+
+ for base in bases:
+ for root in _candidate_roots(base):
+ for path in paths:
+ url = urljoin(root, path)
+ if url not in seen:
+ seen.add(url)
+ urls.append(url)
+ return urls
+
+
+def _as_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _normalize_payload(payload: Any) -> dict[str, float] | None:
+ """Extrae total_available / granted / used de respuestas conocidas."""
+ if not isinstance(payload, dict):
+ return None
+
+ # OpenAI credit_grants
+ total_available = _as_float(payload.get("total_available"))
+ total_granted = _as_float(payload.get("total_granted"))
+ total_used = _as_float(payload.get("total_used"))
+ if total_available is not None:
+ return {
+ "total_available": total_available,
+ "total_granted": total_granted if total_granted is not None else total_available,
+ "total_used": total_used if total_used is not None else 0.0,
+ }
+
+ # OpenRouter: { data: { total_credits, total_usage } }
+ data = payload.get("data")
+ if isinstance(data, dict):
+ credits = _as_float(data.get("total_credits"))
+ usage = _as_float(data.get("total_usage"))
+ if credits is not None:
+ used = usage or 0.0
+ return {
+ "total_available": credits - used,
+ "total_granted": credits,
+ "total_used": used,
+ }
+
+ # DeepSeek: { balance_infos: [{ total_balance }] }
+ infos = payload.get("balance_infos")
+ if isinstance(infos, list) and infos:
+ totals = [_as_float(item.get("total_balance")) for item in infos if isinstance(item, dict)]
+ totals = [t for t in totals if t is not None]
+ if totals:
+ available = sum(totals)
+ return {"total_available": available, "total_granted": available, "total_used": 0.0}
+
+ for key in ("balance", "available", "remaining", "credit_balance", "total_balance"):
+ value = _as_float(payload.get(key))
+ if value is not None:
+ return {"total_available": value, "total_granted": value, "total_used": 0.0}
+
+ return None
+
+
+def _friendly_error(detail: str, status_code: int, *, candidate: str = "") -> str:
+ lower = detail.lower()
+ if "session key" in lower or "browser" in lower or (
+ status_code in {401, 403} and _is_openai_host(candidate)
+ ):
+ return (
+ "OpenAI no permite consultar el crédito con una API key "
+ "(sk-proj / sk-...). Solo se ve en el panel web: "
+ "https://platform.openai.com/settings/organization/billing/overview"
+ )
+ if status_code in {401, 403}:
+ return detail or f"Acceso denegado al consultar el saldo (HTTP {status_code})."
+ return detail or f"HTTP {status_code}"
+
+
+def fetch_openai_balance(
+ *,
+ api_key: str | None = None,
+ base_url: str | None = None,
+) -> dict[str, Any]:
+ """Devuelve el crédito disponible consultando la API del proveedor LLM."""
+ config = extractor_llm_config()
+ key = (api_key if api_key is not None else str(config["api_key"])).strip()
+ url = (base_url if base_url is not None else str(config["base_url"])).strip()
+
+ if not key:
+ return {
+ "ok": False,
+ "available": None,
+ "currency": "USD",
+ "source": None,
+ "error": "API key de OpenAI no configurada (EXTRACTOR_API_KEY).",
+ }
+
+ headers = {
+ "Authorization": f"Bearer {key}",
+ "Content-Type": "application/json",
+ }
+ last_error = "No se pudo obtener el saldo de crédito."
+ dead_hosts: set[str] = set()
+
+ with httpx.Client(timeout=_TIMEOUT, follow_redirects=True) as client:
+ for candidate in _candidate_urls(url):
+ host = (urlparse(candidate).hostname or "").lower()
+ if host in dead_hosts:
+ continue
+
+ try:
+ response = client.get(candidate, headers=headers)
+ except httpx.HTTPError as exc:
+ last_error = f"Error de red al consultar {candidate}: {exc}"
+ _log.warning(last_error)
+ # Si el mock/local está caído, no insistir en ese host
+ if _is_local_or_mock(candidate) or "Connection refused" in str(exc):
+ dead_hosts.add(host)
+ continue
+
+ if response.status_code == 404:
+ continue
+
+ if response.status_code >= 400:
+ detail = ""
+ try:
+ body = response.json()
+ if isinstance(body, dict):
+ err = body.get("error")
+ if isinstance(err, dict):
+ detail = str(err.get("message") or err)
+ else:
+ detail = str(body.get("detail") or body.get("message") or "")
+ except Exception:
+ detail = (response.text or "")[:300]
+ last_error = _friendly_error(detail, response.status_code, candidate=candidate)
+ # 401/403 en OpenAI billing suelen aplicar a todos los endpoints
+ if response.status_code in {401, 403} and _is_openai_host(candidate):
+ break
+ continue
+
+ try:
+ payload = response.json()
+ except Exception:
+ last_error = f"Respuesta no JSON desde {candidate}"
+ continue
+
+ normalized = _normalize_payload(payload)
+ if normalized is None:
+ last_error = f"Formato de saldo no reconocido desde {candidate}"
+ continue
+
+ return {
+ "ok": True,
+ "available": round(normalized["total_available"], 4),
+ "total_granted": round(normalized["total_granted"], 4),
+ "total_used": round(normalized["total_used"], 4),
+ "currency": "USD",
+ "source": candidate,
+ "error": None,
+ }
+
+ return {
+ "ok": False,
+ "available": None,
+ "currency": "USD",
+ "source": None,
+ "error": last_error,
+ }
diff --git a/services/extraction/requirements.txt b/services/extraction/requirements.txt
index 926f263..ba77584 100644
--- a/services/extraction/requirements.txt
+++ b/services/extraction/requirements.txt
@@ -1,6 +1,7 @@
coloredlogs==15.0.1
fastapi==0.115.6
python-multipart==0.0.20
+httpx==0.28.1
openai==2.1.0
pdf2image==1.17.0
pillow==11.3.0
diff --git a/services/poblacion_apta/Dockerfile b/services/poblacion_apta/Dockerfile
new file mode 100644
index 0000000..b0c8e23
--- /dev/null
+++ b/services/poblacion_apta/Dockerfile
@@ -0,0 +1,17 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PYTHONPATH=/app
+
+WORKDIR /app
+
+COPY requirements.txt /app/requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY domain /app/domain
+COPY infrastructure /app/infrastructure
+COPY api /app/api
+
+EXPOSE 8003
+CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8003"]
diff --git a/services/poblacion_apta/README.md b/services/poblacion_apta/README.md
new file mode 100644
index 0000000..248b026
--- /dev/null
+++ b/services/poblacion_apta/README.md
@@ -0,0 +1,45 @@
+# poblacion_apta
+
+Microservicio Python (FastAPI) que consolida varias limpiezas y aplica las validaciones
+de `Verificador_ST` para producir la **Población apta** (registros `PASO_BOT`).
+
+## Puerto
+
+**8003**
+
+## Endpoints
+
+| Método | Ruta | Descripción |
+|--------|------|-------------|
+| `GET` | `/health` | Liveness |
+| `POST` | `/jobs` | Crear job desde `cleaning_job_ids` (+ `job_id` Desktop) |
+| `POST` | `/jobs/from-csv` | Crear job desde uno o varios CSV (multipart) |
+| `GET` | `/jobs?job_id=` | Listar jobs |
+| `GET` | `/jobs/{id}` | Detalle + summary |
+| `GET` | `/jobs/{id}/rows` | Filas (`observacion` opcional) |
+| `GET` | `/jobs/{id}/export.csv?subset=aptos\|no_aptos` | CSV `\|` aptos o no aptos |
+
+## Crear un job desde limpiezas
+
+```bash
+POST /jobs
+{
+ "job_id": "...",
+ "cleaning_job_ids": ["uuid-limpieza-1", "uuid-limpieza-2"]
+}
+```
+
+## Crear un job desde CSV
+
+```bash
+POST /jobs/from-csv
+# multipart/form-data
+# files: uno o más .csv
+# job_id: (opcional) UUID del trabajo Desktop
+```
+
+## Variables de entorno
+
+| Variable | Descripción |
+|----------|-------------|
+| `DATABASE_URL` | PostgreSQL (`postgresql+psycopg://...`) |
diff --git a/services/poblacion_apta/api/__init__.py b/services/poblacion_apta/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/poblacion_apta/api/main.py b/services/poblacion_apta/api/main.py
new file mode 100644
index 0000000..8b00363
--- /dev/null
+++ b/services/poblacion_apta/api/main.py
@@ -0,0 +1,517 @@
+"""API REST del microservicio Población apta."""
+from __future__ import annotations
+
+import json
+from contextlib import asynccontextmanager
+from typing import Any
+
+from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+from sqlalchemy import delete, select
+
+from domain.csv_io import load_csv_bytes
+from domain.verificador_st import (
+ consolidado_to_records,
+ export_no_aptos_csv,
+ export_paso_bot_csv,
+ process_cleaning_sources,
+)
+from domain.verificador_st.core import cargar_origen_servicio_desde_bytes
+from infrastructure.database import (
+ CleanedRow,
+ CleaningJob,
+ Job,
+ PoblacionAptaJob,
+ PoblacionAptaRow,
+ Task,
+ init_db,
+ session_scope,
+ utcnow,
+)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ init_db()
+ yield
+
+
+app = FastAPI(
+ title="IPSExtractor Población Apta Service",
+ version="1.0.0",
+ lifespan=lifespan,
+)
+
+
+class PoblacionAptaRequest(BaseModel):
+ job_id: str | None = None
+ cleaning_job_ids: list[str] = Field(default_factory=list, min_length=1)
+
+
+def serialize_job(job: PoblacionAptaJob) -> dict[str, Any]:
+ finished = (
+ job.updated_at.isoformat()
+ if job.status in {"completed", "failed"}
+ else None
+ )
+ return {
+ "id": job.id,
+ "job_id": job.job_id,
+ "status": job.status,
+ "request": job.request,
+ "log": job.log,
+ "summary": job.summary,
+ "error": job.error,
+ "created_at": job.created_at.isoformat(),
+ "updated_at": job.updated_at.isoformat(),
+ "started_at": job.created_at.isoformat(),
+ "finished_at": finished,
+ }
+
+
+def _validate_job_id(session: Any, job_id: str | None) -> None:
+ if not job_id:
+ return
+ if session.get(Job, job_id) is None:
+ raise HTTPException(status_code=404, detail="Trabajo (job) no encontrado")
+
+
+def _task_title(request: dict[str, Any]) -> str:
+ source = str(request.get("source") or "cleaning")
+ if source == "csv":
+ files = request.get("filenames") or []
+ n = len(files) if isinstance(files, list) else 0
+ return f"Población apta ({n} CSV)" if n else "Población apta (CSV)"
+ n = len(request.get("cleaning_job_ids") or [])
+ return f"Población apta ({n} limpiezas)"
+
+
+def _create_task(
+ session: Any,
+ *,
+ job_id: str | None,
+ poblacion_apta_job_id: str,
+ status: str,
+ request: dict[str, Any],
+ error: str | None = None,
+ title: str | None = None,
+ started_at: Any | None = None,
+) -> str | None:
+ if not job_id:
+ return None
+ now = utcnow()
+ task = Task(
+ job_id=job_id,
+ type="poblacion_apta",
+ status=status,
+ title=title or _task_title(request),
+ request=request,
+ result={"poblacion_apta_job_id": poblacion_apta_job_id},
+ error=error,
+ started_at=started_at or now,
+ finished_at=now,
+ updated_at=now,
+ )
+ session.add(task)
+ session.flush()
+ return task.id
+
+
+def _persist_rows(
+ session: Any,
+ *,
+ job_id: str,
+ records: list[dict[str, Any]],
+) -> None:
+ session.execute(
+ delete(PoblacionAptaRow).where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
+ )
+ for idx, row in enumerate(records, start=1):
+ session.add(
+ PoblacionAptaRow(
+ poblacion_apta_job_id=job_id,
+ cleaning_job_id=str(row.get("cleaning_job_id") or "") or None,
+ row_index=idx,
+ row_json=row,
+ )
+ )
+
+
+def _run_and_persist(
+ *,
+ pa_job_id: str,
+ desktop_job_id: str | None,
+ request_dump: dict[str, Any],
+ sources: list[dict[str, Any]],
+ mapa_servicios: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ try:
+ consolidado, summary, log = process_cleaning_sources(
+ sources, mapa_servicios=mapa_servicios
+ )
+ records = consolidado_to_records(consolidado)
+
+ with session_scope() as session:
+ _persist_rows(session, job_id=pa_job_id, records=records)
+ job = session.get(PoblacionAptaJob, pa_job_id)
+ if job is None:
+ raise RuntimeError("No se encontró el job de población apta recién creado.")
+ job.status = "completed"
+ job.log = log
+ job.summary = summary
+ job.updated_at = utcnow()
+ _create_task(
+ session,
+ job_id=desktop_job_id,
+ poblacion_apta_job_id=pa_job_id,
+ status="completed",
+ request=request_dump,
+ started_at=job.created_at,
+ )
+ session.flush()
+ return serialize_job(job)
+ except HTTPException:
+ raise
+ except Exception as exc:
+ with session_scope() as session:
+ job = session.get(PoblacionAptaJob, pa_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)
+ job.updated_at = utcnow()
+ _create_task(
+ session,
+ job_id=desktop_job_id,
+ poblacion_apta_job_id=pa_job_id,
+ status="failed",
+ request=request_dump,
+ error=str(exc),
+ started_at=started,
+ )
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+
+def _load_cleaning_sources(
+ session: Any,
+ cleaning_ids: list[str],
+ desktop_job_id: str | None,
+) -> list[dict[str, Any]]:
+ sources: list[dict[str, Any]] = []
+ for cid in cleaning_ids:
+ cleaning = session.get(CleaningJob, cid)
+ if cleaning is None:
+ raise HTTPException(status_code=404, detail=f"Job de limpieza no encontrado: {cid}")
+ if cleaning.status != "completed":
+ raise HTTPException(
+ status_code=400,
+ detail=f"Job de limpieza {cid} no está completed (status={cleaning.status})",
+ )
+ if desktop_job_id and cleaning.job_id and cleaning.job_id != desktop_job_id:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Job de limpieza {cid} no pertenece al trabajo {desktop_job_id}",
+ )
+ rows = session.scalars(
+ select(CleanedRow)
+ .where(CleanedRow.cleaning_job_id == cid)
+ .order_by(CleanedRow.row_index, CleanedRow.id)
+ ).all()
+ sources.append(
+ {
+ "cleaning_job_id": cid,
+ "label": f"limpieza_{cid[:8]}",
+ "rows": [r.row_json for r in rows],
+ }
+ )
+ return sources
+
+
+async def _parse_origen_servicio(
+ upload: UploadFile | None,
+) -> tuple[dict[str, str], str | None]:
+ """Lee el archivo Origen Servicio opcional. Devuelve (mapa, filename)."""
+ if upload is None:
+ return {}, None
+ filename = upload.filename or "origen_servicio.xlsx"
+ content = await upload.read()
+ if not content:
+ raise HTTPException(status_code=400, detail="El archivo Origen Servicio está vacío")
+ try:
+ mapa = cargar_origen_servicio_desde_bytes(content, filename)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=400,
+ detail=f"No se pudo leer Origen Servicio: {exc}",
+ ) from exc
+ if not mapa:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ "Origen Servicio sin columnas de radicado/servicio reconocibles "
+ "o sin filas válidas"
+ ),
+ )
+ return mapa, filename
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+
+@app.post("/jobs")
+def run_poblacion_apta(request: PoblacionAptaRequest) -> dict[str, Any]:
+ """Crea job desde limpiezas (JSON). Sin Origen Servicio; usar /jobs/from-cleaning."""
+ cleaning_ids = [cid.strip() for cid in request.cleaning_job_ids if cid and cid.strip()]
+ if not cleaning_ids:
+ raise HTTPException(status_code=400, detail="cleaning_job_ids es requerido (mínimo 1)")
+
+ request_dump = request.model_dump()
+ request_dump["cleaning_job_ids"] = cleaning_ids
+ request_dump["source"] = "cleaning"
+
+ with session_scope() as session:
+ _validate_job_id(session, request.job_id)
+ sources = _load_cleaning_sources(session, cleaning_ids, request.job_id)
+ job = PoblacionAptaJob(
+ job_id=request.job_id,
+ status="running",
+ request=request_dump,
+ updated_at=utcnow(),
+ )
+ session.add(job)
+ session.flush()
+ pa_job_id = job.id
+ desktop_job_id = request.job_id
+
+ return _run_and_persist(
+ pa_job_id=pa_job_id,
+ desktop_job_id=desktop_job_id,
+ request_dump=request_dump,
+ sources=sources,
+ )
+
+
+# Ruta estática ANTES de /jobs/{job_id} para evitar 405 (job_id="from-cleaning").
+@app.post("/jobs/from-cleaning")
+async def run_poblacion_apta_from_cleaning(
+ cleaning_job_ids: str = Form(...),
+ job_id: str | None = Form(None),
+ origen_servicio: UploadFile | None = File(None),
+) -> dict[str, Any]:
+ """Limpiezas + Origen Servicio opcional (multipart)."""
+ try:
+ parsed = json.loads(cleaning_job_ids)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(
+ status_code=400,
+ detail="cleaning_job_ids debe ser un JSON array de ids",
+ ) from exc
+ if not isinstance(parsed, list):
+ raise HTTPException(status_code=400, detail="cleaning_job_ids debe ser un array")
+
+ cleaning_ids = [str(cid).strip() for cid in parsed if str(cid).strip()]
+ if not cleaning_ids:
+ raise HTTPException(status_code=400, detail="cleaning_job_ids es requerido (mínimo 1)")
+
+ mapa, origen_filename = await _parse_origen_servicio(origen_servicio)
+
+ request_dump: dict[str, Any] = {
+ "source": "cleaning",
+ "job_id": job_id,
+ "cleaning_job_ids": cleaning_ids,
+ "origen_servicio": origen_filename,
+ }
+
+ with session_scope() as session:
+ _validate_job_id(session, job_id)
+ sources = _load_cleaning_sources(session, cleaning_ids, job_id)
+ job = PoblacionAptaJob(
+ job_id=job_id,
+ status="running",
+ request=request_dump,
+ updated_at=utcnow(),
+ )
+ session.add(job)
+ session.flush()
+ pa_job_id = job.id
+ desktop_job_id = job_id
+
+ return _run_and_persist(
+ pa_job_id=pa_job_id,
+ desktop_job_id=desktop_job_id,
+ request_dump=request_dump,
+ sources=sources,
+ mapa_servicios=mapa or None,
+ )
+
+
+# Ruta estática ANTES de /jobs/{job_id} para evitar 405 (job_id="from-csv").
+@app.post("/jobs/from-csv")
+async def run_poblacion_apta_from_csv(
+ files: list[UploadFile] = File(...),
+ job_id: str | None = Form(None),
+ origen_servicio: UploadFile | None = File(None),
+) -> dict[str, Any]:
+ if not files:
+ raise HTTPException(status_code=400, detail="Se requiere al menos un archivo CSV")
+
+ sources: list[dict[str, Any]] = []
+ filenames: list[str] = []
+ delimiters: list[str] = []
+
+ for upload in files:
+ filename = upload.filename or "upload.csv"
+ content = await upload.read()
+ if not content:
+ raise HTTPException(status_code=400, detail=f"El archivo {filename} está vacío")
+ try:
+ df, delimiter = load_csv_bytes(content, filename)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=400,
+ detail=f"No se pudo leer {filename}: {exc}",
+ ) from exc
+ if df.empty:
+ raise HTTPException(status_code=400, detail=f"El CSV {filename} no tiene filas")
+ records = df.fillna("").astype(str).to_dict(orient="records")
+ sources.append(
+ {
+ "cleaning_job_id": "",
+ "label": filename,
+ "rows": records,
+ }
+ )
+ filenames.append(filename)
+ delimiters.append(delimiter)
+
+ mapa, origen_filename = await _parse_origen_servicio(origen_servicio)
+
+ request_dump: dict[str, Any] = {
+ "source": "csv",
+ "job_id": job_id,
+ "filenames": filenames,
+ "delimiters": delimiters,
+ "cleaning_job_ids": [],
+ "origen_servicio": origen_filename,
+ }
+
+ with session_scope() as session:
+ _validate_job_id(session, job_id)
+ job = PoblacionAptaJob(
+ job_id=job_id,
+ status="running",
+ request=request_dump,
+ updated_at=utcnow(),
+ )
+ session.add(job)
+ session.flush()
+ pa_job_id = job.id
+ desktop_job_id = job_id
+
+ return _run_and_persist(
+ pa_job_id=pa_job_id,
+ desktop_job_id=desktop_job_id,
+ request_dump=request_dump,
+ sources=sources,
+ mapa_servicios=mapa or None,
+ )
+
+
+@app.get("/jobs")
+def list_jobs(job_id: str | None = None) -> list[dict[str, Any]]:
+ with session_scope() as session:
+ stmt = select(PoblacionAptaJob).order_by(PoblacionAptaJob.created_at.desc())
+ if job_id:
+ stmt = stmt.where(PoblacionAptaJob.job_id == job_id)
+ jobs = session.scalars(stmt).all()
+ return [serialize_job(job) for job in jobs]
+
+
+@app.get("/jobs/{job_id}")
+def get_job(job_id: str) -> dict[str, Any]:
+ if job_id in {"from-csv", "from-cleaning"}:
+ raise HTTPException(status_code=405, detail=f"Use POST /jobs/{job_id}")
+ with session_scope() as session:
+ job = session.get(PoblacionAptaJob, job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
+ return serialize_job(job)
+
+
+@app.get("/jobs/{job_id}/rows")
+def get_rows(
+ job_id: str,
+ limit: int = 500,
+ offset: int = 0,
+ observacion: str | None = Query(None),
+) -> dict[str, Any]:
+ with session_scope() as session:
+ job = session.get(PoblacionAptaJob, job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
+ stmt = (
+ select(PoblacionAptaRow)
+ .where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
+ .order_by(PoblacionAptaRow.row_index, PoblacionAptaRow.id)
+ )
+ rows = session.scalars(stmt).all()
+ payload = [row.row_json for row in rows]
+ if observacion:
+ obs = observacion.strip().upper()
+ if obs == "PASO_BOT":
+ payload = [r for r in payload if str(r.get("OBSERVACION", "")).strip() == "PASO_BOT"]
+ else:
+ payload = [
+ r
+ for r in payload
+ if obs in str(r.get("OBSERVACION", "")).upper()
+ ]
+ total = len(payload)
+ page = payload[offset : offset + limit]
+ return {
+ "job_id": job_id,
+ "rows": page,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ }
+
+
+@app.get("/jobs/{job_id}/export.csv")
+def export_csv(
+ job_id: str,
+ subset: str = Query("aptos", description="aptos | no_aptos"),
+) -> Response:
+ subset_norm = (subset or "aptos").strip().lower()
+ if subset_norm not in {"aptos", "no_aptos", "errores"}:
+ raise HTTPException(status_code=400, detail="subset debe ser aptos o no_aptos")
+ if subset_norm == "errores":
+ subset_norm = "no_aptos"
+
+ with session_scope() as session:
+ job = session.get(PoblacionAptaJob, job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
+ rows = session.scalars(
+ select(PoblacionAptaRow)
+ .where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
+ .order_by(PoblacionAptaRow.row_index, PoblacionAptaRow.id)
+ ).all()
+ payload = [r.row_json for r in rows]
+ if subset_norm == "aptos":
+ csv_text = export_paso_bot_csv(payload)
+ filename = f"poblacion_apta_aptos_{job_id[:8]}.csv"
+ else:
+ csv_text = export_no_aptos_csv(payload)
+ filename = f"poblacion_apta_no_aptos_{job_id[:8]}.csv"
+
+ return Response(
+ content=csv_text.encode("utf-8-sig"),
+ media_type="text/csv; charset=utf-8",
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"',
+ },
+ )
diff --git a/services/poblacion_apta/domain/__init__.py b/services/poblacion_apta/domain/__init__.py
new file mode 100644
index 0000000..c6c62e8
--- /dev/null
+++ b/services/poblacion_apta/domain/__init__.py
@@ -0,0 +1 @@
+"""Dominio del servicio Población apta."""
diff --git a/services/poblacion_apta/domain/csv_io.py b/services/poblacion_apta/domain/csv_io.py
new file mode 100644
index 0000000..4b5671e
--- /dev/null
+++ b/services/poblacion_apta/domain/csv_io.py
@@ -0,0 +1,89 @@
+"""Carga robusta de CSV desde bytes (uploads)."""
+from __future__ import annotations
+
+import csv as _csv
+import os
+import tempfile
+from collections import Counter
+from typing import Optional
+
+import pandas as pd
+
+_CANDIDATE_DELIMITERS = ["|", ";", "\t", ",", ":"]
+
+
+def _detect_delimiter_from_sample(sample: str) -> str:
+ if not sample:
+ return ","
+ try:
+ dialect = _csv.Sniffer().sniff(sample, delimiters="".join(_CANDIDATE_DELIMITERS))
+ sniffed = dialect.delimiter
+ except _csv.Error:
+ sniffed = None
+
+ lines = [ln for ln in sample.splitlines() if ln.strip()]
+ if not lines:
+ return sniffed or ","
+
+ scores: dict[str, tuple[int, int]] = {}
+ for delim in _CANDIDATE_DELIMITERS:
+ counts = [ln.count(delim) for ln in lines[:50]]
+ if not counts or max(counts) == 0:
+ continue
+ mode_count, mode_freq = Counter(counts).most_common(1)[0]
+ if mode_count == 0:
+ continue
+ scores[delim] = (mode_count, mode_freq)
+
+ if scores:
+ best = max(scores.items(), key=lambda kv: (kv[1][0], kv[1][1]))
+ if sniffed and sniffed in scores:
+ sniffed_score = scores[sniffed]
+ if sniffed_score[0] >= best[1][0] - 1:
+ return sniffed
+ return best[0]
+ return sniffed or ","
+
+
+def load_csv_bytes(content: bytes, filename: str = "upload.csv") -> tuple[pd.DataFrame, str]:
+ """Carga un CSV desde bytes. Devuelve `(df, delimiter)`."""
+ if not content:
+ raise RuntimeError(f"El archivo {filename} está vacío")
+
+ sample = ""
+ for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
+ try:
+ sample = content[:65536].decode(enc, errors="replace")
+ break
+ except Exception:
+ continue
+ delimiter = _detect_delimiter_from_sample(sample)
+
+ suffix = os.path.splitext(filename)[1] or ".csv"
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ last_error: Optional[Exception] = None
+ try:
+ for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
+ try:
+ df = pd.read_csv(
+ tmp_path,
+ delimiter=delimiter,
+ dtype=str,
+ encoding=enc,
+ engine="python",
+ on_bad_lines="skip",
+ keep_default_na=False,
+ )
+ df.columns = [str(c).strip() for c in df.columns]
+ return df, delimiter
+ except Exception as exc: # noqa: BLE001
+ last_error = exc
+ raise RuntimeError(f"No se pudo leer el CSV {filename}: {last_error}")
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
diff --git a/services/poblacion_apta/domain/verificador_st/__init__.py b/services/poblacion_apta/domain/verificador_st/__init__.py
new file mode 100644
index 0000000..e9e7da9
--- /dev/null
+++ b/services/poblacion_apta/domain/verificador_st/__init__.py
@@ -0,0 +1,14 @@
+"""Paquete de validación Población apta (Verificador ST)."""
+from domain.verificador_st.pipeline import (
+ consolidado_to_records,
+ export_no_aptos_csv,
+ export_paso_bot_csv,
+ process_cleaning_sources,
+)
+
+__all__ = [
+ "consolidado_to_records",
+ "export_no_aptos_csv",
+ "export_paso_bot_csv",
+ "process_cleaning_sources",
+]
diff --git a/services/poblacion_apta/domain/verificador_st/core.py b/services/poblacion_apta/domain/verificador_st/core.py
new file mode 100644
index 0000000..bc76df4
--- /dev/null
+++ b/services/poblacion_apta/domain/verificador_st/core.py
@@ -0,0 +1,1448 @@
+# -*- coding: utf-8 -*-
+"""Nucleo de validacion/normalizacion portado desde Verificador_ST.py (sin GUI)."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+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_desde_df(df_serv: pd.DataFrame) -> dict[str, str]:
+ """
+ Arma un diccionario radicado -> tipo_servicio.
+
+ Soporta encabezados como:
+ - radicado / NumeroRadicacion / numero radicacion
+ - tipo_servicio / servicio / Origen_Servicio
+ """
+ if df_serv is None or df_serv.empty:
+ return {}
+
+ df_serv = df_serv.copy()
+ 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: dict[str, str] = {}
+ 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 cargar_origen_servicio_desde_bytes(content: bytes, filename: str = "origen.xlsx") -> dict[str, str]:
+ """Carga Origen Servicio desde bytes (CSV o Excel)."""
+ if not content:
+ return {}
+
+ from io import BytesIO
+
+ name = (filename or "origen.xlsx").lower()
+ try:
+ if name.endswith(".csv"):
+ text = None
+ for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
+ try:
+ text = content.decode(enc)
+ break
+ except UnicodeDecodeError:
+ continue
+ if text is None:
+ text = content.decode("utf-8", errors="replace")
+ sample = text[:8192]
+ try:
+ import csv as _csv
+
+ dialect = _csv.Sniffer().sniff(sample, delimiters="|,;\t")
+ sep = dialect.delimiter
+ except Exception: # noqa: BLE001
+ sep = "|" if sample.count("|") >= sample.count(",") else ","
+ df_serv = pd.read_csv(
+ BytesIO(text.encode("utf-8")),
+ sep=sep,
+ dtype=str,
+ keep_default_na=False,
+ engine="python",
+ )
+ elif name.endswith((".xlsx", ".xlsm", ".xls")):
+ df_serv = pd.read_excel(BytesIO(content), dtype=str, keep_default_na=False)
+ else:
+ # Intentar Excel y luego CSV
+ try:
+ df_serv = pd.read_excel(BytesIO(content), dtype=str, keep_default_na=False)
+ except Exception: # noqa: BLE001
+ return cargar_origen_servicio_desde_bytes(content, "origen.csv")
+ except Exception as exc: # noqa: BLE001
+ raise RuntimeError(f"No se pudo leer Origen Servicio ({filename}): {exc}") from exc
+
+ return cargar_origen_servicio_desde_df(df_serv)
+
+
+def cargar_origen_servicio(ruta_archivo):
+ """
+ Carga el Excel/CSV seleccionado por el usuario y arma un diccionario:
+ radicado -> tipo_servicio.
+ """
+ if not ruta_archivo:
+ return {}
+
+ archivo_servicio = Path(ruta_archivo)
+
+ if not archivo_servicio.exists():
+ return {}
+
+ df_serv = leer_archivo_flexible(archivo_servicio)
+ return cargar_origen_servicio_desde_df(df_serv)
+
+
+def aplicar_origen_servicio(base: pd.DataFrame, mapa_servicios: dict[str, str]) -> tuple[pd.DataFrame, int, int]:
+ """
+ Rellena tipo_servicio desde el mapa Origen Servicio usando radicado.
+ Devuelve (base, encontrados, sin_cruce).
+ """
+ if not mapa_servicios or base is None or base.empty or "radicado" not in base.columns:
+ return base, 0, 0
+
+ out = base.copy()
+ if "tipo_servicio" not in out.columns:
+ out["tipo_servicio"] = ""
+
+ encontrados = 0
+ sin_cruce = 0
+ for i, row in out.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:
+ out.at[i, "tipo_servicio"] = servicio_excel
+ encontrados += 1
+ else:
+ sin_cruce += 1
+
+ return out, encontrados, sin_cruce
+
+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
+
+
diff --git a/services/poblacion_apta/domain/verificador_st/pipeline.py b/services/poblacion_apta/domain/verificador_st/pipeline.py
new file mode 100644
index 0000000..5129c99
--- /dev/null
+++ b/services/poblacion_apta/domain/verificador_st/pipeline.py
@@ -0,0 +1,225 @@
+# -*- coding: utf-8 -*-
+"""Pipeline: filas limpias → consolidado Población apta."""
+from __future__ import annotations
+
+from typing import Any
+
+import pandas as pd
+
+from domain.verificador_st.core import (
+ COLUMNAS_FINAL_IT,
+ CSV_FINAL_IT_HEADERS,
+ CSV_FINAL_IT_RENAME,
+ aplicar_origen_servicio,
+ construir_base_especial,
+ construir_base_normal,
+ es_archivo_especial,
+ normalizar_registros,
+ preparar_consolidado,
+ preparar_resumen,
+)
+
+
+def _records_to_dataframe(records: list[dict[str, Any]]) -> pd.DataFrame:
+ if not records:
+ return pd.DataFrame()
+ return pd.DataFrame(records).fillna("")
+
+
+def process_cleaning_sources(
+ sources: list[dict[str, Any]],
+ mapa_servicios: dict[str, str] | None = None,
+) -> tuple[pd.DataFrame, dict[str, Any], list[str]]:
+ """
+ Procesa varias fuentes de limpieza.
+
+ Cada source: {
+ "cleaning_job_id": str,
+ "label": str, # archivo_origen
+ "rows": list[dict],
+ }
+
+ mapa_servicios: opcional, radicado -> tipo_servicio (Origen Servicio).
+ """
+ log: list[str] = []
+ ok_parts: list[pd.DataFrame] = []
+ err_parts: list[pd.DataFrame] = []
+ resumen_archivos: list[dict[str, Any]] = []
+ mapa = mapa_servicios or {}
+
+ if mapa:
+ # len(mapa) cuenta variantes de clave; estimar radicados únicos aproximados
+ log.append(f"Origen Servicio cargado: {len(mapa)} claves de cruce")
+
+ for source in sources:
+ cleaning_job_id = str(source.get("cleaning_job_id") or "")
+ label = str(source.get("label") or cleaning_job_id)
+ rows = source.get("rows") or []
+ df = _records_to_dataframe(list(rows))
+ log.append(f"Fuente {label}: {len(df)} filas")
+
+ if df.empty:
+ resumen_archivos.append(
+ {
+ "archivo": label,
+ "cleaning_job_id": cleaning_job_id,
+ "filas": 0,
+ "paso_bot": 0,
+ "duplicados": 0,
+ "con_error": 0,
+ }
+ )
+ continue
+
+ if es_archivo_especial(df):
+ base = construir_base_especial(df, label)
+ log.append(f" tipo ESPECIAL")
+ else:
+ base = construir_base_normal(df, label)
+ log.append(f" tipo NORMAL")
+
+ if mapa:
+ base, encontrados, sin_cruce = aplicar_origen_servicio(base, mapa)
+ log.append(
+ f" Origen Servicio: {encontrados} con servicio / {sin_cruce} sin cruce"
+ )
+
+ # Propagar cleaning_job_id para persistencia
+ base["cleaning_job_id"] = cleaning_job_id
+
+ df_ok, df_err = normalizar_registros(base)
+ if not df_ok.empty:
+ df_ok = df_ok.copy()
+ df_ok["cleaning_job_id"] = cleaning_job_id
+ ok_parts.append(df_ok)
+ if not df_err.empty:
+ df_err = df_err.copy()
+ df_err["cleaning_job_id"] = cleaning_job_id
+ err_parts.append(df_err)
+
+ resumen_archivos.append(
+ {
+ "archivo": label,
+ "cleaning_job_id": cleaning_job_id,
+ "filas": len(df),
+ "paso_bot": 0,
+ "duplicados": 0,
+ "con_error": 0,
+ }
+ )
+
+ df_ok_total = pd.concat(ok_parts, ignore_index=True) if ok_parts else pd.DataFrame()
+ df_err_total = pd.concat(err_parts, ignore_index=True) if err_parts else pd.DataFrame()
+
+ consolidado = preparar_consolidado(df_ok_total, df_err_total)
+
+ # Reinyectar cleaning_job_id si se perdió en preparar_consolidado
+ if "cleaning_job_id" not in consolidado.columns:
+ consolidado["cleaning_job_id"] = ""
+ # mapear por archivo_origen
+ label_to_id = {
+ str(s.get("label") or s.get("cleaning_job_id")): str(s.get("cleaning_job_id") or "")
+ for s in sources
+ }
+ if "archivo_origen" in consolidado.columns:
+ consolidado["cleaning_job_id"] = consolidado["archivo_origen"].map(
+ lambda x: label_to_id.get(str(x), "")
+ )
+
+ # Actualizar resumen por fuente
+ if not consolidado.empty and "archivo_origen" in consolidado.columns:
+ paso = (
+ consolidado[consolidado["OBSERVACION"].eq("PASO_BOT")]
+ .groupby("archivo_origen")
+ .size()
+ .to_dict()
+ )
+ dup = (
+ consolidado[consolidado["OBSERVACION"].str.contains("RADICADO_DUPLICADO", na=False)]
+ .groupby("archivo_origen")
+ .size()
+ .to_dict()
+ )
+ err = (
+ consolidado[~consolidado["OBSERVACION"].eq("PASO_BOT")]
+ .groupby("archivo_origen")
+ .size()
+ .to_dict()
+ )
+ for r in resumen_archivos:
+ a = r.get("archivo", "")
+ r["paso_bot"] = int(paso.get(a, 0))
+ r["duplicados"] = int(dup.get(a, 0))
+ r["con_error"] = int(err.get(a, 0))
+
+ resumen_obs, _ = preparar_resumen(consolidado, resumen_archivos)
+ summary: dict[str, Any] = {
+ "total": int(len(consolidado)),
+ "paso_bot": int((consolidado["OBSERVACION"] == "PASO_BOT").sum()) if not consolidado.empty else 0,
+ "con_error": int((consolidado["OBSERVACION"] != "PASO_BOT").sum()) if not consolidado.empty else 0,
+ "duplicados": int(
+ consolidado["OBSERVACION"].astype(str).str.contains("RADICADO_DUPLICADO", na=False).sum()
+ )
+ if not consolidado.empty
+ else 0,
+ "por_fuente": resumen_archivos,
+ "por_observacion": resumen_obs.to_dict(orient="records") if not resumen_obs.empty else [],
+ }
+ log.append(
+ f"Consolidado: {summary['total']} filas, "
+ f"{summary['paso_bot']} PASO_BOT, {summary['con_error']} con observación"
+ )
+ return consolidado, summary, log
+
+
+def consolidado_to_records(df: pd.DataFrame) -> list[dict[str, Any]]:
+ if df is None or df.empty:
+ return []
+ cols = [
+ *COLUMNAS_FINAL_IT,
+ "OBSERVACION",
+ "archivo_origen",
+ "tipo_archivo",
+ "cleaning_job_id",
+ ]
+ out = df.copy()
+ for c in cols:
+ if c not in out.columns:
+ out[c] = ""
+ records = out[cols].fillna("").astype(str).to_dict(orient="records")
+ return records
+
+
+def export_paso_bot_csv(rows: list[dict[str, Any]]) -> str:
+ """CSV pipe-separated solo PASO_BOT, headers IT."""
+ aptos = [r for r in rows if str(r.get("OBSERVACION", "")).strip() == "PASO_BOT"]
+ if not aptos:
+ df = pd.DataFrame(columns=CSV_FINAL_IT_HEADERS)
+ else:
+ df = pd.DataFrame(aptos)
+ for c in COLUMNAS_FINAL_IT:
+ if c not in df.columns:
+ df[c] = ""
+ df = df.reindex(columns=COLUMNAS_FINAL_IT).fillna("")
+ df = df.rename(columns=CSV_FINAL_IT_RENAME)
+ df = df.reindex(columns=CSV_FINAL_IT_HEADERS)
+ return df.to_csv(sep="|", index=False, encoding="utf-8-sig")
+
+
+def export_no_aptos_csv(rows: list[dict[str, Any]]) -> str:
+ """CSV pipe-separated de filas que NO son PASO_BOT (incluye OBSERVACION)."""
+ headers = [*CSV_FINAL_IT_HEADERS, "OBSERVACION"]
+ no_aptos = [r for r in rows if str(r.get("OBSERVACION", "")).strip() != "PASO_BOT"]
+ if not no_aptos:
+ df = pd.DataFrame(columns=headers)
+ else:
+ df = pd.DataFrame(no_aptos)
+ for c in COLUMNAS_FINAL_IT:
+ if c not in df.columns:
+ df[c] = ""
+ if "OBSERVACION" not in df.columns:
+ df["OBSERVACION"] = ""
+ df = df.reindex(columns=[*COLUMNAS_FINAL_IT, "OBSERVACION"]).fillna("")
+ df = df.rename(columns=CSV_FINAL_IT_RENAME)
+ df = df.reindex(columns=headers)
+ return df.to_csv(sep="|", index=False, encoding="utf-8-sig")
diff --git a/services/poblacion_apta/infrastructure/__init__.py b/services/poblacion_apta/infrastructure/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/poblacion_apta/infrastructure/database.py b/services/poblacion_apta/infrastructure/database.py
new file mode 100644
index 0000000..e8a4224
--- /dev/null
+++ b/services/poblacion_apta/infrastructure/database.py
@@ -0,0 +1,155 @@
+"""Persistencia del servicio Población apta."""
+from __future__ import annotations
+
+import os
+import uuid
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from typing import Any, Iterator
+
+from sqlalchemy import (
+ JSON,
+ DateTime,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+ create_engine,
+ text,
+)
+from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
+
+
+DATABASE_URL = os.getenv(
+ "DATABASE_URL",
+ "postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor",
+)
+
+engine = create_engine(DATABASE_URL, pool_pre_ping=True)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def new_id() -> str:
+ return str(uuid.uuid4())
+
+
+class Job(Base):
+ __tablename__ = "jobs"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
+ status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+ title: Mapped[str | None] = mapped_column(Text, nullable=True)
+ job_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class Task(Base):
+ __tablename__ = "tasks"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
+ job_id: Mapped[str | None] = mapped_column(
+ String(36), ForeignKey("jobs.id"), nullable=True, index=True
+ )
+ type: Mapped[str] = mapped_column(String(64), index=True)
+ status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+ title: Mapped[str | None] = mapped_column(Text, nullable=True)
+ request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
+class CleaningJob(Base):
+ __tablename__ = "cleaning_jobs"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
+ extraction_batch_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+ job_id: Mapped[str | None] = mapped_column(
+ String(36), ForeignKey("jobs.id"), nullable=True, index=True
+ )
+ source: Mapped[str] = mapped_column(String(32), default="extraction", index=True)
+ status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+ request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ log: Mapped[list[str]] = mapped_column(JSON, default=list)
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class CleanedRow(Base):
+ __tablename__ = "cleaned_rows"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ cleaning_job_id: Mapped[str] = mapped_column(ForeignKey("cleaning_jobs.id"), index=True)
+ extraction_batch_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+ row_index: Mapped[int] = mapped_column(Integer, default=0)
+ row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class PoblacionAptaJob(Base):
+ __tablename__ = "poblacion_apta_jobs"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
+ job_id: Mapped[str | None] = mapped_column(
+ String(36), ForeignKey("jobs.id"), nullable=True, index=True
+ )
+ status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+ request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ log: Mapped[list[str]] = mapped_column(JSON, default=list)
+ summary: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class PoblacionAptaRow(Base):
+ __tablename__ = "poblacion_apta_rows"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ poblacion_apta_job_id: Mapped[str] = mapped_column(
+ ForeignKey("poblacion_apta_jobs.id"), index=True
+ )
+ cleaning_job_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+ row_index: Mapped[int] = mapped_column(Integer, default=0)
+ row_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+def init_db() -> None:
+ if DATABASE_URL.startswith("postgresql"):
+ with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
+ connection.execute(text("SELECT pg_advisory_lock(74512002)"))
+ try:
+ Base.metadata.create_all(bind=connection)
+ finally:
+ connection.execute(text("SELECT pg_advisory_unlock(74512002)"))
+ return
+
+ Base.metadata.create_all(bind=engine)
+
+
+@contextmanager
+def session_scope() -> Iterator[Session]:
+ session = SessionLocal()
+ try:
+ yield session
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+ finally:
+ session.close()
diff --git a/services/poblacion_apta/requirements.txt b/services/poblacion_apta/requirements.txt
new file mode 100644
index 0000000..1e18262
--- /dev/null
+++ b/services/poblacion_apta/requirements.txt
@@ -0,0 +1,9 @@
+fastapi==0.115.6
+numpy==2.2.6
+openpyxl==3.1.5
+pandas==2.2.3
+psycopg[binary]==3.2.3
+pydantic==2.11.9
+python-multipart==0.0.20
+SQLAlchemy==2.0.36
+uvicorn[standard]==0.32.1