g360-cli 1.7.0 → 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 (42) 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/assets/templates/python-flet/src/test_ingestion.py +1 -1
  35. package/src/cli.js +25 -2
  36. package/src/commands/ingest.js +193 -0
  37. package/src/commands/scan.js +102 -0
  38. package/src/commands/validate.js +126 -0
  39. package/src/lib/python_runner.js +89 -0
  40. package/py/src/g360_core/flet/__init__.py +0 -3
  41. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  42. package/py/src/g360_core/ingestion.py +0 -480
@@ -0,0 +1,634 @@
1
+ """
2
+ G360 Insight Lens - Motor de Procesamiento de Datos
3
+ ====================================================
4
+ Modulo central que transforma datos crudos de archivos ERP (.xls/.xlsx) en
5
+ informacion analitica estructurada para la toma de decisiones comerciales.
6
+
7
+ Responsabilidades principales:
8
+ - Limpieza y normalizacion de headers ERP (ignora logos, filas vacias)
9
+ - Normalizacion de Notas de Credito (invierte signo de montos positivos)
10
+ - Calculo de KPIs: Venta Bruta, Venta Neta, Tasa de Devolucion
11
+ - Deteccion automatica de perfil: Asistente (1 vendedor) vs Supervisor (>1)
12
+ - Agregaciones por vendedor, linea, canal, departamento, distrito, sucursal
13
+ - Analisis de cartera: clientes dormidos, concentracion HHI
14
+ - Gap Analysis: SKUs no comprados por cliente
15
+ - Comparador temporal: periodo A vs periodo B con variacion porcentual
16
+ - Historial de precios por SKU con deteccion de anomalias (>10% variacion)
17
+ - Analisis de sucursales: deteccion automatica con/sin sucursales
18
+ - Trazabilidad por pedido: agrupacion de facturas por ID_PEDIDO
19
+ - Adopcion de lineas nuevas: porcentaje de clientes tradicionales que compran nuevas
20
+
21
+ Formato ERP esperado (columnas):
22
+ ANHO, MES, DOC_CLIENTE, ID_CLIENTE, NOM_CLIENTE, ID_LOCALIDAD_UBIGEO,
23
+ NOM_DEPARTAMENTO, NOM_PROVINCIA, NOM_DISTRITO, ID_LINEA, NOM_LINEA,
24
+ ID_GRUPO, NOM_GRUPO, ID_TIPO, NOM_TIPO, ID_FAMILIA, NOM_FAMILIA,
25
+ ESTADO_LINEA, ID_ARTICULO, NOM_ARTICULO, ID_VENDEDOR, NOM_VENDEDOR,
26
+ CANAL DE DISTRIBUCION, COD_SUCURSAL, NOM_SUCURSAL, TPO_DOC, SERIE_DOC,
27
+ NRO_DOC, ORD_COMPRA, ID_GUIA, FECHA_ORIG, REFERENCIA, FECHA_REF,
28
+ MONEDA, CANTIDAD, SOLES, DOLARES, NOM_CONDICION_PAGO, ID_PEDIDO,
29
+ FECHA_VENC, DIVISION, FEC_CARGO
30
+
31
+ Autor: G360 Ecosystem
32
+ Version: 1.0
33
+ """
34
+
35
+ import pandas as pd
36
+ import numpy as np
37
+ from typing import Optional, Dict, Tuple
38
+ from datetime import datetime
39
+ from .utils import clean_erp_headers, normalize_ids, build_doc_completo, build_entity_labels, NC_PREFIXES, ERP_TPO_DOC_NC, ERP_TPO_DOC_NDB, validate_columns, parse_excel_date
40
+ from .processor_sku import ProcessorSKU
41
+ from .processor_segmentacion import ProcessorSegmentacion
42
+ from .logger import get_logger
43
+ from .commercial_engine import (
44
+ classify_base,
45
+ parse_referencia,
46
+ resolve_document_relationships,
47
+ calculate_prices,
48
+ )
49
+
50
+ log = get_logger("processor")
51
+
52
+
53
+ class InsightProcessor(ProcessorSKU, ProcessorSegmentacion):
54
+ """
55
+ Motor principal de procesamiento de datos para G360 Insight Lens.
56
+
57
+ Transforma un DataFrame crudo de un archivo ERP en datos limpios,
58
+ enriquecidos y listos para analisis comercial.
59
+
60
+ Hereda de:
61
+ ProcessorSKU: Metodos de analisis a nivel de SKU
62
+ ProcessorSegmentacion: Metodos de segmentacion, agrupacion y comparacion
63
+ """
64
+
65
+ def __init__(self, df: pd.DataFrame, devolucion_threshold: float = 8.0):
66
+ """
67
+ Inicializa el procesador y ejecuta el pipeline de transformacion.
68
+
69
+ Args:
70
+ df: DataFrame crudo del archivo ERP con todas las columnas originales.
71
+ Se espera que los datos vengan como strings (dtype=str en read_excel).
72
+ devolucion_threshold: Umbral porcentual para activar alerta de tasa de
73
+ devolucion. Si la tasa supera este valor, se marca como alerta.
74
+ Default: 8.0 (8%).
75
+
76
+ Raises:
77
+ ValueError: Si faltan columnas ERP minimas requeridas.
78
+ """
79
+ self.df: Optional[pd.DataFrame] = None
80
+ self.devolucion_threshold = devolucion_threshold
81
+ self.profile = "asistente"
82
+ self._cache: Dict[str, any] = {}
83
+ log.info("Inicializando InsightProcessor con %d filas, umbral devolucion=%.1f%%", len(df), devolucion_threshold)
84
+ self._process(df)
85
+
86
+
87
+ def _process(self, df: pd.DataFrame):
88
+ """
89
+ Ejecuta el pipeline completo de procesamiento de datos.
90
+
91
+ El orden de las operaciones es critico:
92
+ - Los headers deben limpiarse primero para que las columnas sean reconocibles
93
+ - Los IDs se normalizan antes de cualquier agrupacion
94
+ - Las columnas numericas se convierten antes de calculos matematicos
95
+ - El precio unitario se calcula antes de normalizar NC (para preservar logica)
96
+ - Las NC se normalizan antes de calcular KPIs (para que los montos sean correctos)
97
+ - Los KPIs se calculan antes de cualquier agregacion
98
+ - Las fechas se parsean para permitir analisis temporal
99
+ - El perfil se detecta al final basado en datos ya limpios
100
+
101
+ Raises:
102
+ ValueError: Si faltan columnas ERP minimas requeridas.
103
+ """
104
+ # Paso 0: Validacion de columnas minimas
105
+ missing = validate_columns(df)
106
+ if missing:
107
+ raise ValueError(
108
+ f"Columnas ERP requeridas faltantes: {', '.join(missing)}. "
109
+ f"Verifica que el archivo sea un reporte valido del ERP."
110
+ )
111
+
112
+ log.info("Pipeline iniciado - %d filas, %d columnas", len(df), len(df.columns))
113
+
114
+ # Paso 1: Limpieza de headers ERP (elimina filas de logos/titulos)
115
+ self.df = clean_erp_headers(df)
116
+ log.info("Headers limpiados: %d filas restantes", len(self.df))
117
+
118
+ # Paso 1b: Validar formato ERP conocido (dgvVentas)
119
+ expected_cols = {"ANHO", "MES", "ID_CLIENTE", "NOM_CLIENTE", "ID_ARTICULO",
120
+ "NOM_ARTICULO", "ID_VENDEDOR", "NOM_VENDEDOR", "TPO_DOC",
121
+ "SERIE_DOC", "NRO_DOC", "FECHA_ORIG", "REFERENCIA",
122
+ "CANTIDAD", "SOLES", "ESTADO_LINEA"}
123
+ found_cols = set(self.df.columns)
124
+ missing_known = expected_cols - found_cols
125
+ if missing_known:
126
+ log.warning("Columnas ERP conocidas faltantes: %s — el archivo puede no ser dgvVentas", missing_known)
127
+
128
+ # Paso 1c: Eliminar fila de totales del reporte ERP
129
+ # Se identifica porque tiene TPO_DOC, ID_CLIENTE y FECHA_ORIG vacios
130
+ id_cols = [c for c in ["TPO_DOC", "FECHA_ORIG", "ID_CLIENTE"] if c in self.df.columns]
131
+ if id_cols:
132
+ mask_totales = self.df[id_cols].apply(
133
+ lambda r: r.isna().all() or (r.astype(str).str.strip() == "").all(), axis=1
134
+ )
135
+ n_totales = mask_totales.sum()
136
+ if n_totales > 0:
137
+ self.df = self.df[~mask_totales]
138
+ log.info("Fila de totales eliminada: %d fila(s)", n_totales)
139
+
140
+ # Paso 2: Normalizacion de IDs clave para consistencia en agrupaciones
141
+ # Se preservan ceros a la izquierda en columnas críticas (ver utils.py)
142
+ for col in ["ID_VENDEDOR", "ID_CLIENTE", "ID_ARTICULO", "NRO_DOC", "SERIE_DOC",
143
+ "ID_LINEA", "ID_GRUPO", "ID_TIPO", "ID_FAMILIA", "COD_SUCURSAL"]:
144
+ self.df = normalize_ids(self.df, col)
145
+
146
+ # Paso 2b: Construir columna DOC_COMPLETO (tipo + serie + numero)
147
+ self.df = build_doc_completo(self.df)
148
+
149
+ # Paso 2c: Construir columnas _LABEL (ID - NOMBRE) para display
150
+ self.df = build_entity_labels(self.df)
151
+
152
+ # Paso 2d: Crear alias de columnas para compatibilidad con vistas (sin eliminar originales)
153
+ # Aliases para nombres de productos y vendedores
154
+ if "NOM_ARTICULO" in self.df.columns and "PRODUCTO_NOM" not in self.df.columns:
155
+ self.df["PRODUCTO_NOM"] = self.df["NOM_ARTICULO"]
156
+ if "NOM_VENDEDOR" in self.df.columns and "VENDEDOR_NOM" not in self.df.columns:
157
+ self.df["VENDEDOR_NOM"] = self.df["NOM_VENDEDOR"]
158
+ # Alias para tipo de documento
159
+ if "TPO_DOC" in self.df.columns and "TIPO_DOC" not in self.df.columns:
160
+ self.df["TIPO_DOC"] = self.df["TPO_DOC"]
161
+ # Aliases para cantidad y monto
162
+ if "CANTIDAD" in self.df.columns and "CANT_FISICA" not in self.df.columns:
163
+ self.df["CANT_FISICA"] = self.df["CANTIDAD"]
164
+ if "SOLES" in self.df.columns and "NETO_SOLES" not in self.df.columns:
165
+ self.df["NETO_SOLES"] = self.df["SOLES"]
166
+
167
+ # Paso 3-8: Transformaciones secuenciales
168
+ self._convert_numeric_columns()
169
+ self._normalize_nc()
170
+ classify_base(self.df)
171
+ if "REFERENCIA" in self.df.columns:
172
+ parse_referencia(self.df)
173
+ resolve_document_relationships(self.df)
174
+ calculate_prices(self.df)
175
+ self._calculate_monto_factura()
176
+ self._calculate_kpis()
177
+ self._parse_fechas()
178
+ self._detect_stock_dormido()
179
+ self._detect_profile()
180
+ log.info("Pipeline completado - perfil=%s, filas=%d, columnas=%d", self.profile, len(self.df), len(self.df.columns))
181
+
182
+
183
+ def _convert_numeric_columns(self):
184
+ """
185
+ Convierte columnas de texto a numerico con manejo robusto de errores.
186
+
187
+ Las columnas SOLES, CANTIDAD y DOLARES vienen como strings del archivo ERP.
188
+ Se convierten a float usando pd.to_numeric con errors="coerce" que convierte
189
+ valores no numericos a NaN, luego se reemplazan con 0 para evitar errores
190
+ en operaciones matematicas posteriores.
191
+
192
+ Nota: Se usa fillna(0) en lugar de dropna para preservar las filas completas
193
+ y evitar perdida de datos en otras columnas.
194
+ """
195
+ numeric_cols = ["SOLES", "CANTIDAD", "DOLARES"]
196
+ for col in numeric_cols:
197
+ if col in self.df.columns:
198
+ self.df[col] = pd.to_numeric(self.df[col], errors="coerce").fillna(0)
199
+
200
+
201
+ def _normalize_nc(self):
202
+ """
203
+ Normaliza los montos de Notas de Credito para que sean negativos.
204
+
205
+ Logica de negocio:
206
+ - Las Notas de Credito (NC) representan devoluciones o anulaciones
207
+ - En el ERP, algunas NC vienen con montos POSITIVOS (error comun)
208
+ - Para calculos correctos de Venta Neta, las NC DEBEN ser negativas
209
+ - Se detectan por TPO_DOC: valores que empiezan con "NC", "NOTA" o "BV"
210
+ (BV = Boleta de Venta anulada, que funciona como NC)
211
+
212
+ Proceso:
213
+ 1. Identificar columna TPO_DOC (busqueda flexible por substring)
214
+ 2. Crear mascara de filas que son NC (regex: ^NC|^NOTA|^BV)
215
+ 3. Crear mascara de filas con monto SOLES positivo
216
+ 4. Invertir signo de SOLES y CANTIDAD donde ambas condiciones se cumplen
217
+
218
+ Ejemplo:
219
+ Antes: TPO_DOC="NC01", SOLES=5000, CANTIDAD=100
220
+ Despues: TPO_DOC="NC01", SOLES=-5000, CANTIDAD=-100
221
+ """
222
+ tpo_col = next((c for c in self.df.columns if "TPO_DOC" in c), None)
223
+ soles_col = "SOLES" if "SOLES" in self.df.columns else None
224
+
225
+ if tpo_col and soles_col:
226
+ # Vectorized approach for better performance
227
+ # Mascara: documentos que son Notas de Credito o anulaciones
228
+ nc_pattern = "|".join([f"^{p}" for p in NC_PREFIXES]) + "|^NOTA"
229
+ mask_nc = self.df[tpo_col].astype(str).str.upper().str.contains(
230
+ nc_pattern, na=False
231
+ )
232
+ # Mascara: montos que son positivos (necesitan inversion de signo)
233
+ mask_positivo = self.df[soles_col] > 0
234
+
235
+ # Combined mask: NC AND positive amount
236
+ combined_mask = mask_nc & mask_positivo
237
+
238
+ # Apply sign inversion using vectorized operations
239
+ if combined_mask.any():
240
+ self.df.loc[combined_mask, soles_col] = -self.df.loc[combined_mask, soles_col]
241
+ self.df.loc[combined_mask, "CANTIDAD"] = -self.df.loc[combined_mask, "CANTIDAD"]
242
+
243
+ def _calculate_monto_factura(self):
244
+ """
245
+ Calcula el monto total por factura (suma de todas las filas con el mismo documento).
246
+
247
+ Esta columna es util para:
248
+ - Verificar que una factura individual tenga un total esperado
249
+ - Validar consistencia de datos antes de enviar a SAP/ERP
250
+ - Auditoria de facturas con montos inusuales
251
+
252
+ La columna MONTO_FACTURA se calcula sumando todos los SOLES de cada NRO_DOC
253
+ una vez que las NC han sido normalizadas (pueden tener el mismo NRO_DOC).
254
+ """
255
+ if "NRO_DOC" not in self.df.columns or "SOLES" not in self.df.columns:
256
+ return
257
+
258
+ # Agrupar por NRO_DOC y sumar SOLES (NC ya estan normalizadas)
259
+ total_por_doc = self.df.groupby("NRO_DOC")["SOLES"].transform("sum")
260
+ self.df["MONTO_FACTURA"] = total_por_doc.round(2)
261
+
262
+
263
+ def _calculate_kpis(self):
264
+ """
265
+ Calcula las columnas derivadas de KPIs para cada fila del dataset.
266
+
267
+ Columnas creadas:
268
+ ES_VENTA: Booleano que indica si la fila es una venta legitima
269
+ (Factura o Boleta) vs una Nota de Credito.
270
+ VENTA_BRUTA: Monto SOLES si es venta positiva, 0 si es NC o monto negativo.
271
+ Representa el total facturado sin considerar devoluciones.
272
+ MONTO_NC: Valor absoluto del monto si es NC negativa, 0 si es venta.
273
+ Representa el total devuelto/anulado.
274
+ VENTA_NETA: VENTA_BRUTA - MONTO_NC. Es el valor real de ventas
275
+ despues de descontar devoluciones.
276
+
277
+ Formula de Venta Neta:
278
+ Venta Neta = (Facturas + Boletas) - Notas de Credito
279
+
280
+ Esta logica es fundamental para el KPI de "Tasa de Devolucion" que se
281
+ calcula posteriormente como: (Monto NC / Venta Bruta) * 100
282
+ """
283
+ soles = "SOLES" if "SOLES" in self.df.columns else None
284
+ if not soles:
285
+ return
286
+
287
+ tpo_col = next((c for c in self.df.columns if "TPO_DOC" in c), None)
288
+
289
+ # Known ERP format: F01/BDI = venta, NCR = credito, NDB = debito
290
+ if tpo_col:
291
+ tpo_upper = self.df[tpo_col].astype(str).str.upper().str.strip()
292
+ self.df["ES_VENTA"] = ~tpo_upper.isin(ERP_TPO_DOC_NC)
293
+ self.df["ES_NDB"] = tpo_upper.isin(ERP_TPO_DOC_NDB)
294
+ else:
295
+ self.df["ES_VENTA"] = self.df[soles] > 0
296
+ self.df["ES_NDB"] = False
297
+
298
+ # Calcular Venta Bruta: solo ventas positivas cuentan
299
+ # Vectorized operations for better performance
300
+ es_venta = self.df["ES_VENTA"]
301
+ soles_vals = self.df[soles]
302
+ # Venta Bruta: solo si es venta Y el monto es positivo
303
+ self.df["VENTA_BRUTA"] = np.where(es_venta & (soles_vals > 0), soles_vals, 0.0)
304
+ # Monto NC: valor absoluto si NO es venta Y el monto es negativo
305
+ self.df["MONTO_NC"] = np.where(~es_venta & (soles_vals < 0), np.abs(soles_vals), 0.0)
306
+
307
+ # Venta Neta = Bruta - Devoluciones
308
+ self.df["VENTA_NETA"] = self.df["VENTA_BRUTA"] - self.df["MONTO_NC"]
309
+
310
+
311
+ def _parse_fechas(self):
312
+ """
313
+ Convierte la columna FECHA_ORIG de string a datetime.
314
+
315
+ Formato ERP conocido: DD/MM/YYYY (ej: "24/06/2026")
316
+ Tambien soporta: numeros serial Excel, YYYY-MM-DD
317
+
318
+ La columna resultante FECHA_DT se usa para:
319
+ - Analisis temporal (tendencias mensuales)
320
+ - Comparador de periodos
321
+ - Deteccion de clientes dormidos (dias sin comprar)
322
+ - Rango de fechas del reporte
323
+ """
324
+ if "FECHA_ORIG" not in self.df.columns:
325
+ return
326
+
327
+ try:
328
+ if self.df["FECHA_ORIG"].apply(lambda x: isinstance(x, (int, float)) and not pd.isna(x)).all():
329
+ self.df["FECHA_DT"] = pd.Timestamp("1899-12-30") + pd.to_timedelta(self.df["FECHA_ORIG"], unit='D')
330
+ else:
331
+ # ERP format: DD/MM/YYYY — dayfirst=True handles this
332
+ self.df["FECHA_DT"] = pd.to_datetime(self.df["FECHA_ORIG"], dayfirst=True, errors='coerce')
333
+ except Exception:
334
+ self.df["FECHA_DT"] = self.df["FECHA_ORIG"].apply(parse_excel_date)
335
+
336
+
337
+ def _detect_profile(self):
338
+ """
339
+ Detecta automaticamente el perfil del usuario basado en los datos.
340
+
341
+ Criterio:
342
+ - Si hay MAS DE 1 vendedor unico -> Modo "supervisor"
343
+ (tiene vision del equipo completo, puede ver comparativos)
344
+ - Si hay 1 solo vendedor -> Modo "asistente"
345
+ (solo ve sus propios datos, foco en cliente y SKU)
346
+
347
+ Esta deteccion permite que la UI adapte su comportamiento:
348
+ - En modo supervisor: muestra ranking de vendedores, comparativos, metas
349
+ - En modo asistente: muestra analisis de cliente, historial de SKU
350
+
351
+ Nota: Se usa dropna() para excluir valores nulos del conteo,
352
+ evitando falsos positivos si la columna tiene filas vacias.
353
+ """
354
+ vendedor_col = next((c for c in self.df.columns if "ID_VENDEDOR" in c), None)
355
+ if vendedor_col:
356
+ unique_vendedores = self.df[vendedor_col].dropna().nunique()
357
+ self.profile = "supervisor" if unique_vendedores > 1 else "asistente"
358
+
359
+ def _detect_stock_dormido(self):
360
+ """
361
+ Marca STOCK_DORMIDO = 1 si FECHA_ORIG - FECHA_REF > 90 dias.
362
+
363
+ Detecta productos que permanecieron en stock mas de 90 dias
364
+ antes de ser vendidos o devueltos.
365
+ """
366
+ if "FECHA_ORIG" not in self.df.columns or "FECHA_REF" not in self.df.columns:
367
+ # Si faltan columnas, inicializar en 0
368
+ self.df["STOCK_DORMIDO"] = 0
369
+ return
370
+
371
+ try:
372
+ orig = pd.to_datetime(
373
+ self.df["FECHA_ORIG"],
374
+ dayfirst=True,
375
+ errors="coerce",
376
+ )
377
+ ref = pd.to_datetime(
378
+ self.df["FECHA_REF"],
379
+ dayfirst=True,
380
+ errors="coerce",
381
+ )
382
+ delta = (orig - ref).dt.days
383
+ self.df["STOCK_DORMIDO"] = np.where(
384
+ delta.notna() & (delta > 90),
385
+ 1,
386
+ 0,
387
+ ).astype(int)
388
+ except Exception:
389
+ # En caso de error, columna en 0
390
+ self.df["STOCK_DORMIDO"] = 0
391
+
392
+
393
+ def _cache_key(self, method_name: str, **kwargs) -> str:
394
+ """Genera clave de cache para un metodo con sus argumentos."""
395
+ args_str = ",".join(f"{k}={v}" for k, v in sorted(kwargs.items()))
396
+ return f"{method_name}({args_str})"
397
+
398
+
399
+ def _get_cached(self, key: str) -> Optional[any]:
400
+ """Retorna valor cacheado si existe y no ha expirado."""
401
+ entry = self._cache.get(key)
402
+ if entry:
403
+ return entry["value"]
404
+ return None
405
+
406
+
407
+ def _set_cached(self, key: str, value: any):
408
+ """Almacena valor en cache."""
409
+ self._cache[key] = {"value": value}
410
+
411
+
412
+ def clear_cache(self):
413
+ """Limpia toda la cache de resultados."""
414
+ self._cache.clear()
415
+
416
+
417
+ def get_venta_bruta_total(self) -> float:
418
+ """
419
+ Retorna la suma total de Venta Bruta del dataset.
420
+
421
+ Venta Bruta = Suma de todos los montos de Facturas y Boletas positivas.
422
+ No incluye Notas de Credito.
423
+
424
+ Returns:
425
+ float: Total de venta bruta en soles. 0.0 si la columna no existe.
426
+ """
427
+ return self.df["VENTA_BRUTA"].sum() if "VENTA_BRUTA" in self.df.columns else 0.0
428
+
429
+
430
+ def get_venta_neta_total(self) -> float:
431
+ """
432
+ Retorna la suma total de Venta Neta del dataset.
433
+
434
+ Venta Neta = Venta Bruta - Monto total de Notas de Credito.
435
+ Representa el valor real de ventas despues de devoluciones.
436
+
437
+ Returns:
438
+ float: Total de venta neta en soles. 0.0 si la columna no existe.
439
+ """
440
+ return self.df["VENTA_NETA"].sum() if "VENTA_NETA" in self.df.columns else 0.0
441
+
442
+
443
+ def get_tasa_devolucion(self) -> float:
444
+ """
445
+ Calcula la Tasa de Devolucion como porcentaje.
446
+
447
+ Formula:
448
+ Tasa de Devolucion = (Monto NC / Venta Bruta) * 100
449
+
450
+ Interpretacion:
451
+ - 0%: No hubo devoluciones en el periodo
452
+ - <8%: Nivel aceptable (verde)
453
+ - >=8%: Nivel de alerta (rojo) - requiere investigacion
454
+ - >15%: Nivel critico - posible problema de calidad o logistica
455
+
456
+ Returns:
457
+ float: Porcentaje de devolucion. 0.0 si venta bruta es 0.
458
+ """
459
+ venta_bruta = self.get_venta_bruta_total()
460
+ monto_nc = self.df["MONTO_NC"].sum() if "MONTO_NC" in self.df.columns else 0.0
461
+ if venta_bruta == 0:
462
+ return 0.0
463
+ return (monto_nc / venta_bruta) * 100
464
+
465
+
466
+ def is_devolucion_alert(self) -> bool:
467
+ """
468
+ Verifica si la tasa de devolucion supera el umbral configurado.
469
+
470
+ Returns:
471
+ bool: True si la tasa de devolucion > devolucion_threshold.
472
+ """
473
+ return self.get_tasa_devolucion() > self.devolucion_threshold
474
+
475
+
476
+ def get_stats_generales(self) -> dict:
477
+ """
478
+ Retorna un diccionario con estadisticas generales del dataset.
479
+
480
+ Este metodo es el punto de entrada principal para los KPI cards
481
+ de la interfaz de usuario. Proporciona un resumen rapido de
482
+ todos los indicadores clave en una sola llamada.
483
+
484
+ Returns:
485
+ dict: Con las siguientes claves:
486
+ - total_registros: Numero de filas en el dataset
487
+ - total_soles: Suma total de la columna SOLES
488
+ - total_cantidad: Suma total de unidades
489
+ - venta_bruta: Total de ventas brutas
490
+ - venta_neta: Total de ventas netas
491
+ - tasa_devolucion: Porcentaje de devolucion
492
+ - alerta_devolucion: Booleano si supera threshold
493
+ - perfil: "asistente" o "supervisor"
494
+ - n_vendedores: Numero de vendedores unicos
495
+ - n_clientes: Numero de clientes unicos
496
+ - n_articulos: Numero de articulos unicos
497
+ - n_lineas: Numero de lineas unicas
498
+ - rango_fechas: Tuple (fecha_min, fecha_max)
499
+ """
500
+ n_docs = self.df["NRO_DOC"].nunique() if "NRO_DOC" in self.df.columns else 0
501
+ v_neta = self.get_venta_neta_total()
502
+ ticket_prom = v_neta / n_docs if n_docs > 0 else 0.0
503
+
504
+ skus_ped = 0.0
505
+ if "NRO_DOC" in self.df.columns and "ID_ARTICULO" in self.df.columns and n_docs > 0:
506
+ try:
507
+ skus_ped = float(self.df.groupby("NRO_DOC")["ID_ARTICULO"].nunique().mean())
508
+ except Exception:
509
+ skus_ped = 0.0
510
+
511
+ def _nunique_sin_vacios(col: str) -> int:
512
+ if col not in self.df.columns:
513
+ return 0
514
+ s = self.df[col].dropna()
515
+ s = s[s.astype(str).str.strip() != ""]
516
+ return s.nunique()
517
+
518
+ stats = {
519
+ "total_registros": len(self.df),
520
+ "total_soles": self.df["SOLES"].sum() if "SOLES" in self.df.columns else 0,
521
+ "total_cantidad": self.df["CANTIDAD"].sum() if "CANTIDAD" in self.df.columns else 0,
522
+ "venta_bruta": self.get_venta_bruta_total(),
523
+ "venta_neta": v_neta,
524
+ "tasa_devolucion": self.get_tasa_devolucion(),
525
+ "alerta_devolucion": self.is_devolucion_alert(),
526
+ "perfil": self.profile,
527
+ "n_vendedores": _nunique_sin_vacios("ID_VENDEDOR"),
528
+ "n_clientes": _nunique_sin_vacios("ID_CLIENTE"),
529
+ "n_articulos": _nunique_sin_vacios("ID_ARTICULO"),
530
+ "n_lineas": _nunique_sin_vacios("NOM_LINEA"),
531
+ "rango_fechas": self.get_rango_fechas(),
532
+ "ticket_promedio": ticket_prom,
533
+ "skus_por_pedido": skus_ped,
534
+ }
535
+ return stats
536
+
537
+
538
+ def get_resumen_por_mes(self) -> pd.DataFrame:
539
+ """
540
+ Genera resumen temporal agrupado por la columna MES del ERP.
541
+
542
+ La columna MES tiene formato "04-ABRIL", "03-MARZO", etc.
543
+ Se usa para analisis de tendencia mensual cuando no se requiere
544
+ precision a nivel de dia.
545
+
546
+ Returns:
547
+ pd.DataFrame: Resumen por mes con total_soles, venta_bruta,
548
+ venta_neta, n_documentos.
549
+ """
550
+ mes_col = next((c for c in self.df.columns if "MES" in c and "MES_" not in c), None)
551
+ if not mes_col:
552
+ return pd.DataFrame()
553
+
554
+ resumen = self.df.groupby(mes_col).agg(
555
+ total_soles=("SOLES", "sum"),
556
+ venta_bruta=("VENTA_BRUTA", "sum"),
557
+ venta_neta=("VENTA_NETA", "sum"),
558
+ n_documentos=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
559
+ ).reset_index()
560
+
561
+ return resumen
562
+
563
+
564
+ def get_resumen_temporal(self) -> pd.DataFrame:
565
+ """
566
+ Genera resumen temporal agrupado por mes-anio usando FECHA_DT.
567
+
568
+ A diferencia de get_resumen_por_mes(), este metodo usa la columna
569
+ FECHA_DT (datetime) para crear periodos mensuales precisos con
570
+ pd.to_period("M"). Esto permite comparaciones exactas entre meses
571
+ independientemente del formato de la columna MES del ERP.
572
+
573
+ Returns:
574
+ pd.DataFrame: Resumen por mes-anio con total_soles, venta_bruta,
575
+ venta_neta, n_documentos. MES_ANIO como string "2026-04".
576
+ """
577
+ if "FECHA_DT" not in self.df.columns:
578
+ return pd.DataFrame()
579
+
580
+ df_temp = self.df.dropna(subset=["FECHA_DT"]).copy()
581
+ df_temp["MES_ANIO"] = df_temp["FECHA_DT"].dt.to_period("M")
582
+
583
+ resumen = df_temp.groupby("MES_ANIO").agg(
584
+ total_soles=("SOLES", "sum"),
585
+ venta_bruta=("VENTA_BRUTA", "sum"),
586
+ venta_neta=("VENTA_NETA", "sum"),
587
+ n_documentos=("NRO_DOC", "nunique") if "NRO_DOC" in self.df.columns else ("SOLES", "count"),
588
+ ).reset_index()
589
+
590
+ resumen["MES_ANIO"] = resumen["MES_ANIO"].astype(str)
591
+
592
+ return resumen
593
+
594
+
595
+ def get_rango_fechas(self) -> Tuple[Optional[datetime], Optional[datetime]]:
596
+ """
597
+ Retorna el rango de fechas del dataset (min y max).
598
+
599
+ Intenta usar FECHA_DT primero (columna parseada a datetime).
600
+ Si no existe, intenta parsear FECHA_ORIG directamente.
601
+
602
+ Returns:
603
+ Tuple: (fecha_minima, fecha_maxima). (None, None) si no hay fechas.
604
+ """
605
+ if "FECHA_DT" not in self.df.columns:
606
+ if "FECHA_ORIG" in self.df.columns:
607
+ fechas = pd.to_datetime(self.df["FECHA_ORIG"], dayfirst=True, errors="coerce").dropna()
608
+ if fechas.empty:
609
+ return None, None
610
+ return fechas.min(), fechas.max()
611
+ return None, None
612
+
613
+ fechas = self.df["FECHA_DT"].dropna()
614
+ if fechas.empty:
615
+ return None, None
616
+
617
+ return fechas.min(), fechas.max()
618
+
619
+
620
+ def get_data(self) -> pd.DataFrame:
621
+ """
622
+ Retorna el DataFrame procesado (sin copia para evitar desperdicio de memoria).
623
+
624
+ NOTA: Los llamadores NO deben modificar el DataFrame retornado.
625
+ Se retorna sin copia deliberadamente para ahorrar RAM con datasets grandes.
626
+
627
+ Returns:
628
+ pd.DataFrame: DataFrame con todas las columnas originales
629
+ mas las columnas derivadas (VENTA_BRUTA, VENTA_NETA,
630
+ MONTO_NC, ES_VENTA, PRECIO_UNID, FECHA_DT).
631
+ """
632
+ return self.df
633
+
634
+