g360-cli 1.7.0 → 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.
Files changed (42) hide show
  1. package/README.md +37 -3
  2. package/package.json +16 -6
  3. package/py/pyproject.toml +4 -4
  4. package/py/requirements.txt +4 -0
  5. package/py/src/g360_core/__init__.py +67 -4
  6. package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
  7. package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
  9. package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
  10. package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
  11. package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
  12. package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
  13. package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
  14. package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
  15. package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
  16. package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
  17. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
  18. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
  19. package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
  20. package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
  21. package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
  22. package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
  23. package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
  24. package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
  25. package/py/src/g360_core/batch_processor.py +120 -0
  26. package/py/src/g360_core/commercial_engine.py +305 -0
  27. package/py/src/g360_core/logger.py +40 -0
  28. package/py/src/g360_core/pipeline.py +578 -0
  29. package/py/src/g360_core/processor.py +634 -0
  30. package/py/src/g360_core/processor_segmentacion.py +859 -0
  31. package/py/src/g360_core/processor_sku.py +427 -0
  32. package/py/src/g360_core/scanner.py +218 -0
  33. package/py/src/g360_core/utils.py +435 -0
  34. package/src/assets/templates/python-flet/src/test_ingestion.py +1 -1
  35. package/src/cli.js +25 -2
  36. package/src/commands/ingest.js +193 -0
  37. package/src/commands/scan.js +102 -0
  38. package/src/commands/validate.js +126 -0
  39. package/src/lib/python_runner.js +89 -0
  40. package/py/src/g360_core/flet/__init__.py +0 -3
  41. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  42. package/py/src/g360_core/ingestion.py +0 -480
