g360-cli 1.7.1 → 1.10.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 (54) hide show
  1. package/README.md +83 -8
  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/cli.js +35 -2
  35. package/src/commands/addon.js +188 -0
  36. package/src/commands/ingest.js +187 -0
  37. package/src/commands/scan.js +90 -0
  38. package/src/commands/validate.js +150 -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
  43. package/src/assets/engine/g360-data-validator.js +0 -44
  44. package/src/assets/engine/g360-engine.js +0 -12
  45. package/src/assets/engine/g360-field-mapper.js +0 -35
  46. package/src/assets/engine/g360-skill-audit.mjs +0 -37
  47. package/src/assets/engine/g360-skill-meta-evaluator.mjs +0 -33
  48. package/src/lib/assets.js +0 -38
  49. package/src/lib/checksum.js +0 -27
  50. package/src/lib/config.js +0 -23
  51. package/src/lib/offline.js +0 -33
  52. package/src/lib/presenter.js +0 -24
  53. package/src/lib/rollback.js +0 -49
  54. package/src/lib/theme.js +0 -30
@@ -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
package/src/cli.js CHANGED
@@ -16,6 +16,10 @@ 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';
22
+ import { addon } from './commands/addon.js';
19
23
 
20
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
25
  const pkg = fs.readJsonSync(path.join(__dirname, '../package.json'));
@@ -116,7 +120,36 @@ program
116
120
  .option('-m, --mode <mode>', 'Signature mode: own or powered', 'powered')
117
121
  .option('-v, --version <version>', 'Version to display')
118
122
  .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);
123
+ .option('-i, --interactive', 'Interactive mode with guided suggestions')
124
+ .action(signature);
125
+
126
+ // Comandos de procesamiento ERP
127
+ program
128
+ .command('scan')
129
+ .argument('<directorio>', 'Directorio a escanear')
130
+ .option('-r, --recursive', 'Buscar recursivamente', true)
131
+ .option('--min-score <n>', 'Puntuación mínima', '10')
132
+ .action(scan);
133
+
134
+ program
135
+ .command('validate')
136
+ .argument('<paths...>', 'Archivos o directorios a validar')
137
+ .option('-r, --recursive', 'Buscar recursivamente en directorios')
138
+ .action(validate);
139
+
140
+ program
141
+ .command('ingest')
142
+ .argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
143
+ .option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
144
+ .action(ingest);
145
+
146
+ program
147
+ .command('addon')
148
+ .argument('<command>', 'Command: install, list, remove')
149
+ .argument('[package]', 'Package name to install')
150
+ .option('-p, --path <path>', 'Target path', '.')
151
+ .option('--dry-run', 'Preview without installing')
152
+ .option('--force', 'Force reinstall/remove')
153
+ .action(addon);
121
154
 
122
155
  program.parse();
