"""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"(?= 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