g360-cli 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/package.json +16 -6
- package/py/pyproject.toml +4 -4
- package/py/requirements.txt +4 -0
- package/py/src/g360_core/__init__.py +67 -4
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/py/src/g360_core/batch_processor.py +120 -0
- package/py/src/g360_core/commercial_engine.py +305 -0
- package/py/src/g360_core/logger.py +40 -0
- package/py/src/g360_core/pipeline.py +578 -0
- package/py/src/g360_core/processor.py +634 -0
- package/py/src/g360_core/processor_segmentacion.py +859 -0
- package/py/src/g360_core/processor_sku.py +427 -0
- package/py/src/g360_core/scanner.py +218 -0
- package/py/src/g360_core/utils.py +435 -0
- package/src/cli.js +25 -2
- package/src/commands/ingest.js +193 -0
- package/src/commands/scan.js +102 -0
- package/src/commands/validate.js +126 -0
- package/src/lib/python_runner.js +89 -0
- package/py/src/g360_core/flet/__init__.py +0 -3
- package/py/src/g360_core/flet/ingestion_panel.py +0 -218
- package/py/src/g360_core/ingestion.py +0 -480
|
@@ -1,480 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import unicodedata
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import numpy as np
|
|
6
|
-
import pandas as pd
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
_KEYWORDS_ID = [
|
|
10
|
-
"sku", "codigo", "cod_art", "articulo", "id_cliente", "doc_cliente",
|
|
11
|
-
"id_vendedor", "id_linea", "id_grupo", "id_tipo", "id_familia",
|
|
12
|
-
"id_articulo", "cod_sucursal", "serie_doc", "nro_doc", "id_guia",
|
|
13
|
-
"id_pedido", "id_localidad_ubigeo",
|
|
14
|
-
]
|
|
15
|
-
_KEYWORDS_TEXTO = [
|
|
16
|
-
"linea", "nombre", "razon_social", "descripcion", "departamento",
|
|
17
|
-
"provincia", "distrito", "vendedor", "condicion", "division", "estado",
|
|
18
|
-
"canal", "moneda", "referencia", "grupo", "tipo", "familia", "sucursal",
|
|
19
|
-
]
|
|
20
|
-
_KEYWORDS_DINERO = [
|
|
21
|
-
"neto", "bruto", "venta", "valor", "total", "soles", "dolares",
|
|
22
|
-
]
|
|
23
|
-
_KEYWORDS_CANTIDAD = [
|
|
24
|
-
"cantidad",
|
|
25
|
-
]
|
|
26
|
-
_KEYWORDS_FECHA = ["fecha", "fec_", "venc"]
|
|
27
|
-
|
|
28
|
-
_MAPA_TPO_DOC = {
|
|
29
|
-
"NCR": "NOTA DE CREDITO",
|
|
30
|
-
"NDB": "NOTA DE DEBITO",
|
|
31
|
-
"BDI": "BIEN / BOLETA",
|
|
32
|
-
"FAC": "FACTURA",
|
|
33
|
-
"NV": "NOTA DE VENTA",
|
|
34
|
-
"GV": "GUIA DE VENTA",
|
|
35
|
-
"FC": "FACTURA CREDITO",
|
|
36
|
-
"NC": "NOTA DE CREDITO",
|
|
37
|
-
"ND": "NOTA DE DEBITO",
|
|
38
|
-
"BO": "BOLETA",
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
_MAPA_MESES = {
|
|
42
|
-
"ENERO": 1, "FEBRERO": 2, "MARZO": 3, "ABRIL": 4, "MAYO": 5, "JUNIO": 6,
|
|
43
|
-
"JULIO": 7, "AGOSTO": 8, "SETIEMBRE": 9, "OCTUBRE": 10, "NOVIEMBRE": 11, "DICIEMBRE": 12,
|
|
44
|
-
"ENE": 1, "FEB": 2, "MAR": 3, "ABR": 4, "MAY": 5, "JUN": 6,
|
|
45
|
-
"JUL": 7, "AGO": 8, "SET": 9, "OCT": 10, "NOV": 11, "DIC": 12,
|
|
46
|
-
"ENERO": 1, "FEBRERO": 2, "MARZO": 3, "ABRIL": 4, "MAYO": 5, "JUNIO": 6,
|
|
47
|
-
"JULIO": 7, "AGOSTO": 8, "SEPTIEMBRE": 9, "OCTUBRE": 10, "NOVIEMBRE": 11, "DICIEMBRE": 12,
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
_PATRON_DIRECCION = re.compile(
|
|
51
|
-
r'^(JR\.?\s|AV\.?\s|AVENIDA\s|CALLE\s|PASAJE\s|PSJE\.?\s|'
|
|
52
|
-
r'CARRETERA\s|CARRE\.?\s|MZA\.?\s|LOTE\s|URB\.?\s|'
|
|
53
|
-
r'FUNDO\s|SECTOR\s|PARQUE\s|PLAZA\s|ALAMEDA\s|JR|AV)\b',
|
|
54
|
-
re.IGNORECASE,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
_PATRON_REFERENCIA = re.compile(
|
|
58
|
-
r'^([A-Za-z]+)(\d+)[/.-]?(\d{1,4})?[/.-]?(\d+)?$'
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _limpiar_cabecera(col: str) -> str:
|
|
63
|
-
if not isinstance(col, str):
|
|
64
|
-
col = str(col)
|
|
65
|
-
col = col.strip().lower()
|
|
66
|
-
col = "".join(
|
|
67
|
-
c for c in unicodedata.normalize("NFD", col)
|
|
68
|
-
if unicodedata.category(c) != "Mn"
|
|
69
|
-
)
|
|
70
|
-
col = col.replace(" ", "_").replace(".", "").replace("/", "_")
|
|
71
|
-
col = re.sub(r"_+", "_", col)
|
|
72
|
-
col = col.strip("_")
|
|
73
|
-
return col
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _coincide_keywords(nombre_col: str, keywords: list[str]) -> bool:
|
|
77
|
-
return any(kw in nombre_col for kw in keywords)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _detectar_formato_numero(series: pd.Series) -> str:
|
|
81
|
-
muestras = series.dropna().astype(str).head(50)
|
|
82
|
-
coma_decimal = muestras.str.contains(r',\d{2}$', regex=True).sum()
|
|
83
|
-
punto_decimal = muestras.str.contains(r'\.\d{2}$', regex=True).sum()
|
|
84
|
-
if coma_decimal > punto_decimal:
|
|
85
|
-
return "spring"
|
|
86
|
-
if punto_decimal > coma_decimal:
|
|
87
|
-
return "sap"
|
|
88
|
-
return "simple"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _normalizar_monetario(series: pd.Series) -> pd.Series:
|
|
92
|
-
if series.dtype != object:
|
|
93
|
-
return pd.to_numeric(series, errors="coerce").fillna(0.0)
|
|
94
|
-
fmt = _detectar_formato_numero(series)
|
|
95
|
-
if fmt == "spring":
|
|
96
|
-
series = series.astype(str).str.replace(".", "", regex=False)
|
|
97
|
-
series = series.str.replace(",", ".", regex=False)
|
|
98
|
-
elif fmt == "sap":
|
|
99
|
-
series = series.astype(str).str.replace(",", "", regex=False)
|
|
100
|
-
series = (
|
|
101
|
-
series.astype(str)
|
|
102
|
-
.str.replace("S/.", "", regex=False)
|
|
103
|
-
.str.replace("S//.", "", regex=False)
|
|
104
|
-
.str.replace("$", "", regex=False)
|
|
105
|
-
.str.replace("US$", "", regex=False)
|
|
106
|
-
.str.strip()
|
|
107
|
-
)
|
|
108
|
-
return pd.to_numeric(series, errors="coerce").fillna(0.0)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _parsear_referencia(series: pd.Series) -> pd.DataFrame:
|
|
112
|
-
tipos: list[str | None] = []
|
|
113
|
-
series_doc: list[str | None] = []
|
|
114
|
-
periodos: list[str | None] = []
|
|
115
|
-
numeros: list[str | None] = []
|
|
116
|
-
for val in series:
|
|
117
|
-
if pd.isna(val):
|
|
118
|
-
tipos.append(None)
|
|
119
|
-
series_doc.append(None)
|
|
120
|
-
periodos.append(None)
|
|
121
|
-
numeros.append(None)
|
|
122
|
-
continue
|
|
123
|
-
m = _PATRON_REFERENCIA.match(str(val).strip())
|
|
124
|
-
if m:
|
|
125
|
-
tipos.append(m.group(1).upper())
|
|
126
|
-
series_doc.append(m.group(2))
|
|
127
|
-
periodos.append(m.group(3))
|
|
128
|
-
numeros.append(m.group(4))
|
|
129
|
-
else:
|
|
130
|
-
tipos.append(None)
|
|
131
|
-
series_doc.append(None)
|
|
132
|
-
periodos.append(None)
|
|
133
|
-
numeros.append(None)
|
|
134
|
-
return pd.DataFrame({
|
|
135
|
-
"ref_tipo_doc": tipos,
|
|
136
|
-
"ref_serie": series_doc,
|
|
137
|
-
"ref_periodo": periodos,
|
|
138
|
-
"ref_nro": numeros,
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _extraer_nombre_desde_direccion(
|
|
143
|
-
direccion: str,
|
|
144
|
-
lugares: list[str],
|
|
145
|
-
) -> str | None:
|
|
146
|
-
tokens = re.split(r'[,\s]+', direccion.strip())
|
|
147
|
-
tokens = [t for t in tokens if t]
|
|
148
|
-
for t in reversed(tokens):
|
|
149
|
-
t_clean = t.strip(".").upper()
|
|
150
|
-
if t_clean in lugares:
|
|
151
|
-
return t_clean.capitalize()
|
|
152
|
-
if tokens:
|
|
153
|
-
return tokens[-1].capitalize()
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _es_direccion(val: object) -> bool:
|
|
158
|
-
if pd.isna(val):
|
|
159
|
-
return False
|
|
160
|
-
s = str(val).strip()
|
|
161
|
-
if not s:
|
|
162
|
-
return False
|
|
163
|
-
return bool(_PATRON_DIRECCION.match(s)) or "N°" in s or "NRO" in s.upper()
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _parsear_sucursal_fila(
|
|
167
|
-
val: object, lugares: list[str]
|
|
168
|
-
) -> tuple[str | None, str | None]:
|
|
169
|
-
if pd.isna(val):
|
|
170
|
-
return None, None
|
|
171
|
-
s = str(val).strip()
|
|
172
|
-
if not s:
|
|
173
|
-
return None, None
|
|
174
|
-
if _es_direccion(s):
|
|
175
|
-
nombre = _extraer_nombre_desde_direccion(s, lugares)
|
|
176
|
-
return nombre, s
|
|
177
|
-
return s, None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _parsear_sucursal(
|
|
181
|
-
df: pd.DataFrame,
|
|
182
|
-
col_nom: str = "nom_sucursal",
|
|
183
|
-
col_depto: str = "nom_departamento",
|
|
184
|
-
col_prov: str = "nom_provincia",
|
|
185
|
-
col_dist: str = "nom_distrito",
|
|
186
|
-
) -> pd.DataFrame:
|
|
187
|
-
if col_nom not in df.columns:
|
|
188
|
-
df["sucursal_nombre"] = None
|
|
189
|
-
df["sucursal_direccion"] = None
|
|
190
|
-
return df
|
|
191
|
-
|
|
192
|
-
lugares: list[str] = []
|
|
193
|
-
for c in [col_depto, col_prov, col_dist]:
|
|
194
|
-
if c in df.columns:
|
|
195
|
-
lugares.extend(
|
|
196
|
-
str(v).strip().upper()
|
|
197
|
-
for v in df[c].dropna().unique()
|
|
198
|
-
if str(v).strip()
|
|
199
|
-
)
|
|
200
|
-
lugares = list(set(lugares))
|
|
201
|
-
|
|
202
|
-
resultados = df[col_nom].apply(lambda v: _parsear_sucursal_fila(v, lugares))
|
|
203
|
-
df["sucursal_nombre"] = resultados.apply(lambda x: x[0])
|
|
204
|
-
df["sucursal_direccion"] = resultados.apply(lambda x: x[1])
|
|
205
|
-
|
|
206
|
-
return df
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _clasificar_doc_cliente(val: object) -> tuple[str, str]:
|
|
210
|
-
limpio = re.sub(r"\D", "", str(val))
|
|
211
|
-
if len(limpio) == 11:
|
|
212
|
-
return limpio, "RUC"
|
|
213
|
-
elif len(limpio) == 8:
|
|
214
|
-
return limpio.zfill(8), "DNI"
|
|
215
|
-
elif len(limpio) > 0:
|
|
216
|
-
return limpio, "OTRO"
|
|
217
|
-
return "", "SIN_DOC"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _parsear_mes(val: object) -> tuple[int | None, str | None]:
|
|
221
|
-
if pd.isna(val):
|
|
222
|
-
return None, None
|
|
223
|
-
s = str(val).strip().upper()
|
|
224
|
-
m = re.match(r'^(\d{1,2})[-/](\w+)$', s)
|
|
225
|
-
if m:
|
|
226
|
-
num = int(m.group(1))
|
|
227
|
-
nombre = m.group(2).capitalize()
|
|
228
|
-
return num, nombre
|
|
229
|
-
if s.isdigit():
|
|
230
|
-
return int(s), None
|
|
231
|
-
if s in _MAPA_MESES:
|
|
232
|
-
return _MAPA_MESES[s], s.capitalize()
|
|
233
|
-
return None, None
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _clasificar_transaccion(row: dict) -> str:
|
|
237
|
-
cant = row.get("cantidad", 0)
|
|
238
|
-
cant_fae = row.get("cantidad_fae", 0)
|
|
239
|
-
if cant > 0 and cant_fae == 0:
|
|
240
|
-
return "venta"
|
|
241
|
-
if cant < 0 and cant_fae == 0:
|
|
242
|
-
return "devolucion"
|
|
243
|
-
if cant == 0 and cant_fae != 0:
|
|
244
|
-
return "regularizacion"
|
|
245
|
-
if cant != 0 and cant_fae != 0:
|
|
246
|
-
return "mixto"
|
|
247
|
-
return "indefinido"
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def estabilizar_excel_crudo(ruta_archivo: str | Path) -> tuple[pd.DataFrame, dict]:
|
|
251
|
-
ruta = Path(ruta_archivo)
|
|
252
|
-
if not ruta.exists():
|
|
253
|
-
raise FileNotFoundError(f"Archivo no encontrado: {ruta}")
|
|
254
|
-
|
|
255
|
-
sufijo = ruta.suffix.lower()
|
|
256
|
-
if sufijo == ".xls":
|
|
257
|
-
engine = "xlrd"
|
|
258
|
-
elif sufijo in (".xlsx", ".xlsm"):
|
|
259
|
-
engine = "openpyxl"
|
|
260
|
-
else:
|
|
261
|
-
engine = None
|
|
262
|
-
|
|
263
|
-
transformaciones: list[str] = []
|
|
264
|
-
alertas: list[str] = []
|
|
265
|
-
|
|
266
|
-
df = pd.read_excel(ruta, engine=engine)
|
|
267
|
-
transformaciones.append(f"lectura: {len(df)} filas, {len(df.columns)} columnas")
|
|
268
|
-
|
|
269
|
-
cols_originales = list(df.columns)
|
|
270
|
-
|
|
271
|
-
# ── 1. Normalizar cabeceras ──
|
|
272
|
-
df.columns = [_limpiar_cabecera(c) for c in df.columns]
|
|
273
|
-
transformaciones.append("cabeceras: normalizadas (lower, sin tildes, snake_case)")
|
|
274
|
-
|
|
275
|
-
# ── 2. Resolver duplicados en cabeceras ──
|
|
276
|
-
duplicados = [c for c in df.columns.tolist() if df.columns.tolist().count(c) > 1]
|
|
277
|
-
if duplicados:
|
|
278
|
-
vistos: dict[str, int] = {}
|
|
279
|
-
nuevas: list[str] = []
|
|
280
|
-
for c in df.columns:
|
|
281
|
-
if c in vistos:
|
|
282
|
-
vistos[c] += 1
|
|
283
|
-
nuevas.append(f"{c}_{vistos[c]}")
|
|
284
|
-
else:
|
|
285
|
-
vistos[c] = 0
|
|
286
|
-
nuevas.append(c)
|
|
287
|
-
df.columns = nuevas
|
|
288
|
-
transformaciones.append(f"cabeceras: {len(set(duplicados))} columna(s) duplicada(s) renombrada(s)")
|
|
289
|
-
|
|
290
|
-
# ── 3. Eliminar filas totalmente vacías ──
|
|
291
|
-
df = df.dropna(how="all").copy()
|
|
292
|
-
transformaciones.append(f"filas: {len(df)} tras eliminar vacias totales")
|
|
293
|
-
|
|
294
|
-
# ── 4. Parsear REFERENCIA ──
|
|
295
|
-
if "referencia" in df.columns:
|
|
296
|
-
ref_parsed = _parsear_referencia(df["referencia"])
|
|
297
|
-
for c in ref_parsed.columns:
|
|
298
|
-
df[c] = ref_parsed[c].values
|
|
299
|
-
transformaciones.append("referencia: parseada en ref_tipo_doc, ref_serie, ref_periodo, ref_nro")
|
|
300
|
-
|
|
301
|
-
# ── 5. Parsear NOM_SUCURSAL ──
|
|
302
|
-
df = _parsear_sucursal(df)
|
|
303
|
-
if "sucursal_nombre" in df.columns and "sucursal_direccion" in df.columns:
|
|
304
|
-
transformaciones.append("sucursal: separada nombre/direccion")
|
|
305
|
-
|
|
306
|
-
if ("nom_sucursal" in df.columns and "sucursal_nombre" in df.columns and
|
|
307
|
-
df["sucursal_nombre"].isna().any()):
|
|
308
|
-
alertas.append("sucursal: algunos nombres no pudieron determinarse")
|
|
309
|
-
|
|
310
|
-
# ── 6. Normalizar MES ──
|
|
311
|
-
if "mes" in df.columns:
|
|
312
|
-
parsed = df["mes"].apply(_parsear_mes)
|
|
313
|
-
df["mes_num"] = parsed.apply(lambda x: x[0])
|
|
314
|
-
df["mes_nombre"] = parsed.apply(lambda x: x[1])
|
|
315
|
-
transformaciones.append("mes: separado en mes_num y mes_nombre")
|
|
316
|
-
|
|
317
|
-
# ── 7. Mapear TPO_DOC ──
|
|
318
|
-
if "tpo_doc" in df.columns:
|
|
319
|
-
df["tipo_doc_nombre"] = df["tpo_doc"].map(_MAPA_TPO_DOC)
|
|
320
|
-
codigos_no_mapeados = df["tpo_doc"].dropna().unique()
|
|
321
|
-
no_map = [c for c in codigos_no_mapeados if c not in _MAPA_TPO_DOC]
|
|
322
|
-
if no_map:
|
|
323
|
-
for c in no_map:
|
|
324
|
-
limpio = re.sub(r'\d', '', str(c)).strip()
|
|
325
|
-
_MAPA_TPO_DOC[c] = limpio if limpio else c
|
|
326
|
-
df["tipo_doc_nombre"] = df["tpo_doc"].map(_MAPA_TPO_DOC).fillna("OTRO")
|
|
327
|
-
alertas.append(f"tpo_doc: codigos sin mapeo: {', '.join(sorted(str(c) for c in no_map))}")
|
|
328
|
-
transformaciones.append("tpo_doc: mapeado a nombre legible")
|
|
329
|
-
|
|
330
|
-
# ── 8. Columnas ID ──
|
|
331
|
-
for col in df.columns:
|
|
332
|
-
if _coincide_keywords(col, _KEYWORDS_ID):
|
|
333
|
-
was_numeric = pd.api.types.is_numeric_dtype(df[col])
|
|
334
|
-
df[col] = (
|
|
335
|
-
df[col].astype(str)
|
|
336
|
-
.str.replace(r"\.0$", "", regex=True)
|
|
337
|
-
.str.strip()
|
|
338
|
-
)
|
|
339
|
-
if was_numeric:
|
|
340
|
-
vals_clean = df[col].replace("nan", np.nan).dropna()
|
|
341
|
-
if len(vals_clean) > 0:
|
|
342
|
-
max_len = int(vals_clean.str.len().max())
|
|
343
|
-
has_shorter = (vals_clean.str.len() < max_len).any()
|
|
344
|
-
if has_shorter and max_len > 1:
|
|
345
|
-
df[col] = df[col].str.zfill(max_len)
|
|
346
|
-
transformaciones.append(f"{col}: leading zeros restaurados (zfill={max_len})")
|
|
347
|
-
df[col] = df[col].replace("nan", np.nan)
|
|
348
|
-
|
|
349
|
-
# ── 9. Normalizar DOC_CLIENTE (despues de ID para tener strings limpios) ──
|
|
350
|
-
for col in [c for c in df.columns if _coincide_keywords(c, ["doc_cliente"])]:
|
|
351
|
-
parsed = df[col].apply(_clasificar_doc_cliente)
|
|
352
|
-
tipo_col = f"tipo_{col}"
|
|
353
|
-
clean_col = f"{col}_clean"
|
|
354
|
-
df[clean_col] = parsed.apply(lambda x: x[0])
|
|
355
|
-
df[tipo_col] = parsed.apply(lambda x: x[1])
|
|
356
|
-
ruc_count = (df[tipo_col] == "RUC").sum()
|
|
357
|
-
dni_count = (df[tipo_col] == "DNI").sum()
|
|
358
|
-
transformaciones.append(
|
|
359
|
-
f"{col}: normalizado ({ruc_count} RUC, {dni_count} DNI, "
|
|
360
|
-
f"{(~df[tipo_col].isin(['RUC','DNI'])).sum()} otros)"
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
# ── 10. Columnas TEXTO ──
|
|
364
|
-
for col in df.columns:
|
|
365
|
-
if _coincide_keywords(col, _KEYWORDS_TEXTO):
|
|
366
|
-
df[col] = df[col].astype(str).str.strip()
|
|
367
|
-
df[col] = df[col].replace("nan", np.nan)
|
|
368
|
-
df[col] = df[col].apply(
|
|
369
|
-
lambda v: unicodedata.normalize("NFC", v) if isinstance(v, str) else v
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
# ── 11. Columnas DINERO ──
|
|
373
|
-
for col in df.columns:
|
|
374
|
-
if _coincide_keywords(col, _KEYWORDS_DINERO):
|
|
375
|
-
antes = df[col].dtype
|
|
376
|
-
df[col] = _normalizar_monetario(df[col])
|
|
377
|
-
if str(antes) != str(df[col].dtype):
|
|
378
|
-
transformaciones.append(f"{col}: monetario normalizado a float64")
|
|
379
|
-
nulos = (df[col] == 0.0).sum()
|
|
380
|
-
if nulos > 0:
|
|
381
|
-
transformaciones.append(f"{col}: {nulos} valor(es) zero por NaN original")
|
|
382
|
-
|
|
383
|
-
# ── 12. Columnas CANTIDAD ──
|
|
384
|
-
cols_cantidad = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_CANTIDAD)]
|
|
385
|
-
for col in cols_cantidad:
|
|
386
|
-
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)
|
|
387
|
-
transformaciones.append(f"{col}: forzado a float64, NaN → 0.0")
|
|
388
|
-
|
|
389
|
-
# ── 13. cantidad + cantidad_fae → cantidad_total, tipo_transaccion ──
|
|
390
|
-
if "cantidad" in df.columns:
|
|
391
|
-
if "cantidad_fae" in df.columns:
|
|
392
|
-
df["cantidad_total"] = df["cantidad"] + df["cantidad_fae"]
|
|
393
|
-
transformaciones.append("cantidad_total: suma de cantidad + cantidad_fae")
|
|
394
|
-
else:
|
|
395
|
-
df["cantidad_total"] = df["cantidad"]
|
|
396
|
-
df["tipo_transaccion"] = df.apply(_clasificar_transaccion, axis=1)
|
|
397
|
-
t_counts = df["tipo_transaccion"].value_counts().to_dict()
|
|
398
|
-
transformaciones.append(f"tipo_transaccion: {t_counts}")
|
|
399
|
-
|
|
400
|
-
# ── 14. Columnas FECHA ──
|
|
401
|
-
for col in df.columns:
|
|
402
|
-
if _coincide_keywords(col, _KEYWORDS_FECHA) and col != "fec_":
|
|
403
|
-
if pd.api.types.is_datetime64_any_dtype(df[col]):
|
|
404
|
-
continue
|
|
405
|
-
antes_nulos = df[col].isna().sum()
|
|
406
|
-
df[col] = pd.to_datetime(df[col], errors="coerce", dayfirst=True)
|
|
407
|
-
ahora_nulos = df[col].isna().sum()
|
|
408
|
-
if ahora_nulos > antes_nulos:
|
|
409
|
-
alertas.append(f"{col}: {ahora_nulos - antes_nulos} fecha(s) invalida(s) → NaT")
|
|
410
|
-
df[col] = df[col].fillna(pd.Timestamp.now().normalize())
|
|
411
|
-
transformaciones.append(f"{col}: datetime + NaT → today")
|
|
412
|
-
|
|
413
|
-
# ── 15. Validar MONEDA ──
|
|
414
|
-
if "moneda" in df.columns:
|
|
415
|
-
monedas = df["moneda"].dropna().unique()
|
|
416
|
-
monedas_str = [str(m).strip().upper() for m in monedas]
|
|
417
|
-
validas = {"SOL", "S/.", "PEN", "S/."}
|
|
418
|
-
if not all(m in validas for m in monedas_str):
|
|
419
|
-
raras = [m for m in monedas_str if m not in validas]
|
|
420
|
-
alertas.append(f"moneda: valores no esperados: {raras}")
|
|
421
|
-
transformaciones.append(f"moneda: todas en soles ({', '.join(monedas_str)})")
|
|
422
|
-
df["moneda_cod"] = "PEN"
|
|
423
|
-
|
|
424
|
-
# ── 16. Purga filas basura ──
|
|
425
|
-
for col in df.columns:
|
|
426
|
-
if df[col].dtype == object:
|
|
427
|
-
muestra = df[col].dropna().head(20).astype(str).tolist()
|
|
428
|
-
if any(
|
|
429
|
-
isinstance(v, str) and v.lower().strip() in ("total", "general", "totales", "acumulado")
|
|
430
|
-
for v in muestra
|
|
431
|
-
):
|
|
432
|
-
mascara = df[col].astype(str).str.lower().str.strip().isin(
|
|
433
|
-
["total", "general", "totales", "acumulado"]
|
|
434
|
-
)
|
|
435
|
-
n = mascara.sum()
|
|
436
|
-
df = df[~mascara].copy()
|
|
437
|
-
if n:
|
|
438
|
-
transformaciones.append(f"basura: {n} fila(s) 'total/general' eliminadas")
|
|
439
|
-
|
|
440
|
-
cols_id_presentes = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_ID)]
|
|
441
|
-
if cols_id_presentes:
|
|
442
|
-
mascara_ids_nulos = df[cols_id_presentes].isna().all(axis=1) | (
|
|
443
|
-
df[cols_id_presentes].astype(str).replace("nan", "") == ""
|
|
444
|
-
).all(axis=1)
|
|
445
|
-
mascara_dinero_cero = True
|
|
446
|
-
cols_dinero_presentes = [
|
|
447
|
-
c for c in df.columns if _coincide_keywords(c, _KEYWORDS_DINERO)
|
|
448
|
-
]
|
|
449
|
-
if cols_dinero_presentes:
|
|
450
|
-
mascara_dinero_cero = (df[cols_dinero_presentes] == 0.0).all(axis=1)
|
|
451
|
-
cols_cant_presentes = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_CANTIDAD)]
|
|
452
|
-
if cols_cant_presentes:
|
|
453
|
-
mascara_dinero_cero = mascara_dinero_cero & (df[cols_cant_presentes] == 0.0).all(axis=1)
|
|
454
|
-
mascara_fila_total = mascara_ids_nulos & (
|
|
455
|
-
~mascara_dinero_cero if isinstance(mascara_dinero_cero, pd.Series) else True
|
|
456
|
-
)
|
|
457
|
-
n_purge = mascara_fila_total.sum()
|
|
458
|
-
df = df[~mascara_fila_total].copy()
|
|
459
|
-
if n_purge:
|
|
460
|
-
transformaciones.append(f"basura: {n_purge} fila(s) sin ID y con dinero eliminadas")
|
|
461
|
-
|
|
462
|
-
df = df.reset_index(drop=True)
|
|
463
|
-
|
|
464
|
-
metadata = {
|
|
465
|
-
"archivo": str(ruta),
|
|
466
|
-
"filas_originales": len(df) + sum(
|
|
467
|
-
1 for t in transformaciones if "eliminadas" in t
|
|
468
|
-
),
|
|
469
|
-
"filas_estabilizadas": len(df),
|
|
470
|
-
"columnas_originales": cols_originales,
|
|
471
|
-
"columnas_finales": list(df.columns),
|
|
472
|
-
"columnas_nuevas": [
|
|
473
|
-
c for c in df.columns if c not in [_limpiar_cabecera(x) for x in cols_originales]
|
|
474
|
-
],
|
|
475
|
-
"transformaciones": transformaciones,
|
|
476
|
-
"alertas": alertas,
|
|
477
|
-
"moneda": "PEN",
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return df, metadata
|