@@ -0,0 +1,435 @@
1
+ import pandas as pd
2
+ import re
3
+ from typing import Optional
4
+
5
+
6
+ ENTITY_MAP = {
7
+ "cliente": {"id": "ID_CLIENTE", "nombre": "NOM_CLIENTE", "label": "CLIENTE_LABEL"},
8
+ "vendedor": {"id": "ID_VENDEDOR", "nombre": "NOM_VENDEDOR", "label": "VENDEDOR_LABEL"},
9
+ "articulo": {"id": "ID_ARTICULO", "nombre": "NOM_ARTICULO", "label": "ARTICULO_LABEL"},
10
+ "linea": {"id": "ID_LINEA", "nombre": "NOM_LINEA", "label": "LINEA_LABEL"},
11
+ "sucursal": {"id": "COD_SUCURSAL", "nombre": "NOM_SUCURSAL", "label": "SUCURSAL_LABEL"},
12
+ "grupo": {"id": "ID_GRUPO", "nombre": "NOM_GRUPO", "label": "GRUPO_LABEL"},
13
+ "tipo": {"id": "ID_TIPO", "nombre": "NOM_TIPO", "label": "TIPO_LABEL"},
14
+ "familia": {"id": "ID_FAMILIA", "nombre": "NOM_FAMILIA", "label": "FAMILIA_LABEL"},
15
+ "pedido": {"id": "ID_PEDIDO", "nombre": None, "label": "PEDIDO_LABEL"},
16
+ "documento": {"id": "NRO_DOC", "serie": "SERIE_DOC", "tipo": "TPO_DOC"},
17
+ }
18
+
19
+ DOC_TYPE_LABELS = {
20
+ "F01": "Factura",
21
+ "F03": "Factura",
22
+ "F07": "Factura",
23
+ "F08": "Factura",
24
+ "B01": "Boleta",
25
+ "B03": "Boleta",
26
+ "B07": "Boleta",
27
+ "B08": "Boleta",
28
+ "NC01": "Nota de Credito",
29
+ "NC07": "Nota de Credito",
30
+ "NCR": "Nota de Credito",
31
+ "NDB": "Nota de Debito",
32
+ "ND01": "Nota de Debito",
33
+ "BDI": "Boleta de Intermediacion",
34
+ "T001": "Ticket",
35
+ "R01": "Recibo",
36
+ }
37
+
38
+ NC_PREFIXES = ("NC", "NCR", "NOTA")
39
+ ND_PREFIXES = ("NDB", "ND", "ND01")
40
+
41
+ REQUIRED_COLUMNS = ["SOLES", "CANTIDAD", "ID_ARTICULO", "ID_CLIENTE", "TPO_DOC", "FECHA_ORIG"]
42
+
43
+ # KNOWN ERP FORMAT (dgvVentas):
44
+ # 44 columnas fijas: ANHO, MES, ID_CLIENTE, DOC_CLIENTE, NOM_CLIENTE,
45
+ # ID_LOCALIDAD_UBIGEO, NOM_DEPARTAMENTO, NOM_PROVINCIA, NOM_DISTRITO,
46
+ # ID_LINEA, NOM_LINEA, ID_GRUPO, NOM_GRUPO, ID_TIPO, NOM_TIPO,
47
+ # ID_FAMILIA, NOM_FAMILIA, ESTADO_LINEA, ID_ARTICULO, NOM_ARTICULO,
48
+ # ID_VENDEDOR, NOM_VENDEDOR, CANAL DE DISTRIBUCION, COD_SUCURSAL,
49
+ # NOM_SUCURSAL, TPO_DOC, SERIE_DOC, NRO_DOC, ORD_COMPRA, ID_GUIA,
50
+ # FECHA_ORIG, REFERENCIA, FECHA_REF, MONEDA, CANTIDAD, SOLES,
51
+ # DOLARES, NOM_CONDICION_PAGO, ID_PEDIDO, FECHA_VENC, DIVISION,
52
+ # FEC_CARGO, DOC_CLIENTE (dup), CANTIDAD FAE
53
+ #
54
+ # TPO_DOC: F01 (factura), NCR (nota credito), NDB (nota debito), BDI (boleta)
55
+ # ESTADO_LINEA: "LINEA NUEVA", "LINEA TRADICIONAL"
56
+ # CANAL DE DISTRIBUCION: "MAYORISTA", "SIN ASIGNAR", "DISTRIBUIDOR"
57
+ # REFERENCIA: "F01/204-56287" (traceability NCR/NDB -> factura)
58
+ # FECHA_ORIG: DD/MM/YYYY
59
+ # SERIE_DOC, NRO_DOC: floats (204.0, 56287.0) — truncate decimals
60
+
61
+ ID_COLS_PRESERVE_ZEROS = (
62
+ "ID_ARTICULO", "ID_CLIENTE", "ID_VENDEDOR", "ID_LINEA",
63
+ "ID_GRUPO", "ID_TIPO", "ID_FAMILIA", "COD_SUCURSAL",
64
+ "NRO_DOC", "SERIE_DOC", "DOC_CLIENTE"
65
+ )
66
+
67
+ ID_COLS_STRIP_ZEROS = ()
68
+
69
+ # Known ERP document types — no guessing needed
70
+ ERP_TPO_DOC_SALES = ("F01", "BDI")
71
+ ERP_TPO_DOC_NC = ("NCR",)
72
+ ERP_TPO_DOC_NDB = ("NDB",)
73
+ ERP_ESTADO_LINEA = ("LINEA NUEVA", "LINEA TRADICIONAL")
74
+ ERP_CANAL_DISTRIBUCION = ("MAYORISTA", "SIN ASIGNAR", "DISTRIBUIDOR")
75
+
76
+ # Mapeo de columnas ERP críticas → etiquetas UI en español
77
+ # Usado en preview_table y donde se muestren columnas crudas
78
+ ERP_COLUMN_LABELS = {
79
+ "ANHO": "AÑO",
80
+ "MES": "MES",
81
+ "ID_CLIENTE": "ID CLIENTE",
82
+ "DOC_CLIENTE": "DOC CLIENTE",
83
+ "NOM_CLIENTE": "CLIENTE",
84
+ "ID_LOCALIDAD_UBIGEO": "UBIGEO",
85
+ "NOM_DEPARTAMENTO": "DEPARTAMENTO",
86
+ "NOM_PROVINCIA": "PROVINCIA",
87
+ "NOM_DISTRITO": "DISTRITO",
88
+ "ID_LINEA": "ID LÍNEA",
89
+ "NOM_LINEA": "LÍNEA",
90
+ "ID_GRUPO": "ID GRUPO",
91
+ "NOM_GRUPO": "GRUPO",
92
+ "ID_TIPO": "ID TIPO",
93
+ "NOM_TIPO": "TIPO",
94
+ "ID_FAMILIA": "ID FAMILIA",
95
+ "NOM_FAMILIA": "FAMILIA",
96
+ "ESTADO_LINEA": "ESTADO LÍNEA",
97
+ "ID_ARTICULO": "SKU",
98
+ "NOM_ARTICULO": "ARTÍCULO",
99
+ "ID_VENDEDOR": "ID VENDEDOR",
100
+ "NOM_VENDEDOR": "VENDEDOR",
101
+ "CANAL DE DISTRIBUCION": "CANAL",
102
+ "COD_SUCURSAL": "CÓD SUCURSAL",
103
+ "NOM_SUCURSAL": "SUCURSAL",
104
+ "TPO_DOC": "TIPO DOC",
105
+ "SERIE_DOC": "SERIE",
106
+ "NRO_DOC": "NRO DOC",
107
+ "ORD COMPRA": "ORD COMPRA",
108
+ "ID_GUIA": "ID GUÍA",
109
+ "FECHA_ORIG": "FECHA",
110
+ "REFERENCIA": "REFERENCIA",
111
+ "FECHA_REF": "FECHA REF",
112
+ "MONEDA": "MONEDA",
113
+ "CANTIDAD": "CANTIDAD",
114
+ "SOLES": "SOLES",
115
+ "DOLARES": "DÓLARES",
116
+ "NOM_CONDICION_PAGO": "COND PAGO",
117
+ "ID_PEDIDO": "ID PEDIDO",
118
+ "FECHA_VENC": "FECHA VENC",
119
+ "DIVISION": "DIVISIÓN",
120
+ "FEC_CARGO": "FECHA CARGO",
121
+ "CANTIDAD FAE": "CANT FAE",
122
+ }
123
+
124
+
125
+ def clean_erp_headers(df: pd.DataFrame) -> pd.DataFrame:
126
+ """
127
+ Identifica y limpia los headers de un archivo ERP Vinifan.
128
+ Ignora logos, filas vacias y basura del ERP.
129
+ Estandariza nombres de columnas con espacios a guiones bajos.
130
+ """
131
+ for idx, row in df.iterrows():
132
+ row_str = " ".join([str(v).strip().upper() for v in row if pd.notna(v)])
133
+ if any(kw in row_str for kw in ["ID_VENDEDOR", "ID_CLIENTE", "ID_ARTICULO", "NOM_VENDEDOR"]):
134
+ df.columns = df.iloc[idx]
135
+ df = df.iloc[idx + 1:].reset_index(drop=True)
136
+ break
137
+
138
+ new_cols = []
139
+ for i, c in enumerate(df.columns):
140
+ c_str = str(c).strip() if pd.notna(c) else ""
141
+ c_upper = c_str.upper()
142
+ if not c_str or c_upper in ("NAN", "NONE") or c_upper.startswith("UNNAMED"):
143
+ new_cols.append(f"UNNAMED_{i}")
144
+ else:
145
+ new_cols.append(c_upper.replace(" ", "_"))
146
+ df.columns = new_cols
147
+
148
+ # Renombrar columnas duplicadas (ej: DOC_CLIENTE -> DOC_CLIENTE, DOC_CLIENTE_2)
149
+ seen = {}
150
+ final_cols = []
151
+ for c in df.columns:
152
+ if c in seen:
153
+ seen[c] += 1
154
+ final_cols.append(f"{c}_{seen[c]}")
155
+ else:
156
+ seen[c] = 0
157
+ final_cols.append(c)
158
+ df.columns = final_cols
159
+
160
+ df = df.dropna(how="all")
161
+
162
+ return df
163
+
164
+
165
+ def validate_columns(df: pd.DataFrame) -> list[str]:
166
+ """
167
+ Valida que el DataFrame tenga las columnas ERP minimas requeridas.
168
+ Retorna lista de columnas faltantes. Lista vacia = todo OK.
169
+ """
170
+ df_cols_upper = {c.upper() for c in df.columns}
171
+ return [col for col in REQUIRED_COLUMNS if col.upper() not in df_cols_upper]
172
+
173
+
174
+ def normalize_ids(df: pd.DataFrame, column: str) -> pd.DataFrame:
175
+ """
176
+ Normaliza IDs: elimina espacios y caracteres especiales.
177
+ Preserva ceros a la izquierda para columnas críticas.
178
+ Trunca decimales de floats (204.0 -> 204, no 2040).
179
+
180
+ Para columnas en ID_COLS_PRESERVE_ZEROS, los ceros a la izquierda
181
+ se mantienen intactos (ej: "01240" sigue siendo "01240").
182
+ Para otras columnas, se eliminan ceros iniciales no significativos.
183
+
184
+ Args:
185
+ df: DataFrame a normalizar
186
+ column: Nombre de la columna a normalizar
187
+
188
+ Returns:
189
+ DataFrame con columna normalizada
190
+ """
191
+ if column not in df.columns:
192
+ return df
193
+
194
+ preserve_zeros = column.upper() in ID_COLS_PRESERVE_ZEROS
195
+
196
+ def _clean_id(val):
197
+ if pd.isna(val):
198
+ return ""
199
+ s = str(val).strip()
200
+ # Si es float como "204.0", truncar a entero SIN perder ceros a la izquierda
201
+ if "." in s:
202
+ try:
203
+ f = float(s)
204
+ if f == int(f):
205
+ # Para columnas que preservan ceros, usar zfill después
206
+ s = str(int(f)) if not preserve_zeros else s.split(".")[0].zfill(len(s.split(".")[0]))
207
+ except (ValueError, OverflowError):
208
+ pass
209
+ # Eliminar solo caracteres no alfanuméricos (excepto guiones)
210
+ s = re.sub(r"[^A-Z0-9\-]", "", s.upper())
211
+ # Para columnas que NO preservan ceros, eliminar ceros iniciales
212
+ if not preserve_zeros and s:
213
+ s = s.lstrip('0')
214
+ return s
215
+
216
+ df[column] = df[column].apply(_clean_id)
217
+ return df
218
+
219
+
220
+ def format_entity_label(id_val: str, name_val: str) -> str:
221
+ """
222
+ Crea etiqueta compuesta ID - NOMBRE para display en UI.
223
+
224
+ Ejemplos:
225
+ "68414", "PRIMAVERA DISTRIBUIDORES" -> "68414 - PRIMAVERA DISTRIBUIDORES"
226
+ "01178", "MILCA SARAY" -> "01178 - MILCA SARAY"
227
+ "", "Solo Nombre" -> "Solo Nombre"
228
+ "12345", "" -> "12345"
229
+ """
230
+ id_str = str(id_val).strip() if pd.notna(id_val) else ""
231
+ name_str = str(name_val).strip() if pd.notna(name_val) else ""
232
+
233
+ if id_str and name_str:
234
+ return f"{id_str} - {name_str}"
235
+ return id_str or name_str
236
+
237
+
238
+ def build_entity_labels(df: pd.DataFrame) -> pd.DataFrame:
239
+ """
240
+ Agrega columnas _LABEL para todas las entidades con ID + nombre.
241
+
242
+ Columnas creadas:
243
+ CLIENTE_LABEL, VENDEDOR_LABEL, ARTICULO_LABEL, LINEA_LABEL,
244
+ SUCURSAL_LABEL, GRUPO_LABEL, TIPO_LABEL, FAMILIA_LABEL, PEDIDO_LABEL
245
+ """
246
+ label_entities = [
247
+ ("cliente", "ID_CLIENTE", "NOM_CLIENTE", "CLIENTE_LABEL"),
248
+ ("vendedor", "ID_VENDEDOR", "NOM_VENDEDOR", "VENDEDOR_LABEL"),
249
+ ("articulo", "ID_ARTICULO", "NOM_ARTICULO", "ARTICULO_LABEL"),
250
+ ("linea", "ID_LINEA", "NOM_LINEA", "LINEA_LABEL"),
251
+ ("sucursal", "COD_SUCURSAL", "NOM_SUCURSAL", "SUCURSAL_LABEL"),
252
+ ("grupo", "ID_GRUPO", "NOM_GRUPO", "GRUPO_LABEL"),
253
+ ("tipo", "ID_TIPO", "NOM_TIPO", "TIPO_LABEL"),
254
+ ("familia", "ID_FAMILIA", "NOM_FAMILIA", "FAMILIA_LABEL"),
255
+ ]
256
+
257
+ for _, id_col, name_col, label_col in label_entities:
258
+ id_exists = id_col in df.columns
259
+ name_exists = name_col in df.columns
260
+
261
+ if id_exists and name_exists:
262
+ df[label_col] = df.apply(
263
+ lambda r: format_entity_label(r[id_col], r[name_col]),
264
+ axis=1,
265
+ )
266
+ elif id_exists:
267
+ df[label_col] = df[id_col].astype(str).str.strip()
268
+ elif name_exists:
269
+ df[label_col] = df[name_col].astype(str).str.strip()
270
+
271
+ if "ID_PEDIDO" in df.columns:
272
+ df["PEDIDO_LABEL"] = df["ID_PEDIDO"].astype(str).str.strip()
273
+
274
+ # Asegurar CLIENTE_UI para compatibilidad con vistas (se usa en dashboard_helpers, etc.)
275
+ if "CLIENTE_LABEL" in df.columns:
276
+ df["CLIENTE_UI"] = df["CLIENTE_LABEL"]
277
+
278
+ return df
279
+
280
+
281
+ def format_documento(tipo: str, serie: str, numero: str) -> str:
282
+ """
283
+ Concatena tipo + serie + numero en formato legible.
284
+
285
+ Usa solo la primera letra del tipo para el formato compacto:
286
+ F01 + 204 + 55238 -> "F204-55238"
287
+ NCR + 215 + 29845 -> "N215-29845 (NC)"
288
+ NDB + 214 + 3843 -> "N214-3843 (NC)"
289
+ BDI + 203 + 52481 -> "B203-52481"
290
+ """
291
+ if not tipo or not serie or not numero:
292
+ return ""
293
+
294
+ tipo = str(tipo).strip().upper()
295
+ serie = str(serie).strip()
296
+ numero = str(numero).strip()
297
+
298
+ primer_char = tipo[0] if tipo else ""
299
+ doc_base = f"{primer_char}{serie}-{numero}"
300
+
301
+ if tipo.startswith(NC_PREFIXES):
302
+ doc_base += " (NC)"
303
+
304
+ return doc_base
305
+
306
+
307
+ def build_doc_completo(df: pd.DataFrame) -> pd.DataFrame:
308
+ """
309
+ Agrega columna DOC_COMPLETO al DataFrame concatenando
310
+ TPO_DOC + SERIE_DOC + NRO_DOC con formato legible.
311
+ """
312
+ tpo_col = next((c for c in df.columns if "TPO_DOC" in c), None)
313
+ serie_col = next((c for c in df.columns if "SERIE_DOC" in c), None)
314
+ nro_col = next((c for c in df.columns if "NRO_DOC" in c), None)
315
+
316
+ if not all([tpo_col, serie_col, nro_col]):
317
+ return df
318
+
319
+ df["DOC_COMPLETO"] = df.apply(
320
+ lambda r: format_documento(
321
+ str(r.get(tpo_col, "")),
322
+ str(r.get(serie_col, "")),
323
+ str(r.get(nro_col, "")),
324
+ ),
325
+ axis=1,
326
+ )
327
+
328
+ return df
329
+
330
+
331
+ def get_entity_columns(entity: str) -> dict:
332
+ """
333
+ Retorna el mapeo de columnas ID + nombre + label para una entidad.
334
+ """
335
+ return ENTITY_MAP.get(entity, {})
336
+
337
+
338
+ def detectar_tipo_documento(df: pd.DataFrame) -> Optional[str]:
339
+ """Detecta el tipo de documento (Factura, Boleta, NC) basado en TPO_DOC."""
340
+ col = next((c for c in df.columns if "TPO_DOC" in c), None)
341
+ if col is None:
342
+ return None
343
+
344
+ valores = df[col].dropna().unique()
345
+ tipos = set()
346
+ for v in valores:
347
+ v_str = str(v).upper().strip()
348
+ if v_str.startswith("F"):
349
+ tipos.add("FACTURA")
350
+ elif v_str.startswith("B"):
351
+ tipos.add("BOLETA")
352
+ elif v_str.startswith(NC_PREFIXES) or "NOTA" in v_str:
353
+ tipos.add("NC")
354
+
355
+ return ", ".join(tipos) if tipos else None
356
+
357
+
358
+ def get_doc_label(tipo_doc: str) -> str:
359
+ """
360
+ Retorna la etiqueta legible para un tipo de documento.
361
+ """
362
+ return DOC_TYPE_LABELS.get(str(tipo_doc).upper().strip(), str(tipo_doc))
363
+
364
+
365
+ def parse_excel_date(value) -> Optional[pd.Timestamp]:
366
+ """
367
+ Convierte fecha estilo Excel a datetime.
368
+
369
+ Soporta 3 formatos:
370
+ 1. Numero serial Excel (45454 -> 2024-07-15)
371
+ 2. String DD/MM/YYYY ("02/04/2026" -> 2026-04-02)
372
+ 3. String YYYY-MM-DD ("2026-04-02" -> 2026-04-02)
373
+
374
+ Para numeros seriales Excel:
375
+ Excel usa dias desde 1900-01-01 con bug del ano 1900.
376
+ Formula: datetime(1899, 12, 30) + timedelta(days=serial)
377
+ """
378
+ if pd.isna(value):
379
+ return None
380
+
381
+ if isinstance(value, pd.Timestamp):
382
+ return value
383
+
384
+ if isinstance(value, (int, float)):
385
+ try:
386
+ return pd.Timestamp("1899-12-30") + pd.Timedelta(days=value)
387
+ except Exception:
388
+ return None
389
+
390
+ text = str(value).strip()
391
+ if not text:
392
+ return None
393
+
394
+ # Verificar si es una representacion en texto de un numero serial (ej: "45454" o "45454.0")
395
+ if re.match(r"^\d+(\.\d+)?$", text):
396
+ try:
397
+ serial_val = float(text)
398
+ if 0 < serial_val < 1000000:
399
+ return pd.Timestamp("1899-12-30") + pd.Timedelta(days=serial_val)
400
+ except Exception:
401
+ pass
402
+
403
+ if re.match(r"^\d{1,2}/\d{1,2}/\d{2,4}$", text):
404
+ try:
405
+ return pd.to_datetime(text, dayfirst=True)
406
+ except Exception:
407
+ return None
408
+
409
+ if re.match(r"^\d{4}-\d{1,2}-\d{1,2}", text):
410
+ try:
411
+ return pd.to_datetime(text)
412
+ except Exception:
413
+ return None
414
+
415
+ try:
416
+ return pd.to_datetime(text, dayfirst=True)
417
+ except Exception:
418
+ return None
419
+
420
+
421
+ def parse_mes_column(mes_str: str, anho: str = None) -> tuple:
422
+ """
423
+ Parsea la columna MES formato '04-ABRIL' -> (4, 'ABRIL', 'YYYY-MM').
424
+ Si anho no se provee, usa el anio actual.
425
+ """
426
+ if pd.isna(mes_str):
427
+ return None, None, None
428
+
429
+ parts = str(mes_str).strip().split("-")
430
+ if len(parts) == 2:
431
+ mes_num = int(parts[0])
432
+ mes_nom = parts[1].upper()
433
+ year = anho if anho else str(pd.Timestamp.now().year)
434
+ return mes_num, mes_nom, f"{year}-{mes_num:02d}"
435
+ return None, None, None
@@ -93,7 +93,7 @@ class TestEstabilizarExcelCrudo(unittest.TestCase):
93
93
  def test_metadata_creada(self):
