g360-cli 1.6.1 → 1.7.1

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
@@ -2,6 +2,7 @@ import sys
2
2
  from pathlib import Path
3
3
  import flet as ft
4
4
  from core.g360_theme import G360Theme
5
+ from ui.ingestion_panel import IngestionPanel
5
6
 
6
7
  BASE_DIR = Path(__file__).resolve().parent.parent
7
8
  if str(BASE_DIR) not in sys.path:
@@ -12,9 +13,15 @@ class G360App:
12
13
  def __init__(self, page: ft.Page):
13
14
  self.page = page
14
15
  self.theme = G360Theme()
16
+ self.df = None
17
+ self.metadata = None
15
18
  self._setup_page()
16
19
  self._build_ui()
17
20
 
21
+ def _on_data_loaded(self, df, metadata):
22
+ self.df = df
23
+ self.metadata = metadata
24
+
18
25
  def _setup_page(self):
19
26
  self.page.title = self.theme.build_name()
20
27
  self.page.theme_mode = ft.ThemeMode.DARK
@@ -27,6 +34,10 @@ class G360App:
27
34
 
28
35
  def _build_ui(self):
29
36
  self.loading = self.theme.loading_overlay()
37
+ self.ingestion_panel = IngestionPanel(
38
+ self.theme, on_data_loaded=self._on_data_loaded
39
+ )
40
+ self.page.overlay.append(self.ingestion_panel.file_picker)
30
41
  self.page.add(
31
42
  ft.Stack(
32
43
  controls=[
@@ -88,21 +99,29 @@ class G360App:
88
99
  return ft.Column(
89
100
  controls=[
90
101
  self.theme.styled_text(
91
- "Bienvenido a G360 Desktop",
102
+ "Ingesta de Datos - Escudo de Saneamiento",
92
103
  size=24,
93
104
  weight=ft.FontWeight.BOLD,
94
105
  ),
106
+ ft.Container(height=8),
107
+ self.theme.styled_text(
108
+ "Carga tu archivo .xls/.xlsx del ERP. Los datos se normalizan automaticamente.",
109
+ size=14,
110
+ color=self.theme.muted,
111
+ ),
112
+ ft.Container(height=16),
113
+ self.ingestion_panel,
95
114
  ft.Container(height=20),
96
115
  ft.Row(
97
116
  controls=[
98
117
  self._build_card(
99
- "Usuarios", "Gestión de usuarios", ft.icons.PEOPLE
118
+ "Usuarios", "Gestion de usuarios", ft.icons.PEOPLE
100
119
  ),
101
120
  self._build_card(
102
121
  "Reportes", "Ver reportes", ft.icons.ANALYTICS
103
122
  ),
104
123
  self._build_card(
105
- "Configuración", "Ajustes del sistema", ft.icons.SETTINGS
124
+ "Configuracion", "Ajustes del sistema", ft.icons.SETTINGS
106
125
  ),
107
126
  ],
108
127
  spacing=20,
@@ -0,0 +1,121 @@
1
+ import unittest
2
+ from pathlib import Path
3
+ from unittest.mock import patch, MagicMock
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from core.ingestion import estabilizar_excel_crudo, _limpiar_cabecera, _coincide_keywords
8
+
9
+
10
+ class TestLimpiarCabecera(unittest.TestCase):
11
+ def test_espacios_y_mayusculas(self):
12
+ self.assertEqual(_limpiar_cabecera(" NOM_CLIENTE "), "nom_cliente")
13
+
14
+ def test_tildes(self):
15
+ self.assertEqual(_limpiar_cabecera("División"), "division")
16
+ self.assertEqual(_limpiar_cabecera("Línea"), "linea")
17
+
18
+ def test_espacios_intermedios(self):
19
+ self.assertEqual(_limpiar_cabecera("CANAL DE DISTRIBUCION"), "canal_de_distribucion")
20
+
21
+ def test_puntos(self):
22
+ self.assertEqual(_limpiar_cabecera("DOC_CLIENTE.1"), "doc_cliente1")
23
+
24
+ def test_barras(self):
25
+ self.assertEqual(_limpiar_cabecera("S/PRECIO"), "s_precio")
26
+
27
+ def test_guiones_dobles(self):
28
+ self.assertEqual(_limpiar_cabecera("NOM CLIENTE"), "nom_cliente")
29
+
30
+ def test_no_string(self):
31
+ self.assertEqual(_limpiar_cabecera(42), "42")
32
+
33
+
34
+ class TestCoincideKeywords(unittest.TestCase):
35
+ def test_id(self):
36
+ self.assertTrue(_coincide_keywords("id_cliente", ["id_cliente", "doc"]))
37
+
38
+ def test_parcial(self):
39
+ self.assertTrue(_coincide_keywords("soles", ["soles", "dolares"]))
40
+
41
+ def test_no_coincide(self):
42
+ self.assertFalse(_coincide_keywords("nombre", ["soles", "dolares"]))
43
+
44
+
45
+ _SAMPLE_DF = pd.DataFrame({
46
+ "ANHO": [2026.0, 2026.0, np.nan],
47
+ "ID_ARTICULO": ["11030", "78456", np.nan],
48
+ "NOM_CLIENTE": [" CLIENTE A ", "CLIENTE B", np.nan],
49
+ "SOLES": [1172.10, "S/.2,500.50", np.nan],
50
+ "FECHA_ORIG": ["24/06/2026", "20/06/2026", np.nan],
51
+ "ID_LINEA": ["0111", "0178", np.nan],
52
+ })
53
+
54
+
55
+ def _patched_ingestion(df_data, ruta="fake.xls"):
56
+ with patch("core.ingestion.pd.read_excel", return_value=df_data.copy()), \
57
+ patch.object(Path, "exists", return_value=True):
58
+ return estabilizar_excel_crudo(ruta)
59
+
60
+
61
+ class TestEstabilizarExcelCrudo(unittest.TestCase):
62
+
63
+ def test_normalizacion_completa(self):
64
+ df, meta = _patched_ingestion(_SAMPLE_DF)
65
+
66
+ self.assertIn("anho", df.columns)
67
+ self.assertIn("id_articulo", df.columns)
68
+ self.assertIn("nom_cliente", df.columns)
69
+ self.assertIn("soles", df.columns)
70
+ self.assertIn("fecha_orig", df.columns)
71
+ self.assertIn("id_linea", df.columns)
72
+ self.assertEqual(df["id_linea"].iloc[0], "0111")
73
+ self.assertEqual(df["id_linea"].iloc[1], "0178")
74
+
75
+ def test_ids_como_texto_sin_ceros_perdidos(self):
76
+ df, _ = _patched_ingestion(_SAMPLE_DF)
77
+ self.assertEqual(df["id_articulo"].iloc[0], "11030")
78
+ self.assertEqual(df["id_articulo"].iloc[1], "78456")
79
+
80
+ def test_dinero_limpio(self):
81
+ df, _ = _patched_ingestion(_SAMPLE_DF)
82
+ self.assertAlmostEqual(df["soles"].iloc[0], 1172.10, places=2)
83
+ self.assertAlmostEqual(df["soles"].iloc[1], 2500.50, places=2)
84
+
85
+ def test_fechas_como_datetime(self):
86
+ df, _ = _patched_ingestion(_SAMPLE_DF)
87
+ self.assertTrue(pd.api.types.is_datetime64_any_dtype(df["fecha_orig"]))
88
+
89
+ def test_filas_vacias_eliminadas(self):
90
+ df, _ = _patched_ingestion(_SAMPLE_DF)
91
+ self.assertEqual(len(df), 2)
92
+
93
+ def test_metadata_creada(self):
94
+ df, meta = _patched_ingestion(_SAMPLE_DF)
95
+ self.assertIn("filas_estabilizadas", meta)
96
+ self.assertIn("columnas_finales", meta)
97
+ self.assertEqual(meta["filas_estabilizadas"], 2)
98
+
99
+ def test_soles_sin_nan(self):
100
+ df, _ = _patched_ingestion(_SAMPLE_DF)
101
+ self.assertFalse(df["soles"].isna().any())
102
+
103
+ def test_archivo_no_existe(self):
104
+ with self.assertRaises(FileNotFoundError):
105
+ estabilizar_excel_crudo("no_existe_archivo_falso_999.xls")
106
+
107
+
108
+ class TestColumnasDuplicadas(unittest.TestCase):
109
+ def test_duplicados_se_sufijan(self):
110
+ df_dup = pd.DataFrame({
111
+ "DOC_CLIENTE": [1.0, 2.0],
112
+ "DOC_CLIENTE.1": [1.0, 2.0],
113
+ "SOLES": [100.0, 200.0],
114
+ })
115
+ df, meta = _patched_ingestion(df_dup)
116
+ cols = list(df.columns)
117
+ self.assertEqual(len(set(cols)), len(cols), f"Columnas duplicadas: {cols}")
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()