g360-cli 1.7.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -8
- 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 +35 -2
- package/src/commands/addon.js +188 -0
- package/src/commands/ingest.js +187 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/validate.js +150 -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
- package/src/assets/engine/g360-data-validator.js +0 -44
- package/src/assets/engine/g360-engine.js +0 -12
- package/src/assets/engine/g360-field-mapper.js +0 -35
- package/src/assets/engine/g360-skill-audit.mjs +0 -37
- package/src/assets/engine/g360-skill-meta-evaluator.mjs +0 -33
- package/src/lib/assets.js +0 -38
- package/src/lib/checksum.js +0 -27
- package/src/lib/config.js +0 -23
- package/src/lib/offline.js +0 -33
- package/src/lib/presenter.js +0 -24
- package/src/lib/rollback.js +0 -49
- package/src/lib/theme.js +0 -30
|
@@ -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
|