94
94
  df, meta = _patched_ingestion(_SAMPLE_DF)
95
95
  self.assertIn("filas_estabilizadas", meta)
96
- self.assertIn("columnas", meta)
96
+ self.assertIn("columnas_finales", meta)
97
97
  self.assertEqual(meta["filas_estabilizadas"], 2)
98
98
 
99
99
  def test_soles_sin_nan(self):
package/src/cli.js CHANGED
@@ -16,6 +16,9 @@ import { health } from './commands/health.js';
16
16
  import { update } from './commands/update.js';
17
17
  import { convert } from './commands/convert.js';
18
18
  import { signature } from './commands/signature.js';
19
+ import { scan } from './commands/scan.js';
20
+ import { validate } from './commands/validate.js';
21
+ import { ingest } from './commands/ingest.js';
19
22
 
20
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
24
  const pkg = fs.readJsonSync(path.join(__dirname, '../package.json'));
@@ -116,7 +119,27 @@ program
116
119
  .option('-m, --mode <mode>', 'Signature mode: own or powered', 'powered')
117
120
  .option('-v, --version <version>', 'Version to display')
118
121
  .option('--position <position>', 'Signature position: bottom-right, bottom-left, bottom-center, footer-right, footer-left', 'bottom-right')
119
- .option('-i, --interactive', 'Interactive mode with guided suggestions')
120
- .action(signature);
122
+ .option('-i, --interactive', 'Interactive mode with guided suggestions')
123
+ .action(signature);
124
+
125
+ // Comandos de procesamiento ERP
126
+ program
127
+ .command('scan')
128
+ .argument('<directorio>', 'Directorio a escanear')
129
+ .option('-r, --recursive', 'Buscar recursivamente', true)
130
+ .option('--min-score <n>', 'Puntuación mínima', '10')
131
+ .action(scan);
132
+
133
+ program
134
+ .command('validate')
135
+ .argument('<paths...>', 'Archivos o directorios a validar')
136
+ .option('-r, --recursive', 'Buscar recursivamente en directorios')
137
+ .action(validate);
138
+
139
+ program
140
+ .command('ingest')
141
+ .argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
142
+ .option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
143
+ .action(ingest);
121
144
 
