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:
erik
2026-07-11 00:27:05 -05:00
parent cf0ebc72ba
commit b770761832
73 changed files with 7614 additions and 2671 deletions
@@ -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,
)