Files
IPSExtractor/services/cleaning/domain/csv_cleaning/documents.py
T

176 lines
5.6 KiB
Python

"""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)"
)