1848 lines
64 KiB
Python
1848 lines
64 KiB
Python
# -*- 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()
|