@@ -0,0 +1,188 @@
1
+ import chalk from 'chalk';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const execAsync = promisify(exec);
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ const ADDON_REGISTRY = {
12
+ '@google/design.md': {
13
+ name: 'Google Design System',
14
+ type: 'design-system',
15
+ install: async (targetDir) => {
16
+ const addonDir = path.join(targetDir, 'g360', 'addons', 'google-design');
17
+ fs.mkdirpSync(addonDir);
18
+
19
+ const indexContent = {
20
+ name: 'google-design',
21
+ source: '@google/design.md',
22
+ installedAt: new Date().toISOString(),
23
+ files: [
24
+ 'tokens.json',
25
+ 'components.css',
26
+ 'md3.css'
27
+ ]
28
+ };
29
+
30
+ fs.writeJsonSync(path.join(addonDir, 'addon.json'), indexContent, { spaces: 2 });
31
+ return addonDir;
32
+ }
33
+ },
34
+ '@m3/material': {
35
+ name: 'Material 3',
36
+ type: 'design-system',
37
+ install: async (targetDir) => {
38
+ const addonDir = path.join(targetDir, 'g360', 'addons', 'm3-material');
39
+ fs.mkdirpSync(addonDir);
40
+ fs.writeJsonSync(path.join(addonDir, 'addon.json'), {
41
+ name: 'm3-material',
42
+ source: '@m3/material',
43
+ installedAt: new Date().toISOString()
44
+ }, { spaces: 2 });
45
+ return addonDir;
46
+ }
47
+ }
48
+ };
49
+
50
+ export async function addon(command, options) {
51
+ const { package: pkg, path: targetPath = '.', dryRun = false, force = false } = options;
52
+
53
+ console.log(chalk.bold.cyan('\nšŸ“¦ G360 Addon Manager\n'));
54
+
55
+ if (command === 'install' || command === 'add') {
56
+ return installAddon(pkg, targetPath, dryRun, force);
57
+ }
58
+
59
+ if (command === 'list') {
60
+ return listAddons(targetPath);
61
+ }
62
+
63
+ if (command === 'remove' || command === 'uninstall') {
64
+ return removeAddon(pkg, targetPath, force);
65
+ }
66
+
67
+ console.log(chalk.yellow('Usage:'));
68
+ console.log(chalk.gray(' g360 addon install <package>'));
69
+ console.log(chalk.gray(' g360 addon list'));
70
+ console.log(chalk.gray(' g360 addon remove <package>'));
71
+ }
72
+
73
+ async function installAddon(pkg, targetPath, dryRun, force) {
74
+ if (!pkg) {
75
+ console.error(chalk.red('āŒ Package name required'));
76
+ console.log(chalk.gray('\nAvailable addons:'));
77
+ Object.entries(ADDON_REGISTRY).forEach(([key, value]) => {
78
+ console.log(chalk.gray(` - ${key} (${value.name})`));
79
+ });
80
+ return;
81
+ }
82
+
83
+ const targetDir = path.resolve(process.cwd(), targetPath);
84
+ const addonDir = path.join(targetDir, 'g360', 'addons', pkg.replace('@', '').replace('/', '-'));
85
+
86
+ if (fs.existsSync(addonDir) && !force) {
87
+ console.error(chalk.red(`āŒ Addon "${pkg}" already installed`));
88
+ console.log(chalk.gray('Use --force to reinstall'));
89
+ return;
90
+ }
91
+
92
+ if (dryRun) {
93
+ console.log(chalk.yellow('šŸ“‹ DRY RUN - Would install:'));
94
+ console.log(chalk.gray(` Package: ${pkg}`));
95
+ console.log(chalk.gray(` Target: ${addonDir}`));
96
+ return;
97
+ }
98
+
99
+ const registryEntry = ADDON_REGISTRY[pkg];
100
+
101
+ try {
102
+ fs.mkdirpSync(addonDir);
103
+
104
+ if (registryEntry?.install) {
105
+ await registryEntry.install(targetDir);
106
+ } else {
107
+ fs.writeJsonSync(path.join(addonDir, 'addon.json'), {
108
+ name: pkg,
109
+ source: pkg,
110
+ installedAt: new Date().toISOString(),
111
+ type: 'external'
112
+ }, { spaces: 2 });
113
+ }
114
+
115
+ const manifestPath = path.join(targetDir, 'g360', 'manifest.json');
116
+ if (fs.existsSync(manifestPath)) {
117
+ const manifest = fs.readJsonSync(manifestPath);
118
+ if (!manifest.addons) manifest.addons = [];
119
+ manifest.addons.push({ name: pkg, installedAt: new Date().toISOString() });
120
+ fs.writeJsonSync(manifestPath, manifest, { spaces: 2 });
121
+ }
122
+
123
+ console.log(chalk.green(`\nāœ… Addon "${pkg}" installed successfully`));
124
+ console.log(chalk.gray(` Location: ${addonDir}`));
125
+ } catch (error) {
126
+ console.error(chalk.red(`\nāŒ Error installing addon: ${error.message}`));
127
+ }
128
+ }
129
+
130
+ async function listAddons(targetPath) {
131
+ const targetDir = path.resolve(process.cwd(), targetPath);
132
+ const addonsDir = path.join(targetDir, 'g360', 'addons');
133
+
134
+ if (!fs.existsSync(addonsDir)) {
135
+ console.log(chalk.gray('No addons installed'));
136
+ return;
137
+ }
138
+
139
+ const addons = fs.readdirSync(addonsDir);
140
+
141
+ if (addons.length === 0) {
142
+ console.log(chalk.gray('No addons installed'));
143
+ return;
144
+ }
145
+
146
+ console.log(chalk.bold.yellow('\nšŸ“¦ Installed Addons:'));
147
+ for (const addon of addons) {
148
+ const addonJsonPath = path.join(addonsDir, addon, 'addon.json');
149
+ if (fs.existsSync(addonJsonPath)) {
150
+ const addonData = fs.readJsonSync(addonJsonPath);
151
+ console.log(chalk.gray(` - ${addonData.name || addon}`));
152
+ console.log(chalk.gray(` Source: ${addonData.source || 'external'}`));
153
+ console.log(chalk.gray(` Installed: ${addonData.installedAt || 'unknown'}`));
154
+ }
155
+ }
156
+ }
157
+
158
+ async function removeAddon(pkg, targetPath, force) {
159
+ if (!pkg) {
160
+ console.error(chalk.red('āŒ Package name required'));
161
+ return;
162
+ }
163
+
164
+ const targetDir = path.resolve(process.cwd(), targetPath);
165
+ const addonDir = path.join(targetDir, 'g360', 'addons', pkg.replace('@', '').replace('/', '-'));
166
+
167
+ if (!fs.existsSync(addonDir)) {
168
+ console.error(chalk.red(`āŒ Addon "${pkg}" not found`));
169
+ return;
170
+ }
171
+
172
+ try {
173
+ fs.removeSync(addonDir);
174
+
175
+ const manifestPath = path.join(targetDir, 'g360', 'manifest.json');
176
+ if (fs.existsSync(manifestPath)) {
177
+ const manifest = fs.readJsonSync(manifestPath);
178
+ if (manifest.addons) {
179
+ manifest.addons = manifest.addons.filter(a => a.name !== pkg);
180
+ fs.writeJsonSync(manifestPath, manifest, { spaces: 2 });
181
+ }
182
+ }
183
+
184
+ console.log(chalk.green(`\nāœ… Addon "${pkg}" removed successfully`));
185
+ } catch (error) {
186
+ console.error(chalk.red(`\nāŒ Error removing addon: ${error.message}`));
187
+ }
188
+ }