209 lines
6.1 KiB
Python
209 lines
6.1 KiB
Python
"""Heurísticas por nombre de columna y presentación del contenido."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
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
|
|
|