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,578 @@
1
+ """
2
+ Pipeline de Ingesta para CRM Auxiliar — Plan Maestro 4 Fases.
3
+
4
+ Transforma el CSV plano de dgvVentas del ERP en un DataFrame maestro
5
+ de 12 columnas listo para dashboarding, IA y alimentar la UI de Flet.
6
+
7
+ Fases:
8
+ 1. Saneamiento de la Ingesta (elimina duplicados, unifica moneda, crea FECHA_PROCESO)
9
+ 2. Resolución de Jerarquía de Clientes (EMP_RUC, SUCURSAL_ID, CLIENTE_UI)
10
+ 3. Tipificación Comercial y Trazabilidad (CATEGORIA_OP, STOCK_DORMIDO)
11
+ 4. Exportación del Molde Inmutable (12 columnas destino exactas)
12
+ """
13
+
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import pandas as pd
18
+ import numpy as np
19
+
20
+ from .logger import get_logger
21
+ from .commercial_engine import (
22
+ classify_base,
23
+ parse_referencia,
24
+ resolve_document_relationships,
25
+ calculate_prices,
26
+ )
27
+
28
+ log = get_logger("ingestion")
29
+
30
+ # ─── Constantes ──────────────────────────────────────────────────────────────
31
+
32
+ MESES_ESP = {
33
+ "ENERO": "01", "FEBRERO": "02", "MARZO": "03", "ABRIL": "04",
34
+ "MAYO": "05", "JUNIO": "06", "JULIO": "07", "AGOSTO": "08",
35
+ "SETIEMBRE": "09", "OCTUBRE": "10", "NOVIEMBRE": "11", "DICIEMBRE": "12",
36
+ }
37
+
38
+ MOLDE_INMUTABLE = [
39
+ "FECHA_PROCESO",
40
+ "EMPRESA_RUC", # RUC o DNI del cliente
41
+ "CLIENTE_TIPO_DOC", # "RUC" o "DNI"
42
+ "EMPRESA_NOM", # Nombre legal del cliente
43
+ "SUCURSAL_ID", # ID único de sucursal
44
+ "CLIENTE_UI", # Nombre display con sucursal
45
+ "PRODUCTO_NOM", # Nombre del artículo
46
+ "VENDEDOR_NOM", # Nombre del vendedor
47
+ "TIPO_DOC", # Tipo de comprobante (F01, BDI, NCR, NDB, etc.)
48
+ "CATEGORIA_OP", # VENTA, DEVOLUCION, AJUSTE
49
+ "SUBTIPO_AJUSTE", # PRECIO_LINEA, PRECIO_PARCIAL, CARGO_FIJO, SIN_BASE
50
+ "CANT_FISICA", # Cantidad física (positiva o negativa)
51
+ "NETO_SOLES", # Monto en soles
52
+ "CANTIDAD_FAE", # Cantidad financiera FAE (para ajustes de precio)
53
+ "PRECIO_BASE", # Precio unitario base (solo movimientos físicos)
54
+ "PRECIO_EFECTIVO", # Precio base + recargo unitario
55
+ "STOCK_DORMIDO", # 1 si stock dormido (>90 días), 0 si no
56
+ ]
57
+
58
+ COLUMNAS_ORIGEN_REQUERIDAS = {
59
+ "ANHO", "MES", "DOC_CLIENTE", "NOM_CLIENTE", "COD_SUCURSAL",
60
+ "NOM_SUCURSAL", "ID_ARTICULO", "NOM_ARTICULO", "ID_VENDEDOR",
61
+ "NOM_VENDEDOR", "TPO_DOC", "REFERENCIA", "FECHA_ORIG", "FECHA_REF",
62
+ "CANTIDAD", "SOLES",
63
+ }
64
+
65
+
66
+ # ═══════════════════════════════════════════════════════════════════════════════
67
+ # FASE 1: SANEAMIENTO DE LA INGESTA
68
+ # ═══════════════════════════════════════════════════════════════════════════════
69
+
70
+ def clean_duplicate_columns(df: pd.DataFrame) -> pd.DataFrame:
71
+ """
72
+ Elimina columnas duplicadas por nombre.
73
+
74
+ El ERP exporta DOC_CLIENTE dos veces (cols 3 y 42).
75
+ La segunda aparicion (DOC_CLIENTE.1) se descarta.
76
+
77
+ Returns:
78
+ DataFrame sin columnas repetidas.
79
+ """
80
+ cols = df.columns.tolist()
81
+ vistos = {}
82
+ keep = []
83
+ for i, c in enumerate(cols):
84
+ key = c.strip().lower()
85
+ if key in vistos:
86
+ log.info("Columna duplicada eliminada: '%s' (idx %d)", c, i)
87
+ else:
88
+ vistos[key] = i
89
+ keep.append(c)
90
+ return df[keep]
91
+
92
+
93
+ def unify_currency(df: pd.DataFrame) -> pd.DataFrame:
94
+ """
95
+ Fuerza la columna SOLES a float64 y descarta DOLARES.
96
+
97
+ - Ignora la columna DOLARES (la operacion es estrictamente en Soles).
98
+ - Convierte SOLES a float, reemplaza NaN/vacios con 0.0.
99
+ """
100
+ if "DOLARES" in df.columns:
101
+ df = df.drop(columns=["DOLARES"])
102
+
103
+ if "SOLES" in df.columns:
104
+ df["SOLES"] = (
105
+ pd.to_numeric(df["SOLES"], errors="coerce")
106
+ .fillna(0.0)
107
+ )
108
+ return df
109
+
110
+
111
+ def build_fecha_proceso(df: pd.DataFrame) -> pd.DataFrame:
112
+ """
113
+ Construye FECHA_PROCESO como el primer dia del mes.
114
+
115
+ Toma ANHO (ej: "2026") y MES (ej: "06-JUNIO") y crea
116
+ una columna datetime YYYY-MM-01.
117
+
118
+ Si ANHO o MES faltan, intenta inferir de FECHA_ORIG.
119
+ """
120
+ fecha_proceso = pd.Series(pd.NaT, index=df.index, dtype="datetime64[ns]")
121
+
122
+ if "ANHO" in df.columns and "MES" in df.columns:
123
+ mes_num = (
124
+ df["MES"]
125
+ .astype(str)
126
+ .str.upper()
127
+ .str.strip()
128
+ .str.extract(r"(\d{2})", expand=False)
129
+ )
130
+ anho = (
131
+ df["ANHO"]
132
+ .astype(str)
133
+ .str.strip()
134
+ .str.extract(r"(\d{4})", expand=False)
135
+ )
136
+ valido = mes_num.notna() & anho.notna()
137
+ fecha_proceso[valido] = pd.to_datetime(
138
+ anho[valido] + "-" + mes_num[valido] + "-01",
139
+ errors="coerce",
140
+ )
141
+
142
+ fallbacks = fecha_proceso.isna()
143
+ if fallbacks.any() and "FECHA_ORIG" in df.columns:
144
+ orig = pd.to_datetime(df.loc[fallbacks, "FECHA_ORIG"], dayfirst=True, errors="coerce")
145
+ fecha_proceso[fallbacks] = orig - pd.offsets.MonthBegin(1)
146
+ fecha_proceso[fallbacks & orig.isna()] = pd.NaT
147
+
148
+ df["FECHA_PROCESO"] = fecha_proceso
149
+ return df
150
+
151
+
152
+ # ═══════════════════════════════════════════════════════════════════════════════
153
+ # FASE 2: RESOLUCION DE JERARQUIA DE CLIENTES
154
+ # ═══════════════════════════════════════════════════════════════════════════════
155
+
156
+ def resolve_client_hierarchy(df: pd.DataFrame) -> pd.DataFrame:
157
+ """
158
+ Crea CLIENTE_DOC, CLIENTE_TIPO_DOC y EMPRESA_NOM usando DOC_CLIENTE.
159
+
160
+ - CLIENTE_DOC: DOC_CLIENTE limpio (RUC o DNI segun longitud).
161
+ - CLIENTE_TIPO_DOC: "RUC" si 11 digitos, "DNI" si 8 digitos.
162
+ - EMPRESA_NOM: primer NOM_CLIENTE asociado a ese documento.
163
+ """
164
+ if "DOC_CLIENTE" not in df.columns or "NOM_CLIENTE" not in df.columns:
165
+ df["CLIENTE_DOC"] = ""
166
+ df["CLIENTE_TIPO_DOC"] = ""
167
+ df["EMPRESA_NOM"] = ""
168
+ return df
169
+
170
+ raw = df["DOC_CLIENTE"].astype(str).str.strip()
171
+ df["CLIENTE_DOC"] = raw
172
+
173
+ tipo = pd.Series("RUC", index=df.index)
174
+ tipo[raw.str.match(r"^\d{8}$")] = "DNI"
175
+ df["CLIENTE_TIPO_DOC"] = tipo
176
+
177
+ doc_nombre = (
178
+ df[df["NOM_CLIENTE"].notna() & (df["NOM_CLIENTE"].astype(str).str.strip() != "")]
179
+ .groupby("DOC_CLIENTE")["NOM_CLIENTE"]
180
+ .first()
181
+ )
182
+ df["EMPRESA_NOM"] = raw.map(doc_nombre).fillna("")
183
+ return df
184
+
185
+
186
+ def build_sucursal_id(df: pd.DataFrame) -> pd.DataFrame:
187
+ """
188
+ Crea SUCURSAL_ID combinando CLIENTE_DOC + COD_SUCURSAL.
189
+
190
+ Formato: {CLIENTE_DOC}_{COD_SUCURSAL}
191
+ Si COD_SUCURSAL falta o es nulo, se usa "00".
192
+ """
193
+ if "COD_SUCURSAL" not in df.columns:
194
+ df["SUCURSAL_ID"] = df["CLIENTE_DOC"].astype(str) + "_00"
195
+ return df
196
+
197
+ suc = df["COD_SUCURSAL"].fillna("00").astype(str).str.strip()
198
+ df["SUCURSAL_ID"] = df["CLIENTE_DOC"].astype(str) + "_" + suc
199
+ return df
200
+
201
+
202
+ def build_cliente_ui(df: pd.DataFrame) -> pd.DataFrame:
203
+ """
204
+ Crea CLIENTE_UI para los dropdowns de Flet.
205
+
206
+ Formato:
207
+ - Con sucursal: "EMPRESA_NOM (COD_SUCURSAL - NOM_SUCURSAL)"
208
+ - Sin sucursal: "EMPRESA_NOM"
209
+
210
+ Ejemplos:
211
+ "CIPSA (01 - SUCURSAL CENTRO)"
212
+ "EMPRESA SIN SUCURSAL"
213
+ """
214
+ # EMPRESA_NOM debe existir (creada en resolve_client_hierarchy)
215
+ base_name = df.get("EMPRESA_NOM", pd.Series("", index=df.index)).fillna("").astype(str).str.strip()
216
+
217
+ # Manejar ausencia de COD_SUCURSAL
218
+ if "COD_SUCURSAL" in df.columns:
219
+ suc_cod = df["COD_SUCURSAL"].fillna("00").astype(str).str.strip()
220
+ else:
221
+ suc_cod = pd.Series("00", index=df.index)
222
+
223
+ # Obligar a string por si suc_cod es numérico
224
+ suc_cod = suc_cod.astype(str).str.strip()
225
+
226
+ suc_nom = df.get("NOM_SUCURSAL", pd.Series("", index=df.index)).fillna("").astype(str).str.strip()
227
+
228
+ # Por defecto: solo nombre de empresa
229
+ df["CLIENTE_UI"] = base_name
230
+
231
+ # Filas que tienen sucursal (código no vacío y no "00")
232
+ tiene_suc = (suc_cod != "") & (suc_cod != "00")
233
+ if tiene_suc.any():
234
+ # Construir cadena con código y, si existe, nombre de sucursal
235
+ suc_str = suc_cod[tiene_suc]
236
+ # Agregar nombre si está presente y no es "acumulado"
237
+ con_nombre = tiene_suc & (suc_nom != "") & (suc_nom.str.lower() != "acumulado")
238
+ if con_nombre.any():
239
+ suc_str[con_nombre] = suc_str[con_nombre] + " - " + suc_nom[con_nombre]
240
+ df.loc[tiene_suc, "CLIENTE_UI"] = base_name[tiene_suc] + " (" + suc_str[tiene_suc] + ")"
241
+ return df
242
+
243
+
244
+ # ═══════════════════════════════════════════════════════════════════════════════
245
+ # FASE 3: TIPIFICACION COMERCIAL Y TRAZABILIDAD
246
+ # ═══════════════════════════════════════════════════════════════════════════════
247
+
248
+ def detect_stock_dormido(df: pd.DataFrame) -> pd.DataFrame:
249
+ """
250
+ Marca ES_STOCK_DORMIDO = 1 si FECHA_ORIG - FECHA_REF > 90 dias.
251
+
252
+ La logica detecta productos que permanecieron en stock
253
+ mas de 90 dias antes de ser vendidos o devueltos.
254
+ """
255
+ STOCK_THRESHOLD = 90
256
+
257
+ orig = pd.to_datetime(
258
+ df.get("FECHA_ORIG", pd.Series("", index=df.index)),
259
+ dayfirst=True, errors="coerce",
260
+ )
261
+ ref = pd.to_datetime(
262
+ df.get("FECHA_REF", pd.Series("", index=df.index)),
263
+ dayfirst=True, errors="coerce",
264
+ )
265
+
266
+ delta = (orig - ref).dt.days
267
+
268
+ df["STOCK_DORMIDO"] = np.where(
269
+ delta.notna() & (delta > STOCK_THRESHOLD),
270
+ 1,
271
+ 0,
272
+ ).astype(int)
273
+ return df
274
+
275
+
276
+ # ═══════════════════════════════════════════════════════════════════════════════
277
+ # FASE 4: EXPORTACION DEL MOLDE INMUTABLE
278
+ # ═══════════════════════════════════════════════════════════════════════════════
279
+
280
+ def _find_fae_col(df: pd.DataFrame) -> str:
281
+ """Encuentra la columna CANTIDAD FAE (puede venir como CANTIDAD_FAE)."""
282
+ for col in df.columns:
283
+ norm = col.upper().replace(" ", "_").replace("-", "_")
284
+ if norm == "CANTIDAD_FAE":
285
+ return col
286
+ return None
287
+
288
+
289
+ def export_master(df: pd.DataFrame) -> pd.DataFrame:
290
+ """
291
+ Selecciona y renombra las columnas del molde inmutable (16 columnas).
292
+
293
+ Mapping de columnas origen a destino:
294
+ FECHA_PROCESO ← FECHA_PROCESO
295
+ EMPRESA_RUC ← CLIENTE_DOC (RUC o DNI)
296
+ CLIENTE_TIPO_DOC ← CLIENTE_TIPO_DOC ("RUC" o "DNI")
297
+ EMPRESA_NOM ← NOM_CLIENTE
298
+ SUCURSAL_ID ← SUCURSAL_ID
299
+ CLIENTE_UI ← CLIENTE_UI
300
+ PRODUCTO_NOM ← NOM_ARTICULO
301
+ VENDEDOR_NOM ← NOM_VENDEDOR
302
+ TIPO_DOC ← TPO_DOC
303
+ CATEGORIA_OP ← CATEGORIA_OP
304
+ SUBTIPO_AJUSTE ← SUBTIPO_AJUSTE
305
+ CANT_FISICA ← CANTIDAD (como float)
306
+ NETO_SOLES ← SOLES
307
+ CANTIDAD_FAE ← CANTIDAD_FAE / CANTIDAD FAE
308
+ PRECIO_BASE ← PRECIO_BASE
309
+ PRECIO_EFECTIVO ← PRECIO_EFECTIVO
310
+ STOCK_DORMIDO ← STOCK_DORMIDO
311
+
312
+ Retorna:
313
+ DataFrame con las 16 columnas en el orden definido por MOLDE_INMUTABLE.
314
+ Si alguna columna origen falta, se rellena con valores por defecto.
315
+ """
316
+ fae_col = _find_fae_col(df)
317
+
318
+ mapping = {
319
+ "FECHA_PROCESO": "FECHA_PROCESO",
320
+ "EMPRESA_RUC": "CLIENTE_DOC",
321
+ "CLIENTE_TIPO_DOC": "CLIENTE_TIPO_DOC",
322
+ "EMPRESA_NOM": "NOM_CLIENTE",
323
+ "SUCURSAL_ID": "SUCURSAL_ID",
324
+ "CLIENTE_UI": "CLIENTE_UI",
325
+ "PRODUCTO_NOM": "NOM_ARTICULO",
326
+ "VENDEDOR_NOM": "NOM_VENDEDOR",
327
+ "TIPO_DOC": "TPO_DOC",
328
+ "CATEGORIA_OP": "CATEGORIA_OP",
329
+ "SUBTIPO_AJUSTE": "SUBTIPO_AJUSTE",
330
+ "CANT_FISICA": "CANTIDAD",
331
+ "NETO_SOLES": "SOLES",
332
+ "CANTIDAD_FAE": fae_col if fae_col else "CANTIDAD",
333
+ "PRECIO_BASE": "PRECIO_BASE",
334
+ "PRECIO_EFECTIVO": "PRECIO_EFECTIVO",
335
+ "STOCK_DORMIDO": "STOCK_DORMIDO",
336
+ }
337
+
338
+ resultado = pd.DataFrame(index=df.index)
339
+
340
+ for col_dest, col_origen in mapping.items():
341
+ if col_origen in df.columns:
342
+ resultado[col_dest] = df[col_origen].copy()
343
+ elif col_dest in df.columns:
344
+ # Ya fue creada con otro nombre
345
+ resultado[col_dest] = df[col_dest].copy()
346
+ else:
347
+ resultado[col_dest] = _default_value(col_dest)
348
+
349
+ # Reordenar exactamente como el molde
350
+ resultado = resultado[MOLDE_INMUTABLE]
351
+
352
+ # Normalizaciones finales
353
+ resultado["CANT_FISICA"] = pd.to_numeric(resultado["CANT_FISICA"], errors="coerce").fillna(0.0)
354
+ resultado["NETO_SOLES"] = pd.to_numeric(resultado["NETO_SOLES"], errors="coerce").fillna(0.0)
355
+ resultado["CANTIDAD_FAE"] = pd.to_numeric(resultado["CANTIDAD_FAE"], errors="coerce").fillna(0.0)
356
+ resultado["PRECIO_BASE"] = pd.to_numeric(resultado["PRECIO_BASE"], errors="coerce")
357
+ resultado["PRECIO_EFECTIVO"] = pd.to_numeric(resultado["PRECIO_EFECTIVO"], errors="coerce")
358
+ resultado["TIPO_DOC"] = resultado["TIPO_DOC"].astype(str).str.upper().str.strip()
359
+ resultado["STOCK_DORMIDO"] = resultado["STOCK_DORMIDO"].fillna(0).astype(int)
360
+
361
+ # Strip de todos los strings para evitar duplicados por espacios
362
+ for col in resultado.select_dtypes(include="object").columns:
363
+ resultado[col] = resultado[col].astype(str).str.strip()
364
+
365
+ return resultado
366
+
367
+
368
+ def _default_value(col: str):
369
+ if col in ("STOCK_DORMIDO",):
370
+ return 0
371
+ if col == "CLIENTE_TIPO_DOC":
372
+ return "RUC"
373
+ if col in ("CANT_FISICA", "NETO_SOLES", "CANTIDAD_FAE", "PRECIO_BASE", "PRECIO_EFECTIVO"):
374
+ return 0.0
375
+ return ""
376
+
377
+
378
+ # ═══════════════════════════════════════════════════════════════════════════════
379
+ # ORQUESTADOR PRINCIPAL
380
+ # ═══════════════════════════════════════════════════════════════════════════════
381
+
382
+ def ingest_to_master(
383
+ filepath: str,
384
+ output_path: Optional[str] = None,
385
+ drop_totales: bool = True,
386
+ ) -> pd.DataFrame:
387
+ """
388
+ Orquesta las 4 fases del pipeline de ingesta.
389
+
390
+ Parametros:
391
+ filepath: Ruta al archivo CSV o Excel de dgvVentas.
392
+ output_path: Si se especifica, guarda el resultado como CSV.
393
+ drop_totales: Si True, elimina la fila de gran total del ERP.
394
+
395
+ Returns:
396
+ DataFrame con las 13 columnas del molde inmutable.
397
+ """
398
+ log.info("=== INICIO PIPELINE INGESTA ===")
399
+ log.info("Leyendo: %s", filepath)
400
+
401
+ df = _read_file(filepath)
402
+ if df.empty:
403
+ log.warning("Archivo vacio — devolviendo DataFrame vacio del molde")
404
+ return pd.DataFrame(columns=MOLDE_INMUTABLE)
405
+
406
+ log.info("Filas leidas: %d, Columnas: %d", len(df), len(df.columns))
407
+
408
+ # ── Fase 1: Saneamiento ──────────────────────────────────────────
409
+ log.info("[Fase 1/4] Saneamiento de la ingesta")
410
+ df = clean_duplicate_columns(df)
411
+ df = unify_currency(df)
412
+
413
+ if drop_totales:
414
+ df = _remove_totals_row(df)
415
+
416
+ df = build_fecha_proceso(df)
417
+
418
+ # ── Fase 2: Jerarquia de Clientes ────────────────────────────────
419
+ log.info("[Fase 2/4] Resolucion de jerarquia de clientes")
420
+ df = resolve_client_hierarchy(df)
421
+ df = build_sucursal_id(df)
422
+ df = build_cliente_ui(df)
423
+
424
+ # ── Fase 3: Tipificacion Comercial ───────────────────────────────
425
+ log.info("[Fase 3/4] Tipificacion comercial y trazabilidad")
426
+ df = parse_referencia(df)
427
+ df = classify_base(df)
428
+ df = resolve_document_relationships(df)
429
+ df = detect_stock_dormido(df)
430
+
431
+ # Columnas derivadas adicionales (precios, flags, agregados por documento)
432
+ df = calculate_prices(df)
433
+ df = _create_derived_columns(df)
434
+
435
+ # ── Fase 4: Exportacion del molde ───────────────────────────────────────
436
+ log.info("[Fase 4/4] Exportacion del molde inmutable")
437
+ master = export_master(df)
438
+ log.info("Filas en molde final: %d", len(master))
439
+
440
+ if output_path:
441
+ master.to_csv(output_path, index=False, encoding="utf-8-sig")
442
+ log.info("Archivo guardado: %s", output_path)
443
+
444
+ log.info("=== PIPELINE INGESTA COMPLETADO ===")
445
+ return master
446
+
447
+
448
+ def batch_ingest(
449
+ filepaths: List[str],
450
+ output_path: Optional[str] = None,
451
+ drop_totales: bool = True,
452
+ merge_results: bool = True,
453
+ ) -> pd.DataFrame:
454
+ """
455
+ Procesa múltiples archivos ERP en lote.
456
+
457
+ Parametros:
458
+ filepaths: Lista de rutas a archivos CSV/Excel.
459
+ output_path: Si se especifica, guarda el resultado combinado como CSV.
460
+ drop_totales: Si True, elimina filas de totales en cada archivo.
461
+ merge_results: Si True, combina todos los archivos en un solo DataFrame.
462
+ Si False, retorna una lista de DataFrames individuales.
463
+
464
+ Returns:
465
+ DataFrame combinado (merge_results=True) o lista de DataFrames (False).
466
+ """
467
+ log.info("=== INICIO BATCH INGESTA ===")
468
+ log.info("Archivos a procesar: %d", len(filepaths))
469
+
470
+ results = []
471
+ sources = []
472
+
473
+ for i, fp in enumerate(filepaths, 1):
474
+ log.info("Procesando archivo %d/%d: %s", i, len(filepaths), fp)
475
+ try:
476
+ master_df = ingest_to_master(
477
+ filepath=fp,
478
+ output_path=None, # No guardar individualmente
479
+ drop_totales=drop_totales
480
+ )
481
+ if not master_df.empty:
482
+ # Añadir columna de trazabilidad
483
+ master_df['ARCHIVO_ORIGEN'] = Path(fp).name
484
+ master_df['ORDEN_LOTE'] = i
485
+ results.append(master_df)
486
+ sources.append(Path(fp))
487
+ log.info(" ✓ %d filas procesadas", len(master_df))
488
+ else:
489
+ log.warning(" ⚠ Archivo vacío o sin datos procesables")
490
+ except Exception as e:
491
+ log.error(" ✗ Error procesando %s: %s", fp, e)
492
+ # Continuar con el siguiente archivo
493
+
494
+ if not results:
495
+ log.warning("No se pudo procesar ningún archivo")
496
+ return pd.DataFrame(columns=MOLDE_INMUTABLE + ['ARCHIVO_ORIGEN', 'ORDEN_LOTE'])
497
+
498
+ if merge_results:
499
+ combined = pd.concat(results, ignore_index=True)
500
+ log.info("=== BATCH COMPLETADO: %d filas totales de %d archivos ===",
501
+ len(combined), len(results))
502
+
503
+ if output_path:
504
+ combined.to_csv(output_path, index=False, encoding="utf-8-sig")
505
+ log.info("Archivo combinado guardado: %s", output_path)
506
+
507
+ return combined
508
+ else:
509
+ log.info("=== BATCH COMPLETADO: %d DataFrames individuales ===", len(results))
510
+ return results
511
+
512
+
513
+
514
+ # ─── Funciones auxiliares internas ──────────────────────────────────────────
515
+
516
+ def _read_file(filepath: str) -> pd.DataFrame:
517
+ from .batch_processor import read_erp_file
518
+ try:
519
+ ext = Path(filepath).suffix.lower()
520
+ return read_erp_file(filepath, ext)
521
+ except Exception as exc:
522
+ log.error("Error leyendo archivo '%s': %s", filepath, exc)
523
+ return pd.DataFrame()
524
+
525
+
526
+ def _create_derived_columns(df: pd.DataFrame) -> pd.DataFrame:
527
+ """
528
+ Crea columnas derivadas adicionales:
529
+ - FECHA_DT: datetime de FECHA_PROCESO para filtros rápidos
530
+ - ES_NC, ES_NDB: flags de tipo de documento
531
+ - CANTIDAD_FACTURA: suma de CANT_FISICA por NRO_DOC
532
+ - ITEMS_FACTURA: número de items (filas) por NRO_DOC
533
+ """
534
+ # FECHA_DT
535
+ if "FECHA_PROCESO" in df.columns:
536
+ df["FECHA_DT"] = pd.to_datetime(
537
+ df["FECHA_PROCESO"],
538
+ dayfirst=True,
539
+ errors="coerce"
540
+ )
541
+
542
+ # Flags de documentos
543
+ if "TPO_DOC" in df.columns:
544
+ tpo = df["TPO_DOC"].astype(str).str.upper().str.strip()
545
+ df["ES_NC"] = tpo.str.startswith(("NC", "NCR"))
546
+ df["ES_NDB"] = tpo.str.startswith("NDB")
547
+
548
+ # Agregados por documento
549
+ if "NRO_DOC" in df.columns:
550
+ # Cantidad total por documento
551
+ if "CANT_FISICA" in df.columns:
552
+ cant_por_doc = df.groupby("NRO_DOC")["CANT_FISICA"].transform("sum")
553
+ df["CANTIDAD_FACTURA"] = cant_por_doc
554
+ # Número de items por documento
555
+ items_counts = df.groupby("NRO_DOC").size()
556
+ df["ITEMS_FACTURA"] = df["NRO_DOC"].map(items_counts)
557
+
558
+ return df
559
+
560
+
561
+ def _remove_totals_row(df: pd.DataFrame) -> pd.DataFrame:
562
+ """
563
+ Elimina la fila de gran total que el ERP incluye al final.
564
+
565
+ Se identifica porque TPO_DOC, FECHA_ORIG e ID_CLIENTE estan vacios.
566
+ """
567
+ ref_cols = [c for c in ["TPO_DOC", "FECHA_ORIG", "ID_CLIENTE"] if c in df.columns]
568
+ if not ref_cols:
569
+ return df
570
+ mask_total = df[ref_cols].apply(
571
+ lambda r: r.isna().all() or (r.astype(str).str.strip() == "").all(),
572
+ axis=1,
573
+ )
574
+ n = mask_total.sum()
575
+ if n > 0:
576
+ log.info("Fila(s) de totales eliminada(s): %d", n)
577
+ return df[~mask_total]
578
+ return df