122
145
  program.parse();
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Comando: g360 ingest <archivo|directorio> [-o salida.csv]
4
+ * Procesa archivos ERP y genera maestro_ventas_crm.csv
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import chalk from 'chalk';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { spawn } from 'child_process';
12
+ import fs from 'fs-extra';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ export = (program: Command) => {
17
+ program
18
+ .command('ingest')
19
+ .argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
20
+ .option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
21
+ .action(async (input, options) => {
22
+ console.log(chalk.blue(`\n🚀 Iniciando ingesta: ${input}`));
23
+
24
+ const inputPath = path.resolve(input);
25
+ const outputPath = path.resolve(options.output);
26
+
27
+ // Determinar si es archivo o directorio
28
+ try {
29
+ await fs.access(inputPath);
30
+ } catch {
31
+ console.error(chalk.red(`❌ Ruta no encontrada: ${inputPath}`));
32
+ process.exit(1);
33
+ }
34
+
35
+ const stat = await fs.stat(inputPath);
36
+ let filepaths: string[];
37
+
38
+ if (stat.isFile()) {
39
+ filepaths = [inputPath];
40
+ } else if (stat.isDirectory()) {
41
+ console.log(chalk.gray(`📁 Escaneando directorio...`));
42
+ const { valid } = await scanDirectory(inputPath);
43
+ filepaths = valid.map((info: any) => info.path);
44
+ if (filepaths.length === 0) {
45
+ console.error(chalk.red('❌ No se encontraron archivos ERP válidos en el directorio'));
46
+ process.exit(1);
47
+ }
48
+ console.log(chalk.green(` Encontrados ${filepaths.length} archivos válidos`));
49
+ } else {
50
+ console.error(chalk.red(`❌ Ruta no válida: ${inputPath}`));
51
+ process.exit(1);
52
+ }
53
+
54
+ // Procesar archivos
55
+ console.log(chalk.blue(`\n⚙️ Procesando ${filepaths.length} archivo(s)...`));
56
+
57
+ try {
58
+ const combinedCsv = await runBatchIngest(filepaths);
59
+
60
+ // Escribir salida
61
+ await fs.ensureDir(path.dirname(outputPath));
62
+ await fs.writeFile(outputPath, combinedCsv, 'utf-8');
63
+ console.log(chalk.green(`\n✅ Ingesta completada: ${outputPath}`));
64
+
65
+ // Resumen por archivo
66
+ const lines = combinedCsv.split('\n');
67
+ const header = lines[0];
68
+ const dataLines = lines.filter(l => l && l !== header);
69
+ console.log(chalk.gray(` Total filas: ${dataLines.length}`));
70
+
71
+ // Conteo por ARCHIVO_ORIGEN
72
+ const counts = new Map<string, number>();
73
+ for (const line of dataLines) {
74
+ const cols = line.split(',');
75
+ const idx = header.split(',').indexOf('ARCHIVO_ORIGEN');
76
+ if (idx !== -1 && cols[idx]) {
77
+ counts.set(cols[idx], (counts.get(cols[idx]) || 0) + 1);
78
+ }
79
+ }
80
+ if (counts.size > 0) {
81
+ console.log(chalk.cyan('\n📊 Resumen por archivo:'));
82
+ for (const [file, count] of counts) {
83
+ console.log(` ${file}: ${count} filas`);
84
+ }
85
+ }
86
+
87
+ } catch (err: any) {
88
+ console.error(chalk.red('\n❌ Error durante la ingesta:'), err.message);
89
+ if (err.stdout) console.error(chalk.gray(err.stdout));
90
+ if (err.stderr) console.error(chalk.red(err.stderr));
91
+ process.exit(1);
92
+ }
93
+ });
94
+ };
95
+
96
+ async function scanDirectory(dir: string): Promise<{ valid: any[]; invalid: any[] }> {
97
+ const pyCode = `
98
+ import sys
99
+ sys.path.insert(0, '${path.join(__dirname, '..', 'py', 'src')}')
100
+ from g360_core.scanner import find_erp_files_in_dir
101
+ from pathlib import Path
102
+
103
+ valid, invalid = find_erp_files_in_dir(Path('${dir}'), recursive=True)
104
+ for v in valid:
105
+ print(f"VALID::{v.path.name}::{v.erp_type}::${v.size_bytes}")
106
+ for i in invalid[:10]:
107
+ print(f"INVALID::{i.path.name}::${i.error_msg}")
108
+ `;
109
+
110
+ const result = await runPython(pyCode);
111
+ const lines = result.stdout.split('\n').filter(l => l.trim());
112
+ const valid: any[] = [];
113
+ const invalid: any[] = [];
114
+
115
+ for (const line of lines) {
116
+ if (line.startsWith('VALID::')) {
117
+ const [, name, type, size] = line.split('::');
118
+ valid.push({ path: path.join(dir, name), erp_type: type, size_bytes: parseInt(size) });
119
+ } else if (line.startsWith('INVALID::')) {
120
+ const [, name, error] = line.split('::');
121
+ invalid.push({ path: path.join(dir, name), error_msg: error });
122
+ }
123
+ }
124
+
125
+ return { valid, invalid };
126
+ }
127
+
128
+ async function runBatchIngest(filepaths: string[]): Promise<string> {
129
+ const pyCode = `
130
+ import sys
131
+ sys.path.insert(0, '${path.join(__dirname, '..', 'py', 'src')}')
132
+ from g360_core.scanner import batch_process_files
133
+ from pathlib import Path
134
+ import pandas as pd
135
+
136
+ filepaths = [${JSON.stringify(filepaths).replace(/"/g, "'")}]
137
+ df = batch_process_files([Path(p) for p in filepaths], merge_results=True)
138
+ # Output as CSV to stdout
139
+ sys.stdout.write(df.to_csv(index=False))
140
+ `;
141
+
142
+ return new Promise((resolve, reject) => {
143
+ const pyExec = process.env.PYTHON || 'python3';
144
+ const proc = spawn(pyExec, ['-c', pyCode], {
145
+ stdio: ['ignore', 'pipe', 'pipe'],
146
+ });
147
+
148
+ let stdout = '';
149
+ let stderr = '';
150
+
151
+ proc.stdout?.on('data', (data) => { stdout += data.toString(); });
152
+ proc.stderr?.on('data', (data) => { stderr += data.toString(); });
153
+
154
+ proc.on('close', (code) => {
155
+ if (code === 0) {
156
+ resolve(stdout);
157
+ } else {
158
+ reject(new Error(stderr || `Python terminó con código ${code}`));
159
+ }
160
+ });
161
+
162
+ proc.on('error', (err) => {
163
+ reject(new Error(`No se pudo ejecutar Python: ${err.message}`));
164
+ });
165
+ });
166
+ }
167
+
168
+ function runPython(code: string): Promise<{ stdout: string; stderr: string }> {
169
+ return new Promise((resolve, reject) => {
170
+ const pyExec = process.env.PYTHON || 'python3';
171
+ const proc = spawn(pyExec, ['-c', code], {
172
+ stdio: ['ignore', 'pipe', 'pipe'],
173
+ });
174
+
175
+ let stdout = '';
176
+ let stderr = '';
177
+
178
+ proc.stdout?.on('data', (data) => { stdout += data.toString(); });
179
+ proc.stderr?.on('data', (data) => { stderr += data.toString(); });
180
+
181
+ proc.on('close', (code) => {
182
+ if (code === 0) {
183
+ resolve({ stdout, stderr });
184
+ } else {
185
+ reject(new Error(stderr || `Python terminó con código ${code}`));
186
+ }
187
+ });
188
+
189
+ proc.on('error', (err) => {
190
+ reject(new Error(`No se pudo ejecutar Python: ${err.message}`));
191
+ });
192
+ });
193
+ }