g360-cli 1.6.1 → 1.7.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.
@@ -0,0 +1,221 @@
1
+ import threading
2
+ from pathlib import Path
3
+
4
+ import flet as ft
5
+ import pandas as pd
6
+
7
+ try:
8
+ from g360_core.ingestion import estabilizar_excel_crudo
9
+ except ImportError:
10
+ from core.ingestion import estabilizar_excel_crudo
11
+
12
+
13
+ class IngestionPanel(ft.Container):
14
+ def __init__(self, theme, on_data_loaded=None):
15
+ super().__init__()
16
+ self.theme = theme
17
+ self.on_data_loaded = on_data_loaded
18
+ self.df: pd.DataFrame | None = None
19
+ self.metadata: dict | None = None
20
+ self._build()
21
+
22
+ def _build(self):
23
+ self.file_picker = ft.FilePicker(on_result=self._on_file_result)
24
+ self.status_text = ft.Text(
25
+ "Selecciona un archivo .xls o .xlsx del ERP",
26
+ size=14,
27
+ color=self.theme.muted,
28
+ )
29
+ self.stats_container = ft.Column(spacing=4, visible=False)
30
+ self.alertas_container = ft.Column(spacing=2, visible=False)
31
+ self.preview_table = ft.Column(scroll=ft.ScrollMode.AUTO, visible=False)
32
+
33
+ self.content = ft.Column(
34
+ controls=[
35
+ self.file_picker,
36
+ ft.Row(
37
+ controls=[
38
+ self.theme.accent_button(
39
+ text="Cargar Archivo Excel",
40
+ on_click=self._open_picker,
41
+ ),
42
+ ft.Container(width=12),
43
+ self.status_text,
44
+ ],
45
+ alignment=ft.MainAxisAlignment.START,
46
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
47
+ ),
48
+ ft.Container(height=12),
49
+ self.stats_container,
50
+ ft.Container(height=8),
51
+ self.alertas_container,
52
+ ft.Container(height=8),
53
+ self.preview_table,
54
+ ],
55
+ spacing=0,
56
+ )
57
+ self.bgcolor = self.theme.surface
58
+ self.border_radius = self.theme.rounded
59
+ self.padding = 20
60
+ self.expand = True
61
+
62
+ def _open_picker(self, e):
63
+ self.file_picker.pick_files(
64
+ file_type=ft.FilePickerFileType.CUSTOM,
65
+ allowed_extensions=["xls", "xlsx", "xlsm"],
66
+ dialog_title="Seleccionar reporte del ERP",
67
+ )
68
+
69
+ def _on_file_result(self, e: ft.FilePickerResultEvent):
70
+ if not e.files:
71
+ self.status_text.value = "No se selecciono ningun archivo."
72
+ self.status_text.color = self.theme.warning
73
+ self.update()
74
+ return
75
+
76
+ archivo = e.files[0]
77
+ ruta = archivo.path
78
+ nombre = archivo.name
79
+ self.status_text.value = f"Procesando: {nombre}..."
80
+ self.status_text.color = self.theme.text
81
+ self.update()
82
+
83
+ self._show_loading(True)
84
+
85
+ def _procesar():
86
+ try:
87
+ df, metadata = estabilizar_excel_crudo(ruta)
88
+ self.df = df
89
+ self.metadata = metadata
90
+ self._mostrar_resultado(df, metadata, nombre)
91
+ if self.on_data_loaded:
92
+ self.on_data_loaded(df, metadata)
93
+ except Exception as exc:
94
+ self.status_text.value = f"Error: {exc}"
95
+ self.status_text.color = self.theme.error
96
+ self._show_loading(False)
97
+ self.update()
98
+
99
+ thread = threading.Thread(target=_procesar, daemon=True)
100
+ thread.start()
101
+
102
+ def _show_loading(self, visible: bool):
103
+ pass
104
+
105
+ def _mostrar_resultado(self, df: pd.DataFrame, metadata: dict, nombre: str):
106
+ self.status_text.value = f"OK: {nombre} ({len(df):,} filas)"
107
+ self.status_text.color = self.theme.success
108
+
109
+ columnas = metadata.get("columnas_finales", metadata.get("columnas", []))
110
+ transformaciones = metadata.get("transformaciones", [])
111
+ alertas = metadata.get("alertas", [])
112
+ columnas_nuevas = metadata.get("columnas_nuevas", [])
113
+
114
+ self.stats_container.controls = [
115
+ ft.Text("Resumen de Ingesta", size=16, weight=ft.FontWeight.BOLD, color=self.theme.text),
116
+ ft.Text(f"Filas estabilizadas: {len(df):,}", size=13, color=self.theme.muted),
117
+ ft.Text(f"Columnas originales: {metadata.get('filas_originales', 0)}", size=13, color=self.theme.muted),
118
+ ft.Text(f"Columnas finales: {len(columnas)}", size=13, color=self.theme.muted),
119
+ ft.Text(f"Archivo: {metadata.get('archivo', 'N/A')}", size=13, color=self.theme.muted),
120
+ ft.Text(f"Moneda: {metadata.get('moneda', 'N/A')}", size=13, color=self.theme.muted),
121
+ ft.Divider(color=self.theme.bg, height=8),
122
+ ]
123
+
124
+ if columnas_nuevas:
125
+ nuevas_text = ft.Text(
126
+ "Columnas derivadas:",
127
+ size=13, weight=ft.FontWeight.BOLD, color=self.theme.accent,
128
+ )
129
+ chips = ft.Row(
130
+ controls=[
131
+ ft.Container(
132
+ content=ft.Text(col, size=10, color=self.theme.bg),
133
+ bgcolor=self.theme.accent,
134
+ border_radius=12,
135
+ padding=ft.padding.only(left=8, right=8, top=3, bottom=3),
136
+ )
137
+ for col in columnas_nuevas
138
+ ],
139
+ spacing=6,
140
+ wrap=True,
141
+ )
142
+ self.stats_container.controls.extend([nuevas_text, chips])
143
+
144
+ if transformaciones:
145
+ self.stats_container.controls.append(
146
+ ft.Divider(color=self.theme.bg, height=8)
147
+ )
148
+ self.stats_container.controls.append(
149
+ ft.Text("Transformaciones:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text)
150
+ )
151
+ for t in transformaciones[-8:]:
152
+ self.stats_container.controls.append(
153
+ ft.Row(
154
+ controls=[
155
+ ft.Container(
156
+ content=ft.Text(">", size=11, color=self.theme.accent),
157
+ width=16,
158
+ ),
159
+ ft.Text(t, size=11, color=self.theme.muted),
160
+ ],
161
+ spacing=0,
162
+ )
163
+ )
164
+
165
+ self.stats_container.visible = True
166
+
167
+ if alertas:
168
+ self.alertas_container.controls = [
169
+ ft.Divider(color=self.theme.bg, height=4),
170
+ ft.Text("Alertas:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.warning),
171
+ ]
172
+ for a in alertas:
173
+ self.alertas_container.controls.append(
174
+ ft.Row(
175
+ controls=[
176
+ ft.Icon(ft.icons.WARNING_AMBER_ROUNDED, size=14, color=self.theme.warning),
177
+ ft.Text(a, size=11, color=self.theme.warning),
178
+ ],
179
+ spacing=4,
180
+ )
181
+ )
182
+ self.alertas_container.visible = True
183
+
184
+ filas_preview = df.head(5)
185
+ columnas_preview = columnas[:8]
186
+
187
+ data_rows = []
188
+ for _, row in filas_preview.iterrows():
189
+ cells = []
190
+ for col in columnas_preview:
191
+ val = row.get(col, "")
192
+ v_str = str(val)[:30] if not pd.isna(val) else "-"
193
+ cells.append(
194
+ ft.DataCell(ft.Text(v_str, size=10, color=self.theme.text))
195
+ )
196
+ data_rows.append(ft.DataRow(cells=cells))
197
+
198
+ header_cells = [
199
+ ft.DataColumn(ft.Text(col[:16], size=10, color=self.theme.accent, weight=ft.FontWeight.BOLD))
200
+ for col in columnas_preview
201
+ ]
202
+
203
+ tabla = ft.DataTable(
204
+ columns=header_cells,
205
+ rows=data_rows,
206
+ heading_text_color=self.theme.accent,
207
+ horizontal_margin=4,
208
+ column_spacing=16,
209
+ )
210
+
211
+ self.preview_table.controls = [
212
+ ft.Text("Vista previa (5 primeras filas):", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text),
213
+ ft.Container(
214
+ content=tabla,
215
+ bgcolor=self.theme.bg,
216
+ border_radius=8,
217
+ padding=8,
218
+ ),
219
+ ]
220
+ self.preview_table.visible = True
221
+ self.update()
@@ -5,6 +5,12 @@ import { fileURLToPath } from 'url';
5
5
  import { progress } from '../lib/progress.js';
6
6
 
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const INGESTION_FILES = [
9
+ { src: 'ingestion/core/ingestion.py', dest: 'src/core/ingestion.py' },
10
+ { src: 'ingestion/core/__init__.py', dest: 'src/core/__init__.py' },
11
+ { src: 'ingestion/ui/ingestion_panel.py', dest: 'src/ui/ingestion_panel.py' },
12
+ { src: 'ingestion/ui/__init__.py', dest: 'src/ui/__init__.py' },
13
+ ];
8
14
 
9
15
  export async function bring(asset, options) {
10
16
  const { path: targetPath = '.', dryRun = false, force = false } = options;
@@ -26,6 +32,10 @@ export async function bring(asset, options) {
26
32
  return;
27
33
  }
28
34
 
35
+ if (asset === 'ingestion') {
36
+ return installIngestion(targetDir, dryRun, force);
37
+ }
38
+
29
39
  await copyAssets(assetPath, targetDir, dryRun, force);
30
40
 
31
41
  if (asset.startsWith('brand')) {
@@ -104,6 +114,83 @@ async function applyBrand(targetDir, brandName, dryRun) {
104
114
  console.log(chalk.gray(` Signature: ${brand.signature?.text || 'none'}`));
105
115
  }
106
116
 
117
+ async function installIngestion(targetDir, dryRun, force) {
118
+ const assetsPath = path.join(__dirname, '../assets');
119
+ const ingestionDir = path.join(assetsPath, 'ingestion');
120
+
121
+ if (!fs.existsSync(ingestionDir)) {
122
+ console.error(chalk.red('❌ Ingestion asset not found in package.'));
123
+ return;
124
+ }
125
+
126
+ const hasFlet = (
127
+ fs.existsSync(path.join(targetDir, 'pyproject.toml')) &&
128
+ fs.existsSync(path.join(targetDir, 'src', 'main.py'))
129
+ );
130
+
131
+ if (!hasFlet) {
132
+ console.error(chalk.red('❌ This command requires a G360 Flet project.'));
133
+ console.log(chalk.gray(' Run "g360 init <name> --template python-flet" first.'));
134
+ return;
135
+ }
136
+
137
+ const srcCore = path.join(targetDir, 'src', 'core');
138
+ const srcUi = path.join(targetDir, 'src', 'ui');
139
+
140
+ if (!fs.existsSync(srcCore)) {
141
+ if (!dryRun) fs.mkdirpSync(srcCore);
142
+ console.log(chalk.gray(' Created src/core/'));
143
+ }
144
+ if (!fs.existsSync(srcUi)) {
145
+ if (!dryRun) fs.mkdirpSync(srcUi);
146
+ console.log(chalk.gray(' Created src/ui/'));
147
+ }
148
+
149
+ if (dryRun) {
150
+ console.log(chalk.yellow('\n📋 DRY RUN - Would install:'));
151
+ for (const f of INGESTION_FILES) {
152
+ console.log(chalk.gray(` ${f.dest}`));
153
+ }
154
+ return;
155
+ }
156
+
157
+ console.log(chalk.cyan('\n📦 Option A — Install via pip (recommended):'));
158
+ console.log(chalk.white(' pip install g360-core'));
159
+
160
+ const progressBar = progress('Installing local fallback files...');
161
+
162
+ try {
163
+ let copied = 0;
164
+ for (const f of INGESTION_FILES) {
165
+ const srcFile = path.join(assetsPath, f.src);
166
+ const destFile = path.join(targetDir, f.dest);
167
+
168
+ if (fs.existsSync(destFile) && !force) {
169
+ console.log(chalk.yellow(`\n⚠ ${f.dest} exists. Use --force to overwrite.`));
170
+ continue;
171
+ }
172
+
173
+ await fs.copy(srcFile, destFile, { overwrite: force });
174
+ copied++;
175
+ }
176
+
177
+ progressBar.stop();
178
+
179
+ if (copied > 0) {
180
+ console.log(chalk.green(`\n✅ ${copied} local fallback file(s) installed\n`));
181
+ console.log(chalk.gray(' These files are used when g360-core pip package is not available.'));
182
+ console.log(chalk.cyan('\n Import (auto-detects pip package → local fallback):'));
183
+ console.log(chalk.white(' from ui.ingestion_panel import IngestionPanel'));
184
+ }
185
+
186
+ console.log(chalk.cyan('\n Or add to your pyproject.toml:'));
187
+ console.log(chalk.white(' "g360-core>=0.1.0"'));
188
+ } catch (error) {
189
+ progressBar.stop();
190
+ console.error(chalk.red(`\n❌ Error: ${error.message}`));
191
+ }
192
+ }
193
+
107
194
  async function copyAssets(src, dest, dryRun, force) {
108
195
  if (dryRun) {
109
196
  console.log(chalk.yellow('📋 DRY RUN - No files will be copied\n'));
@@ -15,7 +15,8 @@ export async function list(type, options) {
15
15
  templates: [],
16
16
  components: [],
17
17
  skills: [],
18
- brands: []
18
+ brands: [],
19
+ ingestion: []
19
20
  };
20
21
 
21
22
  const templatesPath = path.join(assetsPath, 'templates');
@@ -47,6 +48,11 @@ export async function list(type, options) {
47
48
  }
48
49
  }
49
50
 
51
+ const ingestionPath = path.join(assetsPath, 'ingestion');
52
+ if (fs.existsSync(ingestionPath)) {
53
+ assets.ingestion = ['ingestion'];
54
+ }
55
+
50
56
  if (fs.existsSync(snippetsPath)) {
51
57
  const snippetsJsonPath = path.join(snippetsPath, 'snippets.json');
52
58
  if (fs.existsSync(snippetsJsonPath)) {
@@ -115,6 +121,17 @@ export async function list(type, options) {
115
121
  }
116
122
  }
117
123
 
124
+ if (!type || type === 'all' || type === 'ingestion') {
125
+ console.log(chalk.bold.yellow('\n📥 Ingestion Modules:'));
126
+ const ingestionPath = path.join(assetsPath, 'ingestion');
127
+ if (fs.existsSync(ingestionPath)) {
128
+ console.log(chalk.gray(' - ingestion (ERP data normalizer for Flet)'));
129
+ console.log(chalk.gray(' Run: g360 bring ingestion'));
130
+ } else {
131
+ console.log(chalk.gray(' No ingestion module found'));
132
+ }
133
+ }
134
+
118
135
  if (!type || type === 'all' || type === 'brands') {
119
136
  console.log(chalk.bold.yellow('\n🎨 Brands:'));
120
137
  if (assets.brands.length) {