g360-cli 1.7.1 ā 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -8
- package/package.json +16 -6
- package/py/pyproject.toml +4 -4
- package/py/requirements.txt +4 -0
- package/py/src/g360_core/__init__.py +67 -4
- package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
- package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
- package/py/src/g360_core/batch_processor.py +120 -0
- package/py/src/g360_core/commercial_engine.py +305 -0
- package/py/src/g360_core/logger.py +40 -0
- package/py/src/g360_core/pipeline.py +578 -0
- package/py/src/g360_core/processor.py +634 -0
- package/py/src/g360_core/processor_segmentacion.py +859 -0
- package/py/src/g360_core/processor_sku.py +427 -0
- package/py/src/g360_core/scanner.py +218 -0
- package/py/src/g360_core/utils.py +435 -0
- package/src/cli.js +35 -2
- package/src/commands/addon.js +188 -0
- package/src/commands/ingest.js +187 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/validate.js +150 -0
- package/src/lib/python_runner.js +89 -0
- package/py/src/g360_core/flet/__init__.py +0 -3
- package/py/src/g360_core/flet/ingestion_panel.py +0 -218
- package/py/src/g360_core/ingestion.py +0 -480
- package/src/assets/engine/g360-data-validator.js +0 -44
- package/src/assets/engine/g360-engine.js +0 -12
- package/src/assets/engine/g360-field-mapper.js +0 -35
- package/src/assets/engine/g360-skill-audit.mjs +0 -37
- package/src/assets/engine/g360-skill-meta-evaluator.mjs +0 -33
- package/src/lib/assets.js +0 -38
- package/src/lib/checksum.js +0 -27
- package/src/lib/config.js +0 -23
- package/src/lib/offline.js +0 -33
- package/src/lib/presenter.js +0 -24
- package/src/lib/rollback.js +0 -49
- package/src/lib/theme.js +0 -30
|
@@ -0,0 +1,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
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,10 @@ 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';
|
|
22
|
+
import { addon } from './commands/addon.js';
|
|
19
23
|
|
|
20
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
25
|
const pkg = fs.readJsonSync(path.join(__dirname, '../package.json'));
|
|
@@ -116,7 +120,36 @@ program
|
|
|
116
120
|
.option('-m, --mode <mode>', 'Signature mode: own or powered', 'powered')
|
|
117
121
|
.option('-v, --version <version>', 'Version to display')
|
|
118
122
|
.option('--position <position>', 'Signature position: bottom-right, bottom-left, bottom-center, footer-right, footer-left', 'bottom-right')
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
.option('-i, --interactive', 'Interactive mode with guided suggestions')
|
|
124
|
+
.action(signature);
|
|
125
|
+
|
|
126
|
+
// Comandos de procesamiento ERP
|
|
127
|
+
program
|
|
128
|
+
.command('scan')
|
|
129
|
+
.argument('<directorio>', 'Directorio a escanear')
|
|
130
|
+
.option('-r, --recursive', 'Buscar recursivamente', true)
|
|
131
|
+
.option('--min-score <n>', 'Puntuación mĆnima', '10')
|
|
132
|
+
.action(scan);
|
|
133
|
+
|
|
134
|
+
program
|
|
135
|
+
.command('validate')
|
|
136
|
+
.argument('<paths...>', 'Archivos o directorios a validar')
|
|
137
|
+
.option('-r, --recursive', 'Buscar recursivamente en directorios')
|
|
138
|
+
.action(validate);
|
|
139
|
+
|
|
140
|
+
program
|
|
141
|
+
.command('ingest')
|
|
142
|
+
.argument('<input>', 'Archivo CSV/Excel o directorio con archivos ERP')
|
|
143
|
+
.option('-o, --output <archivo>', 'Ruta de salida', 'maestro_ventas_crm.csv')
|
|
144
|
+
.action(ingest);
|
|
145
|
+
|
|
146
|
+
program
|
|
147
|
+
.command('addon')
|
|
148
|
+
.argument('<command>', 'Command: install, list, remove')
|
|
149
|
+
.argument('[package]', 'Package name to install')
|
|
150
|
+
.option('-p, --path <path>', 'Target path', '.')
|
|
151
|
+
.option('--dry-run', 'Preview without installing')
|
|
152
|
+
.option('--force', 'Force reinstall/remove')
|
|
153
|
+
.action(addon);
|
|
121
154
|
|
|
122
155
|
program.parse();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
const ADDON_REGISTRY = {
|
|
12
|
+
'@google/design.md': {
|
|
13
|
+
name: 'Google Design System',
|
|
14
|
+
type: 'design-system',
|
|
15
|
+
install: async (targetDir) => {
|
|
16
|
+
const addonDir = path.join(targetDir, 'g360', 'addons', 'google-design');
|
|
17
|
+
fs.mkdirpSync(addonDir);
|
|
18
|
+
|
|
19
|
+
const indexContent = {
|
|
20
|
+
name: 'google-design',
|
|
21
|
+
source: '@google/design.md',
|
|
22
|
+
installedAt: new Date().toISOString(),
|
|
23
|
+
files: [
|
|
24
|
+
'tokens.json',
|
|
25
|
+
'components.css',
|
|
26
|
+
'md3.css'
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
fs.writeJsonSync(path.join(addonDir, 'addon.json'), indexContent, { spaces: 2 });
|
|
31
|
+
return addonDir;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
'@m3/material': {
|
|
35
|
+
name: 'Material 3',
|
|
36
|
+
type: 'design-system',
|
|
37
|
+
install: async (targetDir) => {
|
|
38
|
+
const addonDir = path.join(targetDir, 'g360', 'addons', 'm3-material');
|
|
39
|
+
fs.mkdirpSync(addonDir);
|
|
40
|
+
fs.writeJsonSync(path.join(addonDir, 'addon.json'), {
|
|
41
|
+
name: 'm3-material',
|
|
42
|
+
source: '@m3/material',
|
|
43
|
+
installedAt: new Date().toISOString()
|
|
44
|
+
}, { spaces: 2 });
|
|
45
|
+
return addonDir;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function addon(command, options) {
|
|
51
|
+
const { package: pkg, path: targetPath = '.', dryRun = false, force = false } = options;
|
|
52
|
+
|
|
53
|
+
console.log(chalk.bold.cyan('\nš¦ G360 Addon Manager\n'));
|
|
54
|
+
|
|
55
|
+
if (command === 'install' || command === 'add') {
|
|
56
|
+
return installAddon(pkg, targetPath, dryRun, force);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (command === 'list') {
|
|
60
|
+
return listAddons(targetPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (command === 'remove' || command === 'uninstall') {
|
|
64
|
+
return removeAddon(pkg, targetPath, force);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(chalk.yellow('Usage:'));
|
|
68
|
+
console.log(chalk.gray(' g360 addon install <package>'));
|
|
69
|
+
console.log(chalk.gray(' g360 addon list'));
|
|
70
|
+
console.log(chalk.gray(' g360 addon remove <package>'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installAddon(pkg, targetPath, dryRun, force) {
|
|
74
|
+
if (!pkg) {
|
|
75
|
+
console.error(chalk.red('ā Package name required'));
|
|
76
|
+
console.log(chalk.gray('\nAvailable addons:'));
|
|
77
|
+
Object.entries(ADDON_REGISTRY).forEach(([key, value]) => {
|
|
78
|
+
console.log(chalk.gray(` - ${key} (${value.name})`));
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const targetDir = path.resolve(process.cwd(), targetPath);
|
|
84
|
+
const addonDir = path.join(targetDir, 'g360', 'addons', pkg.replace('@', '').replace('/', '-'));
|
|
85
|
+
|
|
86
|
+
if (fs.existsSync(addonDir) && !force) {
|
|
87
|
+
console.error(chalk.red(`ā Addon "${pkg}" already installed`));
|
|
88
|
+
console.log(chalk.gray('Use --force to reinstall'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (dryRun) {
|
|
93
|
+
console.log(chalk.yellow('š DRY RUN - Would install:'));
|
|
94
|
+
console.log(chalk.gray(` Package: ${pkg}`));
|
|
95
|
+
console.log(chalk.gray(` Target: ${addonDir}`));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const registryEntry = ADDON_REGISTRY[pkg];
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
fs.mkdirpSync(addonDir);
|
|
103
|
+
|
|
104
|
+
if (registryEntry?.install) {
|
|
105
|
+
await registryEntry.install(targetDir);
|
|
106
|
+
} else {
|
|
107
|
+
fs.writeJsonSync(path.join(addonDir, 'addon.json'), {
|
|
108
|
+
name: pkg,
|
|
109
|
+
source: pkg,
|
|
110
|
+
installedAt: new Date().toISOString(),
|
|
111
|
+
type: 'external'
|
|
112
|
+
}, { spaces: 2 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const manifestPath = path.join(targetDir, 'g360', 'manifest.json');
|
|
116
|
+
if (fs.existsSync(manifestPath)) {
|
|
117
|
+
const manifest = fs.readJsonSync(manifestPath);
|
|
118
|
+
if (!manifest.addons) manifest.addons = [];
|
|
119
|
+
manifest.addons.push({ name: pkg, installedAt: new Date().toISOString() });
|
|
120
|
+
fs.writeJsonSync(manifestPath, manifest, { spaces: 2 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(chalk.green(`\nā
Addon "${pkg}" installed successfully`));
|
|
124
|
+
console.log(chalk.gray(` Location: ${addonDir}`));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(chalk.red(`\nā Error installing addon: ${error.message}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function listAddons(targetPath) {
|
|
131
|
+
const targetDir = path.resolve(process.cwd(), targetPath);
|
|
132
|
+
const addonsDir = path.join(targetDir, 'g360', 'addons');
|
|
133
|
+
|
|
134
|
+
if (!fs.existsSync(addonsDir)) {
|
|
135
|
+
console.log(chalk.gray('No addons installed'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const addons = fs.readdirSync(addonsDir);
|
|
140
|
+
|
|
141
|
+
if (addons.length === 0) {
|
|
142
|
+
console.log(chalk.gray('No addons installed'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(chalk.bold.yellow('\nš¦ Installed Addons:'));
|
|
147
|
+
for (const addon of addons) {
|
|
148
|
+
const addonJsonPath = path.join(addonsDir, addon, 'addon.json');
|
|
149
|
+
if (fs.existsSync(addonJsonPath)) {
|
|
150
|
+
const addonData = fs.readJsonSync(addonJsonPath);
|
|
151
|
+
console.log(chalk.gray(` - ${addonData.name || addon}`));
|
|
152
|
+
console.log(chalk.gray(` Source: ${addonData.source || 'external'}`));
|
|
153
|
+
console.log(chalk.gray(` Installed: ${addonData.installedAt || 'unknown'}`));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function removeAddon(pkg, targetPath, force) {
|
|
159
|
+
if (!pkg) {
|
|
160
|
+
console.error(chalk.red('ā Package name required'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const targetDir = path.resolve(process.cwd(), targetPath);
|
|
165
|
+
const addonDir = path.join(targetDir, 'g360', 'addons', pkg.replace('@', '').replace('/', '-'));
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(addonDir)) {
|
|
168
|
+
console.error(chalk.red(`ā Addon "${pkg}" not found`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
fs.removeSync(addonDir);
|
|
174
|
+
|
|
175
|
+
const manifestPath = path.join(targetDir, 'g360', 'manifest.json');
|
|
176
|
+
if (fs.existsSync(manifestPath)) {
|
|
177
|
+
const manifest = fs.readJsonSync(manifestPath);
|
|
178
|
+
if (manifest.addons) {
|
|
179
|
+
manifest.addons = manifest.addons.filter(a => a.name !== pkg);
|
|
180
|
+
fs.writeJsonSync(manifestPath, manifest, { spaces: 2 });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(chalk.green(`\nā
Addon "${pkg}" removed successfully`));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error(chalk.red(`\nā Error removing addon: ${error.message}`));
|
|
187
|
+
}
|
|
188
|
+
}
|