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.
Files changed (54) hide show
  1. package/README.md +83 -8
  2. package/package.json +16 -6
  3. package/py/pyproject.toml +4 -4
  4. package/py/requirements.txt +4 -0
  5. package/py/src/g360_core/__init__.py +67 -4
  6. package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
  7. package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
  9. package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
  10. package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
  11. package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
  12. package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
  13. package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
  14. package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
  15. package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
  16. package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
  17. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
  18. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
  19. package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
  20. package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
  21. package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
  22. package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
  23. package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
  24. package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
  25. package/py/src/g360_core/batch_processor.py +120 -0
  26. package/py/src/g360_core/commercial_engine.py +305 -0
  27. package/py/src/g360_core/logger.py +40 -0
  28. package/py/src/g360_core/pipeline.py +578 -0
  29. package/py/src/g360_core/processor.py +634 -0
  30. package/py/src/g360_core/processor_segmentacion.py +859 -0
  31. package/py/src/g360_core/processor_sku.py +427 -0
  32. package/py/src/g360_core/scanner.py +218 -0
  33. package/py/src/g360_core/utils.py +435 -0
  34. package/src/cli.js +35 -2
  35. package/src/commands/addon.js +188 -0
  36. package/src/commands/ingest.js +187 -0
  37. package/src/commands/scan.js +90 -0
  38. package/src/commands/validate.js +150 -0
  39. package/src/lib/python_runner.js +89 -0
  40. package/py/src/g360_core/flet/__init__.py +0 -3
  41. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  42. package/py/src/g360_core/ingestion.py +0 -480
  43. package/src/assets/engine/g360-data-validator.js +0 -44
  44. package/src/assets/engine/g360-engine.js +0 -12
  45. package/src/assets/engine/g360-field-mapper.js +0 -35
  46. package/src/assets/engine/g360-skill-audit.mjs +0 -37
  47. package/src/assets/engine/g360-skill-meta-evaluator.mjs +0 -33
  48. package/src/lib/assets.js +0 -38
  49. package/src/lib/checksum.js +0 -27
  50. package/src/lib/config.js +0 -23
  51. package/src/lib/offline.js +0 -33
  52. package/src/lib/presenter.js +0 -24
  53. package/src/lib/rollback.js +0 -49
  54. package/src/lib/theme.js +0 -30
