g360-cli 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +37 -3
  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 +25 -2
  35. package/src/commands/ingest.js +193 -0
  36. package/src/commands/scan.js +102 -0
  37. package/src/commands/validate.js +126 -0
  38. package/src/lib/python_runner.js +89 -0
  39. package/py/src/g360_core/flet/__init__.py +0 -3
  40. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  41. package/py/src/g360_core/ingestion.py +0 -480
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Comando: g360 scan <directorio>
4
+ * Escanea un directorio para detectar archivos ERP válidos.
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import chalk from 'chalk';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ export = (program: Command) => {
15
+ program
16
+ .command('scan')
17
+ .argument('<directorio>', 'Directorio a escanear')
18
+ .option('-r, --recursive', 'Buscar recursivamente', true)
19
+ .option('--min-score <n>', 'Puntuación mínima', '10')
20
+ .action(async (directorio, options) => {
21
+ console.log(chalk.blue(`\n🔍 Escaneando directorio: ${directorio}`));
22
+
23
+ const pyCode = `
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from g360_core.scanner import ERPScanner
28
+
29
+ scanner = ERPScanner(min_valid_score=${options.minScore})
30
+ files = scanner.scan_directory(Path('${directorio}'), recursive=${options.recursive})
31
+ valid = scanner.get_valid_files(files)
32
+ invalid = scanner.get_invalid_files(files)
33
+
34
+ print(f"TOTAL={len(files)}")
35
+ print(f"VALIDOS={len(valid)}")
36
+ print(f"INVALIDOS={len(invalid)}")
37
+
38
+ for erp_type, flist in scanner.group_by_erp_type(files).items():
39
+ if erp_type not in ('UNKNOWN', 'ERROR'):
40
+ print(f"TIPO::{erp_type}::{len(flist)}")
41
+ for info in flist[:5]:
42
+ print(f"FILE::{info.path.name}::{info.size_bytes}")
43
+
44
+ for info in invalid[:10]:
45
+ print(f"INVALID::{info.path.name}::${info.error_msg}")
46
+ `;
47
+
48
+ try {
49
+ const { runPython } = await import('../lib/python_runner.js');
50
+ const result = await runPython(pyCode);
51
+ const lines = result.stdout.split('\n').filter(l => l.trim());
52
+
53
+ let total = 0, validCount = 0, invalidCount = 0;
54
+ const types = new Map();
55
+
56
+ for (const line of lines) {
57
+ if (line.startsWith('TOTAL=')) total = parseInt(line.split('=')[1]);
58
+ else if (line.startsWith('VALIDOS=')) validCount = parseInt(line.split('=')[1]);
59
+ else if (line.startsWith('INVALIDOS=')) invalidCount = parseInt(line.split('=')[1]);
60
+ else if (line.startsWith('TIPO::')) {
61
+ const [, tipo, count] = line.split('::');
62
+ types.set(tipo, parseInt(count));
63
+ }
64
+ }
65
+
66
+ console.log(chalk.gray(`\n📈 Resultados:`));
67
+ console.log(` Total archivos: ${total}`);
68
+ console.log(chalk.green(` Válidos: ${validCount}`));
69
+ console.log(chalk.red(` Inválidos/Error: ${invalidCount}`));
70
+
71
+ if (types.size > 0) {
72
+ console.log(chalk.green('\n✅ Archivos válidos por tipo ERP:'));
73
+ for (const [tipo, count] of types) {
74
+ console.log(` ${tipo}: ${count} archivos`);
75
+ }
76
+ }
77
+
78
+ if (invalidCount > 0) {
79
+ console.log(chalk.red('\n❌ Archivos con problemas:'));
80
+ let invalidLines = lines.filter(l => l.startsWith('INVALID::'));
81
+ for (const line of invalidLines.slice(0, 10)) {
82
+ const parts = line.split('::');
83
+ console.log(` • ${parts[1]}: ${parts.slice(2).join('::')}`);
84
+ }
85
+ if (invalidLines.length > 10) {
86
+ console.log(` ... y ${invalidLines.length - 10} más`);
87
+ }
88
+ }
89
+
90
+ if (validCount > 0) {
91
+ console.log(chalk.cyan('\n💡 Para procesar todos estos archivos:'));
92
+ console.log(` g360 ingest "${directorio}" -o maestro_ventas_crm.csv`);
93
+ }
94
+
95
+ } catch (err: any) {
96
+ console.error(chalk.red('❌ Error durante escaneo:'), err.message);
97
+ if (err.stdout) console.error(chalk.gray(err.stdout));
98
+ if (err.stderr) console.error(chalk.red(err.stderr));
99
+ process.exit(1);
100
+ }
101
+ });
102
+ };
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Comando: g360 validate <archivos/directorios>
4
+ * Valida archivos ERP sin procesar completamente.
5
+ */
6
+
7
+ import { Command } from 'commander';
8
+ import chalk from 'chalk';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import fs from 'fs-extra';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ export = (program: Command) => {
16
+ program
17
+ .command('validate')
18
+ .argument('<paths...>', 'Archivos o directorios a validar')
19
+ .option('-r, --recursive', 'Buscar recursivamente en directorios')
20
+ .action(async (paths, options) => {
21
+ console.log(chalk.blue('\n🔍 Validando archivos ERP\n'));
22
+
23
+ const filesToCheck: string[] = [];
24
+ for (const rawPath of paths) {
25
+ const p = path.resolve(rawPath);
26
+ try {
27
+ const stat = await fs.stat(p);
28
+ if (stat.isFile()) {
29
+ if (p.match(/\.(xls|xlsx|csv)$/i)) {
30
+ filesToCheck.push(p);
31
+ }
32
+ } else if (stat.isDirectory()) {
33
+ const pattern = options.recursive ? '**/*' : '*';
34
+ const extPattern = /\.(xls|xlsx|csv)$/i;
35
+ const entries = await fs.readdir(p, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ const fullPath = path.join(p, entry.name);
38
+ if (entry.isFile() && extPattern.test(entry.name)) {
39
+ filesToCheck.push(fullPath);
40
+ } else if (entry.isDirectory() && options.recursive) {
41
+ // Simplificado: escaneo recursivo básico
42
+ const sub = await fs.readdir(fullPath, { withFileTypes: true });
43
+ for (const subEntry of sub) {
44
+ if (subEntry.isFile() && extPattern.test(subEntry.name)) {
45
+ filesToCheck.push(path.join(fullPath, subEntry.name));
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ } catch (err) {
52
+ console.warn(chalk.yellow(` ⚠ No se pudo acceder a ${p}: ${err.message}`));
53
+ }
54
+ }
55
+
56
+ if (filesToCheck.length === 0) {
57
+ console.warn(chalk.yellow(' No se encontraron archivos para validar'));
58
+ return;
59
+ }
60
+
61
+ const results: Array<{ path: string; valid: boolean; missing: string[] }> = [];
62
+
63
+ for (const file of filesToCheck) {
64
+ try {
65
+ const validation = await validateSingleFile(file);
66
+ results.push(validation);
67
+ } catch (err: any) {
68
+ results.push({ path: file, valid: false, missing: [`ERROR: ${err.message}`] });
69
+ }
70
+ }
71
+
72
+ let validCount = 0;
73
+ for (const res of results) {
74
+ const status = res.valid ? chalk.green('✅') : chalk.red('❌');
75
+ const filename = path.basename(res.path);
76
+ console.log(`${status} ${filename} (${res.valid ? 'OK' : 'FALLÓ'})`);
77
+ if (!res.valid && res.missing.length > 0) {
78
+ console.log(chalk.gray(` Faltan: ${res.missing.join(', ')}`));
79
+ }
80
+ if (res.valid) validCount++;
81
+ }
82
+
83
+ console.log(chalk.gray(`\n📊 Resumen: ${validCount}/${results.length} archivos válidos`));
84
+ });
85
+ };
86
+
87
+ async function validateSingleFile(filepath: string): Promise<{ path: string; valid: boolean; missing: string[] }> {
88
+ const pyCode = `
89
+ from g360_core.scanner import ERPScanner
90
+ from pathlib import Path
91
+
92
+ scanner = ERPScanner(min_valid_score=10)
93
+ try:
94
+ df = scanner._read_headers(Path('${filepath.replace(/'/g, "\\'")}'), nrows=50)
95
+ missing = scanner._validate_columns(df)
96
+ is_valid = len(missing) == 0
97
+ print(f"VALID={is_valid}")
98
+ if missing:
99
+ print(f"MISSING={','.join(missing)}")
100
+ except Exception as e:
101
+ print(f"ERROR={str(e)}")
102
+ `;
103
+
104
+ try {
105
+ const { runPython } = await import('../lib/python_runner.js');
106
+ const result = await runPython(pyCode);
107
+ const lines = result.stdout.split('\n').filter(l => l.trim());
108
+
109
+ let valid = false;
110
+ let missing: string[] = [];
111
+
112
+ for (const line of lines) {
113
+ if (line.startsWith('VALID=')) {
114
+ valid = line.split('=')[1] === 'True';
115
+ } else if (line.startsWith('MISSING=')) {
116
+ missing = line.split('=')[1].split(',');
117
+ } else if (line.startsWith('ERROR=')) {
118
+ throw new Error(line.split('=')[1]);
119
+ }
120
+ }
121
+
122
+ return { path: filepath, valid, missing };
123
+ } catch (err: any) {
124
+ return { path: filepath, valid: false, missing: [err.message] };
125
+ }
126
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Utilidad para obtener la ruta al módulo Python empaquetado.
4
+ * Funciona tanto en desarrollo como en ejecutables empaquetados con pkg.
5
+ */
6
+
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import fs from 'fs-extra';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * Retorna la ruta al directorio src/g360_core del módulo Python.
15
+ */
16
+ export function getPythonModulePath(): string {
17
+ // Ruta de desarrollo: py/src/g360_core relativo a este archivo
18
+ const devPath = path.join(__dirname, '..', 'py', 'src');
19
+ if (fs.existsSync(devPath)) {
20
+ return devPath;
21
+ }
22
+
23
+ // En pkg (ejecutable empaquetado)
24
+ const basePath = process.execPath || process.argv[0];
25
+ const pkgPath = path.join(basePath, '..', 'resources', 'py', 'src');
26
+ if (fs.existsSync(pkgPath)) {
27
+ return pkgPath;
28
+ }
29
+
30
+ // Fallback: directorio actual
31
+ const cwdPath = path.join(process.cwd(), 'py', 'src');
32
+ if (fs.existsSync(cwdPath)) {
33
+ return cwdPath;
34
+ }
35
+
36
+ return devPath;
37
+ }
38
+
39
+ /**
40
+ * Genera código Python que configura sys.path correctamente.
41
+ */
42
+ export function wrapPythonCode(pyCode: string): string {
43
+ const modulePath = getPythonModulePath().replace(/\\/g, '\\\\');
44
+
45
+ return `
46
+ import sys
47
+ import os
48
+ from pathlib import Path
49
+
50
+ module_path = r"${modulePath}"
51
+ if module_path not in sys.path:
52
+ sys.path.insert(0, module_path)
53
+
54
+ ${pyCode}
55
+ `;
56
+ }
57
+
58
+ /**
59
+ * Ejecuta código Python y retorna su salida.
60
+ */
61
+ export async function runPython(pyCode: string): Promise<{ stdout: string; stderr: string }> {
62
+ const fullCode = wrapPythonCode(pyCode);
63
+ const pyExec = process.env.PYTHON || 'python3';
64
+
65
+ const { spawn } = await import('child_process');
66
+ const proc = spawn(pyExec, ['-c', fullCode], {
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
69
+ });
70
+
71
+ let stdout = '';
72
+ let stderr = '';
73
+
74
+ await new Promise<void>((resolve, reject) => {
75
+ proc.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
76
+ proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
77
+
78
+ proc.on('close', (code) => {
79
+ if (code === 0) resolve();
80
+ else reject(new Error(stderr || `Python código ${code}`));
81
+ });
82
+
83
+ proc.on('error', (err: Error) => {
84
+ reject(new Error(`Python no disponible: ${err.message}`));
85
+ });
86
+ });
87
+
88
+ return { stdout, stderr };
89
+ }
@@ -1,3 +0,0 @@
1
- from g360_core.flet.ingestion_panel import IngestionPanel
2
-
3
- __all__ = ["IngestionPanel"]
@@ -1,218 +0,0 @@
1
- import threading
2
- from pathlib import Path
3
-
4
- import flet as ft
5
- import pandas as pd
6
-
7
- from g360_core.ingestion import estabilizar_excel_crudo
8
-
9
-
10
- class IngestionPanel(ft.Container):
11
- def __init__(self, theme, on_data_loaded=None):
12
- super().__init__()
13
- self.theme = theme
14
- self.on_data_loaded = on_data_loaded
15
- self.df: pd.DataFrame | None = None
16
- self.metadata: dict | None = None
17
- self._build()
18
-
19
- def _build(self):
20
- self.file_picker = ft.FilePicker(on_result=self._on_file_result)
21
- self.status_text = ft.Text(
22
- "Selecciona un archivo .xls o .xlsx del ERP",
23
- size=14,
24
- color=self.theme.muted,
25
- )
26
- self.stats_container = ft.Column(spacing=4, visible=False)
27
- self.alertas_container = ft.Column(spacing=2, visible=False)
28
- self.preview_table = ft.Column(scroll=ft.ScrollMode.AUTO, visible=False)
29
-
30
- self.content = ft.Column(
31
- controls=[
32
- self.file_picker,
33
- ft.Row(
34
- controls=[
35
- self.theme.accent_button(
36
- text="Cargar Archivo Excel",
37
- on_click=self._open_picker,
38
- ),
39
- ft.Container(width=12),
40
- self.status_text,
41
- ],
42
- alignment=ft.MainAxisAlignment.START,
43
- vertical_alignment=ft.CrossAxisAlignment.CENTER,
44
- ),
45
- ft.Container(height=12),
46
- self.stats_container,
47
- ft.Container(height=8),
48
- self.alertas_container,
49
- ft.Container(height=8),
50
- self.preview_table,
51
- ],
52
- spacing=0,
53
- )
54
- self.bgcolor = self.theme.surface
55
- self.border_radius = self.theme.rounded
56
- self.padding = 20
57
- self.expand = True
58
-
59
- def _open_picker(self, e):
60
- self.file_picker.pick_files(
61
- file_type=ft.FilePickerFileType.CUSTOM,
62
- allowed_extensions=["xls", "xlsx", "xlsm"],
63
- dialog_title="Seleccionar reporte del ERP",
64
- )
65
-
66
- def _on_file_result(self, e: ft.FilePickerResultEvent):
67
- if not e.files:
68
- self.status_text.value = "No se selecciono ningun archivo."
69
- self.status_text.color = self.theme.warning
70
- self.update()
71
- return
72
-
73
- archivo = e.files[0]
74
- ruta = archivo.path
75
- nombre = archivo.name
76
- self.status_text.value = f"Procesando: {nombre}..."
77
- self.status_text.color = self.theme.text
78
- self.update()
79
-
80
- self._show_loading(True)
81
-
82
- def _procesar():
83
- try:
84
- df, metadata = estabilizar_excel_crudo(ruta)
85
- self.df = df
86
- self.metadata = metadata
87
- self._mostrar_resultado(df, metadata, nombre)
88
- if self.on_data_loaded:
89
- self.on_data_loaded(df, metadata)
90
- except Exception as exc:
91
- self.status_text.value = f"Error: {exc}"
92
- self.status_text.color = self.theme.error
93
- self._show_loading(False)
94
- self.update()
95
-
96
- thread = threading.Thread(target=_procesar, daemon=True)
97
- thread.start()
98
-
99
- def _show_loading(self, visible: bool):
100
- pass
101
-
102
- def _mostrar_resultado(self, df: pd.DataFrame, metadata: dict, nombre: str):
103
- self.status_text.value = f"OK: {nombre} ({len(df):,} filas)"
104
- self.status_text.color = self.theme.success
105
-
106
- columnas = metadata.get("columnas_finales", metadata.get("columnas", []))
107
- transformaciones = metadata.get("transformaciones", [])
108
- alertas = metadata.get("alertas", [])
109
- columnas_nuevas = metadata.get("columnas_nuevas", [])
110
-
111
- self.stats_container.controls = [
112
- ft.Text("Resumen de Ingesta", size=16, weight=ft.FontWeight.BOLD, color=self.theme.text),
113
- ft.Text(f"Filas estabilizadas: {len(df):,}", size=13, color=self.theme.muted),
114
- ft.Text(f"Columnas originales: {metadata.get('filas_originales', 0)}", size=13, color=self.theme.muted),
115
- ft.Text(f"Columnas finales: {len(columnas)}", size=13, color=self.theme.muted),
116
- ft.Text(f"Archivo: {metadata.get('archivo', 'N/A')}", size=13, color=self.theme.muted),
117
- ft.Text(f"Moneda: {metadata.get('moneda', 'N/A')}", size=13, color=self.theme.muted),
118
- ft.Divider(color=self.theme.bg, height=8),
119
- ]
120
-
121
- if columnas_nuevas:
122
- nuevas_text = ft.Text(
123
- "Columnas derivadas:",
124
- size=13, weight=ft.FontWeight.BOLD, color=self.theme.accent,
125
- )
126
- chips = ft.Row(
127
- controls=[
128
- ft.Container(
129
- content=ft.Text(col, size=10, color=self.theme.bg),
130
- bgcolor=self.theme.accent,
131
- border_radius=12,
132
- padding=ft.padding.only(left=8, right=8, top=3, bottom=3),
133
- )
134
- for col in columnas_nuevas
135
- ],
136
- spacing=6,
137
- wrap=True,
138
- )
139
- self.stats_container.controls.extend([nuevas_text, chips])
140
-
141
- if transformaciones:
142
- self.stats_container.controls.append(
143
- ft.Divider(color=self.theme.bg, height=8)
144
- )
145
- self.stats_container.controls.append(
146
- ft.Text("Transformaciones:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text)
147
- )
148
- for t in transformaciones[-8:]:
149
- self.stats_container.controls.append(
150
- ft.Row(
151
- controls=[
152
- ft.Container(
153
- content=ft.Text(">", size=11, color=self.theme.accent),
154
- width=16,
155
- ),
156
- ft.Text(t, size=11, color=self.theme.muted),
157
- ],
158
- spacing=0,
159
- )
160
- )
161
-
162
- self.stats_container.visible = True
163
-
164
- if alertas:
165
- self.alertas_container.controls = [
166
- ft.Divider(color=self.theme.bg, height=4),
167
- ft.Text("Alertas:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.warning),
168
- ]
169
- for a in alertas:
170
- self.alertas_container.controls.append(
171
- ft.Row(
172
- controls=[
173
- ft.Icon(ft.icons.WARNING_AMBER_ROUNDED, size=14, color=self.theme.warning),
174
- ft.Text(a, size=11, color=self.theme.warning),
175
- ],
176
- spacing=4,
177
- )
178
- )
179
- self.alertas_container.visible = True
180
-
181
- filas_preview = df.head(5)
182
- columnas_preview = columnas[:8]
183
-
184
- data_rows = []
185
- for _, row in filas_preview.iterrows():
186
- cells = []
187
- for col in columnas_preview:
188
- val = row.get(col, "")
189
- v_str = str(val)[:30] if not pd.isna(val) else "-"
190
- cells.append(
191
- ft.DataCell(ft.Text(v_str, size=10, color=self.theme.text))
192
- )
193
- data_rows.append(ft.DataRow(cells=cells))
194
-
195
- header_cells = [
196
- ft.DataColumn(ft.Text(col[:16], size=10, color=self.theme.accent, weight=ft.FontWeight.BOLD))
197
- for col in columnas_preview
198
- ]
199
-
200
- tabla = ft.DataTable(
201
- columns=header_cells,
202
- rows=data_rows,
203
- heading_text_color=self.theme.accent,
204
- horizontal_margin=4,
205
- column_spacing=16,
206
- )
207
-
208
- self.preview_table.controls = [
209
- ft.Text("Vista previa (5 primeras filas):", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text),
210
- ft.Container(
211
- content=tabla,
212
- bgcolor=self.theme.bg,
213
- border_radius=8,
214
- padding=8,
215
- ),
216
- ]
217
- self.preview_table.visible = True
218
- self.update()