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,64 @@
|
||||
"""Limpieza de CSV con análisis inteligente local y reglas configurables.
|
||||
|
||||
API pública (estable):
|
||||
- ``analyze_dataframe`` — resumen por columna
|
||||
- ``CLEANING_RULES`` / ``register_rule`` / ``get_rule`` — catálogo de reglas
|
||||
- ``apply_rules`` — aplica reglas por columna
|
||||
- ``load_csv`` / ``load_csv_bytes`` / ``detect_delimiter`` — I/O
|
||||
|
||||
Para agregar una regla nueva, ver ``rules/registry.py`` y ``rules/builtins.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .analyze import analyze_dataframe, sample_dataframe_rows
|
||||
from .apply import apply_rules
|
||||
from .cop_mod import (
|
||||
DEFAULT_MODERADORA_BY_YEAR,
|
||||
merge_moderadora_config,
|
||||
normalize_moderadora_by_year_dict,
|
||||
)
|
||||
from .documents import homologate_doc_type, split_doc_tipo_y_numero_texto
|
||||
from .io import detect_delimiter, load_csv, load_csv_bytes
|
||||
from .models import (
|
||||
TYPE_BOOLEAN,
|
||||
TYPE_CATEGORICAL,
|
||||
TYPE_DATE,
|
||||
TYPE_DECIMAL,
|
||||
TYPE_EMPTY,
|
||||
TYPE_INTEGER,
|
||||
TYPE_MIXED,
|
||||
TYPE_TEXT,
|
||||
ColumnSummary,
|
||||
)
|
||||
from .notas import extract_autorizacion_nota, extract_paciente_documento_nota
|
||||
from .rules import CLEANING_RULES, get_rule, register_rule
|
||||
from .type_inference import applicable_rule_ids_for_type
|
||||
|
||||
__all__ = [
|
||||
"CLEANING_RULES",
|
||||
"ColumnSummary",
|
||||
"DEFAULT_MODERADORA_BY_YEAR",
|
||||
"TYPE_BOOLEAN",
|
||||
"TYPE_CATEGORICAL",
|
||||
"TYPE_DATE",
|
||||
"TYPE_DECIMAL",
|
||||
"TYPE_EMPTY",
|
||||
"TYPE_INTEGER",
|
||||
"TYPE_MIXED",
|
||||
"TYPE_TEXT",
|
||||
"analyze_dataframe",
|
||||
"applicable_rule_ids_for_type",
|
||||
"apply_rules",
|
||||
"detect_delimiter",
|
||||
"extract_autorizacion_nota",
|
||||
"extract_paciente_documento_nota",
|
||||
"get_rule",
|
||||
"homologate_doc_type",
|
||||
"load_csv",
|
||||
"load_csv_bytes",
|
||||
"merge_moderadora_config",
|
||||
"normalize_moderadora_by_year_dict",
|
||||
"register_rule",
|
||||
"sample_dataframe_rows",
|
||||
"split_doc_tipo_y_numero_texto",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Análisis de DataFrames: resumen por columna y muestras."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .models import (
|
||||
TYPE_CATEGORICAL,
|
||||
TYPE_DATE,
|
||||
TYPE_DECIMAL,
|
||||
TYPE_INTEGER,
|
||||
TYPE_TEXT,
|
||||
ColumnSummary,
|
||||
)
|
||||
from .numbers import _to_numeric_lenient
|
||||
from .type_inference import _applicable_rules_for_type, _infer_column_type
|
||||
|
||||
|
||||
def analyze_dataframe(df: pd.DataFrame) -> list[ColumnSummary]:
|
||||
"""Genera un resumen por columna del DataFrame."""
|
||||
summaries: list[ColumnSummary] = []
|
||||
total_rows = len(df)
|
||||
rng = random.Random(42)
|
||||
|
||||
for col in df.columns:
|
||||
series = df[col]
|
||||
null_count = int(series.isna().sum() + (series.astype(str).str.strip() == "").sum())
|
||||
# Evitar doble conteo: considerar vacío solo si no-NaN
|
||||
null_count = int((series.isna() | (series.astype(str).str.strip() == "")).sum())
|
||||
null_pct = (null_count / total_rows * 100) if total_rows else 0.0
|
||||
|
||||
non_null = series.dropna().astype(str)
|
||||
non_null = non_null[non_null.str.strip() != ""]
|
||||
|
||||
example = ""
|
||||
samples: list[str] = []
|
||||
if not non_null.empty:
|
||||
values = non_null.tolist()
|
||||
k = min(5, len(values))
|
||||
picked = rng.sample(values, k) if len(values) >= k else list(values)
|
||||
for raw in picked:
|
||||
text = str(raw)
|
||||
if len(text) > 80:
|
||||
text = text[:77] + "..."
|
||||
samples.append(text)
|
||||
example = samples[0] if samples else ""
|
||||
|
||||
unique_count = int(non_null.nunique())
|
||||
unique_pct = (unique_count / total_rows * 100) if total_rows else 0.0
|
||||
|
||||
inferred_type, date_fmt = _infer_column_type(series, str(col))
|
||||
|
||||
summary = ColumnSummary(
|
||||
name=str(col),
|
||||
inferred_type=inferred_type,
|
||||
example=example,
|
||||
samples=samples,
|
||||
null_count=null_count,
|
||||
null_pct=null_pct,
|
||||
unique_count=unique_count,
|
||||
unique_pct=unique_pct,
|
||||
detected_date_format=date_fmt,
|
||||
)
|
||||
|
||||
if inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
|
||||
numeric = _to_numeric_lenient(non_null)
|
||||
if numeric.notna().any():
|
||||
summary.min = float(numeric.min())
|
||||
summary.max = float(numeric.max())
|
||||
summary.mean = float(numeric.mean())
|
||||
elif inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
|
||||
if not non_null.empty:
|
||||
summary.avg_length = float(non_null.str.len().mean())
|
||||
counts = non_null.value_counts().head(3)
|
||||
summary.top_values = [(str(v), int(c)) for v, c in counts.items()]
|
||||
|
||||
summary.applicable_rules = _applicable_rules_for_type(inferred_type)
|
||||
summaries.append(summary)
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def sample_dataframe_rows(df: pd.DataFrame, n: int = 8) -> list[dict[str, Any]]:
|
||||
"""Devuelve hasta `n` filas aleatorias como dicts JSON-serializables."""
|
||||
if df.empty or n <= 0:
|
||||
return []
|
||||
take = min(n, len(df))
|
||||
rng = random.Random(42)
|
||||
indices = rng.sample(list(range(len(df))), take)
|
||||
subset = df.iloc[indices]
|
||||
return json.loads(subset.to_json(orient="records", date_format="iso"))
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Aplicación de reglas seleccionadas sobre un DataFrame."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .cop_mod import _apply_cop_mod_split, merge_moderadora_config
|
||||
from .documents import _apply_doc_id_split_columns
|
||||
from .notas import _apply_nota_extract_columns
|
||||
from .rules import get_rule_fn, get_rule_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_rules(
|
||||
df: pd.DataFrame,
|
||||
rules_per_column: dict[str, list[str]],
|
||||
*,
|
||||
cop_mod_fecha_column: Optional[dict[str, str]] = None,
|
||||
cop_mod_config: Optional[dict[str, Any]] = None,
|
||||
) -> tuple[pd.DataFrame, list[str]]:
|
||||
"""Aplica las reglas seleccionadas a cada columna.
|
||||
|
||||
`rules_per_column` es un dict de `columna -> lista de IDs de reglas en el
|
||||
orden a aplicar`. La regla `cop_mod_split` se difiere a una segunda pasada
|
||||
tras el resto, para que la columna de fecha pueda normalizarse antes.
|
||||
|
||||
`cop_mod_fecha_column`: mapa columna de importe -> nombre de columna Fecha Servicio.
|
||||
`cop_mod_config`: opcional; puede incluir ``moderadora_by_year`` (dict año → lista).
|
||||
"""
|
||||
result = df.copy()
|
||||
log: list[str] = []
|
||||
|
||||
moderadora_years = merge_moderadora_config(cop_mod_config)
|
||||
|
||||
def _run_rules_for_column(col: str, rule_ids: list[str]) -> None:
|
||||
if not rule_ids:
|
||||
return
|
||||
if col not in result.columns:
|
||||
log.append(f"[WARN] Columna no encontrada: {col}")
|
||||
return
|
||||
split_has_homologation = (
|
||||
"doc_id_split_tipo_numero" in rule_ids and "doc_type_homologate" in rule_ids
|
||||
)
|
||||
for rule_id in rule_ids:
|
||||
if rule_id == "doc_id_split_tipo_numero":
|
||||
try:
|
||||
_apply_doc_id_split_columns(
|
||||
result, col, log, homologate_type=split_has_homologation
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Error aplicando regla %s a %s", rule_id, col)
|
||||
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
|
||||
continue
|
||||
if rule_id == "nota_paciente_autorizacion":
|
||||
try:
|
||||
_apply_nota_extract_columns(result, col, log)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Error aplicando regla %s a %s", rule_id, col)
|
||||
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
|
||||
continue
|
||||
if rule_id == "doc_type_homologate" and split_has_homologation:
|
||||
continue
|
||||
fn = get_rule_fn(rule_id)
|
||||
if fn is None:
|
||||
log.append(f"[WARN] Regla desconocida '{rule_id}' en columna '{col}'")
|
||||
continue
|
||||
try:
|
||||
result[col] = fn(result[col])
|
||||
log.append(f"[OK] '{col}' <- {get_rule_name(rule_id)}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Error aplicando regla %s a %s", rule_id, col)
|
||||
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
|
||||
|
||||
# Fase 1: todas las reglas excepto cop_mod_split
|
||||
for col, rule_ids in rules_per_column.items():
|
||||
filtered = [r for r in rule_ids if r != "cop_mod_split"]
|
||||
_run_rules_for_column(col, filtered)
|
||||
|
||||
# Fase 2: Separa COP/MOD sobre el DataFrame ya transformado
|
||||
fecha_map = cop_mod_fecha_column or {}
|
||||
for col, rule_ids in rules_per_column.items():
|
||||
if "cop_mod_split" not in rule_ids:
|
||||
continue
|
||||
fecha_col = fecha_map.get(col)
|
||||
if not fecha_col:
|
||||
log.append(
|
||||
f"[WARN] COP/MOD en '{col}': indique la columna «Fecha Servicio» en la tarjeta; se omite."
|
||||
)
|
||||
continue
|
||||
try:
|
||||
_apply_cop_mod_split(
|
||||
result,
|
||||
col,
|
||||
fecha_col,
|
||||
log,
|
||||
moderadora_by_year=moderadora_years,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Error aplicando COP/MOD a %s", col)
|
||||
log.append(f"[ERROR] '{col}' regla 'cop_mod_split': {exc}")
|
||||
|
||||
return result, log
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Heurísticas por nombre de columna y presentación del contenido."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
|
||||
def _column_name_suggests_date_like(name: str) -> bool:
|
||||
"""Columnas cuyo nombre pide tratar el contenido como fecha a priori."""
|
||||
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
|
||||
if not n:
|
||||
return False
|
||||
if re.search(
|
||||
r"\b(fecha|date|año|mes|dia|día|f[_-]?(alta|baja|proc|corte|pago|ini|fin|"
|
||||
r"desde|hasta|vigenc|vence|venc|caduc|f\s?ec)\b|periodo|anio|ano|expir)",
|
||||
n,
|
||||
) or n.startswith("fec_") or n.startswith("fecha"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _column_name_suggests_monetary_value(name: str) -> bool:
|
||||
"""importe, valor, neto, IVA, etc. — no interpretar cifras como fechas.
|
||||
|
||||
Encabezados de tipo `fecha_…` no se tratan como moneda salvo mezcla con monto/valor.
|
||||
"""
|
||||
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
|
||||
if not n:
|
||||
return False
|
||||
if _column_name_suggests_date_like(name) and not re.search(
|
||||
r"\b(monto|valor|importe|pago|total|bruto|neto|import|unit|precio|copago)\b", n
|
||||
):
|
||||
return False
|
||||
for key in (
|
||||
"monto", "importe", "import", "bruto", "neto", "subtotal", "sub total",
|
||||
"total", "unitario", "unidad", "valor", "precio", "pago", "copago", "sald", "dcto",
|
||||
"franq", "cuota", "iva", "tarif", "costo", "carg", "reten", "retencion",
|
||||
):
|
||||
if key in n:
|
||||
if key == "iva" and re.search(r"(priva|giva|diva|viva|cliva)\b", n):
|
||||
continue
|
||||
if key == "carg" and re.search(r"descarg|recarg|abord", n):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _column_name_suggests_code_id(name: str) -> bool:
|
||||
"""Código, ref., radicado, tipoynúm de documento — no forzar 6-8 cifras como fechas (DDMMYY, etc.)."""
|
||||
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
|
||||
if not n or _column_name_suggests_date_like(name):
|
||||
return False
|
||||
for key in (
|
||||
"codigo", "código", "codig", "códig", "cód_",
|
||||
"sku", "ean", "upc", "isbn", "upn",
|
||||
"ref", "referen", "radic", "nro_fac", "nro fac", "nit", "cédul", "cedul",
|
||||
"autoriz", "correlat", "secuenc", "línea de", "linea de", "cupo",
|
||||
"tipy", "tipi", "tipynum", "typnum", "type_n", "n_doc", "n_docu",
|
||||
"núm doc", "n_docu", "doc_id", "docum", "id_doc",
|
||||
):
|
||||
if key in n:
|
||||
return True
|
||||
if re.search(
|
||||
r"tipo[_\s\\-]*(&|/|u|e|y)[_\s\\-]*num|tipo[_\s\\-]+doc|tipo_y_n",
|
||||
n,
|
||||
re.IGNORECASE,
|
||||
) or n.startswith("tip_"):
|
||||
return True
|
||||
if n.startswith("cod_") or n.startswith("cód_") or n.startswith("ref_"):
|
||||
return True
|
||||
return bool(re.search(r"\b(nro[._]fac|#?\s*autor|#?\s*ref)\b", n, re.IGNORECASE))
|
||||
|
||||
|
||||
def _column_name_suggests_quantity_or_number_not_bool(name: str) -> bool:
|
||||
"""Nombres típicos de cantidad, importe, fechas: no inferir bool por 0/1."""
|
||||
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
|
||||
if not n:
|
||||
return False
|
||||
if _column_name_suggests_monetary_value(name) or _column_name_suggests_code_id(name):
|
||||
return True
|
||||
for key in (
|
||||
"fecha",
|
||||
"monto",
|
||||
"importe",
|
||||
"bruto",
|
||||
"neto",
|
||||
"subtotal",
|
||||
"unitario",
|
||||
"unidad",
|
||||
"valor",
|
||||
"precio",
|
||||
"pago",
|
||||
"copago",
|
||||
"cantid",
|
||||
"quantity",
|
||||
"qty",
|
||||
"nro",
|
||||
"núm",
|
||||
"n°",
|
||||
):
|
||||
if key in n:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _raw_strings_suggest_monetary_presentation(non_null: pd.Series) -> bool:
|
||||
"""$ €, miles 1,234,12 o .00: presentación monetaria aunque el float sea "entero"."""
|
||||
if non_null is None or len(non_null) == 0:
|
||||
return False
|
||||
n_hit = 0
|
||||
n_seen = 0
|
||||
for raw in non_null.head(500):
|
||||
if _is_nullish_scalar(raw):
|
||||
continue
|
||||
t = str(raw)
|
||||
s = t.strip()
|
||||
n_seen += 1
|
||||
if re.search(r"[$\u00a3\u20ac\uffe5\uffe6\ufe69\uff04]", t):
|
||||
n_hit += 1
|
||||
continue
|
||||
if re.search(r"\b\d{1,3}(?:[.,\u00a0]\d{3})+\b", s) or re.search(
|
||||
r"(?<!\d)\d+[.,][0-9]{1,2}\b", s
|
||||
):
|
||||
n_hit += 1
|
||||
if n_seen == 0:
|
||||
return False
|
||||
return n_hit / n_seen >= 0.10
|
||||
|
||||
|
||||
def _cell_looks_cifra_con_moneda_iso_al_final(s: str) -> bool:
|
||||
"""Solo cifra/símbolo (no prefijo de documento) y código de moneda al final.
|
||||
|
||||
Excluye filas al estilo `CC12…` (típico alfanumérico).
|
||||
"""
|
||||
t = str(s).strip()
|
||||
u = t.upper()
|
||||
if not re.search(
|
||||
r"(?i)(USD|COP|EUR|CLP|ARS|GBP|MXN|PEN|DOP|UYU|VES|CRC|PYG|BRL|BOB|GTQ)\b\s*\Z", u
|
||||
):
|
||||
return False
|
||||
if re.match(
|
||||
r"^(?i)(CC|CE|NIT|NUIP|TI|PA|MS|RC|DNI|NUIP|CN|SC|AS|PE|PT)\D*[\d0]", u
|
||||
):
|
||||
return False
|
||||
return re.match(
|
||||
r"^[\$€0-9\s,.\-+%/]", t,
|
||||
) is not None
|
||||
|
||||
|
||||
def _raw_strings_suggest_alnum_mixed_presentation(non_null: pd.Series) -> bool:
|
||||
"""Letras y dígitos juntos (p. ej. `CC32…`, `A1-2B`); no es puro 'entero/decimal' lógicamente.
|
||||
|
||||
Excluido: columnas con apariencia monetaria, y celdas solo cifra + ISO al final (100 USD).
|
||||
"""
|
||||
if _raw_strings_suggest_monetary_presentation(non_null):
|
||||
return False
|
||||
n_m = 0
|
||||
n_s = 0
|
||||
for raw in non_null.head(500):
|
||||
if _is_nullish_scalar(raw):
|
||||
continue
|
||||
t = str(raw).strip()
|
||||
n_s += 1
|
||||
if not re.search(r"\d", t) or not re.search(
|
||||
r"[A-Za-zÑñ\u00c0-\u024f]", t, re.IGNORECASE
|
||||
):
|
||||
continue
|
||||
if _cell_looks_cifra_con_moneda_iso_al_final(t):
|
||||
continue
|
||||
n_m += 1
|
||||
if n_s == 0:
|
||||
return False
|
||||
return n_m / n_s >= 0.20
|
||||
|
||||
|
||||
def _cell_looks_like_documento_id_tipo_nit(s: str) -> bool:
|
||||
"""Cadenas estilo `CC32324029`, `CE 1.234.567` — no son fechas (evita 8 cifras como DDMM).
|
||||
|
||||
Al menos una letra al inicio y 5-16 dígitos en la celda (no `Jan`+`2020` = 4 dígitos).
|
||||
"""
|
||||
t = str(s).strip()
|
||||
if not t or len(t) < 3:
|
||||
return False
|
||||
if not re.match(r"^[A-Za-z]{1,3}", t):
|
||||
return False
|
||||
dig = re.sub(r"[^\d]", "", t)
|
||||
if len(dig) < 5 or len(dig) > 16:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _content_looks_like_doc_id_letter_prefix_column(non_null: pd.Series) -> bool:
|
||||
"""Muestras con prefijo documental (CC, NIT, …) y ≥5 dígitos → no columna de fecha."""
|
||||
n_ok = 0
|
||||
n_seen = 0
|
||||
for raw in non_null.head(500):
|
||||
if _is_nullish_scalar(raw):
|
||||
continue
|
||||
s = str(raw).strip()
|
||||
n_seen += 1
|
||||
if _cell_looks_like_documento_id_tipo_nit(s):
|
||||
n_ok += 1
|
||||
if n_seen == 0:
|
||||
return False
|
||||
return n_ok / n_seen >= 0.20
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Separación de copago / cuota moderadora por año."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .dates import (
|
||||
_detect_compact_date_formats,
|
||||
_parse_any_date_to_tuple,
|
||||
detect_date_format,
|
||||
)
|
||||
from .numbers import _infer_decimal_hint_from_column, _parse_lenient_one
|
||||
|
||||
|
||||
DEFAULT_MODERADORA_BY_YEAR: dict[int, set[int]] = {
|
||||
2023: {4100, 16400, 43000},
|
||||
2024: {4500, 18200, 47700},
|
||||
2025: {4700, 19200, 50300},
|
||||
2026: {5000, 20100, 52800},
|
||||
}
|
||||
|
||||
|
||||
def normalize_moderadora_by_year_dict(raw: Any) -> dict[int, set[int]]:
|
||||
"""Normaliza un dict JSON año → lista de enteros (cuotas moderadora)."""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[int, set[int]] = {}
|
||||
for k, v in raw.items():
|
||||
try:
|
||||
yi = int(k)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(v, (list, tuple)):
|
||||
continue
|
||||
nums: set[int] = set()
|
||||
for x in v:
|
||||
try:
|
||||
nums.add(int(round(float(x))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if nums:
|
||||
out[yi] = nums
|
||||
return out
|
||||
|
||||
|
||||
def merge_moderadora_config(
|
||||
cop_mod_config: Optional[dict[str, Any]],
|
||||
) -> dict[int, set[int]]:
|
||||
"""Fusiona defaults con `moderadora_by_year` opcional del usuario."""
|
||||
merged = {y: set(vals) for y, vals in DEFAULT_MODERADORA_BY_YEAR.items()}
|
||||
if cop_mod_config and isinstance(cop_mod_config.get("moderadora_by_year"), dict):
|
||||
custom = normalize_moderadora_by_year_dict(
|
||||
cop_mod_config["moderadora_by_year"]
|
||||
)
|
||||
for y, vals in custom.items():
|
||||
merged[y] = set(vals)
|
||||
return merged
|
||||
|
||||
|
||||
def _alloc_cop_mod_column_names(
|
||||
col_name: str, existing: set[str]
|
||||
) -> tuple[str, str]:
|
||||
"""Nombres únicos para copago y moderadora."""
|
||||
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
|
||||
for i in range(0, 200):
|
||||
suf = f"_{i}" if i else ""
|
||||
ccol, mcol = f"{base}{suf}_copago", f"{base}{suf}_moderadora"
|
||||
if ccol not in existing and mcol not in existing:
|
||||
return ccol, mcol
|
||||
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
|
||||
|
||||
|
||||
def _amount_cell_to_optional_int(
|
||||
val: Any,
|
||||
decimal_hint: Optional[Literal[",", "."]],
|
||||
) -> Optional[int]:
|
||||
try:
|
||||
f = _parse_lenient_one(val, decimal_hint)
|
||||
except Exception:
|
||||
return None
|
||||
if f is None or (isinstance(f, float) and not np.isfinite(f)) or (
|
||||
isinstance(f, (float, np.floating)) and np.isnan(f)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
return int(round(float(f)))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _apply_cop_mod_split(
|
||||
result: pd.DataFrame,
|
||||
amount_col: str,
|
||||
fecha_column: str,
|
||||
log: list[str],
|
||||
*,
|
||||
moderadora_by_year: dict[int, set[int]],
|
||||
) -> None:
|
||||
"""Añade columnas copago y moderadora según cuotas por año de la fecha."""
|
||||
if fecha_column not in result.columns:
|
||||
log.append(
|
||||
f"[WARN] COP/MOD: columna de fecha '{fecha_column}' no existe; se omite."
|
||||
)
|
||||
return
|
||||
if amount_col not in result.columns:
|
||||
log.append(f"[WARN] COP/MOD: columna de importe '{amount_col}' no existe; se omite.")
|
||||
return
|
||||
|
||||
fec_ser = result[fecha_column]
|
||||
amt_ser = result[amount_col]
|
||||
stripped = fec_ser.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
|
||||
stripped = stripped.where(fec_ser.notna(), other=pd.NA)
|
||||
sep_fmt = detect_date_format(fec_ser) or detect_date_format(stripped)
|
||||
compact8_fmt, compact6_fmt = _detect_compact_date_formats(fec_ser)
|
||||
amt_hint = _infer_decimal_hint_from_column(amt_ser.astype(str))
|
||||
|
||||
ccol, mcol = _alloc_cop_mod_column_names(str(amount_col), set(result.columns))
|
||||
cop_vals: list[Any] = []
|
||||
mod_vals: list[Any] = []
|
||||
|
||||
for i in range(len(result)):
|
||||
amt_raw = amt_ser.iloc[i]
|
||||
fec_raw = fec_ser.iloc[i]
|
||||
amt_i = _amount_cell_to_optional_int(amt_raw, amt_hint)
|
||||
if amt_i is None:
|
||||
cop_vals.append(0)
|
||||
mod_vals.append(0)
|
||||
continue
|
||||
|
||||
parsed_d = _parse_any_date_to_tuple(
|
||||
fec_raw,
|
||||
sep_fmt=sep_fmt,
|
||||
compact8_fmt=compact8_fmt,
|
||||
compact6_fmt=compact6_fmt,
|
||||
)
|
||||
if parsed_d is None:
|
||||
cop_vals.append(amt_i)
|
||||
mod_vals.append(0)
|
||||
continue
|
||||
_d, _m, year = parsed_d
|
||||
tiers = moderadora_by_year.get(year)
|
||||
if tiers is None:
|
||||
cop_vals.append(amt_i)
|
||||
mod_vals.append(0)
|
||||
continue
|
||||
|
||||
if amt_i in tiers:
|
||||
cop_vals.append(0)
|
||||
mod_vals.append(amt_i)
|
||||
else:
|
||||
cop_vals.append(amt_i)
|
||||
mod_vals.append(0)
|
||||
|
||||
result[ccol] = cop_vals
|
||||
result[mcol] = mod_vals
|
||||
log.append(
|
||||
f"[OK] '{amount_col}' -> '{ccol}', '{mcol}' <- Separa COP/MOD "
|
||||
f"(fecha: '{fecha_column}')"
|
||||
)
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Detección y normalización de fechas."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
_DATE_NUM_RE = re.compile(r"^\s*(\d{1,4})[\s\/\-\.](\d{1,2})[\s\/\-\.](\d{1,4})\s*$")
|
||||
_DATE_COMPACT_RE = re.compile(r"^\s*(\d{6}|\d{8})\s*$")
|
||||
# Hora opcional: HH[:MM[:SS]][ AM|PM] al final de la cadena
|
||||
_TIME_TAIL_RE = re.compile(
|
||||
r"[\sT]+(\d{1,2})(?::(\d{1,2})(?::(\d{1,2}))?)?\s*([APap]\.?\s*[Mm]\.?)?\s*$"
|
||||
)
|
||||
|
||||
_MONTH_NAME_TO_NUM = {
|
||||
"enero": 1,
|
||||
"ene": 1,
|
||||
"febrero": 2,
|
||||
"feb": 2,
|
||||
"marzo": 3,
|
||||
"mar": 3,
|
||||
"abril": 4,
|
||||
"abr": 4,
|
||||
"mayo": 5,
|
||||
"may": 5,
|
||||
"junio": 6,
|
||||
"jun": 6,
|
||||
"julio": 7,
|
||||
"jul": 7,
|
||||
"agosto": 8,
|
||||
"ago": 8,
|
||||
"septiembre": 9,
|
||||
"setiembre": 9,
|
||||
"sep": 9,
|
||||
"sept": 9,
|
||||
"octubre": 10,
|
||||
"oct": 10,
|
||||
"noviembre": 11,
|
||||
"nov": 11,
|
||||
"diciembre": 12,
|
||||
"dic": 12,
|
||||
}
|
||||
|
||||
|
||||
def _split_date_time(value: str) -> tuple[str, Optional[str]]:
|
||||
"""Separa la parte de fecha de la parte de hora si está presente.
|
||||
|
||||
Devuelve `(date_part, time_part_normalized)`. `time_part_normalized` es
|
||||
None si no hay hora. La hora se normaliza a `HH:MM[:SS][ AM|PM]`.
|
||||
"""
|
||||
if value is None:
|
||||
return "", None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return "", None
|
||||
|
||||
m = _TIME_TAIL_RE.search(s)
|
||||
if not m:
|
||||
return s, None
|
||||
|
||||
hh = int(m.group(1))
|
||||
mm = int(m.group(2)) if m.group(2) else 0
|
||||
ss = m.group(3)
|
||||
ampm = m.group(4)
|
||||
if not (0 <= hh <= 23 and 0 <= mm <= 59):
|
||||
return s, None
|
||||
if ss is not None:
|
||||
try:
|
||||
ssi = int(ss)
|
||||
if not (0 <= ssi <= 59):
|
||||
return s, None
|
||||
except ValueError:
|
||||
return s, None
|
||||
# Validar contra AM/PM (con AM/PM, hora 1-12)
|
||||
if ampm and not (1 <= hh <= 12):
|
||||
return s, None
|
||||
|
||||
# Quitar separadores habituales entre fecha y hora (p. ej. "12/27/24, 12:00 AM").
|
||||
date_part = s[: m.start()].rstrip(" \t,;T")
|
||||
if not date_part:
|
||||
return s, None
|
||||
|
||||
parts: list[str] = [f"{hh:02d}:{mm:02d}"]
|
||||
if ss is not None:
|
||||
parts[0] += f":{int(ss):02d}"
|
||||
if ampm:
|
||||
norm_ampm = ampm.replace(".", "").replace(" ", "").upper()
|
||||
if norm_ampm in ("AM", "PM"):
|
||||
parts.append(norm_ampm)
|
||||
time_norm = " ".join(parts)
|
||||
return date_part, time_norm
|
||||
|
||||
|
||||
def _extract_date_parts(value: str) -> Optional[tuple[int, int, int, str]]:
|
||||
"""Intenta extraer (a, b, c, layout) de una fecha en cualquier formato común.
|
||||
|
||||
`layout` es uno de: 'sep' (con separadores), 'compact8' (YYYYMMDD o
|
||||
DDMMYYYY), 'compact6' (DDMMYY o YYMMDD). No interpreta todavía qué
|
||||
componente es día/mes/año; eso lo decide `detect_date_format` viendo la
|
||||
columna completa. Acepta valores con hora al final (ignorada para los
|
||||
componentes de fecha).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s or s.lower() in ("nan", "none", "nat"):
|
||||
return None
|
||||
|
||||
date_part, _ = _split_date_time(s)
|
||||
if date_part:
|
||||
s = date_part
|
||||
|
||||
m = _DATE_NUM_RE.match(s)
|
||||
if m:
|
||||
try:
|
||||
a, b, c = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
return (a, b, c, "sep")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
m2 = _DATE_COMPACT_RE.match(s)
|
||||
if m2:
|
||||
digits = m2.group(1)
|
||||
if len(digits) == 8:
|
||||
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:8]), "compact8")
|
||||
if len(digits) == 6:
|
||||
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:6]), "compact6")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_date_format(series: pd.Series, sample_size: int = 500) -> Optional[str]:
|
||||
"""Analiza la serie y determina el formato de fecha dominante.
|
||||
|
||||
Devuelve un string con el layout detectado (p. ej. "DD/MM/YYYY",
|
||||
"MM/DD/YYYY", "YYYYMMDD", "DDMMYYYY") o None si no parece fechas.
|
||||
"""
|
||||
if series is None or len(series) == 0:
|
||||
return None
|
||||
|
||||
non_null = series.dropna()
|
||||
if non_null.empty:
|
||||
return None
|
||||
|
||||
if len(non_null) > sample_size:
|
||||
non_null = non_null.sample(sample_size, random_state=42)
|
||||
|
||||
layouts = {"sep": 0, "compact8": 0, "compact6": 0}
|
||||
sep_first_gt12 = 0 # primer componente > 12 → probable día
|
||||
sep_second_gt12 = 0 # segundo componente > 12 → probable mes solo posible si MM/DD
|
||||
sep_third_4digits = 0 # tercer componente de 4 dígitos → año al final
|
||||
sep_first_4digits = 0 # primer componente de 4 dígitos → año al inicio
|
||||
compact8_year_first = 0 # primeros 4 dígitos > 1900 → YYYYMMDD
|
||||
compact8_year_last = 0 # últimos 4 dígitos > 1900 → DDMMYYYY
|
||||
total_parsed = 0
|
||||
|
||||
for val in non_null:
|
||||
parsed = _extract_date_parts(val)
|
||||
if parsed is None:
|
||||
continue
|
||||
total_parsed += 1
|
||||
a, b, c, layout = parsed
|
||||
layouts[layout] = layouts.get(layout, 0) + 1
|
||||
|
||||
if layout == "sep":
|
||||
# año puede estar al inicio o al final según cantidad de dígitos
|
||||
if _digit_count(a) == 4:
|
||||
sep_first_4digits += 1
|
||||
if _digit_count(c) == 4:
|
||||
sep_third_4digits += 1
|
||||
if a > 12:
|
||||
sep_first_gt12 += 1
|
||||
if b > 12:
|
||||
sep_second_gt12 += 1
|
||||
elif layout == "compact8":
|
||||
# a son los primeros 2 dígitos, c son los últimos 4
|
||||
if c >= 1900:
|
||||
compact8_year_last += 1
|
||||
# a concatenado con b son los primeros 4 dígitos
|
||||
first4 = a * 100 + b
|
||||
if first4 >= 1900:
|
||||
compact8_year_first += 1
|
||||
|
||||
if total_parsed == 0:
|
||||
return None
|
||||
|
||||
# Requerimos que >=70% de los no nulos parseen como fecha para considerarla
|
||||
# columna de fechas.
|
||||
if total_parsed / len(non_null) < 0.7:
|
||||
return None
|
||||
|
||||
dominant = max(layouts, key=lambda k: layouts[k])
|
||||
|
||||
if dominant == "compact8":
|
||||
if compact8_year_first > compact8_year_last:
|
||||
return "YYYYMMDD"
|
||||
return "DDMMYYYY"
|
||||
if dominant == "compact6":
|
||||
return "DDMMYY"
|
||||
|
||||
# Caso con separadores
|
||||
if sep_first_4digits > sep_third_4digits:
|
||||
return "YYYY/MM/DD"
|
||||
# año al final
|
||||
if sep_first_gt12 > 0 and sep_second_gt12 == 0:
|
||||
return "DD/MM/YYYY"
|
||||
if sep_second_gt12 > 0 and sep_first_gt12 == 0:
|
||||
return "MM/DD/YYYY"
|
||||
# ambiguo → preferencia hispana
|
||||
return "DD/MM/YYYY"
|
||||
|
||||
|
||||
def _digit_count(n: int) -> int:
|
||||
return len(str(abs(n)))
|
||||
|
||||
|
||||
def _parse_date_with_format(value: str, fmt: str) -> Optional[tuple[int, int, int]]:
|
||||
"""Convierte un valor de fecha a (día, mes, año) según el layout detectado."""
|
||||
parsed = _extract_date_parts(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
a, b, c, layout = parsed
|
||||
|
||||
if fmt == "DD/MM/YYYY":
|
||||
d, m, y = a, b, c
|
||||
elif fmt == "MM/DD/YYYY":
|
||||
m, d, y = a, b, c
|
||||
elif fmt == "YYYY/MM/DD":
|
||||
y, m, d = a, b, c
|
||||
elif fmt == "YYYYMMDD" and layout == "compact8":
|
||||
# reconstruir: primer4 = a*100 + b, luego mes y día
|
||||
y = a * 100 + b
|
||||
m = c // 100
|
||||
d = c % 100
|
||||
elif fmt == "DDMMYYYY" and layout == "compact8":
|
||||
d = a
|
||||
m = b
|
||||
y = c
|
||||
elif fmt == "DDMMYY" and layout == "compact6":
|
||||
d = a
|
||||
m = b
|
||||
y = 2000 + c if c < 50 else 1900 + c
|
||||
else:
|
||||
# intentar como sep genérico
|
||||
d, m, y = a, b, c
|
||||
|
||||
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
|
||||
# año en 2 dígitos
|
||||
if y < 100:
|
||||
y = 2000 + y if y < 50 else 1900 + y
|
||||
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
|
||||
return None
|
||||
return (d, m, y)
|
||||
|
||||
|
||||
def _normalize_date_text(value: Any) -> str:
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return text.lower()
|
||||
|
||||
|
||||
def _valid_date_tuple(d: int, m: int, y: int) -> Optional[tuple[int, int, int]]:
|
||||
if y < 100:
|
||||
y = 2000 + y if y < 50 else 1900 + y
|
||||
if not (1000 <= y <= 9999):
|
||||
return None
|
||||
try:
|
||||
date(y, m, d)
|
||||
except ValueError:
|
||||
return None
|
||||
return (d, m, y)
|
||||
|
||||
|
||||
def _split_glued_year_suffix_hhmm(digits: str) -> Optional[tuple[int, int, int]]:
|
||||
"""Detecta `YYYY` + `HHMM` en una sola tira de 8 cifras (p. ej. `20241344`).
|
||||
|
||||
Tras quitar separadores puede quedar pegado al mes (p. ej. `15may20241344`).
|
||||
Devuelve (año, hora, minuto) si la parte horaria es válida; si no, None.
|
||||
"""
|
||||
if len(digits) != 8 or not digits.isdigit():
|
||||
return None
|
||||
y = int(digits[:4])
|
||||
hh = int(digits[4:6])
|
||||
mm = int(digits[6:8])
|
||||
if not (1000 <= y <= 9999 and 0 <= hh <= 23 and 0 <= mm <= 59):
|
||||
return None
|
||||
return (y, hh, mm)
|
||||
|
||||
|
||||
def _parse_text_month_candidate(
|
||||
first: tuple[int, str],
|
||||
month: int,
|
||||
second: tuple[int, str],
|
||||
*,
|
||||
layout: Literal["around", "after", "before"],
|
||||
) -> Optional[tuple[int, int, int]]:
|
||||
a, astr = first
|
||||
b, bstr = second
|
||||
if layout == "around":
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
if len(bstr) == 4:
|
||||
return _valid_date_tuple(a, month, b)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
if layout == "after":
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
if len(astr) == 4:
|
||||
return _valid_date_tuple(b, month, a)
|
||||
return _valid_date_tuple(a, month, b)
|
||||
|
||||
|
||||
def _parse_date_with_text_month(raw: Any) -> Optional[tuple[int, int, int]]:
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
date_part, _ = _split_date_time(str(raw))
|
||||
normalized = _normalize_date_text(date_part or raw)
|
||||
# `\d{8}` antes que `\d{1,4}` para no partir año+hora pegados tras strip agresivo.
|
||||
tokens = re.findall(r"[a-z]+|\d{8}|\d{1,4}", normalized)
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
month = _MONTH_NAME_TO_NUM.get(token)
|
||||
if month is None:
|
||||
continue
|
||||
nums_before = [
|
||||
(int(t), t) for t in tokens[:i] if t.isdigit()
|
||||
]
|
||||
nums_after = [
|
||||
(int(t), t) for t in tokens[i + 1 :] if t.isdigit()
|
||||
]
|
||||
candidates: list[Optional[tuple[int, int, int]]] = []
|
||||
if nums_before and len(nums_after) == 1:
|
||||
glued = _split_glued_year_suffix_hhmm(nums_after[0][1])
|
||||
if glued is not None:
|
||||
y, _hh, _mm = glued
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-1], month, (y, str(y)), layout="around"
|
||||
)
|
||||
)
|
||||
if nums_before and nums_after:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-1], month, nums_after[0], layout="around"
|
||||
)
|
||||
)
|
||||
if len(nums_after) >= 2:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_after[0], month, nums_after[1], layout="after"
|
||||
)
|
||||
)
|
||||
if len(nums_before) >= 2:
|
||||
candidates.append(
|
||||
_parse_text_month_candidate(
|
||||
nums_before[-2], month, nums_before[-1], layout="before"
|
||||
)
|
||||
)
|
||||
for parsed in candidates:
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _detect_compact_date_formats(
|
||||
series: pd.Series,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
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]
|
||||
)
|
||||
fmt8 = detect_date_format(c8s) if not c8s.empty else None
|
||||
fmt6 = detect_date_format(c6s) if not c6s.empty else None
|
||||
return fmt8, fmt6
|
||||
|
||||
|
||||
def _parse_any_date_to_tuple(
|
||||
raw: Any,
|
||||
*,
|
||||
sep_fmt: Optional[str],
|
||||
compact8_fmt: Optional[str],
|
||||
compact6_fmt: Optional[str],
|
||||
) -> Optional[tuple[int, int, int]]:
|
||||
parsed_text = _parse_date_with_text_month(raw)
|
||||
if parsed_text is not None:
|
||||
return parsed_text
|
||||
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
|
||||
raw_text = str(raw)
|
||||
date_part, _ = _split_date_time(raw_text)
|
||||
target = date_part if date_part else raw_text
|
||||
if sep_fmt:
|
||||
parsed = _parse_date_with_format(target, sep_fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
for fallback_fmt in ("DD/MM/YYYY", "MM/DD/YYYY", "YYYY/MM/DD"):
|
||||
if fallback_fmt == sep_fmt:
|
||||
continue
|
||||
parsed = _parse_date_with_format(target, fallback_fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
compact = _normalize_messy_date_to_compact(raw)
|
||||
if not isinstance(compact, str):
|
||||
return None
|
||||
if len(compact) == 8:
|
||||
for fmt in (compact8_fmt, "DDMMYYYY", "YYYYMMDD"):
|
||||
if not fmt:
|
||||
continue
|
||||
parsed = _parse_date_with_format(compact, fmt)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if len(compact) == 6:
|
||||
parsed = _parse_date_with_format(compact, compact6_fmt or "DDMMYY")
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_messy_date_to_compact(raw: Any) -> Optional[str]:
|
||||
"""Quita ruido (* + _ / espacios, etc.); deja 6 o 8 dígitos o None.
|
||||
|
||||
Soporta separadores entremezclados; hora al final vía `_split_date_time`.
|
||||
Con 9+ dígitos, heurística: tomar 8 iniciales (p. ej. 9.º = 0 colado) y
|
||||
validar luego con `_parse_date_with_format`; con más de 8 sobrantes,
|
||||
8 iniciales.
|
||||
"""
|
||||
if _is_nullish_scalar(raw):
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if s.lower() in ("nan", "none", "nat", "<na>"):
|
||||
return None
|
||||
date_part, _ = _split_date_time(s)
|
||||
body = date_part if date_part else s
|
||||
d = re.sub(r"[^\d]", "", body)
|
||||
if not d:
|
||||
return None
|
||||
n = len(d)
|
||||
if n in (6, 8):
|
||||
return d
|
||||
if n < 6 or n == 7:
|
||||
return None
|
||||
# 9+ dígitos: 8 iniciales (p. ej. 9.º 0 sobrante, o ruido al final)
|
||||
return d[:8]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Homologación y división de tipo/número de documento."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
|
||||
_DOC_TYPE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"CC": ("CC", "Cedula", "CD", "C", "C.C", "C C", "CCC"),
|
||||
"TI": ("TI", "Tarjeta", "Tarjeta Identidad", "T1", "T", "TL"),
|
||||
"CN": ("CN", "Certificado", "Nacido Vivo", "Certificado Nacido Vivo"),
|
||||
"CE": ("CE", "Cedula de Extranjeria", "Extranjera"),
|
||||
"ME": ("ME", "Menor sin Identificacion"),
|
||||
"PS": ("PS", "Pasaporte"),
|
||||
"PT": ("PT", "Permiso por proteccion Temporal"),
|
||||
"RC": ("RC", "Registro Civil", "Registro", "TC"),
|
||||
"SC": ("SC", "Salvo Conducto", "Salvoconducto"),
|
||||
"PE": ("PE", "Permiso especial de permanencia", "Permiso"),
|
||||
}
|
||||
|
||||
|
||||
def _ascii_upper(value: Any) -> str:
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return text.upper()
|
||||
|
||||
|
||||
def _normalize_doc_type_key(value: Any) -> str:
|
||||
"""Clave comparable: sin acentos, espacios, puntos ni símbolos."""
|
||||
return re.sub(r"[^A-Z0-9]", "", _ascii_upper(value))
|
||||
|
||||
|
||||
def _clean_doc_type_text(value: Any) -> Optional[str]:
|
||||
cleaned = re.sub(r"[^A-Z0-9]+", " ", _ascii_upper(value)).strip()
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
return cleaned or None
|
||||
|
||||
|
||||
_DOC_TYPE_ALIAS_BY_KEY: dict[str, str] = {
|
||||
_normalize_doc_type_key(alias): canonical
|
||||
for canonical, aliases in _DOC_TYPE_ALIASES.items()
|
||||
for alias in aliases
|
||||
}
|
||||
|
||||
_DOC_TYPE_ALIAS_DISPLAY_BY_KEY: dict[str, str] = {}
|
||||
for _canonical, _aliases in _DOC_TYPE_ALIASES.items():
|
||||
for _alias in _aliases:
|
||||
_alias_key = _normalize_doc_type_key(_alias)
|
||||
_alias_display = _clean_doc_type_text(_alias)
|
||||
if _alias_key and _alias_display:
|
||||
_DOC_TYPE_ALIAS_DISPLAY_BY_KEY.setdefault(_alias_key, _alias_display)
|
||||
|
||||
_DOC_TYPE_ALIAS_KEYS_SORTED: list[str] = sorted(
|
||||
_DOC_TYPE_ALIAS_BY_KEY,
|
||||
key=lambda k: (len(k), k),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def homologate_doc_type(value: Any) -> Optional[str]:
|
||||
"""Homologa un tipo documental a sigla; si no existe, conserva texto limpio."""
|
||||
if _is_nullish_scalar(value):
|
||||
return None
|
||||
key = _normalize_doc_type_key(value)
|
||||
if not key or key.lower() in ("nan", "none", "nat"):
|
||||
return None
|
||||
return _DOC_TYPE_ALIAS_BY_KEY.get(key) or _clean_doc_type_text(value)
|
||||
|
||||
|
||||
def _compact_doc_text(value: Any) -> str:
|
||||
return _normalize_doc_type_key(value)
|
||||
|
||||
|
||||
def _looks_like_short_alias_prefix(compact: str, alias_key: str) -> bool:
|
||||
"""Evita que alias de 1-2 chars capturen palabras como CODIGO o TOTAL."""
|
||||
if not compact.startswith(alias_key):
|
||||
return False
|
||||
rest = compact[len(alias_key):]
|
||||
if not rest:
|
||||
return True
|
||||
return bool(rest[0].isdigit())
|
||||
|
||||
|
||||
def _match_known_doc_type_prefix(compact: str) -> Optional[tuple[str, str]]:
|
||||
for alias_key in _DOC_TYPE_ALIAS_KEYS_SORTED:
|
||||
if len(alias_key) <= 2:
|
||||
if not _looks_like_short_alias_prefix(compact, alias_key):
|
||||
continue
|
||||
elif not compact.startswith(alias_key):
|
||||
continue
|
||||
return _DOC_TYPE_ALIAS_BY_KEY[alias_key], alias_key
|
||||
return None
|
||||
|
||||
|
||||
def split_doc_tipo_y_numero_texto(
|
||||
raw: Any,
|
||||
*,
|
||||
homologate: bool = True,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Separa tipo documental y número, tolerando nombres largos y símbolos.
|
||||
|
||||
Si `homologate` es True, tipos conocidos se devuelven como sigla (CC, TI,
|
||||
etc.). Los tipos desconocidos se conservan limpios en mayúsculas.
|
||||
"""
|
||||
if _is_nullish_scalar(raw) and not (isinstance(raw, str) and not str(raw).strip()):
|
||||
return (None, None)
|
||||
t = str(raw).strip()
|
||||
if t.lower() in ("nan", "none", "nat", "<na>"):
|
||||
return (None, None)
|
||||
if t and t[0].isdigit():
|
||||
return (None, None)
|
||||
|
||||
compact = _compact_doc_text(t)
|
||||
if not compact:
|
||||
return (None, None)
|
||||
|
||||
match = _match_known_doc_type_prefix(compact)
|
||||
if match is not None:
|
||||
canonical, alias_key = match
|
||||
rest = compact[len(alias_key):]
|
||||
nums = re.sub(r"[^\d]", "", rest)
|
||||
if homologate:
|
||||
return (canonical, nums if nums else None)
|
||||
raw_type = _DOC_TYPE_ALIAS_DISPLAY_BY_KEY.get(alias_key) or canonical
|
||||
return (raw_type, nums if nums else None)
|
||||
|
||||
m = re.match(
|
||||
r"^([^\d]+?)\s*([0-9].*)\s*$",
|
||||
t,
|
||||
)
|
||||
if m:
|
||||
to_t = _clean_doc_type_text(m.group(1))
|
||||
nums = re.sub(r"[^\d]", "", m.group(2))
|
||||
return (homologate_doc_type(to_t) if homologate else to_t, nums if nums else None)
|
||||
|
||||
if not re.search(r"\d", t):
|
||||
to_t = homologate_doc_type(t) if homologate else _clean_doc_type_text(t)
|
||||
return (to_t, None)
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _alloc_doc_split_column_names(
|
||||
col_name: str, existing: set[str]
|
||||
) -> tuple[str, str]:
|
||||
"""Nombres únicos para las dos columnas derivadas."""
|
||||
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
|
||||
for i in range(0, 200):
|
||||
suf = f"_{i}" if i else ""
|
||||
t, n = f"{base}{suf}_doc_tipo", f"{base}{suf}_doc_numeros"
|
||||
if t not in existing and n not in existing:
|
||||
return t, n
|
||||
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
|
||||
|
||||
|
||||
def _apply_doc_id_split_columns(
|
||||
result: pd.DataFrame, col: str, log: list[str], *, homologate_type: bool = True
|
||||
) -> None:
|
||||
tcol, ncol = _alloc_doc_split_column_names(str(col), set(result.columns))
|
||||
st_tipo: list[Optional[str]] = []
|
||||
st_num: list[Any] = []
|
||||
for v in result[col].tolist():
|
||||
tt, nn = split_doc_tipo_y_numero_texto(v, homologate=homologate_type)
|
||||
st_tipo.append(tt)
|
||||
st_num.append(nn if nn is not None else pd.NA)
|
||||
result[tcol] = st_tipo
|
||||
result[ncol] = st_num
|
||||
log.append(
|
||||
f"[OK] '{str(col)}' -> columnas '{tcol}' y '{ncol}' <- Dividir tipo y número (documento)"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Carga robusta de CSV (delimitador, encoding)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv as _csv
|
||||
from collections import Counter
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
_CANDIDATE_DELIMITERS = ["|", ";", "\t", ",", ":"]
|
||||
|
||||
|
||||
def detect_delimiter(filepath: str) -> str:
|
||||
"""Detecta el delimitador del CSV de forma robusta.
|
||||
|
||||
Estrategia:
|
||||
1. Intentar `csv.Sniffer` con un sample grande.
|
||||
2. Si falla o el resultado parece incorrecto, contar ocurrencias por línea
|
||||
de cada delimitador candidato y elegir el que produce el mayor número
|
||||
de columnas estable entre líneas.
|
||||
"""
|
||||
encodings = ("utf-8", "utf-8-sig", "latin-1", "cp1252")
|
||||
sample = ""
|
||||
for enc in encodings:
|
||||
try:
|
||||
with open(filepath, "r", encoding=enc, errors="replace") as f:
|
||||
sample = f.read(65536)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
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 ","
|
||||
|
||||
# Para cada candidato, calcular el número promedio de columnas y la
|
||||
# consistencia (cuántas líneas tienen el mismo conteo dominante).
|
||||
scores: dict[str, tuple[int, int]] = {}
|
||||
for delim in _CANDIDATE_DELIMITERS:
|
||||
counts = [ln.count(delim) for ln in lines[:50]]
|
||||
if max(counts) == 0:
|
||||
continue
|
||||
# Modo (valor más frecuente)
|
||||
common = Counter(counts).most_common(1)[0]
|
||||
mode_count, mode_freq = common
|
||||
if mode_count == 0:
|
||||
continue
|
||||
scores[delim] = (mode_count, mode_freq)
|
||||
|
||||
if scores:
|
||||
# Elegir el delimitador con mayor número de columnas (mode_count) y,
|
||||
# como desempate, mayor consistencia (mode_freq).
|
||||
best = max(scores.items(), key=lambda kv: (kv[1][0], kv[1][1]))
|
||||
best_delim = best[0]
|
||||
# Si el sniffer dio algo razonable y coincide, prevalece; si difiere y
|
||||
# nuestro candidato es claramente mejor, usamos el nuestro.
|
||||
if sniffed and sniffed in scores:
|
||||
sniffed_score = scores[sniffed]
|
||||
if sniffed_score[0] >= best[1][0] - 1:
|
||||
return sniffed
|
||||
return best_delim
|
||||
|
||||
return sniffed or ","
|
||||
|
||||
|
||||
def load_csv(filepath: str) -> tuple[pd.DataFrame, str]:
|
||||
"""Carga un CSV con detección de delimitador y tolerancia a filas malas.
|
||||
|
||||
Devuelve `(df, delimiter)`. Lanza excepción si todos los intentos fallan.
|
||||
"""
|
||||
delimiter = detect_delimiter(filepath)
|
||||
|
||||
encodings_to_try = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for enc in encodings_to_try:
|
||||
try:
|
||||
df = pd.read_csv(
|
||||
filepath,
|
||||
delimiter=delimiter,
|
||||
dtype=str,
|
||||
encoding=enc,
|
||||
engine="python",
|
||||
on_bad_lines="skip",
|
||||
)
|
||||
return df, delimiter
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
# Último intento con quoting permisivo y sin separador detectado
|
||||
try:
|
||||
df = pd.read_csv(
|
||||
filepath,
|
||||
sep=None,
|
||||
dtype=str,
|
||||
engine="python",
|
||||
on_bad_lines="skip",
|
||||
)
|
||||
return df, "(auto)"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError(f"No se pudo leer el CSV: {last_error}")
|
||||
|
||||
|
||||
def load_csv_bytes(content: bytes, filename: str = "upload.csv") -> tuple[pd.DataFrame, str]:
|
||||
"""Carga un CSV desde bytes (upload HTTP) vía archivo temporal."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
suffix = os.path.splitext(filename)[1] or ".csv"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
return load_csv(tmp_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tipos de columna y resumen de análisis."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
TYPE_INTEGER = "integer"
|
||||
TYPE_DECIMAL = "decimal"
|
||||
TYPE_DATE = "date"
|
||||
TYPE_BOOLEAN = "boolean"
|
||||
TYPE_CATEGORICAL = "categorical"
|
||||
TYPE_TEXT = "text"
|
||||
TYPE_MIXED = "mixed"
|
||||
TYPE_EMPTY = "empty"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnSummary:
|
||||
"""Resumen inteligente para una columna del DataFrame."""
|
||||
name: str
|
||||
inferred_type: str
|
||||
example: str = ""
|
||||
samples: list[str] = field(default_factory=list)
|
||||
null_count: int = 0
|
||||
null_pct: float = 0.0
|
||||
unique_count: int = 0
|
||||
unique_pct: float = 0.0
|
||||
min: Optional[float] = None
|
||||
max: Optional[float] = None
|
||||
mean: Optional[float] = None
|
||||
avg_length: Optional[float] = None
|
||||
top_values: list[tuple[str, int]] = field(default_factory=list)
|
||||
detected_date_format: Optional[str] = None
|
||||
applicable_rules: list[str] = field(default_factory=list)
|
||||
|
||||
def info_text(self) -> str:
|
||||
"""Texto humano con información adicional (para subtítulo de la UI)."""
|
||||
parts: list[str] = []
|
||||
parts.append(f"nulos: {self.null_count} ({self.null_pct:.0f}%)")
|
||||
parts.append(f"únicos: {self.unique_count}")
|
||||
if self.inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
|
||||
if self.min is not None:
|
||||
parts.append(f"min: {_fmt_num(self.min)}")
|
||||
if self.max is not None:
|
||||
parts.append(f"max: {_fmt_num(self.max)}")
|
||||
if self.mean is not None:
|
||||
parts.append(f"media: {_fmt_num(self.mean)}")
|
||||
elif self.inferred_type == TYPE_DATE:
|
||||
if self.detected_date_format:
|
||||
parts.append(f"formato: {self.detected_date_format}")
|
||||
elif self.inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
|
||||
if self.avg_length is not None:
|
||||
parts.append(f"long. prom: {self.avg_length:.1f}")
|
||||
if self.top_values:
|
||||
tops = ", ".join(f'"{v}" ({c})' for v, c in self.top_values[:3])
|
||||
parts.append(f"top: {tops}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _fmt_num(x: float) -> str:
|
||||
if x is None:
|
||||
return ""
|
||||
if isinstance(x, float) and x.is_integer():
|
||||
return str(int(x))
|
||||
if isinstance(x, float):
|
||||
return f"{x:.2f}"
|
||||
return str(x)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Extracción de documento y autorización desde notas de texto."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
|
||||
def _normalize_nota_text(raw: Any) -> str:
|
||||
if _is_nullish_scalar(raw):
|
||||
return ""
|
||||
try:
|
||||
if pd.isna(raw):
|
||||
return ""
|
||||
except TypeError:
|
||||
pass
|
||||
s = str(raw).strip()
|
||||
if not s or s.lower() in ("nan", "none", "nat", "<na>"):
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", s)
|
||||
|
||||
|
||||
def extract_paciente_documento_nota(raw: Any) -> Optional[str]:
|
||||
"""Número de documento tras ``Paciente:`` y antes del guión del nombre."""
|
||||
t = _normalize_nota_text(raw)
|
||||
if not t:
|
||||
return None
|
||||
m = _PACIENTE_DOC_NOTA_RE.search(t)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def extract_autorizacion_nota(raw: Any) -> Optional[str]:
|
||||
"""Número tras ``Autorización No.`` (opcional por fila)."""
|
||||
t = _normalize_nota_text(raw)
|
||||
if not t:
|
||||
return None
|
||||
m = _AUTORIZACION_NOTA_RE.search(t)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _alloc_nota_extract_column_names(
|
||||
col_name: str, existing: set[str]
|
||||
) -> tuple[str, str]:
|
||||
"""Nombres únicos para documento paciente y autorización."""
|
||||
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
|
||||
for i in range(0, 200):
|
||||
suf = f"_{i}" if i else ""
|
||||
dcol, acol = f"{base}{suf}_nota_paciente_doc", f"{base}{suf}_nota_autorizacion"
|
||||
if dcol not in existing and acol not in existing:
|
||||
return dcol, acol
|
||||
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
|
||||
|
||||
|
||||
def _apply_nota_extract_columns(result: pd.DataFrame, col: str, log: list[str]) -> None:
|
||||
"""Añade columnas con documento (Paciente:) y número de autorización si existe."""
|
||||
dcol, acol = _alloc_nota_extract_column_names(str(col), set(result.columns))
|
||||
docs: list[Any] = []
|
||||
auths: list[Any] = []
|
||||
for v in result[col].tolist():
|
||||
dd = extract_paciente_documento_nota(v)
|
||||
aa = extract_autorizacion_nota(v)
|
||||
docs.append(dd if dd is not None else pd.NA)
|
||||
auths.append(aa if aa is not None else pd.NA)
|
||||
result[dcol] = docs
|
||||
result[acol] = auths
|
||||
log.append(
|
||||
f"[OK] '{str(col)}' -> columnas '{dcol}' y '{acol}' <- Extraer datos de notas"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Detección de valores nulos / vacíos."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _is_nullish_scalar(x: Any) -> bool:
|
||||
if x is None or x is pd.NA:
|
||||
return True
|
||||
if isinstance(x, (bool, np.bool_)):
|
||||
return False
|
||||
if isinstance(x, (int, float, np.integer, np.floating)) and not isinstance(
|
||||
x, (bool, np.bool_)
|
||||
):
|
||||
if isinstance(x, float) and (np.isnan(x) or pd.isna(x)):
|
||||
return True
|
||||
return False
|
||||
t = str(x).strip().lower()
|
||||
if not t or t in ("nan", "none", "nat", "null", "<na>"):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Parsing numérico tolerante (miles, decimales, moneda)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .nullish import _is_nullish_scalar
|
||||
|
||||
_HINT_MAJORITY = 0.6
|
||||
_MIN_HINT_VOTES = 1
|
||||
|
||||
|
||||
def _strip_currency(s: str) -> str:
|
||||
t = s.replace("\u00a0", " ").replace("\u202f", " ")
|
||||
t = re.sub(r"[\s]+", "", t)
|
||||
t = t.strip()
|
||||
if not t or t.lower() in ("nan", "none", "nat"):
|
||||
return ""
|
||||
neg = False
|
||||
if t[0:1] == "-":
|
||||
neg, t = not neg, t[1:].lstrip()
|
||||
if t[0:1] == "(" and t.endswith(")"):
|
||||
neg, t = not neg, t[1:-1]
|
||||
t = re.sub(
|
||||
r"^[\$€\u00a3\u0e3f\uffe5\uffe6\uff04\ufe69]+",
|
||||
"",
|
||||
t,
|
||||
count=1,
|
||||
)
|
||||
t = re.sub(r"[\$€%]+$", "", t)
|
||||
if not t and neg:
|
||||
return "-0"
|
||||
return ("-" if neg else "") + t
|
||||
|
||||
|
||||
def _extract_numeric_chunk(s: str) -> str:
|
||||
t = s.strip()
|
||||
t = t.replace("$", "").replace("€", "").replace("\u00a3", "")
|
||||
t = t.replace(" ", "")
|
||||
cands1 = re.findall(
|
||||
r"[+\-]?(?:\d{1,3}(?:[.,]\d{3})*|\d+)(?:[.,]\d+)?", t
|
||||
)
|
||||
cands2 = re.findall(
|
||||
r"[+\-]?\d[.,\d ]*\d", t
|
||||
)
|
||||
cands = cands1 + cands2
|
||||
if not cands:
|
||||
m = re.search(r"[\d.,\-]+", t)
|
||||
if m:
|
||||
c = m.group(0).replace("(", "-").replace(")", "")
|
||||
return c.lstrip("+")
|
||||
return ""
|
||||
best = max(cands, key=len) if cands else ""
|
||||
best = best.replace(" ", "").replace("(", "-").replace(")", "")
|
||||
if not re.search(r"\d", best):
|
||||
return ""
|
||||
return best.lstrip("+")
|
||||
|
||||
|
||||
def _as_float_s(s: str) -> Optional[float]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
f = float(s)
|
||||
if not np.isfinite(f) or abs(f) > 1e100:
|
||||
return None
|
||||
return f
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_both_separators(body: str) -> Optional[float]:
|
||||
"""El separador más a la DERECHA entre `,` y `.` es el decimal; el otro, miles."""
|
||||
if "," not in body or "." not in body:
|
||||
return None
|
||||
t = body.strip()
|
||||
if re.search(r"[^0-9,.\-]", t):
|
||||
t2 = re.sub(r"[^0-9,.\-]", "", t)
|
||||
t = t2
|
||||
if not t:
|
||||
return None
|
||||
neg = t[0:1] == "-"
|
||||
b = t[1:].lstrip() if neg else t
|
||||
if not b:
|
||||
return None
|
||||
i_comma, i_dot = b.rfind(","), b.rfind(".")
|
||||
if min(i_comma, i_dot) < 0:
|
||||
return None
|
||||
dpos = max(i_comma, i_dot)
|
||||
th = "," if b[dpos] == "." else "."
|
||||
intp, fr = b[:dpos], b[dpos + 1 :]
|
||||
if not re.fullmatch(r"\d{1,4}$", fr):
|
||||
return None
|
||||
intc = intp.replace(th, "")
|
||||
if not intc.isdigit():
|
||||
return None
|
||||
v = _as_float_s(f"{intc}.{fr}")
|
||||
if v is None:
|
||||
return None
|
||||
if neg and v and v is not None:
|
||||
return -abs(v)
|
||||
return v
|
||||
|
||||
|
||||
def _only_commas(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
|
||||
neg = w[0:1] == "-"
|
||||
wn = w[1:].lstrip() if neg else w
|
||||
if wn and re.search(r"[^0-9,]", wn):
|
||||
wn = re.sub(r"[^0-9,]", "", wn)
|
||||
n = wn.count(",")
|
||||
if n == 0 and wn.isdigit():
|
||||
v = float(wn)
|
||||
return -v if neg else v
|
||||
if n == 0 or "." in wn:
|
||||
return None
|
||||
if n == 1:
|
||||
i = wn.rfind(",")
|
||||
h, t = wn[:i], wn[i + 1 :]
|
||||
if not t.isdigit() or not h.isdigit():
|
||||
return None
|
||||
if len(t) in (1, 2):
|
||||
v = _as_float_s(f"{h}.{t}")
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
if len(t) == 3:
|
||||
if dec_hint is None or dec_hint == ",":
|
||||
v = _as_float_s(f"{h}{t}")
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
if dec_hint == ".":
|
||||
v = _as_float_s(wn.replace(",", ""))
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
v = _as_float_s(wn.replace(",", ""))
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
|
||||
|
||||
def _only_dots(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
|
||||
neg = w[0:1] == "-"
|
||||
wn = w[1:].lstrip() if neg else w
|
||||
if wn and re.search(r"[^0-9.]", wn):
|
||||
wn = re.sub(r"[^0-9.]", "", wn)
|
||||
n = wn.count(".")
|
||||
if n == 0 and wn.isdigit():
|
||||
v = float(wn)
|
||||
return -v if neg else v
|
||||
if n == 0 or "," in wn:
|
||||
return None
|
||||
if n == 1:
|
||||
i = wn.rfind(".")
|
||||
h, t = wn[:i], wn[i + 1 :]
|
||||
if not t.isdigit() or not h.isdigit():
|
||||
return None
|
||||
if len(t) in (1, 2):
|
||||
v = _as_float_s(f"{h}.{t}")
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
if len(t) == 3:
|
||||
if dec_hint is None or dec_hint == ".":
|
||||
v = _as_float_s(f"{h}{t}")
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
if dec_hint == ",":
|
||||
v = _as_float_s(wn.replace(".", ""))
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
v = _as_float_s(wn.replace(".", ""))
|
||||
if v is None:
|
||||
return None
|
||||
return -v if neg else v
|
||||
|
||||
|
||||
def _parse_lenient_one(
|
||||
raw: Any,
|
||||
decimal_hint: Optional[Literal[",", "."]] = None,
|
||||
) -> float:
|
||||
if _is_nullish_scalar(raw):
|
||||
return float("nan")
|
||||
t = _strip_currency(str(raw))
|
||||
if not t or t == "-":
|
||||
return float("nan")
|
||||
c = _extract_numeric_chunk(t) or t
|
||||
c = c.strip()
|
||||
if not c or c == "-":
|
||||
return float("nan")
|
||||
n_comma, n_dot = c.count(","), c.count(".")
|
||||
if n_comma and n_dot:
|
||||
f = _parse_both_separators(c)
|
||||
elif n_comma and not n_dot:
|
||||
f = _only_commas(c, decimal_hint)
|
||||
elif n_dot and not n_comma:
|
||||
f = _only_dots(c, decimal_hint)
|
||||
else:
|
||||
c2 = re.sub(r"[^0-9.\-]", "", c)
|
||||
f = _as_float_s(c2) if c2 and re.fullmatch(r"-?\d+$", c2) else None
|
||||
if f is not None and np.isfinite(f):
|
||||
return f
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _infer_decimal_hint_from_column(
|
||||
series: pd.Series,
|
||||
) -> Optional[Literal[",", "."]]:
|
||||
"""Voto con celdas que traen `,` y `.`: gana el de más a la derecha = decimal."""
|
||||
votes: list[Literal[",", "."]] = []
|
||||
for raw in series.dropna():
|
||||
if _is_nullish_scalar(raw):
|
||||
continue
|
||||
t = _strip_currency(str(raw))
|
||||
c = _extract_numeric_chunk(t) or t
|
||||
c = c.strip()
|
||||
if "," not in c or "." not in c or _parse_both_separators(c) is None:
|
||||
continue
|
||||
if c.rfind(",") > c.rfind("."):
|
||||
votes.append(",")
|
||||
else:
|
||||
votes.append(".")
|
||||
n = len(votes)
|
||||
if n < _MIN_HINT_VOTES:
|
||||
return None
|
||||
a = sum(1 for v in votes if v == ",")
|
||||
b = sum(1 for v in votes if v == ".")
|
||||
if a + b == 0:
|
||||
return None
|
||||
if a / n >= _HINT_MAJORITY and a > b:
|
||||
return ","
|
||||
if b / n >= _HINT_MAJORITY and b > a:
|
||||
return "."
|
||||
return None
|
||||
|
||||
|
||||
def _to_numeric_lenient(
|
||||
series: pd.Series,
|
||||
decimal_hint: Optional[Literal[",", "."]] = None,
|
||||
) -> pd.Series:
|
||||
"""Convierte a flotante; pista de columna por voto (si `decimal_hint` es None)."""
|
||||
hint = decimal_hint if decimal_hint is not None else _infer_decimal_hint_from_column(
|
||||
series
|
||||
)
|
||||
|
||||
def _cell(x: Any) -> float:
|
||||
if _is_nullish_scalar(x):
|
||||
return float("nan")
|
||||
return _parse_lenient_one(x, hint)
|
||||
|
||||
return series.map(_cell)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Inferencia de tipo de columna y reglas aplicables."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .column_hints import (
|
||||
_column_name_suggests_code_id,
|
||||
_column_name_suggests_monetary_value,
|
||||
_column_name_suggests_quantity_or_number_not_bool,
|
||||
_content_looks_like_doc_id_letter_prefix_column,
|
||||
_raw_strings_suggest_alnum_mixed_presentation,
|
||||
_raw_strings_suggest_monetary_presentation,
|
||||
)
|
||||
from .dates import _normalize_messy_date_to_compact, detect_date_format
|
||||
from .models import (
|
||||
TYPE_BOOLEAN,
|
||||
TYPE_CATEGORICAL,
|
||||
TYPE_DATE,
|
||||
TYPE_DECIMAL,
|
||||
TYPE_EMPTY,
|
||||
TYPE_INTEGER,
|
||||
TYPE_MIXED,
|
||||
TYPE_TEXT,
|
||||
)
|
||||
from .numbers import _to_numeric_lenient
|
||||
|
||||
_BOOLEAN_VALUES = {"true", "false", "si", "sí", "no", "1", "0", "yes"}
|
||||
|
||||
|
||||
def _detect_messy_text_date_format_guess(non_null: pd.Series) -> Optional[str]:
|
||||
"""Fechas con ruido: compacta a dígitos; si hay consenso, reutiliza el detector.
|
||||
(Depende de `_normalize_messy_date_to_compact`, definida más abajo en el módulo.)
|
||||
"""
|
||||
# resolución en tiempo de ejecución
|
||||
norm = non_null.map(_normalize_messy_date_to_compact) # type: ignore[name-defined]
|
||||
compacts: list[str] = []
|
||||
for c in norm:
|
||||
if isinstance(c, str) and len(c) in (6, 8):
|
||||
compacts.append(c)
|
||||
n = max(len(non_null), 1)
|
||||
if not compacts or len(compacts) < max(2, int(0.25 * n)):
|
||||
return None
|
||||
s_compact = pd.Series(compacts, dtype=object)
|
||||
return detect_date_format(s_compact)
|
||||
|
||||
|
||||
def _infer_column_type(
|
||||
series: pd.Series, column_name: str = ""
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""Devuelve (tipo, formato_fecha detectado; solo si aplica a tipo fecha)."""
|
||||
non_null = series.dropna().astype(str).str.strip()
|
||||
non_null = non_null[non_null != ""]
|
||||
if non_null.empty:
|
||||
return TYPE_EMPTY, None
|
||||
|
||||
cname = str(column_name or "")
|
||||
|
||||
# Boolean: no aplicar 0/1/ sí-no a columnas cuyo nombre pide importe, cantid., fecha…
|
||||
if not _column_name_suggests_quantity_or_number_not_bool(cname):
|
||||
lowered = non_null.str.lower()
|
||||
if lowered.isin(_BOOLEAN_VALUES).all() and lowered.nunique() <= 3:
|
||||
return TYPE_BOOLEAN, None
|
||||
|
||||
is_money = _column_name_suggests_monetary_value(cname) or _raw_strings_suggest_monetary_presentation(
|
||||
non_null
|
||||
)
|
||||
is_code = _column_name_suggests_code_id(cname)
|
||||
is_doc_pattern = _content_looks_like_doc_id_letter_prefix_column(non_null)
|
||||
|
||||
# Fecha: no forzar en importe/código, ni con datos tipo `CC`+cifra (6–8 dígitos sueltos)
|
||||
# ni cuando el contenido parece moneda (p. ej. 113,400.00 → no es DDMMYYYY).
|
||||
if not is_money and not is_code and not is_doc_pattern:
|
||||
date_fmt = detect_date_format(non_null)
|
||||
if date_fmt:
|
||||
return (TYPE_DATE, date_fmt)
|
||||
messy_date_fmt = _detect_messy_text_date_format_guess(non_null)
|
||||
if messy_date_fmt:
|
||||
return (TYPE_DATE, messy_date_fmt)
|
||||
|
||||
numeric_candidate = _to_numeric_lenient(non_null, decimal_hint=None)
|
||||
numeric_ratio = numeric_candidate.notna().mean()
|
||||
if numeric_ratio >= 0.9:
|
||||
if _raw_strings_suggest_alnum_mixed_presentation(non_null):
|
||||
return (TYPE_MIXED, None)
|
||||
is_all_integer = (numeric_candidate.dropna() % 1 == 0).all()
|
||||
if is_all_integer and not _raw_strings_suggest_monetary_presentation(non_null):
|
||||
return (TYPE_INTEGER, None)
|
||||
return (TYPE_DECIMAL, None)
|
||||
|
||||
unique_ratio = non_null.nunique() / len(non_null)
|
||||
if non_null.nunique() <= 15 and unique_ratio < 0.3:
|
||||
return TYPE_CATEGORICAL, None
|
||||
if 0.1 < numeric_ratio < 0.9:
|
||||
return TYPE_MIXED, None
|
||||
return TYPE_TEXT, None
|
||||
|
||||
|
||||
def _applicable_rules_for_type(inferred_type: str) -> list[str]:
|
||||
"""Identificadores de reglas a mostrar según el tipo inferido (sin redundancias)."""
|
||||
base = ["strip_special_spaces", "strip_special_keep_date_seps", "strip_spaces"]
|
||||
if inferred_type == TYPE_INTEGER:
|
||||
return base + ["cop_mod_split"]
|
||||
if inferred_type == TYPE_DECIMAL:
|
||||
return base + [
|
||||
"to_integer",
|
||||
"round_decimals_custom",
|
||||
"doc_id_split_tipo_numero",
|
||||
"doc_type_homologate",
|
||||
"cop_mod_split",
|
||||
]
|
||||
if inferred_type == TYPE_DATE:
|
||||
return base + [
|
||||
"doc_id_split_tipo_numero",
|
||||
"doc_type_homologate",
|
||||
"date_to_ddmmyyyy",
|
||||
"nota_paciente_autorizacion",
|
||||
]
|
||||
if inferred_type == TYPE_MIXED:
|
||||
return base + [
|
||||
"to_integer",
|
||||
"round_decimals_custom",
|
||||
"doc_id_split_tipo_numero",
|
||||
"doc_type_homologate",
|
||||
"date_to_ddmmyyyy",
|
||||
"cop_mod_split",
|
||||
"nota_paciente_autorizacion",
|
||||
]
|
||||
# Texto / categórico / boolean / empty → todas las de limpieza de texto + las
|
||||
# conversoras (el usuario sabrá si aplica).
|
||||
return base + [
|
||||
"to_integer",
|
||||
"round_decimals_custom",
|
||||
"doc_id_split_tipo_numero",
|
||||
"doc_type_homologate",
|
||||
"date_to_ddmmyyyy",
|
||||
"cop_mod_split",
|
||||
"nota_paciente_autorizacion",
|
||||
]
|
||||
|
||||
|
||||
applicable_rule_ids_for_type = _applicable_rules_for_type
|
||||
Reference in New Issue
Block a user