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.
- 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/assets/templates/python-flet/src/test_ingestion.py +1 -1
- 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,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pipeline de Ingesta para CRM Auxiliar — Plan Maestro 4 Fases.
|
|
3
|
+
|
|
4
|
+
Transforma el CSV plano de dgvVentas del ERP en un DataFrame maestro
|
|
5
|
+
de 12 columnas listo para dashboarding, IA y alimentar la UI de Flet.
|
|
6
|
+
|
|
7
|
+
Fases:
|
|
8
|
+
1. Saneamiento de la Ingesta (elimina duplicados, unifica moneda, crea FECHA_PROCESO)
|
|
9
|
+
2. Resolución de Jerarquía de Clientes (EMP_RUC, SUCURSAL_ID, CLIENTE_UI)
|
|
10
|
+
3. Tipificación Comercial y Trazabilidad (CATEGORIA_OP, STOCK_DORMIDO)
|
|
11
|
+
4. Exportación del Molde Inmutable (12 columnas destino exactas)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .logger import get_logger
|
|
21
|
+
from .commercial_engine import (
|
|
22
|
+
classify_base,
|
|
23
|
+
parse_referencia,
|
|
24
|
+
resolve_document_relationships,
|
|
25
|
+
calculate_prices,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
log = get_logger("ingestion")
|
|
29
|
+
|
|
30
|
+
# ─── Constantes ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
MESES_ESP = {
|
|
33
|
+
"ENERO": "01", "FEBRERO": "02", "MARZO": "03", "ABRIL": "04",
|
|
34
|
+
"MAYO": "05", "JUNIO": "06", "JULIO": "07", "AGOSTO": "08",
|
|
35
|
+
"SETIEMBRE": "09", "OCTUBRE": "10", "NOVIEMBRE": "11", "DICIEMBRE": "12",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
MOLDE_INMUTABLE = [
|
|
39
|
+
"FECHA_PROCESO",
|
|
40
|
+
"EMPRESA_RUC", # RUC o DNI del cliente
|
|
41
|
+
"CLIENTE_TIPO_DOC", # "RUC" o "DNI"
|
|
42
|
+
"EMPRESA_NOM", # Nombre legal del cliente
|
|
43
|
+
"SUCURSAL_ID", # ID único de sucursal
|
|
44
|
+
"CLIENTE_UI", # Nombre display con sucursal
|
|
45
|
+
"PRODUCTO_NOM", # Nombre del artículo
|
|
46
|
+
"VENDEDOR_NOM", # Nombre del vendedor
|
|
47
|
+
"TIPO_DOC", # Tipo de comprobante (F01, BDI, NCR, NDB, etc.)
|
|
48
|
+
"CATEGORIA_OP", # VENTA, DEVOLUCION, AJUSTE
|
|
49
|
+
"SUBTIPO_AJUSTE", # PRECIO_LINEA, PRECIO_PARCIAL, CARGO_FIJO, SIN_BASE
|
|
50
|
+
"CANT_FISICA", # Cantidad física (positiva o negativa)
|
|
51
|
+
"NETO_SOLES", # Monto en soles
|
|
52
|
+
"CANTIDAD_FAE", # Cantidad financiera FAE (para ajustes de precio)
|
|
53
|
+
"PRECIO_BASE", # Precio unitario base (solo movimientos físicos)
|
|
54
|
+
"PRECIO_EFECTIVO", # Precio base + recargo unitario
|
|
55
|
+
"STOCK_DORMIDO", # 1 si stock dormido (>90 días), 0 si no
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
COLUMNAS_ORIGEN_REQUERIDAS = {
|
|
59
|
+
"ANHO", "MES", "DOC_CLIENTE", "NOM_CLIENTE", "COD_SUCURSAL",
|
|
60
|
+
"NOM_SUCURSAL", "ID_ARTICULO", "NOM_ARTICULO", "ID_VENDEDOR",
|
|
61
|
+
"NOM_VENDEDOR", "TPO_DOC", "REFERENCIA", "FECHA_ORIG", "FECHA_REF",
|
|
62
|
+
"CANTIDAD", "SOLES",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
67
|
+
# FASE 1: SANEAMIENTO DE LA INGESTA
|
|
68
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
69
|
+
|
|
70
|
+
def clean_duplicate_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
71
|
+
"""
|
|
72
|
+
Elimina columnas duplicadas por nombre.
|
|
73
|
+
|
|
74
|
+
El ERP exporta DOC_CLIENTE dos veces (cols 3 y 42).
|
|
75
|
+
La segunda aparicion (DOC_CLIENTE.1) se descarta.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
DataFrame sin columnas repetidas.
|
|
79
|
+
"""
|
|
80
|
+
cols = df.columns.tolist()
|
|
81
|
+
vistos = {}
|
|
82
|
+
keep = []
|
|
83
|
+
for i, c in enumerate(cols):
|
|
84
|
+
key = c.strip().lower()
|
|
85
|
+
if key in vistos:
|
|
86
|
+
log.info("Columna duplicada eliminada: '%s' (idx %d)", c, i)
|
|
87
|
+
else:
|
|
88
|
+
vistos[key] = i
|
|
89
|
+
keep.append(c)
|
|
90
|
+
return df[keep]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def unify_currency(df: pd.DataFrame) -> pd.DataFrame:
|
|
94
|
+
"""
|
|
95
|
+
Fuerza la columna SOLES a float64 y descarta DOLARES.
|
|
96
|
+
|
|
97
|
+
- Ignora la columna DOLARES (la operacion es estrictamente en Soles).
|
|
98
|
+
- Convierte SOLES a float, reemplaza NaN/vacios con 0.0.
|
|
99
|
+
"""
|
|
100
|
+
if "DOLARES" in df.columns:
|
|
101
|
+
df = df.drop(columns=["DOLARES"])
|
|
102
|
+
|
|
103
|
+
if "SOLES" in df.columns:
|
|
104
|
+
df["SOLES"] = (
|
|
105
|
+
pd.to_numeric(df["SOLES"], errors="coerce")
|
|
106
|
+
.fillna(0.0)
|
|
107
|
+
)
|
|
108
|
+
return df
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_fecha_proceso(df: pd.DataFrame) -> pd.DataFrame:
|
|
112
|
+
"""
|
|
113
|
+
Construye FECHA_PROCESO como el primer dia del mes.
|
|
114
|
+
|
|
115
|
+
Toma ANHO (ej: "2026") y MES (ej: "06-JUNIO") y crea
|
|
116
|
+
una columna datetime YYYY-MM-01.
|
|
117
|
+
|
|
118
|
+
Si ANHO o MES faltan, intenta inferir de FECHA_ORIG.
|
|
119
|
+
"""
|
|
120
|
+
fecha_proceso = pd.Series(pd.NaT, index=df.index, dtype="datetime64[ns]")
|
|
121
|
+
|
|
122
|
+
if "ANHO" in df.columns and "MES" in df.columns:
|
|
123
|
+
mes_num = (
|
|
124
|
+
df["MES"]
|
|
125
|
+
.astype(str)
|
|
126
|
+
.str.upper()
|
|
127
|
+
.str.strip()
|
|
128
|
+
.str.extract(r"(\d{2})", expand=False)
|
|
129
|
+
)
|
|
130
|
+
anho = (
|
|
131
|
+
df["ANHO"]
|
|
132
|
+
.astype(str)
|
|
133
|
+
.str.strip()
|
|
134
|
+
.str.extract(r"(\d{4})", expand=False)
|
|
135
|
+
)
|
|
136
|
+
valido = mes_num.notna() & anho.notna()
|
|
137
|
+
fecha_proceso[valido] = pd.to_datetime(
|
|
138
|
+
anho[valido] + "-" + mes_num[valido] + "-01",
|
|
139
|
+
errors="coerce",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
fallbacks = fecha_proceso.isna()
|
|
143
|
+
if fallbacks.any() and "FECHA_ORIG" in df.columns:
|
|
144
|
+
orig = pd.to_datetime(df.loc[fallbacks, "FECHA_ORIG"], dayfirst=True, errors="coerce")
|
|
145
|
+
fecha_proceso[fallbacks] = orig - pd.offsets.MonthBegin(1)
|
|
146
|
+
fecha_proceso[fallbacks & orig.isna()] = pd.NaT
|
|
147
|
+
|
|
148
|
+
df["FECHA_PROCESO"] = fecha_proceso
|
|
149
|
+
return df
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
153
|
+
# FASE 2: RESOLUCION DE JERARQUIA DE CLIENTES
|
|
154
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
155
|
+
|
|
156
|
+
def resolve_client_hierarchy(df: pd.DataFrame) -> pd.DataFrame:
|
|
157
|
+
"""
|
|
158
|
+
Crea CLIENTE_DOC, CLIENTE_TIPO_DOC y EMPRESA_NOM usando DOC_CLIENTE.
|
|
159
|
+
|
|
160
|
+
- CLIENTE_DOC: DOC_CLIENTE limpio (RUC o DNI segun longitud).
|
|
161
|
+
- CLIENTE_TIPO_DOC: "RUC" si 11 digitos, "DNI" si 8 digitos.
|
|
162
|
+
- EMPRESA_NOM: primer NOM_CLIENTE asociado a ese documento.
|
|
163
|
+
"""
|
|
164
|
+
if "DOC_CLIENTE" not in df.columns or "NOM_CLIENTE" not in df.columns:
|
|
165
|
+
df["CLIENTE_DOC"] = ""
|
|
166
|
+
df["CLIENTE_TIPO_DOC"] = ""
|
|
167
|
+
df["EMPRESA_NOM"] = ""
|
|
168
|
+
return df
|
|
169
|
+
|
|
170
|
+
raw = df["DOC_CLIENTE"].astype(str).str.strip()
|
|
171
|
+
df["CLIENTE_DOC"] = raw
|
|
172
|
+
|
|
173
|
+
tipo = pd.Series("RUC", index=df.index)
|
|
174
|
+
tipo[raw.str.match(r"^\d{8}$")] = "DNI"
|
|
175
|
+
df["CLIENTE_TIPO_DOC"] = tipo
|
|
176
|
+
|
|
177
|
+
doc_nombre = (
|
|
178
|
+
df[df["NOM_CLIENTE"].notna() & (df["NOM_CLIENTE"].astype(str).str.strip() != "")]
|
|
179
|
+
.groupby("DOC_CLIENTE")["NOM_CLIENTE"]
|
|
180
|
+
.first()
|
|
181
|
+
)
|
|
182
|
+
df["EMPRESA_NOM"] = raw.map(doc_nombre).fillna("")
|
|
183
|
+
return df
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_sucursal_id(df: pd.DataFrame) -> pd.DataFrame:
|
|
187
|
+
"""
|
|
188
|
+
Crea SUCURSAL_ID combinando CLIENTE_DOC + COD_SUCURSAL.
|
|
189
|
+
|
|
190
|
+
Formato: {CLIENTE_DOC}_{COD_SUCURSAL}
|
|
191
|
+
Si COD_SUCURSAL falta o es nulo, se usa "00".
|
|
192
|
+
"""
|
|
193
|
+
if "COD_SUCURSAL" not in df.columns:
|
|
194
|
+
df["SUCURSAL_ID"] = df["CLIENTE_DOC"].astype(str) + "_00"
|
|
195
|
+
return df
|
|
196
|
+
|
|
197
|
+
suc = df["COD_SUCURSAL"].fillna("00").astype(str).str.strip()
|
|
198
|
+
df["SUCURSAL_ID"] = df["CLIENTE_DOC"].astype(str) + "_" + suc
|
|
199
|
+
return df
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_cliente_ui(df: pd.DataFrame) -> pd.DataFrame:
|
|
203
|
+
"""
|
|
204
|
+
Crea CLIENTE_UI para los dropdowns de Flet.
|
|
205
|
+
|
|
206
|
+
Formato:
|
|
207
|
+
- Con sucursal: "EMPRESA_NOM (COD_SUCURSAL - NOM_SUCURSAL)"
|
|
208
|
+
- Sin sucursal: "EMPRESA_NOM"
|
|
209
|
+
|
|
210
|
+
Ejemplos:
|
|
211
|
+
"CIPSA (01 - SUCURSAL CENTRO)"
|
|
212
|
+
"EMPRESA SIN SUCURSAL"
|
|
213
|
+
"""
|
|
214
|
+
# EMPRESA_NOM debe existir (creada en resolve_client_hierarchy)
|
|
215
|
+
base_name = df.get("EMPRESA_NOM", pd.Series("", index=df.index)).fillna("").astype(str).str.strip()
|
|
216
|
+
|
|
217
|
+
# Manejar ausencia de COD_SUCURSAL
|
|
218
|
+
if "COD_SUCURSAL" in df.columns:
|
|
219
|
+
suc_cod = df["COD_SUCURSAL"].fillna("00").astype(str).str.strip()
|
|
220
|
+
else:
|
|
221
|
+
suc_cod = pd.Series("00", index=df.index)
|
|
222
|
+
|
|
223
|
+
# Obligar a string por si suc_cod es numérico
|
|
224
|
+
suc_cod = suc_cod.astype(str).str.strip()
|
|
225
|
+
|
|
226
|
+
suc_nom = df.get("NOM_SUCURSAL", pd.Series("", index=df.index)).fillna("").astype(str).str.strip()
|
|
227
|
+
|
|
228
|
+
# Por defecto: solo nombre de empresa
|
|
229
|
+
df["CLIENTE_UI"] = base_name
|
|
230
|
+
|
|
231
|
+
# Filas que tienen sucursal (código no vacío y no "00")
|
|
232
|
+
tiene_suc = (suc_cod != "") & (suc_cod != "00")
|
|
233
|
+
if tiene_suc.any():
|
|
234
|
+
# Construir cadena con código y, si existe, nombre de sucursal
|
|
235
|
+
suc_str = suc_cod[tiene_suc]
|
|
236
|
+
# Agregar nombre si está presente y no es "acumulado"
|
|
237
|
+
con_nombre = tiene_suc & (suc_nom != "") & (suc_nom.str.lower() != "acumulado")
|
|
238
|
+
if con_nombre.any():
|
|
239
|
+
suc_str[con_nombre] = suc_str[con_nombre] + " - " + suc_nom[con_nombre]
|
|
240
|
+
df.loc[tiene_suc, "CLIENTE_UI"] = base_name[tiene_suc] + " (" + suc_str[tiene_suc] + ")"
|
|
241
|
+
return df
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
245
|
+
# FASE 3: TIPIFICACION COMERCIAL Y TRAZABILIDAD
|
|
246
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
247
|
+
|
|
248
|
+
def detect_stock_dormido(df: pd.DataFrame) -> pd.DataFrame:
|
|
249
|
+
"""
|
|
250
|
+
Marca ES_STOCK_DORMIDO = 1 si FECHA_ORIG - FECHA_REF > 90 dias.
|
|
251
|
+
|
|
252
|
+
La logica detecta productos que permanecieron en stock
|
|
253
|
+
mas de 90 dias antes de ser vendidos o devueltos.
|
|
254
|
+
"""
|
|
255
|
+
STOCK_THRESHOLD = 90
|
|
256
|
+
|
|
257
|
+
orig = pd.to_datetime(
|
|
258
|
+
df.get("FECHA_ORIG", pd.Series("", index=df.index)),
|
|
259
|
+
dayfirst=True, errors="coerce",
|
|
260
|
+
)
|
|
261
|
+
ref = pd.to_datetime(
|
|
262
|
+
df.get("FECHA_REF", pd.Series("", index=df.index)),
|
|
263
|
+
dayfirst=True, errors="coerce",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
delta = (orig - ref).dt.days
|
|
267
|
+
|
|
268
|
+
df["STOCK_DORMIDO"] = np.where(
|
|
269
|
+
delta.notna() & (delta > STOCK_THRESHOLD),
|
|
270
|
+
1,
|
|
271
|
+
0,
|
|
272
|
+
).astype(int)
|
|
273
|
+
return df
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
277
|
+
# FASE 4: EXPORTACION DEL MOLDE INMUTABLE
|
|
278
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
279
|
+
|
|
280
|
+
def _find_fae_col(df: pd.DataFrame) -> str:
|
|
281
|
+
"""Encuentra la columna CANTIDAD FAE (puede venir como CANTIDAD_FAE)."""
|
|
282
|
+
for col in df.columns:
|
|
283
|
+
norm = col.upper().replace(" ", "_").replace("-", "_")
|
|
284
|
+
if norm == "CANTIDAD_FAE":
|
|
285
|
+
return col
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def export_master(df: pd.DataFrame) -> pd.DataFrame:
|
|
290
|
+
"""
|
|
291
|
+
Selecciona y renombra las columnas del molde inmutable (16 columnas).
|
|
292
|
+
|
|
293
|
+
Mapping de columnas origen a destino:
|
|
294
|
+
FECHA_PROCESO ← FECHA_PROCESO
|
|
295
|
+
EMPRESA_RUC ← CLIENTE_DOC (RUC o DNI)
|
|
296
|
+
CLIENTE_TIPO_DOC ← CLIENTE_TIPO_DOC ("RUC" o "DNI")
|
|
297
|
+
EMPRESA_NOM ← NOM_CLIENTE
|
|
298
|
+
SUCURSAL_ID ← SUCURSAL_ID
|
|
299
|
+
CLIENTE_UI ← CLIENTE_UI
|
|
300
|
+
PRODUCTO_NOM ← NOM_ARTICULO
|
|
301
|
+
VENDEDOR_NOM ← NOM_VENDEDOR
|
|
302
|
+
TIPO_DOC ← TPO_DOC
|
|
303
|
+
CATEGORIA_OP ← CATEGORIA_OP
|
|
304
|
+
SUBTIPO_AJUSTE ← SUBTIPO_AJUSTE
|
|
305
|
+
CANT_FISICA ← CANTIDAD (como float)
|
|
306
|
+
NETO_SOLES ← SOLES
|
|
307
|
+
CANTIDAD_FAE ← CANTIDAD_FAE / CANTIDAD FAE
|
|
308
|
+
PRECIO_BASE ← PRECIO_BASE
|
|
309
|
+
PRECIO_EFECTIVO ← PRECIO_EFECTIVO
|
|
310
|
+
STOCK_DORMIDO ← STOCK_DORMIDO
|
|
311
|
+
|
|
312
|
+
Retorna:
|
|
313
|
+
DataFrame con las 16 columnas en el orden definido por MOLDE_INMUTABLE.
|
|
314
|
+
Si alguna columna origen falta, se rellena con valores por defecto.
|
|
315
|
+
"""
|
|
316
|
+
fae_col = _find_fae_col(df)
|
|
317
|
+
|
|
318
|
+
mapping = {
|
|
319
|
+
"FECHA_PROCESO": "FECHA_PROCESO",
|
|
320
|
+
"EMPRESA_RUC": "CLIENTE_DOC",
|
|
321
|
+
"CLIENTE_TIPO_DOC": "CLIENTE_TIPO_DOC",
|
|
322
|
+
"EMPRESA_NOM": "NOM_CLIENTE",
|
|
323
|
+
"SUCURSAL_ID": "SUCURSAL_ID",
|
|
324
|
+
"CLIENTE_UI": "CLIENTE_UI",
|
|
325
|
+
"PRODUCTO_NOM": "NOM_ARTICULO",
|
|
326
|
+
"VENDEDOR_NOM": "NOM_VENDEDOR",
|
|
327
|
+
"TIPO_DOC": "TPO_DOC",
|
|
328
|
+
"CATEGORIA_OP": "CATEGORIA_OP",
|
|
329
|
+
"SUBTIPO_AJUSTE": "SUBTIPO_AJUSTE",
|
|
330
|
+
"CANT_FISICA": "CANTIDAD",
|
|
331
|
+
"NETO_SOLES": "SOLES",
|
|
332
|
+
"CANTIDAD_FAE": fae_col if fae_col else "CANTIDAD",
|
|
333
|
+
"PRECIO_BASE": "PRECIO_BASE",
|
|
334
|
+
"PRECIO_EFECTIVO": "PRECIO_EFECTIVO",
|
|
335
|
+
"STOCK_DORMIDO": "STOCK_DORMIDO",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
resultado = pd.DataFrame(index=df.index)
|
|
339
|
+
|
|
340
|
+
for col_dest, col_origen in mapping.items():
|
|
341
|
+
if col_origen in df.columns:
|
|
342
|
+
resultado[col_dest] = df[col_origen].copy()
|
|
343
|
+
elif col_dest in df.columns:
|
|
344
|
+
# Ya fue creada con otro nombre
|
|
345
|
+
resultado[col_dest] = df[col_dest].copy()
|
|
346
|
+
else:
|
|
347
|
+
resultado[col_dest] = _default_value(col_dest)
|
|
348
|
+
|
|
349
|
+
# Reordenar exactamente como el molde
|
|
350
|
+
resultado = resultado[MOLDE_INMUTABLE]
|
|
351
|
+
|
|
352
|
+
# Normalizaciones finales
|
|
353
|
+
resultado["CANT_FISICA"] = pd.to_numeric(resultado["CANT_FISICA"], errors="coerce").fillna(0.0)
|
|
354
|
+
resultado["NETO_SOLES"] = pd.to_numeric(resultado["NETO_SOLES"], errors="coerce").fillna(0.0)
|
|
355
|
+
resultado["CANTIDAD_FAE"] = pd.to_numeric(resultado["CANTIDAD_FAE"], errors="coerce").fillna(0.0)
|
|
356
|
+
resultado["PRECIO_BASE"] = pd.to_numeric(resultado["PRECIO_BASE"], errors="coerce")
|
|
357
|
+
resultado["PRECIO_EFECTIVO"] = pd.to_numeric(resultado["PRECIO_EFECTIVO"], errors="coerce")
|
|
358
|
+
resultado["TIPO_DOC"] = resultado["TIPO_DOC"].astype(str).str.upper().str.strip()
|
|
359
|
+
resultado["STOCK_DORMIDO"] = resultado["STOCK_DORMIDO"].fillna(0).astype(int)
|
|
360
|
+
|
|
361
|
+
# Strip de todos los strings para evitar duplicados por espacios
|
|
362
|
+
for col in resultado.select_dtypes(include="object").columns:
|
|
363
|
+
resultado[col] = resultado[col].astype(str).str.strip()
|
|
364
|
+
|
|
365
|
+
return resultado
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _default_value(col: str):
|
|
369
|
+
if col in ("STOCK_DORMIDO",):
|
|
370
|
+
return 0
|
|
371
|
+
if col == "CLIENTE_TIPO_DOC":
|
|
372
|
+
return "RUC"
|
|
373
|
+
if col in ("CANT_FISICA", "NETO_SOLES", "CANTIDAD_FAE", "PRECIO_BASE", "PRECIO_EFECTIVO"):
|
|
374
|
+
return 0.0
|
|
375
|
+
return ""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
379
|
+
# ORQUESTADOR PRINCIPAL
|
|
380
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
381
|
+
|
|
382
|
+
def ingest_to_master(
|
|
383
|
+
filepath: str,
|
|
384
|
+
output_path: Optional[str] = None,
|
|
385
|
+
drop_totales: bool = True,
|
|
386
|
+
) -> pd.DataFrame:
|
|
387
|
+
"""
|
|
388
|
+
Orquesta las 4 fases del pipeline de ingesta.
|
|
389
|
+
|
|
390
|
+
Parametros:
|
|
391
|
+
filepath: Ruta al archivo CSV o Excel de dgvVentas.
|
|
392
|
+
output_path: Si se especifica, guarda el resultado como CSV.
|
|
393
|
+
drop_totales: Si True, elimina la fila de gran total del ERP.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
DataFrame con las 13 columnas del molde inmutable.
|
|
397
|
+
"""
|
|
398
|
+
log.info("=== INICIO PIPELINE INGESTA ===")
|
|
399
|
+
log.info("Leyendo: %s", filepath)
|
|
400
|
+
|
|
401
|
+
df = _read_file(filepath)
|
|
402
|
+
if df.empty:
|
|
403
|
+
log.warning("Archivo vacio — devolviendo DataFrame vacio del molde")
|
|
404
|
+
return pd.DataFrame(columns=MOLDE_INMUTABLE)
|
|
405
|
+
|
|
406
|
+
log.info("Filas leidas: %d, Columnas: %d", len(df), len(df.columns))
|
|
407
|
+
|
|
408
|
+
# ── Fase 1: Saneamiento ──────────────────────────────────────────
|
|
409
|
+
log.info("[Fase 1/4] Saneamiento de la ingesta")
|
|
410
|
+
df = clean_duplicate_columns(df)
|
|
411
|
+
df = unify_currency(df)
|
|
412
|
+
|
|
413
|
+
if drop_totales:
|
|
414
|
+
df = _remove_totals_row(df)
|
|
415
|
+
|
|
416
|
+
df = build_fecha_proceso(df)
|
|
417
|
+
|
|
418
|
+
# ── Fase 2: Jerarquia de Clientes ────────────────────────────────
|
|
419
|
+
log.info("[Fase 2/4] Resolucion de jerarquia de clientes")
|
|
420
|
+
df = resolve_client_hierarchy(df)
|
|
421
|
+
df = build_sucursal_id(df)
|
|
422
|
+
df = build_cliente_ui(df)
|
|
423
|
+
|
|
424
|
+
# ── Fase 3: Tipificacion Comercial ───────────────────────────────
|
|
425
|
+
log.info("[Fase 3/4] Tipificacion comercial y trazabilidad")
|
|
426
|
+
df = parse_referencia(df)
|
|
427
|
+
df = classify_base(df)
|
|
428
|
+
df = resolve_document_relationships(df)
|
|
429
|
+
df = detect_stock_dormido(df)
|
|
430
|
+
|
|
431
|
+
# Columnas derivadas adicionales (precios, flags, agregados por documento)
|
|
432
|
+
df = calculate_prices(df)
|
|
433
|
+
df = _create_derived_columns(df)
|
|
434
|
+
|
|
435
|
+
# ── Fase 4: Exportacion del molde ───────────────────────────────────────
|
|
436
|
+
log.info("[Fase 4/4] Exportacion del molde inmutable")
|
|
437
|
+
master = export_master(df)
|
|
438
|
+
log.info("Filas en molde final: %d", len(master))
|
|
439
|
+
|
|
440
|
+
if output_path:
|
|
441
|
+
master.to_csv(output_path, index=False, encoding="utf-8-sig")
|
|
442
|
+
log.info("Archivo guardado: %s", output_path)
|
|
443
|
+
|
|
444
|
+
log.info("=== PIPELINE INGESTA COMPLETADO ===")
|
|
445
|
+
return master
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def batch_ingest(
|
|
449
|
+
filepaths: List[str],
|
|
450
|
+
output_path: Optional[str] = None,
|
|
451
|
+
drop_totales: bool = True,
|
|
452
|
+
merge_results: bool = True,
|
|
453
|
+
) -> pd.DataFrame:
|
|
454
|
+
"""
|
|
455
|
+
Procesa múltiples archivos ERP en lote.
|
|
456
|
+
|
|
457
|
+
Parametros:
|
|
458
|
+
filepaths: Lista de rutas a archivos CSV/Excel.
|
|
459
|
+
output_path: Si se especifica, guarda el resultado combinado como CSV.
|
|
460
|
+
drop_totales: Si True, elimina filas de totales en cada archivo.
|
|
461
|
+
merge_results: Si True, combina todos los archivos en un solo DataFrame.
|
|
462
|
+
Si False, retorna una lista de DataFrames individuales.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
DataFrame combinado (merge_results=True) o lista de DataFrames (False).
|
|
466
|
+
"""
|
|
467
|
+
log.info("=== INICIO BATCH INGESTA ===")
|
|
468
|
+
log.info("Archivos a procesar: %d", len(filepaths))
|
|
469
|
+
|
|
470
|
+
results = []
|
|
471
|
+
sources = []
|
|
472
|
+
|
|
473
|
+
for i, fp in enumerate(filepaths, 1):
|
|
474
|
+
log.info("Procesando archivo %d/%d: %s", i, len(filepaths), fp)
|
|
475
|
+
try:
|
|
476
|
+
master_df = ingest_to_master(
|
|
477
|
+
filepath=fp,
|
|
478
|
+
output_path=None, # No guardar individualmente
|
|
479
|
+
drop_totales=drop_totales
|
|
480
|
+
)
|
|
481
|
+
if not master_df.empty:
|
|
482
|
+
# Añadir columna de trazabilidad
|
|
483
|
+
master_df['ARCHIVO_ORIGEN'] = Path(fp).name
|
|
484
|
+
master_df['ORDEN_LOTE'] = i
|
|
485
|
+
results.append(master_df)
|
|
486
|
+
sources.append(Path(fp))
|
|
487
|
+
log.info(" ✓ %d filas procesadas", len(master_df))
|
|
488
|
+
else:
|
|
489
|
+
log.warning(" ⚠ Archivo vacío o sin datos procesables")
|
|
490
|
+
except Exception as e:
|
|
491
|
+
log.error(" ✗ Error procesando %s: %s", fp, e)
|
|
492
|
+
# Continuar con el siguiente archivo
|
|
493
|
+
|
|
494
|
+
if not results:
|
|
495
|
+
log.warning("No se pudo procesar ningún archivo")
|
|
496
|
+
return pd.DataFrame(columns=MOLDE_INMUTABLE + ['ARCHIVO_ORIGEN', 'ORDEN_LOTE'])
|
|
497
|
+
|
|
498
|
+
if merge_results:
|
|
499
|
+
combined = pd.concat(results, ignore_index=True)
|
|
500
|
+
log.info("=== BATCH COMPLETADO: %d filas totales de %d archivos ===",
|
|
501
|
+
len(combined), len(results))
|
|
502
|
+
|
|
503
|
+
if output_path:
|
|
504
|
+
combined.to_csv(output_path, index=False, encoding="utf-8-sig")
|
|
505
|
+
log.info("Archivo combinado guardado: %s", output_path)
|
|
506
|
+
|
|
507
|
+
return combined
|
|
508
|
+
else:
|
|
509
|
+
log.info("=== BATCH COMPLETADO: %d DataFrames individuales ===", len(results))
|
|
510
|
+
return results
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ─── Funciones auxiliares internas ──────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
def _read_file(filepath: str) -> pd.DataFrame:
|
|
517
|
+
from .batch_processor import read_erp_file
|
|
518
|
+
try:
|
|
519
|
+
ext = Path(filepath).suffix.lower()
|
|
520
|
+
return read_erp_file(filepath, ext)
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
log.error("Error leyendo archivo '%s': %s", filepath, exc)
|
|
523
|
+
return pd.DataFrame()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _create_derived_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
527
|
+
"""
|
|
528
|
+
Crea columnas derivadas adicionales:
|
|
529
|
+
- FECHA_DT: datetime de FECHA_PROCESO para filtros rápidos
|
|
530
|
+
- ES_NC, ES_NDB: flags de tipo de documento
|
|
531
|
+
- CANTIDAD_FACTURA: suma de CANT_FISICA por NRO_DOC
|
|
532
|
+
- ITEMS_FACTURA: número de items (filas) por NRO_DOC
|
|
533
|
+
"""
|
|
534
|
+
# FECHA_DT
|
|
535
|
+
if "FECHA_PROCESO" in df.columns:
|
|
536
|
+
df["FECHA_DT"] = pd.to_datetime(
|
|
537
|
+
df["FECHA_PROCESO"],
|
|
538
|
+
dayfirst=True,
|
|
539
|
+
errors="coerce"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Flags de documentos
|
|
543
|
+
if "TPO_DOC" in df.columns:
|
|
544
|
+
tpo = df["TPO_DOC"].astype(str).str.upper().str.strip()
|
|
545
|
+
df["ES_NC"] = tpo.str.startswith(("NC", "NCR"))
|
|
546
|
+
df["ES_NDB"] = tpo.str.startswith("NDB")
|
|
547
|
+
|
|
548
|
+
# Agregados por documento
|
|
549
|
+
if "NRO_DOC" in df.columns:
|
|
550
|
+
# Cantidad total por documento
|
|
551
|
+
if "CANT_FISICA" in df.columns:
|
|
552
|
+
cant_por_doc = df.groupby("NRO_DOC")["CANT_FISICA"].transform("sum")
|
|
553
|
+
df["CANTIDAD_FACTURA"] = cant_por_doc
|
|
554
|
+
# Número de items por documento
|
|
555
|
+
items_counts = df.groupby("NRO_DOC").size()
|
|
556
|
+
df["ITEMS_FACTURA"] = df["NRO_DOC"].map(items_counts)
|
|
557
|
+
|
|
558
|
+
return df
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _remove_totals_row(df: pd.DataFrame) -> pd.DataFrame:
|
|
562
|
+
"""
|
|
563
|
+
Elimina la fila de gran total que el ERP incluye al final.
|
|
564
|
+
|
|
565
|
+
Se identifica porque TPO_DOC, FECHA_ORIG e ID_CLIENTE estan vacios.
|
|
566
|
+
"""
|
|
567
|
+
ref_cols = [c for c in ["TPO_DOC", "FECHA_ORIG", "ID_CLIENTE"] if c in df.columns]
|
|
568
|
+
if not ref_cols:
|
|
569
|
+
return df
|
|
570
|
+
mask_total = df[ref_cols].apply(
|
|
571
|
+
lambda r: r.isna().all() or (r.astype(str).str.strip() == "").all(),
|
|
572
|
+
axis=1,
|
|
573
|
+
)
|
|
574
|
+
n = mask_total.sum()
|
|
575
|
+
if n > 0:
|
|
576
|
+
log.info("Fila(s) de totales eliminada(s): %d", n)
|
|
577
|
+
return df[~mask_total]
|
|
578
|
+
return df
|