g360-cli 1.6.0 → 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.
- package/README.md +71 -10
- package/package.json +1 -1
- package/py/pyproject.toml +34 -0
- package/py/src/g360_core/__init__.py +7 -0
- package/py/src/g360_core/flet/__init__.py +3 -0
- package/py/src/g360_core/flet/ingestion_panel.py +218 -0
- package/py/src/g360_core/ingestion.py +480 -0
- package/src/assets/ingestion/core/__init__.py +0 -0
- package/src/assets/ingestion/core/ingestion.py +480 -0
- package/src/assets/ingestion/ui/__init__.py +0 -0
- package/src/assets/ingestion/ui/ingestion_panel.py +221 -0
- package/src/assets/templates/python-flet/pyproject.toml +2 -0
- package/src/assets/templates/python-flet/src/core/ingestion.py +480 -0
- package/src/assets/templates/python-flet/src/main.py +22 -3
- package/src/assets/templates/python-flet/src/test_ingestion.py +121 -0
- package/src/assets/templates/python-flet/src/ui/ingestion_panel.py +221 -0
- package/src/commands/bring.js +87 -0
- package/src/commands/list.js +18 -1
- package/src/commands/signature.js +25 -8
|
@@ -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
|
-
"
|
|
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", "
|
|
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
|
-
"
|
|
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", 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()
|