g360-cli 1.7.1 → 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 (41) 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/cli.js +25 -2
  35. package/src/commands/ingest.js +193 -0
  36. package/src/commands/scan.js +102 -0
  37. package/src/commands/validate.js +126 -0
  38. package/src/lib/python_runner.js +89 -0
  39. package/py/src/g360_core/flet/__init__.py +0 -3
  40. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  41. package/py/src/g360_core/ingestion.py +0 -480
@@ -0,0 +1,427 @@
1
+ """SKU drilldown methods for InsightProcessor — extracted from processor.py."""
2
+
3
+ import pandas as pd
4
+ import numpy as np
5
+ from typing import Optional
6
+ from datetime import datetime
7
+
8
+
9
+ class ProcessorSKU:
10
+ """Mixin class with all SKU-level analysis methods."""
11
+
12
+
13
+ def get_historial_sku(self, sku: str) -> pd.DataFrame:
14
+ """
15
+ Retorna el historial completo de un SKU especifico.
16
+
17
+ Filtra todas las filas donde ID_ARTICULO coincide con el SKU solicitado
18
+ y ordena por fecha descendente (mas reciente primero).
19
+
20
+ Util para:
21
+ - Ver todas las facturas que incluyeron este SKU
22
+ - Analizar patron de compras de un articulo especifico
23
+ - Trazabilidad completa de un producto
24
+
25
+ Args:
26
+ sku: ID del articulo (ej: "78339").
27
+
28
+ Returns:
29
+ pd.DataFrame: Historial del SKU ordenado por fecha descendente.
30
+ """
31
+ historial = self.df[self.df["ID_ARTICULO"] == sku]
32
+ if "FECHA_DT" in historial.columns:
33
+ return historial.sort_values("FECHA_DT", ascending=False)
34
+ return historial
35
+
36
+ def get_precio_por_sucursal(self, sku: str) -> pd.DataFrame:
37
+ """
38
+ Ranking de sucursales por precio unitario para un SKU especifico.
39
+
40
+ Responde: "Que sucursales negocian mas barato?"
41
+
42
+ Para cada sucursal que compro el SKU, calcula:
43
+ - precio_promedio: Precio unitario promedio
44
+ - precio_min/max: Rango de precios negociados
45
+ - desviacion_pct: Desviacion vs precio global promedio
46
+ - Negativo = negocia mas barato que el promedio
47
+ - Positivo = negocia mas caro que el promedio
48
+
49
+ La deteccion de sucursales con precios significativamente mas bajos
50
+ puede indicar:
51
+ - Negociacion agresiva del comprador
52
+ - Descuentos no autorizados
53
+ - Estrategia de precios diferenciada por region
54
+
55
+ Args:
56
+ sku: ID del articulo (ej: "78339").
57
+
58
+ Returns:
59
+ pd.DataFrame: Ranking de sucursales con precio_promedio,
60
+ precio_min, precio_max, total_soles, cantidad,
61
+ n_compras, desviacion_pct, precio_global.
62
+ Ordenado por precio_promedio ascendente.
63
+ """
64
+ sucursal_col = next((c for c in self.df.columns if "NOM_SUCURSAL" in c), None)
65
+ if not sucursal_col:
66
+ return pd.DataFrame()
67
+
68
+ # Filtrar solo filas del SKU con cantidad positiva (excluir NCs)
69
+ df_sku = self.df[(self.df["ID_ARTICULO"] == sku) & (self.df["CANTIDAD"] > 0)]
70
+ if df_sku.empty:
71
+ return pd.DataFrame()
72
+
73
+ # Excluir filas sin sucursal registrada
74
+ df_sku = df_sku[df_sku[sucursal_col].astype(str).str.strip() != ""]
75
+
76
+ if df_sku.empty:
77
+ return pd.DataFrame()
78
+
79
+ # Agregar por sucursal
80
+ resumen = df_sku.groupby(sucursal_col).agg(
81
+ precio_promedio=("PRECIO_UNID", "mean"),
82
+ precio_min=("PRECIO_UNID", "min"),
83
+ precio_max=("PRECIO_UNID", "max"),
84
+ total_soles=("SOLES", "sum"),
85
+ cantidad=("CANTIDAD", "sum"),
86
+ n_compras=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
87
+ ).reset_index()
88
+
89
+ # Calcular desviacion vs precio global promedio
90
+ precio_global = resumen["precio_promedio"].mean()
91
+ if precio_global != 0:
92
+ resumen["desviacion_pct"] = ((resumen["precio_promedio"] - precio_global) / precio_global * 100).round(1)
93
+ else:
94
+ resumen["desviacion_pct"] = 0.0
95
+ resumen["precio_global"] = round(precio_global, 4)
96
+
97
+ return resumen.sort_values("precio_promedio", ascending=True)
98
+
99
+ def get_historial_precios_sku(self, sku: str) -> pd.DataFrame:
100
+ """
101
+ Historial de precios de un SKU con deteccion de variaciones anomalias.
102
+
103
+ Para cada transaccion del SKU, calcula:
104
+ - variacion_pct: Cambio porcentual vs la transaccion anterior
105
+ - alerta_precio: Booleano - True si la variacion supera 10%
106
+
107
+ Las alertas de precio son utiles para detectar:
108
+ - "Guerras de precios" internas entre vendedores
109
+ - Descuentos no autorizados
110
+ - Errores de digitacion en facturas
111
+ - Cambios de lista de precios no comunicados
112
+
113
+ Args:
114
+ sku: ID del articulo (ej: "78339").
115
+
116
+ Returns:
117
+ pd.DataFrame: Historial del SKU con columnas adicionales
118
+ variacion_pct y alerta_precio.
119
+ """
120
+ historial = self.get_historial_sku(sku)
121
+ if historial.empty:
122
+ return pd.DataFrame()
123
+
124
+ # Excluir filas con cantidad 0 o negativa (NCs)
125
+ historial = historial[historial["CANTIDAD"] > 0].copy()
126
+
127
+ if "FECHA_DT" in historial.columns:
128
+ # Ordenar cronologicamente para calcular variacion secuencial
129
+ historial = historial.sort_values("FECHA_DT")
130
+ # Calcular variacion porcentual vs transaccion anterior
131
+ historial["variacion_pct"] = historial["PRECIO_UNID"].pct_change() * 100
132
+ historial["variacion_pct"] = historial["variacion_pct"].round(1)
133
+ # Marcar variaciones mayores al 10% como alertas
134
+ historial["alerta_precio"] = historial["variacion_pct"].abs() > 10
135
+
136
+ return historial
137
+
138
+ def get_ventas_sku_por_vendedor(self, sku: str) -> pd.DataFrame:
139
+ """
140
+ Desglose de ventas de un SKU por vendedor.
141
+
142
+ Retorna por cada vendedor que vendio el SKU:
143
+ - Cantidad total de unidades
144
+ - Total en soles
145
+ - Precio promedio, min, max
146
+ - Numero de documentos/facturas
147
+ - Numero de clientes atendidos
148
+ - Primera y ultima fecha de venta
149
+
150
+ Util para:
151
+ - Comparar rendimiento de vendedores en un producto especifico
152
+ - Detectar guerras de precios entre vendedores
153
+ - Identificar que vendedores son mas activos en un SKU
154
+
155
+ Args:
156
+ sku: ID del articulo (ej: "78339").
157
+
158
+ Returns:
159
+ pd.DataFrame: Ranking de vendedores con metricas del SKU.
160
+ """
161
+ vendedor_col = next((c for c in self.df.columns if "ID_VENDEDOR" in c), None)
162
+ nom_vendedor_col = next((c for c in self.df.columns if "NOM_VENDEDOR" in c), None)
163
+ if not vendedor_col:
164
+ return pd.DataFrame()
165
+
166
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku]
167
+ if df_sku.empty:
168
+ return pd.DataFrame()
169
+
170
+ agg_dict = {
171
+ "cantidad": ("CANTIDAD", "sum"),
172
+ "total_soles": ("SOLES", "sum"),
173
+ "precio_promedio": ("PRECIO_UNID", "mean"),
174
+ "precio_min": ("PRECIO_UNID", "min"),
175
+ "precio_max": ("PRECIO_UNID", "max"),
176
+ "n_clientes": ("ID_CLIENTE", "nunique"),
177
+ }
178
+ if "NRO_DOC" in df_sku.columns:
179
+ agg_dict["n_documentos"] = ("NRO_DOC", "nunique")
180
+ else:
181
+ agg_dict["n_documentos"] = ("SOLES", "count")
182
+ if "FECHA_DT" in df_sku.columns:
183
+ agg_dict["primera_venta"] = ("FECHA_DT", "min")
184
+ agg_dict["ultima_venta"] = ("FECHA_DT", "max")
185
+
186
+ resumen = df_sku.groupby(vendedor_col).agg(**agg_dict).reset_index()
187
+
188
+ if nom_vendedor_col and nom_vendedor_col in df_sku.columns:
189
+ nombre_frecuente = df_sku.groupby(vendedor_col)[nom_vendedor_col].agg(
190
+ lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else x.iloc[0]
191
+ )
192
+ resumen[nom_vendedor_col] = resumen[vendedor_col].map(nombre_frecuente)
193
+
194
+ resumen["precio_promedio"] = resumen["precio_promedio"].round(2)
195
+
196
+ precio_global = df_sku["PRECIO_UNID"].mean()
197
+ if precio_global != 0:
198
+ resumen["desviacion_precio"] = (
199
+ (resumen["precio_promedio"] - precio_global) / precio_global * 100
200
+ ).round(1)
201
+ else:
202
+ resumen["desviacion_precio"] = 0.0
203
+ resumen["precio_global"] = round(precio_global, 4)
204
+
205
+ if "FECHA_DT" in resumen.columns:
206
+ resumen = resumen.sort_values("total_soles", ascending=False)
207
+ else:
208
+ resumen = resumen.sort_values("total_soles", ascending=False)
209
+
210
+ return resumen
211
+
212
+ def get_resumen_mensual_sku(self, sku: str) -> pd.DataFrame:
213
+ """
214
+ Evolucion mensual de ventas de un SKU especifico.
215
+
216
+ Retorna por cada mes:
217
+ - Total en soles
218
+ - Cantidad de unidades
219
+ - Numero de clientes
220
+ - Numero de documentos
221
+ - Precio promedio
222
+ - Variacion mensual porcentual
223
+
224
+ Util para:
225
+ - Identificar estacionalidad de un producto
226
+ - Detectar tendencias de crecimiento/decrecimiento
227
+ - Comparar rendimiento mes a mes
228
+
229
+ Args:
230
+ sku: ID del articulo (ej: "78339").
231
+
232
+ Returns:
233
+ pd.DataFrame: Evolucion mensual ordenada cronologicamente.
234
+ """
235
+ if "FECHA_DT" not in self.df.columns:
236
+ return pd.DataFrame()
237
+
238
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku].copy()
239
+ if df_sku.empty:
240
+ return pd.DataFrame()
241
+
242
+ df_sku = df_sku.dropna(subset=["FECHA_DT"])
243
+ df_sku["MES_ANIO"] = df_sku["FECHA_DT"].dt.to_period("M")
244
+
245
+ agg_dict = {
246
+ "total_soles": ("SOLES", "sum"),
247
+ "cantidad": ("CANTIDAD", "sum"),
248
+ "n_clientes": ("ID_CLIENTE", "nunique"),
249
+ "precio_promedio": ("PRECIO_UNID", "mean"),
250
+ }
251
+ if "NRO_DOC" in df_sku.columns:
252
+ agg_dict["n_documentos"] = ("NRO_DOC", "nunique")
253
+ else:
254
+ agg_dict["n_documentos"] = ("SOLES", "count")
255
+
256
+ resumen = df_sku.groupby("MES_ANIO").agg(**agg_dict).reset_index()
257
+ resumen["MES_ANIO"] = resumen["MES_ANIO"].astype(str)
258
+ resumen["precio_promedio"] = resumen["precio_promedio"].round(2)
259
+
260
+ resumen = resumen.sort_values("MES_ANIO")
261
+ resumen["variacion_soles_pct"] = resumen["total_soles"].pct_change() * 100
262
+ resumen["variacion_soles_pct"] = resumen["variacion_soles_pct"].fillna(0).round(1)
263
+
264
+ return resumen
265
+
266
+ def get_precios_por_cliente_sku(self, sku: str) -> pd.DataFrame:
267
+ """
268
+ Distribucion de precios negociados por cliente para un SKU.
269
+
270
+ Para cada cliente que compro el SKU, muestra:
271
+ - Precio promedio, min, max negociado
272
+ - Cantidad total comprada
273
+ - Total en soles
274
+ - Numero de transacciones
275
+ - Desviacion vs precio global
276
+
277
+ Util para:
278
+ - Detectar clientes que negocian precios mas bajos
279
+ - Identificar inconsistencias en politica de precios
280
+ - Analizar Impacto de descuentos por cliente
281
+
282
+ Args:
283
+ sku: ID del articulo (ej: "78339").
284
+
285
+ Returns:
286
+ pd.DataFrame: Clientes con distribucion de precios del SKU.
287
+ """
288
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku]
289
+ if df_sku.empty:
290
+ return pd.DataFrame()
291
+
292
+ df_venta = df_sku[df_sku["CANTIDAD"] > 0]
293
+ if df_venta.empty:
294
+ return pd.DataFrame()
295
+
296
+ agg_dict = {
297
+ "NOM_CLIENTE": ("NOM_CLIENTE", "first"),
298
+ "cantidad": ("CANTIDAD", "sum"),
299
+ "total_soles": ("SOLES", "sum"),
300
+ "precio_promedio": ("PRECIO_UNID", "mean"),
301
+ "precio_min": ("PRECIO_UNID", "min"),
302
+ "precio_max": ("PRECIO_UNID", "max"),
303
+ }
304
+ if "NRO_DOC" in df_venta.columns:
305
+ agg_dict["n_transacciones"] = ("NRO_DOC", "nunique")
306
+ else:
307
+ agg_dict["n_transacciones"] = ("SOLES", "count")
308
+
309
+ resumen = df_venta.groupby("ID_CLIENTE").agg(**agg_dict).reset_index()
310
+ resumen["precio_promedio"] = resumen["precio_promedio"].round(2)
311
+
312
+ precio_global = df_venta["PRECIO_UNID"].mean()
313
+ if precio_global != 0:
314
+ resumen["desviacion_precio"] = (
315
+ (resumen["precio_promedio"] - precio_global) / precio_global * 100
316
+ ).round(1)
317
+ else:
318
+ resumen["desviacion_precio"] = 0.0
319
+ resumen["precio_global"] = round(precio_global, 4)
320
+
321
+ return resumen.sort_values("total_soles", ascending=False)
322
+
323
+ def get_vendedores_count_sku(self, sku: str) -> int:
324
+ """
325
+ Cuenta cuantos vendedores unicos han vendido un SKU.
326
+
327
+ Args:
328
+ sku: ID del articulo.
329
+
330
+ Returns:
331
+ int: Numero de vendedores unicos.
332
+ """
333
+ vendedor_col = next((c for c in self.df.columns if "ID_VENDEDOR" in c), None)
334
+ if not vendedor_col:
335
+ return 0
336
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku]
337
+ return df_sku[vendedor_col].nunique()
338
+
339
+ def query_sku_drilldown(self, sku: str, ref_date: datetime = None) -> dict:
340
+ """
341
+ Analiza a fondo un SKU respondiendo a montos de este mes, año, comparativa YoY,
342
+ clientes principales, precios negociados e historiales de facturas.
343
+ """
344
+ if ref_date is None:
345
+ if "FECHA_DT" in self.df.columns:
346
+ max_dt = self.df["FECHA_DT"].max()
347
+ ref_date = max_dt if pd.notna(max_dt) else datetime.now()
348
+ else:
349
+ ref_date = datetime.now()
350
+
351
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku]
352
+ if df_sku.empty or "FECHA_DT" not in df_sku.columns:
353
+ return {}
354
+
355
+ year_curr = ref_date.year
356
+ month_curr = ref_date.month
357
+
358
+ mask_month_curr = (df_sku["FECHA_DT"].dt.year == year_curr) & (df_sku["FECHA_DT"].dt.month == month_curr)
359
+ mask_month_prev = (df_sku["FECHA_DT"].dt.year == year_curr - 1) & (df_sku["FECHA_DT"].dt.month == month_curr)
360
+ mask_ytd_curr = (df_sku["FECHA_DT"].dt.year == year_curr) & (df_sku["FECHA_DT"].dt.month <= month_curr)
361
+ mask_ytd_prev = (df_sku["FECHA_DT"].dt.year == year_curr - 1) & (df_sku["FECHA_DT"].dt.month <= month_curr)
362
+
363
+ v_mes_curr = df_sku[mask_month_curr]["SOLES"].sum()
364
+ v_mes_prev = df_sku[mask_month_prev]["SOLES"].sum()
365
+ v_ytd_curr = df_sku[mask_ytd_curr]["SOLES"].sum()
366
+ v_ytd_prev = df_sku[mask_ytd_prev]["SOLES"].sum()
367
+
368
+ var_mes = ((v_mes_curr - v_mes_prev) / v_mes_prev * 100) if v_mes_prev != 0 else (100.0 if v_mes_curr > 0 else 0.0)
369
+ var_ytd = ((v_ytd_curr - v_ytd_prev) / v_ytd_prev * 100) if v_ytd_prev != 0 else (100.0 if v_ytd_curr > 0 else 0.0)
370
+
371
+ cliente_group = ["ID_CLIENTE"] + (["NOM_CLIENTE"] if "NOM_CLIENTE" in df_sku.columns else [])
372
+ clientes_analytics = df_sku.groupby(cliente_group).agg(
373
+ cantidad_total=("CANTIDAD", "sum"),
374
+ soles_totales=("SOLES", "sum"),
375
+ precio_promedio=("PRECIO_UNID", "mean"),
376
+ precio_min=("PRECIO_UNID", "min"),
377
+ precio_max=("PRECIO_UNID", "max")
378
+ ).reset_index().sort_values("soles_totales", ascending=False)
379
+
380
+ invoice_cols = [c for c in ["NRO_DOC", "TPO_DOC", "SERIE_DOC", "FECHA_DT", "NOM_CLIENTE", "CANTIDAD", "PRECIO_UNID", "SOLES"] if c in df_sku.columns]
381
+ invoices = df_sku[invoice_cols].sort_values("FECHA_DT", ascending=False)
382
+
383
+ return {
384
+ "sku": sku,
385
+ "ref_date": ref_date.strftime("%Y-%m-%d"),
386
+ "resumen_temporal": {
387
+ "venta_mes_actual": v_mes_curr,
388
+ "venta_mes_anterior_yoy": v_mes_prev,
389
+ "variacion_mes_pct": round(var_mes, 2),
390
+ "venta_ytd_actual": v_ytd_curr,
391
+ "venta_ytd_anterior_yoy": v_ytd_prev,
392
+ "variacion_ytd_pct": round(var_ytd, 2),
393
+ },
394
+ "clientes": clientes_analytics,
395
+ "facturas": invoices
396
+ }
397
+
398
+ def get_ndb_por_sku(self, sku: str) -> pd.DataFrame:
399
+ """
400
+ Retorna las Notas de Débito (NDB) asociadas a un SKU específico.
401
+
402
+ Las NDB representan aumentos en el valor que el cliente debe:
403
+ - Ajustes de precio
404
+ - Penalidades
405
+ - Recargos por flete
406
+ - Diferencias de precio posteriores a la factura
407
+
408
+ Returns:
409
+ pd.DataFrame: NDB del SKU con columnas: NRO_DOC, FECHA_DT,
410
+ NOM_CLIENTE, CANTIDAD, SOLES, PRECIO_UNID, y
411
+ resumen por cliente.
412
+ """
413
+ if "ES_NDB" not in self.df.columns:
414
+ return pd.DataFrame()
415
+
416
+ df_sku = self.df[self.df["ID_ARTICULO"] == sku]
417
+ if df_sku.empty:
418
+ return pd.DataFrame()
419
+
420
+ df_ndb = df_sku[df_sku["ES_NDB"] == True]
421
+ if df_ndb.empty:
422
+ return pd.DataFrame()
423
+
424
+ ndb_cols = [c for c in ["NRO_DOC", "TPO_DOC", "SERIE_DOC", "FECHA_DT",
425
+ "ID_CLIENTE", "NOM_CLIENTE", "ID_VENDEDOR", "NOM_VENDEDOR",
426
+ "CANTIDAD", "PRECIO_UNID", "SOLES"] if c in df_ndb.columns]
427
+ return df_ndb[ndb_cols].sort_values("FECHA_DT", ascending=False)
@@ -0,0 +1,218 @@
1
+ """
2
+ Scanner de archivos ERP para g360-cli.
3
+
4
+ Detección ligera sin dependencia de pipeline completo.
5
+ """
6
+
7
+ from .batch_processor import read_erp_file
8
+ from .utils import validate_columns
9
+ from .pipeline import ingest_to_master
10
+ import pandas as pd
11
+ from pathlib import Path
12
+ from typing import List, Tuple, Optional
13
+ from dataclasses import dataclass, field
14
+ import logging
15
+ from datetime import datetime
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class ERPFileInfo:
22
+ """Información de un archivo ERP detectado."""
23
+ path: Path
24
+ size_bytes: int
25
+ modified_time: datetime
26
+ erp_type: str = "UNKNOWN"
27
+ is_valid: bool = False
28
+ missing_columns: List[str] = field(default_factory=list)
29
+ n_rows_estimate: int = 0
30
+ columnas_encontradas: List[str] = field(default_factory=list)
31
+ error_msg: Optional[str] = None
32
+
33
+
34
+ class ERPScanner:
35
+ """Escanea directorios y detecta archivos ERP válidos."""
36
+
37
+ ERP_SIGNATURES = {
38
+ "dgvVentas": {
39
+ "required": {"ANHO", "MES", "ID_CLIENTE", "NOM_CLIENTE", "ID_ARTICULO",
40
+ "NOM_ARTICULO", "ID_VENDEDOR", "NOM_VENDEDOR", "TPO_DOC",
41
+ "SERIE_DOC", "NRO_DOC", "FECHA_ORIG", "REFERENCIA",
42
+ "CANTIDAD", "SOLES"},
43
+ "weight": 15
44
+ },
45
+ "generic": {
46
+ "required": {"CANTIDAD", "SOLES"},
47
+ "weight": 5
48
+ }
49
+ }
50
+
51
+ MIN_VALID_SCORE = 10
52
+
53
+ def scan_directory(self, directory: Path, recursive: bool = True) -> List[ERPFileInfo]:
54
+ if not directory.exists():
55
+ raise FileNotFoundError(f"Directorio no encontrado: {directory}")
56
+
57
+ patterns = ["*.xls", "*.xlsx", "*.csv"]
58
+ files = []
59
+ if recursive:
60
+ for pattern in patterns:
61
+ files.extend(directory.rglob(pattern))
62
+ else:
63
+ for pattern in patterns:
64
+ files.extend(directory.glob(pattern))
65
+
66
+ results = []
67
+ for file_path in files:
68
+ try:
69
+ info = self.analyze_file(file_path)
70
+ results.append(info)
71
+ except Exception as e:
72
+ info = ERPFileInfo(
73
+ path=file_path,
74
+ size_bytes=file_path.stat().st_size,
75
+ modified_time=datetime.fromtimestamp(file_path.stat().st_mtime),
76
+ erp_type="ERROR",
77
+ is_valid=False,
78
+ error_msg=str(e)
79
+ )
80
+ results.append(info)
81
+
82
+ return results
83
+
84
+ def analyze_file(self, file_path: Path) -> ERPFileInfo:
85
+ stat = file_path.stat()
86
+ info = ERPFileInfo(
87
+ path=file_path,
88
+ size_bytes=stat.st_size,
89
+ modified_time=datetime.fromtimestamp(stat.st_mtime)
90
+ )
91
+
92
+ try:
93
+ df_head = self._read_headers(file_path)
94
+ if df_head.empty:
95
+ info.error_msg = "Archivo vacío"
96
+ return info
97
+
98
+ info.columnas_encontradas = list(df_head.columns)
99
+ info.n_rows_estimate = len(df_head)
100
+
101
+ erp_type, score = self._detect_erp_type(df_head.columns)
102
+ info.erp_type = erp_type
103
+
104
+ missing = validate_columns(df_head)
105
+ info.missing_columns = missing
106
+
107
+ if erp_type != "UNKNOWN" and not missing:
108
+ info.is_valid = True
109
+ elif score >= self.MIN_VALID_SCORE:
110
+ info.is_valid = True
111
+ else:
112
+ info.is_valid = False
113
+ info.error_msg = f"Score {score} bajo o faltan columnas"
114
+
115
+ except Exception as e:
116
+ info.error_msg = f"Error: {str(e)}"
117
+
118
+ return info
119
+
120
+ def _read_headers(self, file_path: Path, nrows: int = 100) -> pd.DataFrame:
121
+ ext = file_path.suffix.lower()
122
+ try:
123
+ if ext == '.csv':
124
+ df = read_erp_file(str(file_path), '.csv', nrows=nrows)
125
+ elif ext in ('.xls', '.xlsx'):
126
+ df = read_erp_file(str(file_path), ext, nrows=nrows)
127
+ else:
128
+ raise ValueError(f"Extensión no soportada: {ext}")
129
+ except Exception as e:
130
+ raise ValueError(f"No se pudo leer: {e}")
131
+
132
+ df.columns = [c.strip().upper() for c in df.columns]
133
+ return df
134
+
135
+ def _detect_erp_type(self, columns: List[str]) -> Tuple[str, int]:
136
+ cols_upper = set(c.upper() for c in columns)
137
+ scores = {}
138
+ for name, sig in self.ERP_SIGNATURES.items():
139
+ matched = cols_upper & set(sig["required"])
140
+ score = len(matched) * sig["weight"]
141
+ specific = {"ANHO", "MES", "TPO_DOC", "DOC_CLIENTE"}
142
+ score += len(cols_upper & specific) * 3
143
+ scores[name] = score
144
+
145
+ best = max(scores, key=scores.get)
146
+ return (best, scores[best]) if scores[best] >= self.MIN_VALID_SCORE else ("UNKNOWN", scores[best])
147
+
148
+ def get_valid_files(self, files: List[ERPFileInfo]) -> List[ERPFileInfo]:
149
+ return [f for f in files if f.is_valid]
150
+
151
+ def get_invalid_files(self, files: List[ERPFileInfo]) -> List[ERPFileInfo]:
152
+ return [f for f in files if not f.is_valid]
153
+
154
+ def group_by_erp_type(self, files: List[ERPFileInfo]) -> dict:
155
+ groups = {}
156
+ for f in files:
157
+ groups.setdefault(f.erp_type, []).append(f)
158
+ return groups
159
+
160
+
161
+ def find_erp_files_in_dir(
162
+ directory: Path,
163
+ recursive: bool = True,
164
+ min_score: int = 10
165
+ ) -> Tuple[List[ERPFileInfo], List[ERPFileInfo]]:
166
+ scanner = ERPScanner()
167
+ scanner.MIN_VALID_SCORE = min_score
168
+ all_files = scanner.scan_directory(directory, recursive=recursive)
169
+ valid = scanner.get_valid_files(all_files)
170
+ invalid = scanner.get_invalid_files(all_files)
171
+ return valid, invalid
172
+
173
+
174
+ def process_single_file(filepath: Path) -> pd.DataFrame:
175
+ """Procesa un archivo ERP y retorna DataFrame."""
176
+ # Import diferido para evitar dependencia circular
177
+ from .pipeline import ingest_to_master
178
+ df = ingest_to_master(str(filepath), output_path=None, drop_totales=True)
179
+ if 'ARCHIVO_ORIGEN' not in df.columns:
180
+ df['ARCHIVO_ORIGEN'] = filepath.name
181
+ return df
182
+
183
+
184
+ def batch_process_files(
185
+ filepaths: List[Path],
186
+ merge_results: bool = True
187
+ ) -> pd.DataFrame:
188
+ results = []
189
+ for i, fp in enumerate(filepaths, 1):
190
+ try:
191
+ df = process_single_file(fp)
192
+ if not df.empty:
193
+ df['ORDEN_LOTE'] = i
194
+ results.append(df)
195
+ except Exception as e:
196
+ log.error(f"Error procesando {fp}: {e}")
197
+
198
+ if not results:
199
+ return pd.DataFrame()
200
+
201
+ if merge_results:
202
+ combined = pd.concat(results, ignore_index=True)
203
+ log.info(f"Batch: {len(combined)} filas de {len(results)} archivos")
204
+ return combined
205
+ return results
206
+
207
+
208
+ def merge_processed_data(dfs: List[pd.DataFrame], source_paths: List[Path]) -> pd.DataFrame:
209
+ if not dfs:
210
+ return pd.DataFrame()
211
+ dfs_with_source = []
212
+ for df, path in zip(dfs, source_paths):
213
+ df_copy = df.copy()
214
+ if 'ARCHIVO_ORIGEN' not in df_copy.columns:
215
+ df_copy['ARCHIVO_ORIGEN'] = path.name
216
+ dfs_with_source.append(df_copy)
217
+ combined = pd.concat(dfs_with_source, ignore_index=True)
218
+ return combined