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,435 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import re
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ENTITY_MAP = {
|
|
7
|
+
"cliente": {"id": "ID_CLIENTE", "nombre": "NOM_CLIENTE", "label": "CLIENTE_LABEL"},
|
|
8
|
+
"vendedor": {"id": "ID_VENDEDOR", "nombre": "NOM_VENDEDOR", "label": "VENDEDOR_LABEL"},
|
|
9
|
+
"articulo": {"id": "ID_ARTICULO", "nombre": "NOM_ARTICULO", "label": "ARTICULO_LABEL"},
|
|
10
|
+
"linea": {"id": "ID_LINEA", "nombre": "NOM_LINEA", "label": "LINEA_LABEL"},
|
|
11
|
+
"sucursal": {"id": "COD_SUCURSAL", "nombre": "NOM_SUCURSAL", "label": "SUCURSAL_LABEL"},
|
|
12
|
+
"grupo": {"id": "ID_GRUPO", "nombre": "NOM_GRUPO", "label": "GRUPO_LABEL"},
|
|
13
|
+
"tipo": {"id": "ID_TIPO", "nombre": "NOM_TIPO", "label": "TIPO_LABEL"},
|
|
14
|
+
"familia": {"id": "ID_FAMILIA", "nombre": "NOM_FAMILIA", "label": "FAMILIA_LABEL"},
|
|
15
|
+
"pedido": {"id": "ID_PEDIDO", "nombre": None, "label": "PEDIDO_LABEL"},
|
|
16
|
+
"documento": {"id": "NRO_DOC", "serie": "SERIE_DOC", "tipo": "TPO_DOC"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
DOC_TYPE_LABELS = {
|
|
20
|
+
"F01": "Factura",
|
|
21
|
+
"F03": "Factura",
|
|
22
|
+
"F07": "Factura",
|
|
23
|
+
"F08": "Factura",
|
|
24
|
+
"B01": "Boleta",
|
|
25
|
+
"B03": "Boleta",
|
|
26
|
+
"B07": "Boleta",
|
|
27
|
+
"B08": "Boleta",
|
|
28
|
+
"NC01": "Nota de Credito",
|
|
29
|
+
"NC07": "Nota de Credito",
|
|
30
|
+
"NCR": "Nota de Credito",
|
|
31
|
+
"NDB": "Nota de Debito",
|
|
32
|
+
"ND01": "Nota de Debito",
|
|
33
|
+
"BDI": "Boleta de Intermediacion",
|
|
34
|
+
"T001": "Ticket",
|
|
35
|
+
"R01": "Recibo",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
NC_PREFIXES = ("NC", "NCR", "NOTA")
|
|
39
|
+
ND_PREFIXES = ("NDB", "ND", "ND01")
|
|
40
|
+
|
|
41
|
+
REQUIRED_COLUMNS = ["SOLES", "CANTIDAD", "ID_ARTICULO", "ID_CLIENTE", "TPO_DOC", "FECHA_ORIG"]
|
|
42
|
+
|
|
43
|
+
# KNOWN ERP FORMAT (dgvVentas):
|
|
44
|
+
# 44 columnas fijas: ANHO, MES, ID_CLIENTE, DOC_CLIENTE, NOM_CLIENTE,
|
|
45
|
+
# ID_LOCALIDAD_UBIGEO, NOM_DEPARTAMENTO, NOM_PROVINCIA, NOM_DISTRITO,
|
|
46
|
+
# ID_LINEA, NOM_LINEA, ID_GRUPO, NOM_GRUPO, ID_TIPO, NOM_TIPO,
|
|
47
|
+
# ID_FAMILIA, NOM_FAMILIA, ESTADO_LINEA, ID_ARTICULO, NOM_ARTICULO,
|
|
48
|
+
# ID_VENDEDOR, NOM_VENDEDOR, CANAL DE DISTRIBUCION, COD_SUCURSAL,
|
|
49
|
+
# NOM_SUCURSAL, TPO_DOC, SERIE_DOC, NRO_DOC, ORD_COMPRA, ID_GUIA,
|
|
50
|
+
# FECHA_ORIG, REFERENCIA, FECHA_REF, MONEDA, CANTIDAD, SOLES,
|
|
51
|
+
# DOLARES, NOM_CONDICION_PAGO, ID_PEDIDO, FECHA_VENC, DIVISION,
|
|
52
|
+
# FEC_CARGO, DOC_CLIENTE (dup), CANTIDAD FAE
|
|
53
|
+
#
|
|
54
|
+
# TPO_DOC: F01 (factura), NCR (nota credito), NDB (nota debito), BDI (boleta)
|
|
55
|
+
# ESTADO_LINEA: "LINEA NUEVA", "LINEA TRADICIONAL"
|
|
56
|
+
# CANAL DE DISTRIBUCION: "MAYORISTA", "SIN ASIGNAR", "DISTRIBUIDOR"
|
|
57
|
+
# REFERENCIA: "F01/204-56287" (traceability NCR/NDB -> factura)
|
|
58
|
+
# FECHA_ORIG: DD/MM/YYYY
|
|
59
|
+
# SERIE_DOC, NRO_DOC: floats (204.0, 56287.0) — truncate decimals
|
|
60
|
+
|
|
61
|
+
ID_COLS_PRESERVE_ZEROS = (
|
|
62
|
+
"ID_ARTICULO", "ID_CLIENTE", "ID_VENDEDOR", "ID_LINEA",
|
|
63
|
+
"ID_GRUPO", "ID_TIPO", "ID_FAMILIA", "COD_SUCURSAL",
|
|
64
|
+
"NRO_DOC", "SERIE_DOC", "DOC_CLIENTE"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ID_COLS_STRIP_ZEROS = ()
|
|
68
|
+
|
|
69
|
+
# Known ERP document types — no guessing needed
|
|
70
|
+
ERP_TPO_DOC_SALES = ("F01", "BDI")
|
|
71
|
+
ERP_TPO_DOC_NC = ("NCR",)
|
|
72
|
+
ERP_TPO_DOC_NDB = ("NDB",)
|
|
73
|
+
ERP_ESTADO_LINEA = ("LINEA NUEVA", "LINEA TRADICIONAL")
|
|
74
|
+
ERP_CANAL_DISTRIBUCION = ("MAYORISTA", "SIN ASIGNAR", "DISTRIBUIDOR")
|
|
75
|
+
|
|
76
|
+
# Mapeo de columnas ERP críticas → etiquetas UI en español
|
|
77
|
+
# Usado en preview_table y donde se muestren columnas crudas
|
|
78
|
+
ERP_COLUMN_LABELS = {
|
|
79
|
+
"ANHO": "AÑO",
|
|
80
|
+
"MES": "MES",
|
|
81
|
+
"ID_CLIENTE": "ID CLIENTE",
|
|
82
|
+
"DOC_CLIENTE": "DOC CLIENTE",
|
|
83
|
+
"NOM_CLIENTE": "CLIENTE",
|
|
84
|
+
"ID_LOCALIDAD_UBIGEO": "UBIGEO",
|
|
85
|
+
"NOM_DEPARTAMENTO": "DEPARTAMENTO",
|
|
86
|
+
"NOM_PROVINCIA": "PROVINCIA",
|
|
87
|
+
"NOM_DISTRITO": "DISTRITO",
|
|
88
|
+
"ID_LINEA": "ID LÍNEA",
|
|
89
|
+
"NOM_LINEA": "LÍNEA",
|
|
90
|
+
"ID_GRUPO": "ID GRUPO",
|
|
91
|
+
"NOM_GRUPO": "GRUPO",
|
|
92
|
+
"ID_TIPO": "ID TIPO",
|
|
93
|
+
"NOM_TIPO": "TIPO",
|
|
94
|
+
"ID_FAMILIA": "ID FAMILIA",
|
|
95
|
+
"NOM_FAMILIA": "FAMILIA",
|
|
96
|
+
"ESTADO_LINEA": "ESTADO LÍNEA",
|
|
97
|
+
"ID_ARTICULO": "SKU",
|
|
98
|
+
"NOM_ARTICULO": "ARTÍCULO",
|
|
99
|
+
"ID_VENDEDOR": "ID VENDEDOR",
|
|
100
|
+
"NOM_VENDEDOR": "VENDEDOR",
|
|
101
|
+
"CANAL DE DISTRIBUCION": "CANAL",
|
|
102
|
+
"COD_SUCURSAL": "CÓD SUCURSAL",
|
|
103
|
+
"NOM_SUCURSAL": "SUCURSAL",
|
|
104
|
+
"TPO_DOC": "TIPO DOC",
|
|
105
|
+
"SERIE_DOC": "SERIE",
|
|
106
|
+
"NRO_DOC": "NRO DOC",
|
|
107
|
+
"ORD COMPRA": "ORD COMPRA",
|
|
108
|
+
"ID_GUIA": "ID GUÍA",
|
|
109
|
+
"FECHA_ORIG": "FECHA",
|
|
110
|
+
"REFERENCIA": "REFERENCIA",
|
|
111
|
+
"FECHA_REF": "FECHA REF",
|
|
112
|
+
"MONEDA": "MONEDA",
|
|
113
|
+
"CANTIDAD": "CANTIDAD",
|
|
114
|
+
"SOLES": "SOLES",
|
|
115
|
+
"DOLARES": "DÓLARES",
|
|
116
|
+
"NOM_CONDICION_PAGO": "COND PAGO",
|
|
117
|
+
"ID_PEDIDO": "ID PEDIDO",
|
|
118
|
+
"FECHA_VENC": "FECHA VENC",
|
|
119
|
+
"DIVISION": "DIVISIÓN",
|
|
120
|
+
"FEC_CARGO": "FECHA CARGO",
|
|
121
|
+
"CANTIDAD FAE": "CANT FAE",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def clean_erp_headers(df: pd.DataFrame) -> pd.DataFrame:
|
|
126
|
+
"""
|
|
127
|
+
Identifica y limpia los headers de un archivo ERP Vinifan.
|
|
128
|
+
Ignora logos, filas vacias y basura del ERP.
|
|
129
|
+
Estandariza nombres de columnas con espacios a guiones bajos.
|
|
130
|
+
"""
|
|
131
|
+
for idx, row in df.iterrows():
|
|
132
|
+
row_str = " ".join([str(v).strip().upper() for v in row if pd.notna(v)])
|
|
133
|
+
if any(kw in row_str for kw in ["ID_VENDEDOR", "ID_CLIENTE", "ID_ARTICULO", "NOM_VENDEDOR"]):
|
|
134
|
+
df.columns = df.iloc[idx]
|
|
135
|
+
df = df.iloc[idx + 1:].reset_index(drop=True)
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
new_cols = []
|
|
139
|
+
for i, c in enumerate(df.columns):
|
|
140
|
+
c_str = str(c).strip() if pd.notna(c) else ""
|
|
141
|
+
c_upper = c_str.upper()
|
|
142
|
+
if not c_str or c_upper in ("NAN", "NONE") or c_upper.startswith("UNNAMED"):
|
|
143
|
+
new_cols.append(f"UNNAMED_{i}")
|
|
144
|
+
else:
|
|
145
|
+
new_cols.append(c_upper.replace(" ", "_"))
|
|
146
|
+
df.columns = new_cols
|
|
147
|
+
|
|
148
|
+
# Renombrar columnas duplicadas (ej: DOC_CLIENTE -> DOC_CLIENTE, DOC_CLIENTE_2)
|
|
149
|
+
seen = {}
|
|
150
|
+
final_cols = []
|
|
151
|
+
for c in df.columns:
|
|
152
|
+
if c in seen:
|
|
153
|
+
seen[c] += 1
|
|
154
|
+
final_cols.append(f"{c}_{seen[c]}")
|
|
155
|
+
else:
|
|
156
|
+
seen[c] = 0
|
|
157
|
+
final_cols.append(c)
|
|
158
|
+
df.columns = final_cols
|
|
159
|
+
|
|
160
|
+
df = df.dropna(how="all")
|
|
161
|
+
|
|
162
|
+
return df
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_columns(df: pd.DataFrame) -> list[str]:
|
|
166
|
+
"""
|
|
167
|
+
Valida que el DataFrame tenga las columnas ERP minimas requeridas.
|
|
168
|
+
Retorna lista de columnas faltantes. Lista vacia = todo OK.
|
|
169
|
+
"""
|
|
170
|
+
df_cols_upper = {c.upper() for c in df.columns}
|
|
171
|
+
return [col for col in REQUIRED_COLUMNS if col.upper() not in df_cols_upper]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def normalize_ids(df: pd.DataFrame, column: str) -> pd.DataFrame:
|
|
175
|
+
"""
|
|
176
|
+
Normaliza IDs: elimina espacios y caracteres especiales.
|
|
177
|
+
Preserva ceros a la izquierda para columnas críticas.
|
|
178
|
+
Trunca decimales de floats (204.0 -> 204, no 2040).
|
|
179
|
+
|
|
180
|
+
Para columnas en ID_COLS_PRESERVE_ZEROS, los ceros a la izquierda
|
|
181
|
+
se mantienen intactos (ej: "01240" sigue siendo "01240").
|
|
182
|
+
Para otras columnas, se eliminan ceros iniciales no significativos.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
df: DataFrame a normalizar
|
|
186
|
+
column: Nombre de la columna a normalizar
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
DataFrame con columna normalizada
|
|
190
|
+
"""
|
|
191
|
+
if column not in df.columns:
|
|
192
|
+
return df
|
|
193
|
+
|
|
194
|
+
preserve_zeros = column.upper() in ID_COLS_PRESERVE_ZEROS
|
|
195
|
+
|
|
196
|
+
def _clean_id(val):
|
|
197
|
+
if pd.isna(val):
|
|
198
|
+
return ""
|
|
199
|
+
s = str(val).strip()
|
|
200
|
+
# Si es float como "204.0", truncar a entero SIN perder ceros a la izquierda
|
|
201
|
+
if "." in s:
|
|
202
|
+
try:
|
|
203
|
+
f = float(s)
|
|
204
|
+
if f == int(f):
|
|
205
|
+
# Para columnas que preservan ceros, usar zfill después
|
|
206
|
+
s = str(int(f)) if not preserve_zeros else s.split(".")[0].zfill(len(s.split(".")[0]))
|
|
207
|
+
except (ValueError, OverflowError):
|
|
208
|
+
pass
|
|
209
|
+
# Eliminar solo caracteres no alfanuméricos (excepto guiones)
|
|
210
|
+
s = re.sub(r"[^A-Z0-9\-]", "", s.upper())
|
|
211
|
+
# Para columnas que NO preservan ceros, eliminar ceros iniciales
|
|
212
|
+
if not preserve_zeros and s:
|
|
213
|
+
s = s.lstrip('0')
|
|
214
|
+
return s
|
|
215
|
+
|
|
216
|
+
df[column] = df[column].apply(_clean_id)
|
|
217
|
+
return df
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def format_entity_label(id_val: str, name_val: str) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Crea etiqueta compuesta ID - NOMBRE para display en UI.
|
|
223
|
+
|
|
224
|
+
Ejemplos:
|
|
225
|
+
"68414", "PRIMAVERA DISTRIBUIDORES" -> "68414 - PRIMAVERA DISTRIBUIDORES"
|
|
226
|
+
"01178", "MILCA SARAY" -> "01178 - MILCA SARAY"
|
|
227
|
+
"", "Solo Nombre" -> "Solo Nombre"
|
|
228
|
+
"12345", "" -> "12345"
|
|
229
|
+
"""
|
|
230
|
+
id_str = str(id_val).strip() if pd.notna(id_val) else ""
|
|
231
|
+
name_str = str(name_val).strip() if pd.notna(name_val) else ""
|
|
232
|
+
|
|
233
|
+
if id_str and name_str:
|
|
234
|
+
return f"{id_str} - {name_str}"
|
|
235
|
+
return id_str or name_str
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_entity_labels(df: pd.DataFrame) -> pd.DataFrame:
|
|
239
|
+
"""
|
|
240
|
+
Agrega columnas _LABEL para todas las entidades con ID + nombre.
|
|
241
|
+
|
|
242
|
+
Columnas creadas:
|
|
243
|
+
CLIENTE_LABEL, VENDEDOR_LABEL, ARTICULO_LABEL, LINEA_LABEL,
|
|
244
|
+
SUCURSAL_LABEL, GRUPO_LABEL, TIPO_LABEL, FAMILIA_LABEL, PEDIDO_LABEL
|
|
245
|
+
"""
|
|
246
|
+
label_entities = [
|
|
247
|
+
("cliente", "ID_CLIENTE", "NOM_CLIENTE", "CLIENTE_LABEL"),
|
|
248
|
+
("vendedor", "ID_VENDEDOR", "NOM_VENDEDOR", "VENDEDOR_LABEL"),
|
|
249
|
+
("articulo", "ID_ARTICULO", "NOM_ARTICULO", "ARTICULO_LABEL"),
|
|
250
|
+
("linea", "ID_LINEA", "NOM_LINEA", "LINEA_LABEL"),
|
|
251
|
+
("sucursal", "COD_SUCURSAL", "NOM_SUCURSAL", "SUCURSAL_LABEL"),
|
|
252
|
+
("grupo", "ID_GRUPO", "NOM_GRUPO", "GRUPO_LABEL"),
|
|
253
|
+
("tipo", "ID_TIPO", "NOM_TIPO", "TIPO_LABEL"),
|
|
254
|
+
("familia", "ID_FAMILIA", "NOM_FAMILIA", "FAMILIA_LABEL"),
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
for _, id_col, name_col, label_col in label_entities:
|
|
258
|
+
id_exists = id_col in df.columns
|
|
259
|
+
name_exists = name_col in df.columns
|
|
260
|
+
|
|
261
|
+
if id_exists and name_exists:
|
|
262
|
+
df[label_col] = df.apply(
|
|
263
|
+
lambda r: format_entity_label(r[id_col], r[name_col]),
|
|
264
|
+
axis=1,
|
|
265
|
+
)
|
|
266
|
+
elif id_exists:
|
|
267
|
+
df[label_col] = df[id_col].astype(str).str.strip()
|
|
268
|
+
elif name_exists:
|
|
269
|
+
df[label_col] = df[name_col].astype(str).str.strip()
|
|
270
|
+
|
|
271
|
+
if "ID_PEDIDO" in df.columns:
|
|
272
|
+
df["PEDIDO_LABEL"] = df["ID_PEDIDO"].astype(str).str.strip()
|
|
273
|
+
|
|
274
|
+
# Asegurar CLIENTE_UI para compatibilidad con vistas (se usa en dashboard_helpers, etc.)
|
|
275
|
+
if "CLIENTE_LABEL" in df.columns:
|
|
276
|
+
df["CLIENTE_UI"] = df["CLIENTE_LABEL"]
|
|
277
|
+
|
|
278
|
+
return df
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def format_documento(tipo: str, serie: str, numero: str) -> str:
|
|
282
|
+
"""
|
|
283
|
+
Concatena tipo + serie + numero en formato legible.
|
|
284
|
+
|
|
285
|
+
Usa solo la primera letra del tipo para el formato compacto:
|
|
286
|
+
F01 + 204 + 55238 -> "F204-55238"
|
|
287
|
+
NCR + 215 + 29845 -> "N215-29845 (NC)"
|
|
288
|
+
NDB + 214 + 3843 -> "N214-3843 (NC)"
|
|
289
|
+
BDI + 203 + 52481 -> "B203-52481"
|
|
290
|
+
"""
|
|
291
|
+
if not tipo or not serie or not numero:
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
tipo = str(tipo).strip().upper()
|
|
295
|
+
serie = str(serie).strip()
|
|
296
|
+
numero = str(numero).strip()
|
|
297
|
+
|
|
298
|
+
primer_char = tipo[0] if tipo else ""
|
|
299
|
+
doc_base = f"{primer_char}{serie}-{numero}"
|
|
300
|
+
|
|
301
|
+
if tipo.startswith(NC_PREFIXES):
|
|
302
|
+
doc_base += " (NC)"
|
|
303
|
+
|
|
304
|
+
return doc_base
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_doc_completo(df: pd.DataFrame) -> pd.DataFrame:
|
|
308
|
+
"""
|
|
309
|
+
Agrega columna DOC_COMPLETO al DataFrame concatenando
|
|
310
|
+
TPO_DOC + SERIE_DOC + NRO_DOC con formato legible.
|
|
311
|
+
"""
|
|
312
|
+
tpo_col = next((c for c in df.columns if "TPO_DOC" in c), None)
|
|
313
|
+
serie_col = next((c for c in df.columns if "SERIE_DOC" in c), None)
|
|
314
|
+
nro_col = next((c for c in df.columns if "NRO_DOC" in c), None)
|
|
315
|
+
|
|
316
|
+
if not all([tpo_col, serie_col, nro_col]):
|
|
317
|
+
return df
|
|
318
|
+
|
|
319
|
+
df["DOC_COMPLETO"] = df.apply(
|
|
320
|
+
lambda r: format_documento(
|
|
321
|
+
str(r.get(tpo_col, "")),
|
|
322
|
+
str(r.get(serie_col, "")),
|
|
323
|
+
str(r.get(nro_col, "")),
|
|
324
|
+
),
|
|
325
|
+
axis=1,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return df
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_entity_columns(entity: str) -> dict:
|
|
332
|
+
"""
|
|
333
|
+
Retorna el mapeo de columnas ID + nombre + label para una entidad.
|
|
334
|
+
"""
|
|
335
|
+
return ENTITY_MAP.get(entity, {})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def detectar_tipo_documento(df: pd.DataFrame) -> Optional[str]:
|
|
339
|
+
"""Detecta el tipo de documento (Factura, Boleta, NC) basado en TPO_DOC."""
|
|
340
|
+
col = next((c for c in df.columns if "TPO_DOC" in c), None)
|
|
341
|
+
if col is None:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
valores = df[col].dropna().unique()
|
|
345
|
+
tipos = set()
|
|
346
|
+
for v in valores:
|
|
347
|
+
v_str = str(v).upper().strip()
|
|
348
|
+
if v_str.startswith("F"):
|
|
349
|
+
tipos.add("FACTURA")
|
|
350
|
+
elif v_str.startswith("B"):
|
|
351
|
+
tipos.add("BOLETA")
|
|
352
|
+
elif v_str.startswith(NC_PREFIXES) or "NOTA" in v_str:
|
|
353
|
+
tipos.add("NC")
|
|
354
|
+
|
|
355
|
+
return ", ".join(tipos) if tipos else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_doc_label(tipo_doc: str) -> str:
|
|
359
|
+
"""
|
|
360
|
+
Retorna la etiqueta legible para un tipo de documento.
|
|
361
|
+
"""
|
|
362
|
+
return DOC_TYPE_LABELS.get(str(tipo_doc).upper().strip(), str(tipo_doc))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def parse_excel_date(value) -> Optional[pd.Timestamp]:
|
|
366
|
+
"""
|
|
367
|
+
Convierte fecha estilo Excel a datetime.
|
|
368
|
+
|
|
369
|
+
Soporta 3 formatos:
|
|
370
|
+
1. Numero serial Excel (45454 -> 2024-07-15)
|
|
371
|
+
2. String DD/MM/YYYY ("02/04/2026" -> 2026-04-02)
|
|
372
|
+
3. String YYYY-MM-DD ("2026-04-02" -> 2026-04-02)
|
|
373
|
+
|
|
374
|
+
Para numeros seriales Excel:
|
|
375
|
+
Excel usa dias desde 1900-01-01 con bug del ano 1900.
|
|
376
|
+
Formula: datetime(1899, 12, 30) + timedelta(days=serial)
|
|
377
|
+
"""
|
|
378
|
+
if pd.isna(value):
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
if isinstance(value, pd.Timestamp):
|
|
382
|
+
return value
|
|
383
|
+
|
|
384
|
+
if isinstance(value, (int, float)):
|
|
385
|
+
try:
|
|
386
|
+
return pd.Timestamp("1899-12-30") + pd.Timedelta(days=value)
|
|
387
|
+
except Exception:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
text = str(value).strip()
|
|
391
|
+
if not text:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
# Verificar si es una representacion en texto de un numero serial (ej: "45454" o "45454.0")
|
|
395
|
+
if re.match(r"^\d+(\.\d+)?$", text):
|
|
396
|
+
try:
|
|
397
|
+
serial_val = float(text)
|
|
398
|
+
if 0 < serial_val < 1000000:
|
|
399
|
+
return pd.Timestamp("1899-12-30") + pd.Timedelta(days=serial_val)
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
if re.match(r"^\d{1,2}/\d{1,2}/\d{2,4}$", text):
|
|
404
|
+
try:
|
|
405
|
+
return pd.to_datetime(text, dayfirst=True)
|
|
406
|
+
except Exception:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
if re.match(r"^\d{4}-\d{1,2}-\d{1,2}", text):
|
|
410
|
+
try:
|
|
411
|
+
return pd.to_datetime(text)
|
|
412
|
+
except Exception:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
return pd.to_datetime(text, dayfirst=True)
|
|
417
|
+
except Exception:
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parse_mes_column(mes_str: str, anho: str = None) -> tuple:
|
|
422
|
+
"""
|
|
423
|
+
Parsea la columna MES formato '04-ABRIL' -> (4, 'ABRIL', 'YYYY-MM').
|
|
424
|
+
Si anho no se provee, usa el anio actual.
|
|
425
|
+
"""
|
|
426
|
+
if pd.isna(mes_str):
|
|
427
|
+
return None, None, None
|
|
428
|
+
|
|
429
|
+
parts = str(mes_str).strip().split("-")
|
|
430
|
+
if len(parts) == 2:
|
|
431
|
+
mes_num = int(parts[0])
|
|
432
|
+
mes_nom = parts[1].upper()
|
|
433
|
+
year = anho if anho else str(pd.Timestamp.now().year)
|
|
434
|
+
return mes_num, mes_nom, f"{year}-{mes_num:02d}"
|
|
435
|
+
return None, None, None
|
|
@@ -93,7 +93,7 @@ class TestEstabilizarExcelCrudo(unittest.TestCase):
|
|
|
93
93
|
def test_metadata_creada(self):
|
|
94
94
|
df, meta = _patched_ingestion(_SAMPLE_DF)
|
|
95
95
|
self.assertIn("filas_estabilizadas", meta)
|
|
96
|
-
self.assertIn("
|
|
96
|
+
self.assertIn("columnas_finales", meta)
|
|
97
97
|
self.assertEqual(meta["filas_estabilizadas"], 2)
|
|
98
98
|
|
|
99
99
|
def test_soles_sin_nan(self):
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,9 @@ import { health } from './commands/health.js';
|
|
|
16
16
|
import { update } from './commands/update.js';
|
|
17
17
|
import { convert } from './commands/convert.js';
|
|
18
18
|
import { signature } from './commands/signature.js';
|
|
19
|
+
import { scan } from './commands/scan.js';
|
|
20
|
+
import { validate } from './commands/validate.js';
|
|
21
|
+
import { ingest } from './commands/ingest.js';
|
|
19
22
|
|
|
20
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
const pkg = fs.readJsonSync(path.join(__dirname, '../package.json'));
|
|
@@ -116,7 +119,27 @@ program
|
|
|
116
119
|
.option('-m, --mode <mode>', 'Signature mode: own or powered', 'powered')
|
|
117
120
|
.option('-v, --version <version>', 'Version to display')
|
|
118
121
|
.option('--position <position>', 'Signature position: bottom-right, bottom-left, bottom-center, footer-right, footer-left', 'bottom-right')
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
.option('-i, --interactive', 'Interactive mode with guided suggestions')
|
|
123
|
+
.action(signature);
|
|
124
|
+
|
|
125
|
+
// Comandos de procesamiento ERP
|
|
126
|
+
program
|
|
127
|
+
.command('scan')
|
|
128
|
+
.argument('<directorio>', 'Directorio a escanear')
|
|
129
|
+
.option('-r, --recursive', 'Buscar recursivamente', true)
|
|
130
|
+
.option('--min-score <n>', 'Puntuación mínima', '10')
|
|
131
|
+
.action(scan);
|
|
132
|
+
|
|
133
|
+
program
|
|
134
|
+
.command('validate')
|
|
135
|
+
.argument('<paths...>', 'Archivos o directorios a validar')
|
|
136
|
+
.option('-r, --recursive', 'Buscar recursivamente en directorios')
|
|
137
|
+
.action(validate);
|
|
138
|
+
|
|
139
|
+
program
|
|
140
|
+
.command('ingest')
|
|
141
|
+
.argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
|
|
142
|
+
.option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
|
|
143
|
+
.action(ingest);
|
|
121
144
|
|
|
122
145
|
program.parse();
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Comando: g360 ingest <archivo|directorio> [-o salida.csv]
|
|
4
|
+
* Procesa archivos ERP y genera maestro_ventas_crm.csv
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
export = (program: Command) => {
|
|
17
|
+
program
|
|
18
|
+
.command('ingest')
|
|
19
|
+
.argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
|
|
20
|
+
.option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
|
|
21
|
+
.action(async (input, options) => {
|
|
22
|
+
console.log(chalk.blue(`\n🚀 Iniciando ingesta: ${input}`));
|
|
23
|
+
|
|
24
|
+
const inputPath = path.resolve(input);
|
|
25
|
+
const outputPath = path.resolve(options.output);
|
|
26
|
+
|
|
27
|
+
// Determinar si es archivo o directorio
|
|
28
|
+
try {
|
|
29
|
+
await fs.access(inputPath);
|
|
30
|
+
} catch {
|
|
31
|
+
console.error(chalk.red(`❌ Ruta no encontrada: ${inputPath}`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stat = await fs.stat(inputPath);
|
|
36
|
+
let filepaths: string[];
|
|
37
|
+
|
|
38
|
+
if (stat.isFile()) {
|
|
39
|
+
filepaths = [inputPath];
|
|
40
|
+
} else if (stat.isDirectory()) {
|
|
41
|
+
console.log(chalk.gray(`📁 Escaneando directorio...`));
|
|
42
|
+
const { valid } = await scanDirectory(inputPath);
|
|
43
|
+
filepaths = valid.map((info: any) => info.path);
|
|
44
|
+
if (filepaths.length === 0) {
|
|
45
|
+
console.error(chalk.red('❌ No se encontraron archivos ERP válidos en el directorio'));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
console.log(chalk.green(` Encontrados ${filepaths.length} archivos válidos`));
|
|
49
|
+
} else {
|
|
50
|
+
console.error(chalk.red(`❌ Ruta no válida: ${inputPath}`));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Procesar archivos
|
|
55
|
+
console.log(chalk.blue(`\n⚙️ Procesando ${filepaths.length} archivo(s)...`));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const combinedCsv = await runBatchIngest(filepaths);
|
|
59
|
+
|
|
60
|
+
// Escribir salida
|
|
61
|
+
await fs.ensureDir(path.dirname(outputPath));
|
|
62
|
+
await fs.writeFile(outputPath, combinedCsv, 'utf-8');
|
|
63
|
+
console.log(chalk.green(`\n✅ Ingesta completada: ${outputPath}`));
|
|
64
|
+
|
|
65
|
+
// Resumen por archivo
|
|
66
|
+
const lines = combinedCsv.split('\n');
|
|
67
|
+
const header = lines[0];
|
|
68
|
+
const dataLines = lines.filter(l => l && l !== header);
|
|
69
|
+
console.log(chalk.gray(` Total filas: ${dataLines.length}`));
|
|
70
|
+
|
|
71
|
+
// Conteo por ARCHIVO_ORIGEN
|
|
72
|
+
const counts = new Map<string, number>();
|
|
73
|
+
for (const line of dataLines) {
|
|
74
|
+
const cols = line.split(',');
|
|
75
|
+
const idx = header.split(',').indexOf('ARCHIVO_ORIGEN');
|
|
76
|
+
if (idx !== -1 && cols[idx]) {
|
|
77
|
+
counts.set(cols[idx], (counts.get(cols[idx]) || 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (counts.size > 0) {
|
|
81
|
+
console.log(chalk.cyan('\n📊 Resumen por archivo:'));
|
|
82
|
+
for (const [file, count] of counts) {
|
|
83
|
+
console.log(` ${file}: ${count} filas`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
console.error(chalk.red('\n❌ Error durante la ingesta:'), err.message);
|
|
89
|
+
if (err.stdout) console.error(chalk.gray(err.stdout));
|
|
90
|
+
if (err.stderr) console.error(chalk.red(err.stderr));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
async function scanDirectory(dir: string): Promise<{ valid: any[]; invalid: any[] }> {
|
|
97
|
+
const pyCode = `
|
|
98
|
+
import sys
|
|
99
|
+
sys.path.insert(0, '${path.join(__dirname, '..', 'py', 'src')}')
|
|
100
|
+
from g360_core.scanner import find_erp_files_in_dir
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
valid, invalid = find_erp_files_in_dir(Path('${dir}'), recursive=True)
|
|
104
|
+
for v in valid:
|
|
105
|
+
print(f"VALID::{v.path.name}::{v.erp_type}::${v.size_bytes}")
|
|
106
|
+
for i in invalid[:10]:
|
|
107
|
+
print(f"INVALID::{i.path.name}::${i.error_msg}")
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const result = await runPython(pyCode);
|
|
111
|
+
const lines = result.stdout.split('\n').filter(l => l.trim());
|
|
112
|
+
const valid: any[] = [];
|
|
113
|
+
const invalid: any[] = [];
|
|
114
|
+
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (line.startsWith('VALID::')) {
|
|
117
|
+
const [, name, type, size] = line.split('::');
|
|
118
|
+
valid.push({ path: path.join(dir, name), erp_type: type, size_bytes: parseInt(size) });
|
|
119
|
+
} else if (line.startsWith('INVALID::')) {
|
|
120
|
+
const [, name, error] = line.split('::');
|
|
121
|
+
invalid.push({ path: path.join(dir, name), error_msg: error });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { valid, invalid };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runBatchIngest(filepaths: string[]): Promise<string> {
|
|
129
|
+
const pyCode = `
|
|
130
|
+
import sys
|
|
131
|
+
sys.path.insert(0, '${path.join(__dirname, '..', 'py', 'src')}')
|
|
132
|
+
from g360_core.scanner import batch_process_files
|
|
133
|
+
from pathlib import Path
|
|
134
|
+
import pandas as pd
|
|
135
|
+
|
|
136
|
+
filepaths = [${JSON.stringify(filepaths).replace(/"/g, "'")}]
|
|
137
|
+
df = batch_process_files([Path(p) for p in filepaths], merge_results=True)
|
|
138
|
+
# Output as CSV to stdout
|
|
139
|
+
sys.stdout.write(df.to_csv(index=False))
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const pyExec = process.env.PYTHON || 'python3';
|
|
144
|
+
const proc = spawn(pyExec, ['-c', pyCode], {
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let stdout = '';
|
|
149
|
+
let stderr = '';
|
|
150
|
+
|
|
151
|
+
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
152
|
+
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
153
|
+
|
|
154
|
+
proc.on('close', (code) => {
|
|
155
|
+
if (code === 0) {
|
|
156
|
+
resolve(stdout);
|
|
157
|
+
} else {
|
|
158
|
+
reject(new Error(stderr || `Python terminó con código ${code}`));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
proc.on('error', (err) => {
|
|
163
|
+
reject(new Error(`No se pudo ejecutar Python: ${err.message}`));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runPython(code: string): Promise<{ stdout: string; stderr: string }> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const pyExec = process.env.PYTHON || 'python3';
|
|
171
|
+
const proc = spawn(pyExec, ['-c', code], {
|
|
172
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let stdout = '';
|
|
176
|
+
let stderr = '';
|
|
177
|
+
|
|
178
|
+
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
179
|
+
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
180
|
+
|
|
181
|
+
proc.on('close', (code) => {
|
|
182
|
+
if (code === 0) {
|
|
183
|
+
resolve({ stdout, stderr });
|
|
184
|
+
} else {
|
|
185
|
+
reject(new Error(stderr || `Python terminó con código ${code}`));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
proc.on('error', (err) => {
|
|
190
|
+
reject(new Error(`No se pudo ejecutar Python: ${err.message}`));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|