g360-cli 1.6.1 → 1.7.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.
@@ -0,0 +1,480 @@
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
File without changes