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,859 @@
1
+ """Segmentation and comparison methods for InsightProcessor — extracted from processor.py."""
2
+
3
+ import pandas as pd
4
+ import numpy as np
5
+
6
+ from .logger import get_logger
7
+
8
+ log = get_logger("processor_seg")
9
+
10
+
11
+ class ProcessorSegmentacion:
12
+ """Mixin class with all segmentation, grouping, and comparison methods."""
13
+
14
+
15
+ def get_resumen_por_vendedor(self) -> pd.DataFrame:
16
+ """
17
+ Genera resumen agregado por vendedor con KPIs completos.
18
+
19
+ Agrupa solo por ID_VENDEDOR. El nombre se toma del valor
20
+ mas frecuente para evitar duplicados por variaciones de nombre.
21
+ """
22
+ cache_key = self._cache_key("vendedor")
23
+ cached = self._get_cached(cache_key)
24
+ if cached is not None:
25
+ return cached.copy()
26
+
27
+ vendedor_col = next((c for c in self.df.columns if "ID_VENDEDOR" in c), None)
28
+ nom_col = next((c for c in self.df.columns if "NOM_VENDEDOR" in c), None)
29
+
30
+ if not vendedor_col:
31
+ return pd.DataFrame()
32
+
33
+ df_filtrado = self.df.dropna(subset=[vendedor_col])
34
+ df_filtrado = df_filtrado[df_filtrado[vendedor_col].astype(str).str.strip() != ""]
35
+
36
+ # Groupby solo por ID, nombre mas frecuente
37
+ resumen = df_filtrado.groupby(vendedor_col).agg(
38
+ venta_bruta=("VENTA_BRUTA", "sum"),
39
+ venta_neta=("VENTA_NETA", "sum"),
40
+ monto_nc=("MONTO_NC", "sum"),
41
+ n_documentos=("SOLES", "count"),
42
+ n_clientes=("ID_CLIENTE", "nunique"),
43
+ ).reset_index()
44
+
45
+ if nom_col:
46
+ nombre_frecuente = df_filtrado.groupby(vendedor_col)[nom_col].agg(
47
+ lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else x.iloc[0]
48
+ )
49
+ resumen[nom_col] = resumen[vendedor_col].map(nombre_frecuente)
50
+
51
+ resumen["tasa_devolucion"] = (
52
+ resumen["monto_nc"] / resumen["venta_bruta"].replace(0, np.nan) * 100
53
+ ).fillna(0)
54
+
55
+ resumen["alerta_devolucion"] = resumen["tasa_devolucion"] > self.devolucion_threshold
56
+
57
+ resultado = resumen.sort_values("venta_neta", ascending=False)
58
+ self._set_cached(cache_key, resultado)
59
+ return resultado
60
+
61
+ def get_resumen_por_linea(self) -> pd.DataFrame:
62
+ """
63
+ Genera resumen agregado por linea de producto con escala visual.
64
+
65
+ Agrupa por ID_LINEA. El nombre se toma del valor mas frecuente.
66
+ """
67
+ cache_key = self._cache_key("linea")
68
+ cached = self._get_cached(cache_key)
69
+ if cached is not None:
70
+ return cached.copy()
71
+
72
+ id_col = next((c for c in self.df.columns if "ID_LINEA" in c), None)
73
+ nom_col = next((c for c in self.df.columns if "NOM_LINEA" in c), None)
74
+
75
+ if not nom_col:
76
+ return pd.DataFrame()
77
+
78
+ group_col = id_col if id_col else nom_col
79
+
80
+ resumen = self.df.groupby(group_col).agg(
81
+ total_soles=("SOLES", "sum"),
82
+ venta_bruta=("VENTA_BRUTA", "sum"),
83
+ venta_neta=("VENTA_NETA", "sum"),
84
+ cantidad=("CANTIDAD", "sum"),
85
+ n_items=(group_col, "count"),
86
+ ).reset_index()
87
+
88
+ if id_col and nom_col and id_col != nom_col:
89
+ nombre_frecuente = self.df.groupby(id_col)[nom_col].agg(
90
+ lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else x.iloc[0]
91
+ )
92
+ resumen[nom_col] = resumen[id_col].map(nombre_frecuente)
93
+ resumen = resumen.rename(columns={id_col: "ID_LINEA"})
94
+
95
+ # Escala visual con raiz cubica para balancear diferencias grandes
96
+ max_val = resumen["total_soles"].abs().max()
97
+ if max_val > 0:
98
+ resumen["escala_visual"] = (
99
+ resumen["total_soles"].abs() ** (1 / 3)
100
+ ) / (max_val ** (1 / 3))
101
+ else:
102
+ resumen["escala_visual"] = 0
103
+
104
+ _total_sum = resumen["total_soles"].sum()
105
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
106
+ resumen["es_negativo"] = resumen["total_soles"] < 0
107
+
108
+ resultado = resumen.sort_values("total_soles", ascending=False)
109
+ self._set_cached(cache_key, resultado)
110
+ return resultado
111
+
112
+ def get_resumen_por_canal(self) -> pd.DataFrame:
113
+ """
114
+ Genera resumen por canal de distribucion (MAYORISTA, MINORISTA, SIN ASIGNAR).
115
+
116
+ Util para identificar que canal genera mas volumen de ventas y
117
+ detectar oportunidades de crecimiento en canales sub-atendidos.
118
+
119
+ Returns:
120
+ pd.DataFrame: Resumen por canal con total_soles, venta_bruta,
121
+ venta_neta, n_clientes, n_documentos, porcentaje.
122
+ """
123
+ canal_col = next((c for c in self.df.columns if "CANAL" in c), None)
124
+ if not canal_col:
125
+ return pd.DataFrame()
126
+
127
+ resumen = self.df.groupby(canal_col).agg(
128
+ total_soles=("SOLES", "sum"),
129
+ venta_bruta=("VENTA_BRUTA", "sum"),
130
+ venta_neta=("VENTA_NETA", "sum"),
131
+ n_clientes=("ID_CLIENTE", "nunique"),
132
+ n_documentos=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
133
+ ).reset_index()
134
+
135
+ _total_sum = resumen["total_soles"].sum()
136
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
137
+
138
+ return resumen.sort_values("total_soles", ascending=False)
139
+
140
+ def get_resumen_por_departamento(self) -> pd.DataFrame:
141
+ """
142
+ Genera resumen geografico por departamento (LA LIBERTAD, ANCASH, etc.).
143
+
144
+ Util para analisis de cobertura territorial y deteccion de
145
+ zonas con bajo rendimiento o sin atencion comercial.
146
+
147
+ Returns:
148
+ pd.DataFrame: Resumen por departamento con total_soles,
149
+ venta_neta, n_clientes, n_vendedores, porcentaje.
150
+ """
151
+ depto_col = next((c for c in self.df.columns if "DEPARTAMENTO" in c), None)
152
+ if not depto_col:
153
+ return pd.DataFrame()
154
+
155
+ resumen = self.df.groupby(depto_col).agg(
156
+ total_soles=("SOLES", "sum"),
157
+ venta_neta=("VENTA_NETA", "sum"),
158
+ n_clientes=("ID_CLIENTE", "nunique"),
159
+ n_vendedores=("ID_VENDEDOR", "nunique"),
160
+ ).reset_index()
161
+
162
+ _total_sum = resumen["total_soles"].sum()
163
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
164
+
165
+ return resumen.sort_values("total_soles", ascending=False)
166
+
167
+ def get_resumen_por_distrito(self) -> pd.DataFrame:
168
+ """
169
+ Genera resumen por distrito para analisis de calor comercial.
170
+
171
+ Permite identificar distritos con mayor concentracion de ventas
172
+ y detectar "silencios comerciales" (zonas sin actividad).
173
+
174
+ Returns:
175
+ pd.DataFrame: Resumen por distrito con total_soles,
176
+ venta_neta, n_clientes, porcentaje.
177
+ """
178
+ distrito_col = next((c for c in self.df.columns if "DISTRITO" in c), None)
179
+ if not distrito_col:
180
+ return pd.DataFrame()
181
+
182
+ resumen = self.df.groupby(distrito_col).agg(
183
+ total_soles=("SOLES", "sum"),
184
+ venta_neta=("VENTA_NETA", "sum"),
185
+ n_clientes=("ID_CLIENTE", "nunique"),
186
+ ).reset_index()
187
+
188
+ _total_sum = resumen["total_soles"].sum()
189
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
190
+
191
+ return resumen.sort_values("total_soles", ascending=False)
192
+
193
+ def get_resumen_por_sucursal(self) -> pd.DataFrame:
194
+ """
195
+ Genera resumen por sucursal para matriz de rendimiento.
196
+
197
+ Filtra automaticamente las filas sin sucursal registrada
198
+ (valores vacios o "ACUMULADO") para evitar distorsion en el analisis.
199
+
200
+ Util para:
201
+ - Identificar sucursales con mejor/peor rendimiento
202
+ - Detectar sucursales que negocian precios mas bajos
203
+ - Analisis de cobertura por punto de venta
204
+
205
+ Returns:
206
+ pd.DataFrame: Resumen por sucursal con total_soles, venta_neta,
207
+ n_clientes, n_documentos, porcentaje.
208
+ """
209
+ sucursal_col = next((c for c in self.df.columns if "NOM_SUCURSAL" in c), None)
210
+ if not sucursal_col:
211
+ return pd.DataFrame()
212
+
213
+ resumen = self.df.groupby(sucursal_col).agg(
214
+ total_soles=("SOLES", "sum"),
215
+ venta_neta=("VENTA_NETA", "sum"),
216
+ n_clientes=("ID_CLIENTE", "nunique"),
217
+ n_documentos=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
218
+ ).reset_index()
219
+
220
+ # Filtrar filas sin sucursal registrada (vacias o "ACUMULADO")
221
+ resumen = resumen[resumen[sucursal_col].astype(str).str.strip() != ""]
222
+ _total_sum = resumen["total_soles"].sum()
223
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
224
+
225
+ return resumen.sort_values("total_soles", ascending=False)
226
+
227
+ def get_resumen_por_cliente(self) -> pd.DataFrame:
228
+ """
229
+ Genera resumen por cliente con analisis de cartera.
230
+
231
+ Agrupa solo por ID_CLIENTE. El nombre se toma del valor mas frecuente.
232
+ """
233
+ cache_key = self._cache_key("cliente")
234
+ cached = self._get_cached(cache_key)
235
+ if cached is not None:
236
+ return cached.copy()
237
+
238
+ cliente_col = next((c for c in self.df.columns if "ID_CLIENTE" in c), None)
239
+ nom_cliente = next((c for c in self.df.columns if "NOM_CLIENTE" in c), None)
240
+
241
+ if not cliente_col:
242
+ return pd.DataFrame()
243
+
244
+ df_filtrado = self.df.dropna(subset=[cliente_col])
245
+ df_filtrado = df_filtrado[df_filtrado[cliente_col].astype(str).str.strip() != ""]
246
+
247
+ resumen = df_filtrado.groupby(cliente_col).agg(
248
+ total_compras=("SOLES", "sum"),
249
+ venta_bruta=("VENTA_BRUTA", "sum"),
250
+ venta_neta=("VENTA_NETA", "sum"),
251
+ n_compras=("NRO_DOC", "nunique") if "NRO_DOC" in df_filtrado.columns else ("SOLES", "count"),
252
+ ).reset_index()
253
+
254
+ if nom_cliente:
255
+ nombre_frecuente = df_filtrado.groupby(cliente_col)[nom_cliente].agg(
256
+ lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else x.iloc[0]
257
+ )
258
+ resumen[nom_cliente] = resumen[cliente_col].map(nombre_frecuente)
259
+
260
+ # Calcular dias sin comprar para deteccion de clientes dormidos
261
+ if "FECHA_DT" in df_filtrado.columns:
262
+ fecha_max = df_filtrado["FECHA_DT"].max()
263
+ ultima_compra = df_filtrado.groupby(cliente_col)["FECHA_DT"].max()
264
+ resumen["ultima_compra"] = resumen[cliente_col].map(ultima_compra)
265
+ resumen["ultima_compra_dt"] = pd.to_datetime(resumen["ultima_compra"], errors="coerce")
266
+ resumen["dias_sin_comprar"] = (fecha_max - resumen["ultima_compra_dt"]).dt.days
267
+ resumen["es_dormido"] = resumen["dias_sin_comprar"] > 30
268
+
269
+ resultado = resumen.sort_values("total_compras", ascending=False)
270
+ self._set_cached(cache_key, resultado)
271
+ return resultado
272
+
273
+ def get_clientes_dormidos(self) -> pd.DataFrame:
274
+ """
275
+ Retorna solo los clientes clasificados como "dormidos".
276
+
277
+ Un cliente dormido es aquel que no ha realizado compras en los
278
+ ultimos 30 dias del periodo analizado. Estos clientes representan
279
+ oportunidades de reactivacion para el equipo comercial.
280
+
281
+ Returns:
282
+ pd.DataFrame: Clientes dormidos con sus datos de resumen.
283
+ DataFrame vacio si no hay clientes dormidos.
284
+ """
285
+ resumen = self.get_resumen_por_cliente()
286
+ if resumen.empty or "es_dormido" not in resumen.columns:
287
+ return pd.DataFrame()
288
+ return resumen[resumen["es_dormido"]]
289
+
290
+ def get_hhi_por_vendedor(self) -> pd.DataFrame:
291
+ """
292
+ Calcula el indice HHI (Herfindahl-Hirschman) por vendedor.
293
+
294
+ El HHI mide la concentracion de la cartera de clientes de un vendedor.
295
+ Se calcula como la suma de los cuadrados de las participaciones de
296
+ cada cliente en las ventas totales del vendedor.
297
+
298
+ Interpretacion del HHI:
299
+ HHI > 0.50: Concentracion ALTA - riesgo de perder gran volumen
300
+ si un cliente clave se va
301
+ HHI 0.25-0.50: Concentracion MEDIA - cartera razonablemente
302
+ diversificada pero con algunos clientes dominantes
303
+ HHI < 0.25: Concentracion BAJA - cartera bien diversificada,
304
+ riesgo distribuido entre muchos clientes
305
+
306
+ Ejemplo:
307
+ Vendedor con 2 clientes: Cliente A = 90%, Cliente B = 10%
308
+ HHI = 0.9^2 + 0.1^2 = 0.81 + 0.01 = 0.82 -> ALTA concentracion
309
+
310
+ Vendedor con 10 clientes iguales: cada uno = 10%
311
+ HHI = 10 * 0.1^2 = 0.10 -> BAJA concentracion
312
+
313
+ Returns:
314
+ pd.DataFrame: HHI por vendedor con columns: ID_VENDEDOR, HHI,
315
+ n_clientes, total_ventas, concentracion.
316
+ """
317
+ vendedor_col = next((c for c in self.df.columns if "ID_VENDEDOR" in c), None)
318
+ cliente_col = next((c for c in self.df.columns if "ID_CLIENTE" in c), None)
319
+
320
+ if not vendedor_col or not cliente_col:
321
+ return pd.DataFrame()
322
+
323
+ ventas_por_vendedor_cliente = self.df.groupby([vendedor_col, cliente_col])["SOLES"].sum().reset_index()
324
+
325
+ hhi_results = []
326
+ for vendedor_id, group in ventas_por_vendedor_cliente.groupby(vendedor_col):
327
+ total = group["SOLES"].sum()
328
+ if total > 0:
329
+ shares = (group["SOLES"] / total) ** 2
330
+ hhi = shares.sum()
331
+ hhi_results.append({
332
+ vendedor_col: vendedor_id,
333
+ "HHI": hhi,
334
+ "n_clientes": len(group),
335
+ "total_ventas": total,
336
+ "concentracion": "ALTA" if hhi > 0.5 else ("MEDIA" if hhi > 0.25 else "BAJA"),
337
+ })
338
+
339
+ return pd.DataFrame(hhi_results).sort_values("HHI", ascending=False)
340
+
341
+ def get_anomalias_vendedores(self) -> pd.DataFrame:
342
+ """Detect anomalous vendors using IsolationForest."""
343
+ from src.core.anomaly_detector import AnomalyDetector
344
+ return AnomalyDetector().detect_vendors(self.df)
345
+
346
+ def get_anomalias_clientes(self) -> pd.DataFrame:
347
+ """Detect anomalous clients using IsolationForest."""
348
+ from src.core.anomaly_detector import AnomalyDetector
349
+ return AnomalyDetector().detect_clients(self.df)
350
+
351
+ def get_resumen_por_grupo(self) -> pd.DataFrame:
352
+ """
353
+ Genera resumen por grupo de producto (subcategoria de linea).
354
+
355
+ La jerarquia de productos es: Linea -> Grupo -> Tipo -> Familia.
356
+ Este metodo agrega a nivel de Grupo para analisis intermedio
357
+ entre linea y articulo individual.
358
+
359
+ Returns:
360
+ pd.DataFrame: Resumen por grupo con total_soles, cantidad,
361
+ n_items, porcentaje.
362
+ """
363
+ grupo_col = next((c for c in self.df.columns if "NOM_GRUPO" in c), None)
364
+ if not grupo_col:
365
+ return pd.DataFrame()
366
+
367
+ resumen = self.df.groupby(grupo_col).agg(
368
+ total_soles=("SOLES", "sum"),
369
+ cantidad=("CANTIDAD", "sum"),
370
+ n_items=(grupo_col, "count"),
371
+ ).reset_index()
372
+
373
+ _total_sum = resumen["total_soles"].sum()
374
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
375
+
376
+ return resumen.sort_values("total_soles", ascending=False)
377
+
378
+ def get_resumen_por_estado_linea(self) -> pd.DataFrame:
379
+ """
380
+ Compara el rendimiento de LINEA NUEVA vs LINEA TRADICIONAL.
381
+
382
+ La columna ESTADO_LINEA clasifica cada linea de producto como:
383
+ - "LINEA NUEVA": Productos recientemente incorporados al catalogo
384
+ - "LINEA TRADICIONAL": Productos establecidos en el catalogo
385
+
386
+ Este analisis es crucial para:
387
+ - Medir la adopcion de nuevos productos en el mercado
388
+ - Detectar si las lineas nuevas estan cannibalizando las tradicionales
389
+ - Evaluar la estrategia de expansion de catalogo
390
+ - Identificar oportunidades de cross-selling
391
+
392
+ Returns:
393
+ pd.DataFrame: Resumen por estado de linea con total_soles,
394
+ venta_neta, cantidad, n_clientes, n_articulos,
395
+ n_lineas, porcentaje.
396
+ """
397
+ estado_col = next((c for c in self.df.columns if "ESTADO_LINEA" in c), None)
398
+ if not estado_col:
399
+ return pd.DataFrame()
400
+
401
+ resumen = self.df.groupby(estado_col).agg(
402
+ total_soles=("SOLES", "sum"),
403
+ venta_neta=("VENTA_NETA", "sum"),
404
+ cantidad=("CANTIDAD", "sum"),
405
+ n_clientes=("ID_CLIENTE", "nunique"),
406
+ n_articulos=("ID_ARTICULO", "nunique"),
407
+ n_lineas=("NOM_LINEA", "nunique"),
408
+ ).reset_index()
409
+
410
+ _total_sum = resumen["total_soles"].sum()
411
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
412
+
413
+ return resumen.sort_values("total_soles", ascending=False)
414
+
415
+ def get_resumen_linea_por_estado(self) -> pd.DataFrame:
416
+ """
417
+ Desglose de cada linea clasificada como nueva o tradicional.
418
+
419
+ Combina NOM_LINEA con ESTADO_LINEA para mostrar exactamente
420
+ que lineas son nuevas y cuales son tradicionales, con sus
421
+ respectivos volumenes de venta.
422
+
423
+ Util para identificar lineas especificas que necesitan
424
+ estrategias de promocion o proteccion diferentes.
425
+
426
+ Returns:
427
+ pd.DataFrame: Resumen por linea y estado con total_soles,
428
+ cantidad, n_items, porcentaje.
429
+ """
430
+ estado_col = next((c for c in self.df.columns if "ESTADO_LINEA" in c), None)
431
+ linea_col = next((c for c in self.df.columns if "NOM_LINEA" in c), None)
432
+
433
+ if not estado_col or not linea_col:
434
+ return pd.DataFrame()
435
+
436
+ resumen = self.df.groupby([linea_col, estado_col]).agg(
437
+ total_soles=("SOLES", "sum"),
438
+ cantidad=("CANTIDAD", "sum"),
439
+ n_items=(linea_col, "count"),
440
+ ).reset_index()
441
+
442
+ _total_sum = resumen["total_soles"].sum()
443
+ resumen["porcentaje"] = resumen["total_soles"] / _total_sum if _total_sum != 0 else 0.0
444
+
445
+ return resumen.sort_values("total_soles", ascending=False)
446
+
447
+ def get_resumen_por_pedido(self) -> pd.DataFrame:
448
+ """
449
+ Genera resumen por ID_PEDIDO para trazabilidad completa.
450
+
451
+ Un pedido (ID_PEDIDO) puede generar multiples facturas cuando:
452
+ - El pedido excede el stock disponible y se despacha parcial
453
+ - El cliente solicita facturas separadas por sucursal
454
+ - Hay condiciones de pago diferentes dentro del mismo pedido
455
+
456
+ La columna es_multiples_facturas indica si un pedido genero
457
+ mas de una factura, lo cual es util para:
458
+ - Medir eficiencia de despacho (pedidos parciales = ineficiencia)
459
+ - Identificar clientes con logistica compleja
460
+ - Tracking de cumplimiento de pedidos
461
+
462
+ Returns:
463
+ pd.DataFrame: Resumen por pedido con total_soles, cantidad,
464
+ n_facturas, n_skus, n_clientes, primer_documento,
465
+ es_multiples_facturas.
466
+ """
467
+ pedido_col = next((c for c in self.df.columns if "ID_PEDIDO" in c), None)
468
+ if not pedido_col:
469
+ return pd.DataFrame()
470
+
471
+ # Filtrar filas con ID_PEDIDO valido (no vacio)
472
+ df_pedidos = self.df[self.df[pedido_col].astype(str).str.strip() != ""]
473
+ if df_pedidos.empty:
474
+ return pd.DataFrame()
475
+
476
+ resumen = df_pedidos.groupby(pedido_col).agg(
477
+ total_soles=("SOLES", "sum"),
478
+ cantidad=("CANTIDAD", "sum"),
479
+ n_facturas=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
480
+ n_skus=("ID_ARTICULO", "nunique"),
481
+ n_clientes=("ID_CLIENTE", "nunique"),
482
+ primer_documento=("FECHA_DT", "min") if "FECHA_DT" in self.df.columns else ("SOLES", "count"),
483
+ ).reset_index()
484
+
485
+ # Marcar pedidos que generaron multiples facturas
486
+ resumen["es_multiples_facturas"] = resumen["n_facturas"] > 1
487
+
488
+ return resumen.sort_values("total_soles", ascending=False)
489
+
490
+ def get_facturas_por_pedido(self, pedido_id: str) -> pd.DataFrame:
491
+ """
492
+ Retorna todas las facturas asociadas a un ID_PEDIDO especifico.
493
+
494
+ Util para drill-down desde el resumen de pedidos hacia el
495
+ detalle de cada documento generado.
496
+
497
+ Args:
498
+ pedido_id: ID del pedido (ej: "CC09028").
499
+
500
+ Returns:
501
+ pd.DataFrame: Todas las filas del pedido solicitado.
502
+ """
503
+ pedido_col = next((c for c in self.df.columns if "ID_PEDIDO" in c), None)
504
+ if not pedido_col:
505
+ return pd.DataFrame()
506
+
507
+ return self.df[self.df[pedido_col] == pedido_id].copy()
508
+
509
+ def get_adopcion_lineas_nuevas_por_cliente(self) -> pd.DataFrame:
510
+ """
511
+ Mide la tasa de adopcion de lineas nuevas por parte de clientes tradicionales.
512
+
513
+ Logica de clasificacion:
514
+ - Cliente tradicional: Ha comprado al menos una vez en LINEA TRADICIONAL
515
+ - Cliente con adopcion: Ha comprado en AMBAS categorias (tradicional + nueva)
516
+ - Cliente solo tradicional: Solo compra lineas tradicionales (oportunidad)
517
+ - Cliente solo nueva: Cliente nuevo que solo compra lineas nuevas
518
+
519
+ La tasa de adopcion se calcula como:
520
+ (Clientes con AMBAS / Clientes tradicionales) * 100
521
+
522
+ Una tasa baja indica que los clientes tradicionales no estan
523
+ probando los nuevos productos, lo cual puede ser senal de:
524
+ - Falta de comunicacion sobre nuevos productos
525
+ - Resistencia al cambio
526
+ - Canal de distribucion inadecuado para nuevas lineas
527
+
528
+ Returns:
529
+ pd.DataFrame: 6 filas con metricas de adopcion:
530
+ - Clientes con lineas tradicionales
531
+ - Clientes con lineas nuevas
532
+ - Clientes con AMBAS (adopcion)
533
+ - Solo tradicional (sin adopcion)
534
+ - Solo nueva (cliente nuevo)
535
+ - Tasa de adopcion (porcentaje)
536
+ """
537
+ estado_col = next((c for c in self.df.columns if "ESTADO_LINEA" in c), None)
538
+ cliente_col = next((c for c in self.df.columns if "ID_CLIENTE" in c), None)
539
+
540
+ if not estado_col or not cliente_col:
541
+ return pd.DataFrame()
542
+
543
+ # Identificar clientes que compraron en cada categoria
544
+ clientes_tradicional = set(
545
+ self.df[self.df[estado_col].astype(str).str.upper().str.contains("TRADICIONAL", na=False)][cliente_col].unique()
546
+ )
547
+ clientes_nueva = set(
548
+ self.df[self.df[estado_col].astype(str).str.upper().str.contains("NUEVA", na=False)][cliente_col].unique()
549
+ )
550
+
551
+ # Calcular intersecciones y diferencias
552
+ clientes_ambas = clientes_tradicional & clientes_nueva
553
+ clientes_solo_tradicional = clientes_tradicional - clientes_nueva
554
+ clientes_solo_nueva = clientes_nueva - clientes_tradicional
555
+
556
+ return pd.DataFrame([{
557
+ "metrica": "Clientes con lineas tradicionales",
558
+ "valor": len(clientes_tradicional),
559
+ }, {
560
+ "metrica": "Clientes con lineas nuevas",
561
+ "valor": len(clientes_nueva),
562
+ }, {
563
+ "metrica": "Clientes con AMBAS (adopcion)",
564
+ "valor": len(clientes_ambas),
565
+ }, {
566
+ "metrica": "Solo tradicional (sin adopcion)",
567
+ "valor": len(clientes_solo_tradicional),
568
+ }, {
569
+ "metrica": "Solo nueva (cliente nuevo)",
570
+ "valor": len(clientes_solo_nueva),
571
+ }, {
572
+ "metrica": "Tasa de adopcion",
573
+ "valor": round(len(clientes_ambas) / len(clientes_tradicional) * 100, 1) if clientes_tradicional else 0,
574
+ }])
575
+
576
+ def get_comparador_temporal(self, fecha_inicio_a, fecha_fin_a, fecha_inicio_b, fecha_fin_b, sku: str = None) -> dict:
577
+ """
578
+ Compara dos rangos de fechas y calcula variacion porcentual.
579
+
580
+ Este metodo responde preguntas como:
581
+ - "Que paso entre febrero y abril?"
582
+ - "Crecieron las ventas este mes vs el anterior?"
583
+ - "Perdimos o ganamos clientes en el periodo?"
584
+
585
+ Para cada metrica (ventas, clientes, SKUs, documentos, lineas) calcula:
586
+ - Valor en periodo A
587
+ - Valor en periodo B
588
+ - Variacion porcentual: ((B - A) / A) * 100
589
+
590
+ Ademas calcula:
591
+ - skus_nuevos: SKUs que aparecieron en B pero no en A
592
+ - skus_perdidos: SKUs que estaban en A pero desaparecieron en B
593
+
594
+ Args:
595
+ fecha_inicio_a: Fecha inicio periodo A (ej: "2026-03-01")
596
+ fecha_fin_a: Fecha fin periodo A (ej: "2026-03-31")
597
+ fecha_inicio_b: Fecha inicio periodo B (ej: "2026-04-01")
598
+ fecha_fin_b: Fecha fin periodo B (ej: "2026-04-30")
599
+ sku: Opcional. ID del SKU para filtrar la comparacion.
600
+
601
+ Returns:
602
+ dict: Con claves para cada metrica conteniendo:
603
+ - periodo_a: Valor en periodo A
604
+ - periodo_b: Valor en periodo B
605
+ - variacion_pct: Cambio porcentual
606
+ - skus_nuevos: SKUs nuevos en periodo B
607
+ - skus_perdidos: SKUs perdidos en periodo B
608
+ """
609
+ if "FECHA_DT" not in self.df.columns:
610
+ return {}
611
+
612
+ df = self.df.dropna(subset=["FECHA_DT"])
613
+ if sku:
614
+ df = df[df["ID_ARTICULO"] == sku]
615
+
616
+ # Crear mascaras para cada periodo
617
+ mask_a = (df["FECHA_DT"] >= pd.to_datetime(fecha_inicio_a)) & (df["FECHA_DT"] <= pd.to_datetime(fecha_fin_a))
618
+ mask_b = (df["FECHA_DT"] >= pd.to_datetime(fecha_inicio_b)) & (df["FECHA_DT"] <= pd.to_datetime(fecha_fin_b))
619
+
620
+ df_a = df[mask_a]
621
+ df_b = df[mask_b]
622
+
623
+ # Funcion interna para calcular estadisticas de un subconjunto
624
+ def calc_stats(d):
625
+ return {
626
+ "ventas": d["SOLES"].sum() if not d.empty else 0,
627
+ "clientes": d["ID_CLIENTE"].nunique() if "ID_CLIENTE" in d.columns and not d.empty else 0,
628
+ "skus": d["ID_ARTICULO"].nunique() if "ID_ARTICULO" in d.columns and not d.empty else 0,
629
+ "documentos": d["NRO_DOC"].nunique() if "NRO_DOC" in d.columns and not d.empty else 0,
630
+ "lineas": d["NOM_LINEA"].nunique() if "NOM_LINEA" in d.columns and not d.empty else 0,
631
+ }
632
+
633
+ stats_a = calc_stats(df_a)
634
+ stats_b = calc_stats(df_b)
635
+
636
+ # Calcular variacion porcentual para cada metrica
637
+ comparacion = {}
638
+ for key in stats_a:
639
+ val_a = stats_a[key]
640
+ val_b = stats_b[key]
641
+ if val_a > 0:
642
+ variacion = round((val_b - val_a) / val_a * 100, 1)
643
+ elif val_b > 0:
644
+ variacion = 100.0 # De 0 a algo = crecimiento del 100%
645
+ else:
646
+ variacion = 0.0 # Sin cambios
647
+ comparacion[key] = {
648
+ "periodo_a": val_a,
649
+ "periodo_b": val_b,
650
+ "variacion_pct": variacion,
651
+ }
652
+
653
+ # Calcular SKUs nuevos y perdidos entre periodos
654
+ skus_a = set(df_a["ID_ARTICULO"].unique()) if "ID_ARTICULO" in df_a.columns else set()
655
+ skus_b = set(df_b["ID_ARTICULO"].unique()) if "ID_ARTICULO" in df_b.columns else set()
656
+
657
+ comparacion["skus_nuevos"] = len(skus_b - skus_a)
658
+ comparacion["skus_perdidos"] = len(skus_a - skus_b)
659
+
660
+ return comparacion
661
+
662
+ def get_skus_no_comprados_por_cliente(self, cliente_id: str) -> pd.DataFrame:
663
+ """
664
+ Gap Analysis: Identifica SKUs del catalogo que el cliente NO compro.
665
+
666
+ Este analisis responde: "Que SKUs de la linea Lapiceros no compra?"
667
+
668
+ Logica:
669
+ 1. Obtener todos los SKUs vendidos en el dataset (catalogo efectivo)
670
+ 2. Obtener los SKUs que compro este cliente especifico
671
+ 3. Retornar la diferencia: SKUs del catalogo - SKUs del cliente
672
+
673
+ Los resultados se ordenan por n_clientes descendente, mostrando
674
+ primero los SKUs que MAS clientes compran pero este cliente no.
675
+ Estos son los de mayor potencial de venta.
676
+
677
+ Util para:
678
+ - Identificar oportunidades de cross-selling
679
+ - Preparar propuestas comerciales personalizadas
680
+ - Detectar gaps en la cartera de productos del cliente
681
+
682
+ Args:
683
+ cliente_id: ID del cliente (ej: "68414").
684
+
685
+ Returns:
686
+ pd.DataFrame: SKUs no comprados con columns: ID_ARTICULO,
687
+ nom_articulo, nom_linea, total_ventas, n_clientes,
688
+ comprado_por_cliente (siempre False).
689
+ """
690
+ cliente_col = next((c for c in self.df.columns if "ID_CLIENTE" in c), None)
691
+ if not cliente_col:
692
+ return pd.DataFrame()
693
+
694
+ # Catalogo completo: todos los SKUs vendidos con sus agregaciones
695
+ todos_skus = self.df.groupby("ID_ARTICULO").agg(
696
+ nom_articulo=("NOM_ARTICULO", "first"),
697
+ nom_linea=("NOM_LINEA", "first"),
698
+ total_ventas=("SOLES", "sum"),
699
+ n_clientes=("ID_CLIENTE", "nunique"),
700
+ ).reset_index()
701
+
702
+ # SKUs que este cliente ya compro
703
+ skus_cliente = set(self.df[self.df[cliente_col] == cliente_id]["ID_ARTICULO"].unique())
704
+
705
+ # Marcar cuales fueron comprados por el cliente
706
+ todos_skus["comprado_por_cliente"] = todos_skus["ID_ARTICULO"].isin(skus_cliente)
707
+
708
+ # Filtrar solo los NO comprados y ordenar por potencial (n_clientes)
709
+ no_comprados = todos_skus[~todos_skus["comprado_por_cliente"]].sort_values("n_clientes", ascending=False)
710
+
711
+ return no_comprados
712
+
713
+ def get_clientes_con_sucursales(self) -> pd.DataFrame:
714
+ """
715
+ Identifica clientes con y sin sucursales registradas.
716
+
717
+ Deteccion automatica:
718
+ - Cuenta sucursales unicas por cliente (excluyendo vacios)
719
+ - Marca clientes con n_sucursales > 0 como "tiene_sucursales"
720
+ - Clientes sin sucursales tienen ventas directas (ACUMULADO)
721
+
722
+ Esta informacion es crucial para:
723
+ - Adaptar la UI: ocultar matriz de sucursales si no hay datos
724
+ - Estrategia comercial: clientes con sucursales necesitan
725
+ gestion de cuenta diferente (pricing por sucursal, etc.)
726
+ - Analisis de cobertura: identificar clientes multi-punto
727
+
728
+ Returns:
729
+ pd.DataFrame: Clientes con columns: ID_CLIENTE, nom_cliente,
730
+ total_soles, n_sucursales, tiene_sucursales.
731
+ Ordenado por total_soles descendente.
732
+ """
733
+ cliente_col = next((c for c in self.df.columns if "ID_CLIENTE" in c), None)
734
+ sucursal_col = next((c for c in self.df.columns if "NOM_SUCURSAL" in c), None)
735
+
736
+ if not cliente_col:
737
+ return pd.DataFrame()
738
+
739
+ resumen = self.df.groupby(cliente_col).agg(
740
+ nom_cliente=("NOM_CLIENTE", "first"),
741
+ total_soles=("SOLES", "sum"),
742
+ ).reset_index()
743
+
744
+ if sucursal_col:
745
+ # Contar sucursales unicas (excluyendo valores vacios)
746
+ resumen["n_sucursales"] = self.df.groupby(cliente_col)[sucursal_col].apply(
747
+ lambda x: x[x.astype(str).str.strip() != ""].nunique()
748
+ ).values
749
+ resumen["n_sucursales"] = resumen["n_sucursales"].fillna(0).astype(int)
750
+ resumen["tiene_sucursales"] = resumen["n_sucursales"] > 0
751
+ else:
752
+ resumen["n_sucursales"] = 0
753
+ resumen["tiene_sucursales"] = False
754
+
755
+ return resumen.sort_values("total_soles", ascending=False)
756
+
757
+
758
+ def filter_by(self, **kwargs) -> pd.DataFrame:
759
+ """
760
+ Filtrado generico por cualquier columna del dataset.
761
+
762
+ Permite filtrar el dataset por multiples criterios simultaneamente.
763
+ La busqueda es case-insensitive y busca coincidencia exacta.
764
+
765
+ Ejemplos de uso:
766
+ >>> proc.filter_by(ID_CLIENTE="68414")
767
+ >>> proc.filter_by(ID_CLIENTE="68414", MES="04-ABRIL")
768
+ >>> proc.filter_by(NOM_LINEA="ARCHIVO", TPO_DOC="F01")
769
+
770
+ Args:
771
+ **kwargs: Pares columna=valor para filtrar. Los nombres de
772
+ columna son case-insensitive.
773
+
774
+ Returns:
775
+ pd.DataFrame: Filas que cumplen TODOS los criterios especificados.
776
+ """
777
+ mask = pd.Series(True, index=self.df.index)
778
+ for col, value in kwargs.items():
779
+ col_upper = col.upper()
780
+ matching_col = next((c for c in self.df.columns if c.upper() == col_upper), None)
781
+ if matching_col:
782
+ mask &= self.df[matching_col].astype(str).str.upper() == str(value).upper()
783
+ return self.df.loc[mask]
784
+
785
+ def filter_by_smart_query(self, query_str: str) -> pd.DataFrame:
786
+ """
787
+ Filtra el dataset usando una query inteligente que soporta:
788
+ - Terminos de texto (para SKU, nombre de articulo, ID de cliente, nombre de cliente, vendedor)
789
+ - Operadores de comparacion para cantidad y precio (ej. cant>10, precio<=5.5, soles>100)
790
+ """
791
+ if not query_str or query_str.strip() == "":
792
+ return self.df.copy()
793
+
794
+ query_str = query_str.strip()
795
+
796
+ if len(query_str) > 200:
797
+ query_str = query_str[:200]
798
+
799
+ import re
800
+ filtered_df = self.df
801
+
802
+ # Expresiones regulares para operadores: cant>10, cantidad>=100, precio<=5.5, soles>200
803
+ op_pattern = r"(cantidad|cant|precio_unit|precio|soles)\s*(>=|<=|>|<|=)\s*([0-9.-]+)"
804
+ matches = re.findall(op_pattern, query_str, re.IGNORECASE)
805
+
806
+ # Limpiar la query de los operadores para quedarnos con el texto libre
807
+ clean_query = re.sub(op_pattern, "", query_str, flags=re.IGNORECASE).strip()
808
+
809
+ # 1. Aplicar filtros de operadores
810
+ for field, op, val_str in matches:
811
+ try:
812
+ val = float(val_str)
813
+ except ValueError:
814
+ log.debug("Valor no numerico ignorado en filtro: %s=%s", field, val_str)
815
+ continue
816
+ col_target = None
817
+ field_lower = field.lower()
818
+
819
+ if "cant" in field_lower:
820
+ col_target = "CANTIDAD"
821
+ elif "precio" in field_lower:
822
+ col_target = "PRECIO_UNID"
823
+ elif "soles" in field_lower:
824
+ col_target = "SOLES"
825
+
826
+ if col_target and col_target in filtered_df.columns:
827
+ if op == ">":
828
+ filtered_df = filtered_df[filtered_df[col_target] > val]
829
+ elif op == "<":
830
+ filtered_df = filtered_df[filtered_df[col_target] < val]
831
+ elif op == ">=":
832
+ filtered_df = filtered_df[filtered_df[col_target] >= val]
833
+ elif op == "<=":
834
+ filtered_df = filtered_df[filtered_df[col_target] <= val]
835
+ elif op == "=":
836
+ filtered_df = filtered_df[filtered_df[col_target] == val]
837
+
838
+ # 2. Aplicar filtro de texto libre
839
+ if clean_query:
840
+ tokens = clean_query.split()
841
+ for token in tokens:
842
+ token_upper = token.upper()
843
+ masks = []
844
+
845
+ search_cols = ["ID_ARTICULO", "NOM_ARTICULO", "ID_CLIENTE", "NOM_CLIENTE", "ID_VENDEDOR", "NOM_VENDEDOR", "NRO_DOC"]
846
+ for col in search_cols:
847
+ if col in filtered_df.columns:
848
+ if col in ["ID_ARTICULO", "ID_CLIENTE", "ID_VENDEDOR", "NRO_DOC"]:
849
+ masks.append(filtered_df[col].astype(str).str.upper() == token_upper)
850
+ else:
851
+ masks.append(filtered_df[col].astype(str).str.upper().str.contains(token_upper, na=False))
852
+
853
+ if masks:
854
+ combined_mask = masks[0]
855
+ for m in masks[1:]:
856
+ combined_mask |= m
857
+ filtered_df = filtered_df[combined_mask]
858
+
859
+ return filtered_df