feat: Enhance CSV cleaning service with new endpoints for CSV analysis and preview functionality. Introduce support for job management linked to extraction batches, and update the cleaning rules structure. Add new utility functions for handling CSV data and improve documentation for clarity on usage and available endpoints.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Reglas de limpieza registradas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import builtins as _builtins # noqa: F401 — registra reglas al importar
|
||||
from .registry import CLEANING_RULES, get_rule, get_rule_fn, get_rule_name, register_rule
|
||||
|
||||
__all__ = [
|
||||
"CLEANING_RULES",
|
||||
"get_rule",
|
||||
"get_rule_fn",
|
||||
"get_rule_name",
|
||||
"register_rule",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Registro de todas las reglas built-in.
|
||||
|
||||
Añadir una regla nueva: implementa la función y llama a register_rule abajo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import dates as date_rules
|
||||
from . import documents as doc_rules
|
||||
from . import numeric as num_rules
|
||||
from . import special
|
||||
from . import text as text_rules
|
||||
from .registry import rebuild_cleaning_rules, register_rule
|
||||
|
||||
register_rule(
|
||||
"strip_special_spaces",
|
||||
name="Eliminar caracteres especiales y espacios",
|
||||
description=(
|
||||
"Deja solo caracteres alfanuméricos (A-Z, a-z, 0-9). No usar antes de "
|
||||
"«Normalizar fecha» en columnas con fecha y hora: pega cifras y puede impedir "
|
||||
"interpretar el valor (aplique primero la normalización de fecha o use la regla "
|
||||
"que conserva separadores)."
|
||||
),
|
||||
fn=text_rules._rule_strip_special_spaces,
|
||||
)
|
||||
register_rule(
|
||||
"strip_special_keep_date_seps",
|
||||
name="Quitar símbolos; conserva /, -, : y espacios (fechas o texto con fechas)",
|
||||
description=(
|
||||
"Quita comas, $, comillas, etc. y deja letras, números, /, -, : (hora) y espacios. "
|
||||
"Adecuado p. ej. en columna 'Fecha Servicio'."
|
||||
),
|
||||
fn=text_rules._rule_strip_special_keep_date_seps,
|
||||
)
|
||||
register_rule(
|
||||
"strip_spaces",
|
||||
name="Eliminar únicamente espacios",
|
||||
description="Elimina todos los caracteres de espacio en blanco.",
|
||||
fn=text_rules._rule_strip_spaces,
|
||||
)
|
||||
register_rule(
|
||||
"to_integer",
|
||||
name="Convertir a Entero",
|
||||
description=(
|
||||
"Convierte a enteros con detección de miles/decimal (`,`/`.`) y moneda; "
|
||||
"redondea decimales."
|
||||
),
|
||||
fn=num_rules._rule_to_integer,
|
||||
)
|
||||
register_rule(
|
||||
"date_to_ddmmyyyy",
|
||||
name="Normalizar fecha → DD/MM/AAAA",
|
||||
description=(
|
||||
"Normaliza fechas numéricas, con separadores, con ruido o con meses en "
|
||||
"texto (ej. 26-abril-2026) al formato DD/MM/AAAA."
|
||||
),
|
||||
fn=date_rules._rule_date_to_ddmmyyyy,
|
||||
)
|
||||
register_rule(
|
||||
"date_numeric_to_ddmmyyyy",
|
||||
name="Conversión de formato numérico (Fechas) → DD/MM/AAAA",
|
||||
description=(
|
||||
"Detecta el formato numérico predominante (YYYYMMDD, DDMMYYYY, etc.) "
|
||||
"y normaliza a DD/MM/AAAA."
|
||||
),
|
||||
fn=date_rules._rule_date_numeric_to_ddmmyyyy,
|
||||
)
|
||||
register_rule(
|
||||
"date_alpha_to_ddmmyyyy",
|
||||
name="Conversión de formato alfanumérico (Fechas) → DD/MM/AAAA",
|
||||
description=(
|
||||
"Detecta el formato con separadores predominante "
|
||||
"(DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD, etc.) y normaliza a DD/MM/AAAA."
|
||||
),
|
||||
fn=date_rules._rule_date_alpha_to_ddmmyyyy,
|
||||
)
|
||||
register_rule(
|
||||
"date_messy_text_to_ddmmyyyy",
|
||||
name="Texto a fecha (ruido en fechas) → DD/MM/AAAA",
|
||||
description=(
|
||||
"Quita ruido entre cifras (*, +, _, /, etc.), detecta el orden en la "
|
||||
"columna (DDMMYYYY, YYYYMMDD, DDMMYY) y unifica a DD/MM/AAAA."
|
||||
),
|
||||
fn=date_rules._rule_date_messy_text_to_ddmmyyyy,
|
||||
)
|
||||
register_rule(
|
||||
"round_decimals_custom",
|
||||
name="Aproximación Decimales (>0,5 sube, ≤0,5 baja)",
|
||||
description=(
|
||||
"Usa el mismo análisis de miles, decimales y moneda que «Entero». Tras leer "
|
||||
"el número, si la parte fraccionaria de |valor| es mayor a 0,5 se aproxima al "
|
||||
"entero hacia lejos de cero; en caso contrario, hacia cero. El 0,5 exacto se baja."
|
||||
),
|
||||
fn=num_rules._rule_round_decimals_custom,
|
||||
)
|
||||
register_rule(
|
||||
"doc_id_split_tipo_numero",
|
||||
name="Dividir tipo y número (documento)",
|
||||
description=(
|
||||
"Añade dos columnas (nombres únicos p. ej. TipoID_doc_tipo, TipoID_doc_numeros): "
|
||||
"prefijo con letras (NIT, CC, CE, …) y cifras sin separadores. No modifica la "
|
||||
"columna de origen."
|
||||
),
|
||||
fn=special._rule_passthrough_series,
|
||||
)
|
||||
register_rule(
|
||||
"doc_type_homologate",
|
||||
name="Homologar tipo de documento",
|
||||
description=(
|
||||
"Convierte tipos de documento escritos como nombre largo, sigla o texto con "
|
||||
"símbolos a su abreviatura estándar (CC, TI, CE, RC, etc.)."
|
||||
),
|
||||
fn=doc_rules._rule_doc_type_homologate,
|
||||
)
|
||||
register_rule(
|
||||
"cop_mod_split",
|
||||
name="Separa COP/MOD (copago / moderadora)",
|
||||
description=(
|
||||
"Según el año en la columna Fecha Servicio que elija en la tarjeta, clasifica "
|
||||
"el importe en cuota moderadora (valores por año en Configuración) o copago. "
|
||||
"Se ejecuta al final del proceso de limpieza para usar fechas ya normalizadas."
|
||||
),
|
||||
fn=special._rule_passthrough_series,
|
||||
)
|
||||
register_rule(
|
||||
"nota_paciente_autorizacion",
|
||||
name="Extraer datos de notas (documento y autorización)",
|
||||
description=(
|
||||
"Desde texto tipo nota hospitalaria, extrae el número tras «Paciente:» (antes del "
|
||||
"guión del nombre) y, si existe, el de «Autorización No.». Añade dos columnas; "
|
||||
"no modifica el texto original."
|
||||
),
|
||||
fn=special._rule_passthrough_series,
|
||||
)
|
||||
|
||||
rebuild_cleaning_rules()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Reglas de normalización de fechas a DD/MM/AAAA."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ..dates import (
|
||||
_detect_compact_date_formats,
|
||||
_normalize_messy_date_to_compact,
|
||||
_parse_any_date_to_tuple,
|
||||
_parse_date_with_format,
|
||||
_parse_date_with_text_month,
|
||||
detect_date_format,
|
||||
)
|
||||
from ..nullish import _is_nullish_scalar
|
||||
|
||||
|
||||
def _rule_date_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
||||
"""Normaliza fechas numéricas, alfanuméricas, con meses en texto y con ruido."""
|
||||
stripped = series.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
|
||||
stripped = stripped.where(series.notna(), other=pd.NA)
|
||||
sep_fmt = detect_date_format(series) or detect_date_format(stripped)
|
||||
compact8_fmt, compact6_fmt = _detect_compact_date_formats(series)
|
||||
|
||||
def _fn(x: Any):
|
||||
if pd.isna(x) or str(x).strip() == "":
|
||||
return x
|
||||
parsed = _parse_any_date_to_tuple(
|
||||
x,
|
||||
sep_fmt=sep_fmt,
|
||||
compact8_fmt=compact8_fmt,
|
||||
compact6_fmt=compact6_fmt,
|
||||
)
|
||||
if parsed is None:
|
||||
return x
|
||||
d, m, y = parsed
|
||||
return f"{d:02d}/{m:02d}/{y:04d}"
|
||||
|
||||
return series.map(_fn)
|
||||
|
||||
|
||||
def _rule_date_numeric_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
||||
"""Entrada: fechas en formato numérico (ej. 20240315 o 15032024).
|
||||
|
||||
Salida: "DD/MM/AAAA" tras detectar el formato predominante.
|
||||
"""
|
||||
# Para esta regla consideramos los valores "numéricos" (quitamos separadores
|
||||
# comunes si los hay para detectar un YYYYMMDD/DDMMYYYY confiable).
|
||||
stripped = series.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
|
||||
stripped = stripped.where(series.notna(), other=pd.NA)
|
||||
fmt = detect_date_format(stripped)
|
||||
if fmt is None:
|
||||
# fallback: intentar con detector normal
|
||||
fmt = detect_date_format(series)
|
||||
return _format_dates_to_ddmmyyyy(series, fmt)
|
||||
|
||||
|
||||
def _rule_date_alpha_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
||||
"""Entrada: fechas en formato alfanumérico con separadores.
|
||||
|
||||
Salida: "DD/MM/AAAA".
|
||||
"""
|
||||
fmt = detect_date_format(series)
|
||||
return _format_dates_to_ddmmyyyy(series, fmt)
|
||||
|
||||
|
||||
def _format_dates_to_ddmmyyyy(series: pd.Series, fmt: Optional[str]) -> pd.Series:
|
||||
"""Convierte cada valor al formato DD/MM/AAAA usando `fmt` como layout de
|
||||
entrada detectado. La parte de hora (si existe) se descarta. Mantiene el
|
||||
valor original si no puede parsear la fecha.
|
||||
"""
|
||||
if fmt is None:
|
||||
return series.copy()
|
||||
|
||||
def _fn(x):
|
||||
if pd.isna(x) or str(x).strip() == "":
|
||||
return x
|
||||
raw = str(x)
|
||||
date_part, _time_part = _split_date_time(raw)
|
||||
target = date_part if date_part else raw
|
||||
parsed = _parse_date_with_format(target, fmt)
|
||||
if parsed is None:
|
||||
return x
|
||||
d, m, y = parsed
|
||||
return f"{d:02d}/{m:02d}/{y:04d}"
|
||||
|
||||
return series.map(_fn)
|
||||
|
||||
|
||||
def _rule_date_messy_text_to_ddmmyyyy(series: pd.Series) -> pd.Series:
|
||||
"""Fechas con símbolos y basura entre cifras → DD/MM/AAAA.
|
||||
|
||||
Normaliza a solo 6/8 dígitos, vota el formato con `detect_date_format` por
|
||||
separado en compact8 y compact6; si el detector no devuelve nada, usa
|
||||
`DDMMYYYY` / `DDMMYY` (alineado con la preferencia hispana en fechas
|
||||
numéricas).
|
||||
"""
|
||||
cser = series.map(_normalize_messy_date_to_compact)
|
||||
c8s = pd.Series(
|
||||
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 8]
|
||||
)
|
||||
c6s = pd.Series(
|
||||
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 6]
|
||||
)
|
||||
if c8s.empty and c6s.empty:
|
||||
return series.copy()
|
||||
|
||||
fmt8: Optional[str] = None
|
||||
fmt6: Optional[str] = None
|
||||
if not c8s.empty:
|
||||
fmt8 = detect_date_format(c8s) or "DDMMYYYY"
|
||||
if not c6s.empty:
|
||||
fmt6 = detect_date_format(c6s) or "DDMMYY"
|
||||
|
||||
def _one(x: Any, c: Any) -> Any:
|
||||
if _is_nullish_scalar(x):
|
||||
return x
|
||||
if not isinstance(c, str) or len(c) not in (6, 8):
|
||||
return x
|
||||
f = (fmt8 or "DDMMYYYY") if len(c) == 8 else (fmt6 or "DDMMYY")
|
||||
t = _parse_date_with_format(c, f)
|
||||
if t is None and len(c) == 8:
|
||||
t = _parse_date_with_format(c, "DDMMYYYY") or _parse_date_with_format(
|
||||
c, "YYYYMMDD"
|
||||
)
|
||||
if t is None and len(c) == 6:
|
||||
t = _parse_date_with_format(c, "DDMMYY")
|
||||
if t is None:
|
||||
return x
|
||||
d, m, y = t
|
||||
return f"{d:02d}/{m:02d}/{y:04d}"
|
||||
|
||||
return pd.Series(
|
||||
[
|
||||
_one(v, cser.iloc[i] if i < len(cser) else None) for i, v in enumerate(series)
|
||||
],
|
||||
index=series.index,
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Reglas de documento (homologación; split se aplica en apply.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ..documents import homologate_doc_type, split_doc_tipo_y_numero_texto
|
||||
from ..nullish import _is_nullish_scalar
|
||||
|
||||
|
||||
def _rule_doc_type_homologate(series: pd.Series) -> pd.Series:
|
||||
"""Homologa una columna que contiene tipos de documento."""
|
||||
|
||||
def _fn(x: Any):
|
||||
if _is_nullish_scalar(x):
|
||||
return x
|
||||
tipo, _num = split_doc_tipo_y_numero_texto(x, homologate=True)
|
||||
if tipo is not None:
|
||||
return tipo
|
||||
return homologate_doc_type(x)
|
||||
|
||||
return series.map(_fn)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Reglas numéricas (entero, redondeo)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ..nullish import _is_nullish_scalar
|
||||
from ..numbers import _to_numeric_lenient
|
||||
|
||||
|
||||
def _rule_to_integer(series: pd.Series) -> pd.Series:
|
||||
"""Convierte a entero (Int64 nullable) con parser tolerante."""
|
||||
numeric = _to_numeric_lenient(series.astype(str))
|
||||
return numeric.round().astype("Int64")
|
||||
|
||||
|
||||
def _round_to_int_half_tie_le(x: float) -> int:
|
||||
"""Fracc. > 0,5 hacia arriba; 0,5 o menos hacia abajo. Banda 1e-9 en 0,5 (float).
|
||||
|
||||
Misma noción que en la descripción de la regla. Trabaja sobre |x| y
|
||||
recompone el signo.
|
||||
"""
|
||||
sign = -1 if x < 0 else 1
|
||||
if x == 0.0 or x == -0.0:
|
||||
return 0
|
||||
ax = abs(float(x))
|
||||
ip = math.trunc(ax)
|
||||
frac = ax - ip
|
||||
if frac < 0.5 - 1e-9:
|
||||
out = ip
|
||||
elif frac > 0.5 + 1e-9:
|
||||
out = ip + 1
|
||||
else:
|
||||
out = ip
|
||||
return -out if sign < 0 else out
|
||||
|
||||
|
||||
def _rule_round_decimals_custom(series: pd.Series) -> pd.Series:
|
||||
"""Mismo parser tolerante que convertir a entero; aprox. con fracc. >0,5 arriba."""
|
||||
numeric = _to_numeric_lenient(series.astype(str))
|
||||
|
||||
def _fn(x):
|
||||
if _is_nullish_scalar(x) or (isinstance(x, (float, np.floating)) and (not np.isfinite(x) or np.isnan(x))):
|
||||
return pd.NA
|
||||
try:
|
||||
f = float(x)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return pd.NA
|
||||
if not np.isfinite(f):
|
||||
return pd.NA
|
||||
if f == 0.0 or f == -0.0:
|
||||
return 0
|
||||
return _round_to_int_half_tie_le(f)
|
||||
|
||||
return numeric.map(_fn).astype("Int64")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Registro de reglas de limpieza.
|
||||
|
||||
Para agregar una regla nueva:
|
||||
1. Implementa ``fn(series: pd.Series) -> pd.Series`` (o passthrough si es especial).
|
||||
2. Llama a ``register_rule(...)`` en ``rules/builtins.py``.
|
||||
3. Si la regla es especial (añade columnas, usa config), maneja el id en ``apply.py``.
|
||||
4. Incluye el id en ``_applicable_rules_for_type`` (type_inference.py) si debe sugerirse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
RuleFn = Callable[[pd.Series], pd.Series]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleSpec:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
fn: RuleFn
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"fn": self.fn,
|
||||
}
|
||||
|
||||
|
||||
_REGISTRY: dict[str, RuleSpec] = {}
|
||||
_ORDER: list[str] = []
|
||||
|
||||
|
||||
def register_rule(
|
||||
rule_id: str,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
fn: RuleFn,
|
||||
) -> RuleSpec:
|
||||
"""Registra una regla. Si el id ya existe, lo reemplaza manteniendo el orden."""
|
||||
spec = RuleSpec(id=rule_id, name=name, description=description, fn=fn)
|
||||
if rule_id not in _REGISTRY:
|
||||
_ORDER.append(rule_id)
|
||||
_REGISTRY[rule_id] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def get_rule(rule_id: str) -> Optional[dict[str, Any]]:
|
||||
spec = _REGISTRY.get(rule_id)
|
||||
return spec.as_dict() if spec else None
|
||||
|
||||
|
||||
def get_rule_fn(rule_id: str) -> Optional[RuleFn]:
|
||||
spec = _REGISTRY.get(rule_id)
|
||||
return spec.fn if spec else None
|
||||
|
||||
|
||||
def get_rule_name(rule_id: str) -> str:
|
||||
spec = _REGISTRY.get(rule_id)
|
||||
return spec.name if spec else rule_id
|
||||
|
||||
|
||||
def list_rules() -> list[dict[str, Any]]:
|
||||
return [_REGISTRY[rid].as_dict() for rid in _ORDER if rid in _REGISTRY]
|
||||
|
||||
|
||||
# Compat: lista mutable usada por la API (se rellena al importar builtins).
|
||||
CLEANING_RULES: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def rebuild_cleaning_rules() -> list[dict[str, Any]]:
|
||||
CLEANING_RULES.clear()
|
||||
CLEANING_RULES.extend(list_rules())
|
||||
return CLEANING_RULES
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Marcadores passthrough para reglas que no transforman la serie in-place."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _rule_passthrough_series(s: pd.Series) -> pd.Series:
|
||||
return s.copy()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Reglas de limpieza de texto / espacios."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _rule_strip_special_spaces(series: pd.Series) -> pd.Series:
|
||||
"""Elimina todo lo que no sea alfanumérico (incluye espacios)."""
|
||||
pattern = re.compile(r"[^A-Za-z0-9]", flags=re.UNICODE)
|
||||
|
||||
def _fn(x):
|
||||
if pd.isna(x):
|
||||
return x
|
||||
return pattern.sub("", str(x))
|
||||
|
||||
return series.map(_fn)
|
||||
|
||||
|
||||
def _rule_strip_special_keep_date_seps(series: pd.Series) -> pd.Series:
|
||||
"""Conserva alfanuméricos, separadores de fecha '/', '-' y hora ':', y espacio."""
|
||||
pattern = re.compile(r"[^A-Za-z0-9\/\-: ]", flags=re.UNICODE)
|
||||
|
||||
def _fn(x):
|
||||
if pd.isna(x):
|
||||
return x
|
||||
return pattern.sub("", str(x))
|
||||
|
||||
return series.map(_fn)
|
||||
|
||||
|
||||
def _rule_strip_spaces(series: pd.Series) -> pd.Series:
|
||||
"""Elimina únicamente espacios (incluidos tabs y saltos de línea)."""
|
||||
def _fn(x):
|
||||
if pd.isna(x):
|
||||
return x
|
||||
return re.sub(r"\s+", "", str(x))
|
||||
|
||||
return series.map(_fn)
|
||||
Reference in New Issue
Block a user