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.

This commit is contained in:
erik
2026-07-28 04:29:14 -05:00
parent 447359166b
commit e73e425e4b
47 changed files with 6978 additions and 84 deletions
@@ -0,0 +1 @@
"""Dominio del servicio Población apta."""
+89
View File
@@ -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
@@ -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",
]
File diff suppressed because it is too large Load Diff
@@ -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")