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,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
|
+
|