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.
- package/README.md +37 -3
- package/package.json +16 -6
- package/py/pyproject.toml +4 -4
- package/py/requirements.txt +4 -0
- package/py/src/g360_core/__init__.py +67 -4
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/py/src/g360_core/batch_processor.py +120 -0
- package/py/src/g360_core/commercial_engine.py +305 -0
- package/py/src/g360_core/logger.py +40 -0
- package/py/src/g360_core/pipeline.py +578 -0
- package/py/src/g360_core/processor.py +634 -0
- package/py/src/g360_core/processor_segmentacion.py +859 -0
- package/py/src/g360_core/processor_sku.py +427 -0
- package/py/src/g360_core/scanner.py +218 -0
- package/py/src/g360_core/utils.py +435 -0
- package/src/cli.js +25 -2
- package/src/commands/ingest.js +193 -0
- package/src/commands/scan.js +102 -0
- package/src/commands/validate.js +126 -0
- package/src/lib/python_runner.js +89 -0
- package/py/src/g360_core/flet/__init__.py +0 -3
- package/py/src/g360_core/flet/ingestion_panel.py +0 -218
- 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
|