feat: Enhance CSV cleaning and management functionalities by adding new date normalization rules, improving document type homologation, and implementing robust sanitization for CSV cell values. Update GUI components to support loading indicators and status messages during CSV processing, ensuring a smoother user experience.

This commit is contained in:
erik
2026-05-04 01:54:06 -05:00
parent 37d32a5a96
commit e2536380d4
7 changed files with 864 additions and 72 deletions
+410 -35
View File
@@ -14,8 +14,10 @@ import logging
import math
import random
import re
import unicodedata
from collections import Counter
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Callable, Literal, Optional
import numpy as np
@@ -101,6 +103,35 @@ _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.
@@ -312,6 +343,118 @@ def _parse_date_with_format(value: str, fmt: str) -> Optional[tuple[int, int, in
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
# ---------------------------------------------------------------------------
# ANÁLISIS DE COLUMNAS
# ---------------------------------------------------------------------------
@@ -856,22 +999,25 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]:
if inferred_type == TYPE_INTEGER:
return base
if inferred_type == TYPE_DECIMAL:
return base + ["to_integer", "round_decimals_custom", "doc_id_split_tipo_numero"]
return base + [
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"doc_type_homologate",
]
if inferred_type == TYPE_DATE:
return base + [
"doc_id_split_tipo_numero",
"date_messy_text_to_ddmmyyyy",
"date_numeric_to_ddmmyyyy",
"date_alpha_to_ddmmyyyy",
"doc_type_homologate",
"date_to_ddmmyyyy",
]
if inferred_type == TYPE_MIXED:
return base + [
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"date_messy_text_to_ddmmyyyy",
"date_numeric_to_ddmmyyyy",
"date_alpha_to_ddmmyyyy",
"doc_type_homologate",
"date_to_ddmmyyyy",
]
# Texto / categórico / boolean / empty → todas las de limpieza de texto + las
# conversoras (el usuario sabrá si aplica).
@@ -879,9 +1025,8 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]:
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"date_messy_text_to_ddmmyyyy",
"date_numeric_to_ddmmyyyy",
"date_alpha_to_ddmmyyyy",
"doc_type_homologate",
"date_to_ddmmyyyy",
]
@@ -962,8 +1107,8 @@ def _rule_strip_special_spaces(series: pd.Series) -> pd.Series:
def _rule_strip_special_keep_date_seps(series: pd.Series) -> pd.Series:
"""Conserva alfanuméricos y separadores de fecha: '/', '-', espacio."""
pattern = re.compile(r"[^A-Za-z0-9\/\- ]", flags=re.UNICODE)
"""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):
@@ -1030,6 +1175,103 @@ def _rule_round_decimals_custom(series: pd.Series) -> pd.Series:
return numeric.map(_fn).astype("Int64")
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)
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 _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).
@@ -1166,12 +1408,102 @@ def _rule_date_messy_text_to_ddmmyyyy(series: pd.Series) -> pd.Series:
# Tipo y número de documento (CC, NIT, CE, MS, …) → 2 columnas
# ---------------------------------------------------------------------------
def split_doc_tipo_y_numero_texto(raw: Any) -> tuple[Optional[str], Optional[str]]:
"""Separa cadenas `CC-######`, `NIT900…`, `CE 12-34-56` en (tipo, solo dígitos).
_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"),
}
Tipo: bloque de letras Unicode al inicio. Número: todos los dígitos a partir de
la primera cifra (miles: solo dígitos en salida). Celdas solo numéricas o sin
letras iniciales devuelven (None, None). Solo letras, sin cifras: (tipo, None).
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)
@@ -1180,23 +1512,33 @@ def split_doc_tipo_y_numero_texto(raw: Any) -> tuple[Optional[str], Optional[str
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"^([A-Za-zÑñ\u00C0-\u017F\u00D1\u00F1\u0100-\u02CC]+)" # letras (1+)
r"[\s\-.#:/_,\u00A0]*" # separación opcional
r"([0-9].*)\s*$", # primera cifra
r"^([^\d]+?)\s*([0-9].*)\s*$",
t,
)
if m:
to_t = "".join(c for c in m.group(1) if c.isalpha())
to_t = to_t.upper() if to_t else None
to_t = _clean_doc_type_text(m.group(1))
nums = re.sub(r"[^\d]", "", m.group(2))
return (to_t, nums if nums else None)
m2 = re.match(
r"^([A-Za-zÑñ\u00C0-\u017F\u00D1\u00F1\u0100-\u02CC]+)\s*$", t,
)
if m2 and not re.search(r"\d", t):
to_t = "".join(c for c in m2.group(1) if c.isalpha())
return (to_t.upper() if to_t else None, None)
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)
@@ -1218,13 +1560,13 @@ def _rule_passthrough_series(s: pd.Series) -> pd.Series:
def _apply_doc_id_split_columns(
result: pd.DataFrame, col: str, log: list[str]
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)
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
@@ -1239,13 +1581,21 @@ CLEANING_RULES: list[dict[str, Any]] = [
{
"id": "strip_special_spaces",
"name": "Eliminar caracteres especiales y espacios",
"description": "Deja solo caracteres alfanuméricos (A-Z, a-z, 0-9).",
"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": _rule_strip_special_spaces,
},
{
"id": "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, /, - y espacios. Adecuado p. ej. en columna 'Fecha Servicio'.",
"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": _rule_strip_special_keep_date_seps,
},
{
@@ -1260,6 +1610,15 @@ CLEANING_RULES: list[dict[str, Any]] = [
"description": "Convierte a enteros con detección de miles/decimal (`,`/`.`) y moneda; redondea decimales.",
"fn": _rule_to_integer,
},
{
"id": "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": _rule_date_to_ddmmyyyy,
},
{
"id": "date_numeric_to_ddmmyyyy",
"name": "Conversión de formato numérico (Fechas) → DD/MM/AAAA",
@@ -1293,6 +1652,15 @@ CLEANING_RULES: list[dict[str, Any]] = [
),
"fn": _rule_passthrough_series,
},
{
"id": "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": _rule_doc_type_homologate,
},
]
_RULE_BY_ID: dict[str, Callable[[pd.Series], pd.Series]] = {
@@ -1333,14 +1701,21 @@ def apply_rules(
if col not in result.columns:
log.append(f"[WARN] Columna no encontrada: {col}")
continue
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)
_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 == "doc_type_homologate" and split_has_homologation:
continue
fn = _RULE_BY_ID.get(rule_id)
if fn is None:
log.append(f"[WARN] Regla desconocida '{rule_id}' en columna '{col}'")
+42 -16
View File
@@ -2,25 +2,51 @@
Maneja la escritura incremental segura (con lock) en archivos CSV.
"""
import os
import csv
import os
import re
import threading
from typing import List, Dict, Any, Optional
from typing import Any, Dict, List
_LINE_BREAK_RE = re.compile(r"[\r\n\v\f\x85\u2028\u2029]+")
def sanitize_csv_cell_value(value: Any) -> Any:
"""Reemplaza saltos de línea en valores de celda por un espacio (una fila física por registro).
Conserva ``None`` y escalares numéricos/booleanos sin tocar. Cadenas y demás
tipos se normalizan solo si la representación en texto contiene saltos.
"""
if value is None:
return value
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
if not _LINE_BREAK_RE.search(value):
return value
return _LINE_BREAK_RE.sub(" ", value).strip()
s = str(value)
if not _LINE_BREAK_RE.search(s):
return value
return _LINE_BREAK_RE.sub(" ", s).strip()
class CSVManager:
def __init__(self):
self._lock = threading.Lock()
def __init__(self):
self._lock = threading.Lock()
def append_rows(self, csv_path: str, rows: List[Dict[str, Any]], header: List[str]) -> None:
"""Anexa filas a un CSV, creando encabezado si el archivo no existe."""
os.makedirs(os.path.dirname(csv_path) or ".", exist_ok=True)
file_exists = os.path.exists(csv_path) and os.path.getsize(csv_path) > 0
with self._lock:
with open(csv_path, "a", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=header, extrasaction="ignore", delimiter='|')
if not file_exists:
writer.writeheader()
for r in rows:
writer.writerow(r)
def append_rows(self, csv_path: str, rows: List[Dict[str, Any]], header: List[str]) -> None:
"""Anexa filas a un CSV, creando encabezado si el archivo no existe."""
os.makedirs(os.path.dirname(csv_path) or ".", exist_ok=True)
file_exists = os.path.exists(csv_path) and os.path.getsize(csv_path) > 0
with self._lock:
with open(csv_path, "a", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=header, extrasaction="ignore", delimiter="|")
if not file_exists:
writer.writeheader()
for r in rows:
sanitized = {k: sanitize_csv_cell_value(v) for k, v in r.items()}
writer.writerow(sanitized)
+52 -16
View File
@@ -1201,35 +1201,71 @@ class AppController:
if not filepath:
return
try:
df, delimiter = csv_cleaning.load_csv(filepath)
except Exception as e:
logger.exception("Error leyendo CSV para limpieza")
mb.showerror("Error", f"No se pudo leer el CSV:\n{e}")
return
self._cleaning_df = df
self._cleaning_result_df = None
self.view.set_cleaning_csv_path(filepath)
self.view.enable_cleaning_controls(False)
self.view.enable_cleaning_save(False)
self.view.set_cleaning_result(None)
self.view.set_cleaning_preview(None)
self.view.set_cleaning_status("Cargando CSV...")
try:
self.view.set_cleaning_loading(True, "Leyendo archivo CSV...")
except Exception:
pass
def worker():
try:
df, delimiter = csv_cleaning.load_csv(filepath)
self.view.after(
0,
lambda d=df, delim=delimiter: self._on_cleaning_csv_loaded(
d, delim
),
)
summaries = csv_cleaning.analyze_dataframe(df)
self._cleaning_df = df
self.view.after(
0,
lambda d=df, s=summaries: self._finish_cleaning_csv_load(d, s),
)
except Exception as e:
logger.exception("Error leyendo o analizando CSV para limpieza")
err = str(e)
self.view.after(0, lambda m=err: self._fail_cleaning_csv_load(m))
threading.Thread(target=worker, daemon=True).start()
def _on_cleaning_csv_loaded(self, df: pd.DataFrame, delimiter: str):
self.view.set_cleaning_status(
f"CSV cargado: {len(df):,} filas x {len(df.columns)} columnas."
f"CSV leído: {len(df):,} filas x {len(df.columns)} columnas. Analizando columnas..."
)
try:
self.view.set_cleaning_loading(True, "Analizando columnas...")
self.view.set_cleaning_delimiter_chip(delimiter)
except Exception:
pass
try:
summaries = csv_cleaning.analyze_dataframe(df)
except Exception as e:
logger.exception("Error analizando CSV para limpieza")
mb.showerror("Error", f"No se pudo analizar el CSV:\n{e}")
return
def _finish_cleaning_csv_load(self, df: pd.DataFrame, summaries):
self.view.populate_cleaning_columns(summaries)
self.view.set_cleaning_preview(df)
self.view.set_cleaning_status(
f"CSV cargado: {len(df):,} filas x {len(df.columns)} columnas."
)
try:
self.view.set_cleaning_loading(False)
except Exception:
pass
self.view.enable_cleaning_controls(True)
def _fail_cleaning_csv_load(self, message: str):
self._cleaning_df = None
self.view.set_cleaning_status("Error al cargar CSV")
try:
self.view.set_cleaning_loading(False)
except Exception:
pass
self.view.enable_cleaning_controls(True)
mb.showerror("Error", f"No se pudo cargar o analizar el CSV:\n{message}")
def on_cleaning_apply(self):
"""Aplica las reglas seleccionadas en un hilo y actualiza el resultado."""
+72 -5
View File
@@ -56,15 +56,17 @@ _TYPE_MENU_VALUES: list[str] = [
]
_RULE_SHORT_LABELS: dict[str, str] = {
"strip_special_spaces": "Sin especiales/espacios",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, espacios",
"strip_special_spaces": "Sin especiales/espacios (no antes de fecha+hora)",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, :, espacios",
"strip_spaces": "Sin espacios",
"to_integer": "A entero",
"date_to_ddmmyyyy": "Normalizar fecha → DD/MM/AAAA",
"date_numeric_to_ddmmyyyy": "Fecha numérica → DD/MM/AAAA",
"date_alpha_to_ddmmyyyy": "Fecha alfanumérica → DD/MM/AAAA",
"date_messy_text_to_ddmmyyyy": "Fecha con ruido (texto) → DD/MM/AAAA",
"round_decimals_custom": "Aprox. decimales (>0.5 sube)",
"doc_id_split_tipo_numero": "Dividir doc (tipo + cifras)",
"doc_type_homologate": "Homologar tipo doc",
}
@@ -109,6 +111,7 @@ class CleaningTab:
self._search_var: Optional[ctk.StringVar] = None
# Tipo de dato forzado por el usuario (id interno) por columna; vacío = inferido
self._type_overrides: dict[str, str] = {}
self._syncing_rule_vars = False
self._setup_layout()
self._create_file_section()
@@ -163,6 +166,27 @@ class CleaningTab:
self.chip_delim = self._make_chip(chips_frame, "Delim: —")
self.chip_delim.pack(side="left")
self.loading_frame = ctk.CTkFrame(frame, fg_color="transparent")
self.loading_frame.grid(
row=2, column=0, columnspan=4, sticky="ew", padx=15, pady=(0, 12)
)
self.loading_frame.grid_columnconfigure(1, weight=1)
self.loading_label = ctk.CTkLabel(
self.loading_frame,
text="",
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
anchor="w",
)
self.loading_label.grid(row=0, column=0, sticky="w", padx=(0, 10))
self.loading_progress = ctk.CTkProgressBar(
self.loading_frame,
mode="indeterminate",
height=8,
)
self.loading_progress.grid(row=0, column=1, sticky="ew")
self.loading_frame.grid_remove()
def _make_chip(self, parent, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(
parent, text=text, font=self.fonts["italic_small"],
@@ -429,6 +453,23 @@ class CleaningTab:
def set_status(self, text: str):
self.lbl_status.configure(text=text)
def set_loading(self, loading: bool, text: str = ""):
"""Muestra u oculta una barra indeterminada para operaciones de CSV."""
if loading:
self.loading_label.configure(text=text or "Procesando CSV...")
self.loading_frame.grid()
try:
self.loading_progress.start()
except Exception:
pass
else:
try:
self.loading_progress.stop()
except Exception:
pass
self.loading_label.configure(text="")
self.loading_frame.grid_remove()
def set_preview(self, df: Optional[pd.DataFrame]):
"""Renderiza el DataFrame original en la tabla de vista previa."""
if df is None or len(df) == 0:
@@ -643,7 +684,10 @@ class CleaningTab:
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add("write", lambda *_a, c=summary.name: self._on_rule_toggle(c))
var.trace_add(
"write",
lambda *_a, c=summary.name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
rules_frame, text=label, variable=var,
@@ -850,7 +894,10 @@ class CleaningTab:
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add("write", lambda *_a, c=col_name: self._on_rule_toggle(c))
var.trace_add(
"write",
lambda *_a, c=col_name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
ui.rules_frame, text=label, variable=var,
@@ -883,10 +930,30 @@ class CleaningTab:
if ui.expanded != expanded:
self._toggle_column(col_name)
def _on_rule_toggle(self, col_name: str):
def _on_rule_toggle(self, col_name: str, changed_rule_id: Optional[str] = None):
ui = self._column_uis.get(col_name)
if ui is None:
return
if (
changed_rule_id == "doc_id_split_tipo_numero"
and not self._syncing_rule_vars
):
split_selected = any(
rid == "doc_id_split_tipo_numero" and bool(var.get())
for rid, _chk, var in ui.rule_vars
)
if split_selected:
for rid, _chk, var in ui.rule_vars:
if rid == "doc_type_homologate" and not bool(var.get()):
self._syncing_rule_vars = True
try:
var.set(True)
finally:
self._syncing_rule_vars = False
self.set_status(
f"Columna «{col_name}»: se activó homologación de tipo de documento."
)
break
selected = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get()))
text = f"{selected} regla{'s' if selected != 1 else ''}"
color = self.colors["success"] if selected else self.colors["gray_light"]
+3
View File
@@ -399,5 +399,8 @@ class InvoiceAppView(ctk.CTk):
def set_cleaning_status(self, text: str):
self.cleaning_tab.set_status(text)
def set_cleaning_loading(self, loading: bool, text: str = ""):
self.cleaning_tab.set_loading(loading, text)
def set_cleaning_delimiter_chip(self, delimiter: str):
self.cleaning_tab.set_delimiter_chip(delimiter)
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import pandas as pd
from application.csv_cleaning import (
apply_rules,
homologate_doc_type,
split_doc_tipo_y_numero_texto,
)
def test_homologate_doc_type_aliases() -> None:
cases = {
"Cedula": "CC",
"C.C": "CC",
"C C": "CC",
"Registro Civil": "RC",
"Salvoconducto": "SC",
"TC": "RC",
"C\u00e9dula de Extranjeria": "CE",
}
for raw, expected in cases.items():
assert homologate_doc_type(raw) == expected
def test_split_doc_type_with_long_names() -> None:
cases = {
"Cedula 123.456": ("CC", "123456"),
"Registro Civil: 001-23": ("RC", "00123"),
"Permiso especial de permanencia 999": ("PE", "999"),
"Permiso por proteccion Temporal / 555": ("PT", "555"),
}
for raw, expected in cases.items():
assert split_doc_tipo_y_numero_texto(raw) == expected
def test_split_doc_type_with_abbreviations_and_noise() -> None:
cases = {
"C.C-123": ("CC", "123"),
"TL_987": ("TI", "987"),
"CE/1.234.567": ("CE", "1234567"),
"T1 # 44": ("TI", "44"),
}
for raw, expected in cases.items():
assert split_doc_tipo_y_numero_texto(raw) == expected
def test_split_unknown_type_preserves_clean_uppercase() -> None:
assert split_doc_tipo_y_numero_texto("ABC Especial: 123-45") == (
"ABC ESPECIAL",
"12345",
)
def test_split_without_homologation_preserves_clean_type() -> None:
assert split_doc_tipo_y_numero_texto(
"Cedula 123.456",
homologate=False,
) == ("CEDULA", "123456")
def test_apply_rules_homologates_type_column_only() -> None:
df = pd.DataFrame(
{
"tipo": [
"Cedula",
"C.C",
"Registro Civil",
"Salvoconducto",
"TC",
"Extranjera",
]
}
)
result, log = apply_rules(df, {"tipo": ["doc_type_homologate"]})
assert result["tipo"].tolist() == ["CC", "CC", "RC", "SC", "RC", "CE"]
assert any("Homologar tipo de documento" in line for line in log)
def test_apply_rules_split_uses_homologation_when_selected() -> None:
df = pd.DataFrame(
{
"documento": [
"Cedula 123.456",
"Registro Civil: 001-23",
"ABC Especial: 123-45",
]
}
)
result, _log = apply_rules(
df,
{"documento": ["doc_id_split_tipo_numero", "doc_type_homologate"]},
)
assert result["documento_doc_tipo"].tolist() == ["CC", "RC", "ABC ESPECIAL"]
assert result["documento_doc_numeros"].tolist() == ["123456", "00123", "12345"]
assert result["documento"].tolist() == df["documento"].tolist()
def test_apply_rules_split_can_skip_homologation() -> None:
df = pd.DataFrame({"documento": ["Cedula 123.456"]})
result, _log = apply_rules(df, {"documento": ["doc_id_split_tipo_numero"]})
assert result["documento_doc_tipo"].tolist() == ["CEDULA"]
assert result["documento_doc_numeros"].tolist() == ["123456"]
def test_apply_rules_unified_date_rule_numeric_and_separated() -> None:
df = pd.DataFrame(
{
"fecha": [
"20260426",
"26042026",
"260426",
"26/04/2026",
"04/26/2026",
"2026-04-26 10:30",
]
}
)
result, log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
assert result["fecha"].tolist() == [
"26/04/2026",
"26/04/2026",
"26/04/2026",
"26/04/2026",
"26/04/2026",
"26/04/2026",
]
assert any("Normalizar fecha" in line for line in log)
def test_apply_rules_unified_date_rule_text_months() -> None:
df = pd.DataFrame(
{
"fecha": [
"26-abril-2026",
"26 abril 2026",
"26/abr/2026",
"abril 26 2026",
"2026 abril 26",
"15-sept-25",
"07 setiembre 2024",
]
}
)
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
assert result["fecha"].tolist() == [
"26/04/2026",
"26/04/2026",
"26/04/2026",
"26/04/2026",
"26/04/2026",
"15/09/2025",
"07/09/2024",
]
def test_apply_rules_unified_date_spanish_abbr_month_with_time() -> None:
df = pd.DataFrame(
{
"fecha": [
"15/may./2024 13:44",
"22/may./2024 11:09",
"30/04/2024",
]
}
)
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
assert result["fecha"].tolist() == [
"15/05/2024",
"22/05/2024",
"30/04/2024",
]
def test_apply_rules_date_after_strip_special_spaces_glued_year_time() -> None:
df = pd.DataFrame({"fecha": ["15/may./2024 13:44"]})
result, _log = apply_rules(
df,
{"fecha": ["strip_special_spaces", "date_to_ddmmyyyy"]},
)
assert result["fecha"].tolist() == ["15/05/2024"]
def test_apply_rules_keep_date_seps_preserves_time_then_normalizes() -> None:
df = pd.DataFrame({"fecha": ["30/04/2024 13:44"]})
result, _log = apply_rules(
df,
{"fecha": ["strip_special_keep_date_seps", "date_to_ddmmyyyy"]},
)
assert result["fecha"].tolist() == ["30/04/2024"]
def test_apply_rules_unified_date_rule_noise_and_unparseable_values() -> None:
df = pd.DataFrame(
{
"fecha": [
"**26_abril_2026++",
"Fecha: 26-abril-2026",
"xx26/04/2026yy",
"no es fecha",
"31-febrero-2026",
]
}
)
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
assert result["fecha"].tolist() == [
"26/04/2026",
"26/04/2026",
"26/04/2026",
"no es fecha",
"31-febrero-2026",
]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from application.csv_manager import CSVManager, sanitize_csv_cell_value
def test_sanitize_collapses_newlines() -> None:
assert sanitize_csv_cell_value("a\nb") == "a b"
assert sanitize_csv_cell_value("a\r\nb") == "a b"
assert sanitize_csv_cell_value(" x\v\fy ") == "x y"
def test_sanitize_unicode_line_separators() -> None:
assert sanitize_csv_cell_value("a\u2028b\u2029c") == "a b c"
assert sanitize_csv_cell_value("a\x85b") == "a b"
def test_sanitize_preserves_none_and_scalars() -> None:
assert sanitize_csv_cell_value(None) is None
assert sanitize_csv_cell_value(42) == 42
assert sanitize_csv_cell_value(3.14) == 3.14
assert sanitize_csv_cell_value(False) is False
assert sanitize_csv_cell_value(True) is True
def test_sanitize_plain_string_unchanged_identity() -> None:
s = "sin saltos"
assert sanitize_csv_cell_value(s) is s
def test_append_rows_one_physical_line_per_record() -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "batch.csv"
mgr = CSVManager()
header = ["radicado", "descripcion"]
rows = [
{
"radicado": "235662283",
"descripcion": (
'"Las retenciones son a nivel informativo.\n'
"Paciente: 1010079549 - HERAZO\n"
'Contrato No. URGENCIAS."'
),
},
{"radicado": "2", "descripcion": "ok"},
]
mgr.append_rows(str(path), rows, header)
text = path.read_text(encoding="utf-8")
physical_lines = text.splitlines()
assert len(physical_lines) == 3