@@ -0,0 +1,305 @@
1
+ """
2
+ Commercial Engine - Motor de lógica de negocio para clasificación documental.
3
+
4
+ Responsabilidades:
5
+ classify_base: Clasificación primaria (VENTA/DEVOLUCION/AJUSTE)
6
+ parse_referencia: Descomposición de REFERENCIA en tipo/serie/numero
7
+ build_invoice_index: Índice de facturas para cruce de referencias
8
+ resolve_document_relationships: Asignación de SUBTIPO_AJUSTE
9
+ calculate_prices: PRECIO_BASE, RECARGO_UNITARIO, PRECIO_EFECTIVO
10
+
11
+ Principio: toda regla de negocio vive aquí, no en processor.py ni pipeline.py.
12
+ """
13
+
14
+ import re
15
+ import pandas as pd
16
+ import numpy as np
17
+
18
+ from .utils import NC_PREFIXES, ND_PREFIXES
19
+ from .logger import get_logger
20
+
21
+ log = get_logger("commercial_engine")
22
+
23
+ # ─── Constantes de clasificación ──────────────────────────────────────────────
24
+
25
+ CAT_VENTA = "VENTA"
26
+ CAT_DEVOLUCION = "DEVOLUCION"
27
+ CAT_AJUSTE = "AJUSTE"
28
+
29
+ SUBTIPO_PRECIO_LINEA = "PRECIO_LINEA"
30
+ SUBTIPO_PRECIO_PARCIAL = "PRECIO_PARCIAL"
31
+ SUBTIPO_CARGO_FIJO = "CARGO_FIJO"
32
+ SUBTIPO_SIN_BASE = "SIN_BASE"
33
+
34
+ REF_PATTERN = re.compile(r"^([A-Z0-9]+)/(\d+)-(\d+)$")
35
+
36
+
37
+ # ─── Helpers internos ─────────────────────────────────────────────────────────
38
+
39
+ def _find_fae_col(df: pd.DataFrame) -> str:
40
+ """Encuentra la columna CANTIDAD FAE (puede venir como CANTIDAD_FAE)."""
41
+ for col in df.columns:
42
+ norm = col.upper().replace(" ", "_").replace("-", "_")
43
+ if norm == "CANTIDAD_FAE":
44
+ return col
45
+ return None
46
+
47
+
48
+ def _find_tpo_col(df: pd.DataFrame) -> str:
49
+ """Encuentra la columna TPO_DOC (puede venir como TIPO_DOC)."""
50
+ for col in df.columns:
51
+ if "TPO_DOC" in col or col == "TIPO_DOC":
52
+ return col
53
+ return None
54
+
55
+
56
+ # ─── Paso 1: Parseo de REFERENCIA ────────────────────────────────────────────
57
+
58
+ def parse_referencia(df: pd.DataFrame) -> pd.DataFrame:
59
+ """
60
+ Extrae REF_TIPO, REF_SERIE y REF_NUMERO del campo REFERENCIA.
61
+
62
+ Formato esperado: "F01/204-56287"
63
+ Para filas sin referencia, se rellena con "S/R".
64
+ """
65
+ ref_tipo = []
66
+ ref_serie = []
67
+ ref_numero = []
68
+
69
+ for val in df.get("REFERENCIA", pd.Series("", index=df.index)):
70
+ raw = str(val).strip().upper() if pd.notna(val) else ""
71
+ m = REF_PATTERN.match(raw)
72
+ if m:
73
+ ref_tipo.append(m.group(1))
74
+ ref_serie.append(m.group(2))
75
+ ref_numero.append(m.group(3))
76
+ else:
77
+ ref_tipo.append("S/R")
78
+ ref_serie.append("S/R")
79
+ ref_numero.append("S/R")
80
+
81
+ df["REF_TIPO"] = ref_tipo
82
+ df["REF_SERIE"] = ref_serie
83
+ df["REF_NUMERO"] = ref_numero
84
+ return df
85
+
86
+
87
+ # ─── Paso 2: Clasificación primaria ──────────────────────────────────────────
88
+
89
+ def classify_base(df: pd.DataFrame) -> pd.DataFrame:
90
+ """
91
+ Clasificación primaria basada solo en la fila actual (sin mirar otras).
92
+
93
+ CATEGORIA_OP:
94
+ VENTA → Facturas, Boletas (F01, BDI, etc.)
95
+ DEVOLUCION → Notas de Crédito con movimiento físico (CANTIDAD != 0)
96
+ AJUSTE → NC/ND sin movimiento físico (CANTIDAD == 0)
97
+
98
+ SUBTIPO_AJUSTE se inicializa vacío (se asigna en resolve_document_relationships).
99
+ """
100
+ tpo_col = _find_tpo_col(df)
101
+ if tpo_col is None:
102
+ df["CATEGORIA_OP"] = CAT_VENTA
103
+ df["SUBTIPO_AJUSTE"] = ""
104
+ return df
105
+
106
+ tpo = df[tpo_col].astype(str).str.upper().str.strip()
107
+ cant = pd.to_numeric(df.get("CANTIDAD", 0), errors="coerce").fillna(0)
108
+
109
+ es_venta = tpo.isin(["F01", "BDI", "F03", "B01", "B03", "F07", "F08", "B07", "B08"])
110
+ nc_pattern = "|".join([f"^{p}" for p in NC_PREFIXES])
111
+ es_nc = tpo.str.contains(nc_pattern, na=False)
112
+ nd_pattern = "|".join([f"^{p}" for p in ND_PREFIXES])
113
+ es_nd = tpo.str.contains(nd_pattern, na=False)
114
+
115
+ condiciones = [
116
+ es_venta,
117
+ es_nc & (cant != 0),
118
+ es_nc & (cant == 0),
119
+ es_nd,
120
+ ]
121
+ opciones = [
122
+ CAT_VENTA,
123
+ CAT_DEVOLUCION,
124
+ CAT_AJUSTE,
125
+ CAT_AJUSTE,
126
+ ]
127
+
128
+ df["CATEGORIA_OP"] = np.select(condiciones, opciones, default=CAT_AJUSTE)
129
+ df["SUBTIPO_AJUSTE"] = ""
130
+
131
+ return df
132
+
133
+
134
+ # ─── Paso 3: Resolución de relaciones documentales ──────────────────────────
135
+
136
+ def _normalize_doc_id(val) -> str:
137
+ """Normaliza ID de documento: quita '.0' de floats, espacios, mayúsculas."""
138
+ s = str(val).strip().upper()
139
+ if s.endswith(".0"):
140
+ s = s[:-2]
141
+ return s
142
+
143
+
144
+ def build_invoice_index(df: pd.DataFrame) -> dict:
145
+ """
146
+ Construye índice de facturas para resolver referencias.
147
+
148
+ Clave: (TPO_DOC, SERIE_DOC, NRO_DOC, ID_ARTICULO)
149
+ Valor: dict con CANTIDAD, SOLES, CANTIDAD_FAE agregados.
150
+
151
+ Solo indexa registros VENTA.
152
+ Retorna dict vacío si no hay facturas en el dataset.
153
+ """
154
+ facturas = df[df["CATEGORIA_OP"] == CAT_VENTA].copy()
155
+ if facturas.empty:
156
+ return {}
157
+
158
+ for col in ["TPO_DOC", "SERIE_DOC", "NRO_DOC"]:
159
+ if col not in facturas.columns:
160
+ return {}
161
+
162
+ fae_col = _find_fae_col(facturas) or "CANTIDAD"
163
+ if fae_col not in facturas.columns:
164
+ facturas[fae_col] = 0
165
+
166
+ # Normalizar IDs de documento y armar clave
167
+ facturas["_IDX_TIPO"] = facturas["TPO_DOC"].astype(str).str.upper().str.strip()
168
+ facturas["_IDX_SERIE"] = facturas["SERIE_DOC"].apply(_normalize_doc_id)
169
+ facturas["_IDX_NRO"] = facturas["NRO_DOC"].apply(_normalize_doc_id)
170
+ facturas["_IDX_SKU"] = facturas["ID_ARTICULO"].astype(str).str.strip() if "ID_ARTICULO" in facturas.columns else ""
171
+
172
+ # Convertir a numérico
173
+ facturas[fae_col] = pd.to_numeric(facturas[fae_col], errors="coerce").fillna(0)
174
+ facturas["SOLES"] = pd.to_numeric(facturas["SOLES"], errors="coerce").fillna(0)
175
+ facturas["CANTIDAD"] = pd.to_numeric(facturas["CANTIDAD"], errors="coerce").fillna(0)
176
+
177
+ idx_cols = ["_IDX_TIPO", "_IDX_SERIE", "_IDX_NRO", "_IDX_SKU"]
178
+ agg_cols = {"CANTIDAD": "sum", "SOLES": "sum", fae_col: "sum"}
179
+
180
+ grouped = facturas.groupby(idx_cols).agg(agg_cols).to_dict("index")
181
+ # Limpiar columnas auxiliares
182
+ facturas.drop(columns=["_IDX_TIPO", "_IDX_SERIE", "_IDX_NRO", "_IDX_SKU"], inplace=True)
183
+ return grouped
184
+
185
+
186
+ def resolve_document_relationships(df: pd.DataFrame) -> pd.DataFrame:
187
+ """
188
+ Resuelve referencias entre documentos.
189
+
190
+ Para registros AJUSTE, cruza REFERENCIA contra el índice de facturas
191
+ y asigna SUBTIPO_AJUSTE:
192
+ PRECIO_LINEA → misma factura + mismo SKU + FAE = CANTIDAD
193
+ PRECIO_PARCIAL → misma factura + mismo SKU + FAE < CANTIDAD
194
+ CARGO_FIJO → CANTIDAD_FAE = 1 (cargo fijo, no proporcional)
195
+ SIN_BASE → factura referenciada no encontrada
196
+ """
197
+ ajustes = df[df["CATEGORIA_OP"] == CAT_AJUSTE]
198
+ if ajustes.empty:
199
+ df["SUBTIPO_AJUSTE"] = ""
200
+ return df
201
+
202
+ for col in ["REF_TIPO", "REF_SERIE", "REF_NUMERO"]:
203
+ if col not in df.columns:
204
+ df[col] = ""
205
+
206
+ idx = build_invoice_index(df)
207
+ fae_col = _find_fae_col(df) or "CANTIDAD"
208
+
209
+ if not idx:
210
+ df.loc[df["CATEGORIA_OP"] == CAT_AJUSTE, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
211
+ return df
212
+
213
+ for idx_row in df[df["CATEGORIA_OP"] == CAT_AJUSTE].index:
214
+ ref_tipo = str(df.at[idx_row, "REF_TIPO"]).strip().upper()
215
+ ref_serie = str(df.at[idx_row, "REF_SERIE"]).strip()
216
+ ref_numero = str(df.at[idx_row, "REF_NUMERO"]).strip()
217
+ sku = str(df.at[idx_row, "ID_ARTICULO"]).strip() if "ID_ARTICULO" in df.columns else ""
218
+
219
+ key_con_sku = (ref_tipo, ref_serie, ref_numero, sku)
220
+ key_sin_sku = (ref_tipo, ref_serie, ref_numero, "")
221
+
222
+ if key_con_sku in idx:
223
+ factura = idx[key_con_sku]
224
+ cant_fae_ajuste = abs(
225
+ pd.to_numeric(df.at[idx_row, fae_col], errors="coerce") or 0
226
+ )
227
+ cant_factura = abs(factura.get("CANTIDAD", 0))
228
+
229
+ if cant_fae_ajuste == 0:
230
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_CARGO_FIJO
231
+ elif abs(cant_fae_ajuste - cant_factura) < 0.01:
232
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_PRECIO_LINEA
233
+ elif cant_fae_ajuste < cant_factura:
234
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_PRECIO_PARCIAL
235
+ else:
236
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
237
+ else:
238
+ cant_fae_val = abs(
239
+ pd.to_numeric(df.at[idx_row, fae_col], errors="coerce") or 0
240
+ )
241
+ if cant_fae_val == 1:
242
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_CARGO_FIJO
243
+ else:
244
+ df.at[idx_row, "SUBTIPO_AJUSTE"] = SUBTIPO_SIN_BASE
245
+
246
+ return df
247
+
248
+
249
+ # ─── Paso 4: Cálculo de precios ─────────────────────────────────────────────
250
+
251
+ def calculate_prices(df: pd.DataFrame) -> pd.DataFrame:
252
+ """
253
+ Calcula columnas de precio:
254
+
255
+ PRECIO_BASE → SOLES / CANTIDAD (solo cuando CANTIDAD != 0)
256
+ RECARGO_UNITARIO → SOLES / abs(CANTIDAD_FAE) (solo ajustes PRECIO_LINEA)
257
+ PRECIO_EFECTIVO → PRECIO_BASE + RECARGO_UNITARIO (ventas con ajuste linkeado)
258
+ """
259
+ fae_col = _find_fae_col(df)
260
+
261
+ # PRECIO_BASE: solo para movimientos físicos
262
+ if "CANTIDAD" in df.columns and "SOLES" in df.columns:
263
+ cant = pd.to_numeric(df["CANTIDAD"], errors="coerce").fillna(0)
264
+ soles = pd.to_numeric(df["SOLES"], errors="coerce").fillna(0)
265
+ mask_fisica = cant != 0
266
+ df["PRECIO_BASE"] = np.where(
267
+ mask_fisica,
268
+ np.round(soles.abs() / cant.abs(), 4),
269
+ np.nan
270
+ )
271
+ else:
272
+ df["PRECIO_BASE"] = np.nan
273
+
274
+ # RECARGO_UNITARIO: solo para ajustes PRECIO_LINEA o PRECIO_PARCIAL
275
+ if fae_col and "SOLES" in df.columns:
276
+ cant_fae = pd.to_numeric(df[fae_col], errors="coerce").fillna(0)
277
+ soles = pd.to_numeric(df["SOLES"], errors="coerce").fillna(0)
278
+
279
+ subtipo = df.get("SUBTIPO_AJUSTE", pd.Series(""))
280
+ es_ajuste_precio = subtipo.isin([SUBTIPO_PRECIO_LINEA, SUBTIPO_PRECIO_PARCIAL])
281
+ mask_recargo = es_ajuste_precio & (cant_fae != 0)
282
+
283
+ df["RECARGO_UNITARIO"] = np.where(
284
+ mask_recargo,
285
+ np.round(soles / cant_fae.abs(), 4),
286
+ np.nan
287
+ )
288
+ else:
289
+ df["RECARGO_UNITARIO"] = np.nan
290
+
291
+ # PRECIO_EFECTIVO: base + recargo
292
+ # Para ventas: PRECIO_EFECTIVO = PRECIO_BASE
293
+ # Para ajustes linkeados: no se suma directamente (se propaga a nivel de agregación)
294
+ if "PRECIO_BASE" in df.columns:
295
+ base_val = df["PRECIO_BASE"].fillna(0)
296
+ recargo_val = df.get("RECARGO_UNITARIO", pd.Series(0)).fillna(0)
297
+ df["PRECIO_EFECTIVO"] = np.where(
298
+ df["PRECIO_BASE"].notna(),
299
+ np.round(base_val + recargo_val, 4),
300
+ np.nan
301
+ )
302
+ else:
303
+ df["PRECIO_EFECTIVO"] = np.nan
304
+
305
+ return df
@@ -0,0 +1,40 @@
1
+ """Structured logging for G360 Insight Lens."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
8
+ _DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
9
+
10
+ _configured = False
11
+
12
+
13
+ def setup_logging(level: int = logging.INFO, log_file: str = None):
14
+ """Configure root logger with console + optional file handler."""
15
+ global _configured
16
+ if _configured:
17
+ return
18
+ _configured = True
19
+
20
+ root = logging.getLogger("g360")
21
+ root.setLevel(level)
22
+
23
+ console = logging.StreamHandler(sys.stdout)
24
+ console.setLevel(level)
25
+ console.setFormatter(logging.Formatter(_LOG_FORMAT, _DATE_FORMAT))
26
+ root.addHandler(console)
27
+
28
+ if log_file:
29
+ Path(log_file).parent.mkdir(parents=True, exist_ok=True)
30
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
31
+ file_handler.setLevel(level)
32
+ file_handler.setFormatter(logging.Formatter(_LOG_FORMAT, _DATE_FORMAT))
33
+ root.addHandler(file_handler)
34
+
35
+
36
+ def get_logger(name: str) -> logging.Logger:
37
+ """Get a child logger under the 'g360' namespace."""
38
+ if not _configured:
39
+ setup_logging()
40
+ return logging.getLogger(f"g360.{name}")