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:
@@ -0,0 +1 @@
|
||||
"""Dominio del servicio Población apta."""
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user