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.
- 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/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,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,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()
|