g360-cli 1.13.1 → 1.15.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/package.json +71 -71
- package/src/assets/config/g360-skills.json +165 -0
- package/src/assets/config/project-types.json +23 -4
- package/src/assets/snippets/snippets.json +4 -0
- package/src/assets/templates/lit-web/src/index.js +1 -1
- package/src/assets/templates/python-cli/src/core/__init__.py +0 -0
- package/src/assets/templates/python-flet/README.md +6 -1
- package/src/assets/templates/python-flet/pyproject.toml +2 -2
- package/src/assets/templates/python-flet/src/core/skill.json +1 -1
- package/src/assets/templates/python-flet-migrate/pyproject.toml +1 -3
- package/src/assets/templates/python-flet-polished/README.md +170 -0
- package/src/assets/templates/python-flet-polished/assets/data/sample_data.json +12 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/Inter-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/app.ico +0 -0
- package/src/assets/templates/python-flet-polished/assets/images/logo.svg +9 -0
- package/src/assets/templates/python-flet-polished/build-portable.bat +42 -0
- package/src/assets/templates/python-flet-polished/create_shortcut.vbs +15 -0
- package/src/assets/templates/python-flet-polished/g360_flet/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/g360_flet/g360_signature.py +210 -0
- package/src/assets/templates/python-flet-polished/launch.vbs +3 -0
- package/src/assets/templates/python-flet-polished/launch_minimized.bat +3 -0
- package/src/assets/templates/python-flet-polished/main.py +47 -0
- package/src/assets/templates/python-flet-polished/pyproject.toml +18 -0
- package/src/assets/templates/python-flet-polished/requirements.txt +3 -0
- package/src/assets/templates/python-flet-polished/run.bat +189 -0
- package/src/assets/templates/python-flet-polished/skill.json +28 -0
- package/src/assets/templates/python-flet-polished/src/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/app.py +343 -0
- package/src/assets/templates/python-flet-polished/src/config/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/config/theme.py +122 -0
- package/src/assets/templates/python-flet-polished/src/core/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/core/constants.py +49 -0
- package/src/assets/templates/python-flet-polished/src/core/processor.py +128 -0
- package/src/assets/templates/python-flet-polished/src/core/skill.json +40 -0
- package/src/assets/templates/python-flet-polished/src/ui/__init__.py +0 -0
- package/src/assets/templates/python-flet-polished/src/ui/dashboard.py +308 -0
- package/src/assets/templates/python-flet-polished/src/ui/kpi_card.py +69 -0
- package/src/assets/templates/python-flet-polished/src/ui/search_overlay.py +143 -0
- package/src/assets/templates/python-flet-polished/sync_portable.py +71 -0
- package/src/assets/templates/solid-web/src/components/App.jsx +2 -2
- package/src/cli.js +4 -4
- package/src/commands/clean.js +0 -12
- package/src/commands/docs.js +105 -1
- package/src/commands/ingest.js +11 -61
- package/src/commands/init.js +34 -3
- package/src/commands/lint.js +35 -77
- package/src/commands/scan.js +9 -37
- package/src/commands/validate.js +6 -36
- package/src/lib/file-utils.js +89 -2
- package/src/lib/python-runner.js +77 -0
- 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/src/assets/ingestion/core/__pycache__/ingestion.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-312.pyc +0 -0
- package/src/assets/signature/g360_flet/__pycache__/g360_signature.cpython-314.pyc +0 -0
- package/src/assets/templates/python-cli/package.json +0 -7
- /package/src/assets/config/{skills.json → agent-skills.json} +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import flet as ft
|
|
12
|
+
|
|
13
|
+
from src.config.theme import get_colors, load_theme_preference, save_theme_preference
|
|
14
|
+
from src.core.constants import (
|
|
15
|
+
AUTO_REFRESH_INTERVAL,
|
|
16
|
+
CACHE_FILE,
|
|
17
|
+
VERSION_CACHE_FILE,
|
|
18
|
+
VERSION_CHECK_INTERVAL,
|
|
19
|
+
WINDOW_WIDTH,
|
|
20
|
+
WINDOW_HEIGHT,
|
|
21
|
+
WINDOW_MIN_WIDTH,
|
|
22
|
+
WINDOW_MIN_HEIGHT,
|
|
23
|
+
get_local_version,
|
|
24
|
+
get_app_name,
|
|
25
|
+
)
|
|
26
|
+
from src.ui.dashboard import Dashboard
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
_log_logger = logging.getLogger("g360.app")
|
|
30
|
+
if not _log_logger.handlers:
|
|
31
|
+
from logging.handlers import RotatingFileHandler
|
|
32
|
+
_log_path = Path(__file__).resolve().parent.parent.parent / "run_log.txt"
|
|
33
|
+
_fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
|
34
|
+
_handler = RotatingFileHandler(_log_path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8")
|
|
35
|
+
_handler.setFormatter(_fmt)
|
|
36
|
+
_log_logger.addHandler(_handler)
|
|
37
|
+
_log_logger.setLevel(logging.INFO)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _log(msg: str):
|
|
41
|
+
_log_logger.info(msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_cache() -> tuple[dict, str | None]:
|
|
45
|
+
"""Carga el cache persistente. Retorna (raw_data, timestamp)."""
|
|
46
|
+
if not CACHE_FILE.exists():
|
|
47
|
+
return {}, None
|
|
48
|
+
try:
|
|
49
|
+
with open(CACHE_FILE, encoding="utf-8") as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
raw = data.get("raw_data", {})
|
|
52
|
+
ts = data.get("timestamp")
|
|
53
|
+
if raw:
|
|
54
|
+
return raw, ts
|
|
55
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as ex:
|
|
56
|
+
_log(f"_load_cache: corrupt cache file, removing: {ex}")
|
|
57
|
+
try:
|
|
58
|
+
CACHE_FILE.unlink(missing_ok=True)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return {}, None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _hash_data(raw_data: dict) -> str:
|
|
67
|
+
"""SHA-256 del raw_data para detectar cambios sin descargar dos veces."""
|
|
68
|
+
serialized = json.dumps(raw_data, sort_keys=True, ensure_ascii=False).encode()
|
|
69
|
+
return hashlib.sha256(serialized).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _data_changed(new_data: dict, last_hash: str | None) -> tuple[bool, str]:
|
|
73
|
+
"""Retorna (changed, nuevo_hash)."""
|
|
74
|
+
h = _hash_data(new_data)
|
|
75
|
+
return h != last_hash, h
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_cache(raw_data: dict, api_timestamp: str | None = None):
|
|
79
|
+
"""Guarda raw_data completo."""
|
|
80
|
+
try:
|
|
81
|
+
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
payload = {
|
|
83
|
+
"raw_data": raw_data,
|
|
84
|
+
"timestamp": api_timestamp or datetime.now().isoformat(),
|
|
85
|
+
"api_timestamp": api_timestamp,
|
|
86
|
+
}
|
|
87
|
+
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
89
|
+
except Exception as ex:
|
|
90
|
+
_log(f"_save_cache: ERROR {ex}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class G360App:
|
|
94
|
+
"""
|
|
95
|
+
Clase principal de la aplicacion G360 con Flet.
|
|
96
|
+
|
|
97
|
+
Patrones implementados:
|
|
98
|
+
- Dual theme (dark/light) con persistencia en ~/.g360/
|
|
99
|
+
- Auto-refresh con lock thread-safe
|
|
100
|
+
- Cache con hash diff para evitar rebuild innecesarios
|
|
101
|
+
- Logger RotatingFileHandler
|
|
102
|
+
- Shutdown limpio via page.on_close
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, page: ft.Page):
|
|
106
|
+
try:
|
|
107
|
+
self.page = page
|
|
108
|
+
self._raw_data: dict = {}
|
|
109
|
+
self._theme_mode = load_theme_preference()
|
|
110
|
+
self._setup_page()
|
|
111
|
+
self.dashboard = Dashboard(page, self._theme_mode)
|
|
112
|
+
if hasattr(self.dashboard, "set_on_theme_toggle"):
|
|
113
|
+
self.dashboard.set_on_theme_toggle(self._toggle_theme)
|
|
114
|
+
self.dashboard.set_on_refresh(self._on_refresh)
|
|
115
|
+
self._cache_timestamp: str | None = None
|
|
116
|
+
self._stale_data: bool = False
|
|
117
|
+
self._local_version = get_local_version()
|
|
118
|
+
self._last_auto_refresh: float = 0.0
|
|
119
|
+
self._auto_refresh_stop: threading.Event | None = None
|
|
120
|
+
self._download_lock = threading.Lock()
|
|
121
|
+
self._last_data_hash: str | None = None
|
|
122
|
+
self._build()
|
|
123
|
+
except Exception:
|
|
124
|
+
_log(f"[FATAL] G360App.__init__:\n{traceback.format_exc()}")
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
def _setup_page(self):
|
|
128
|
+
app_name = get_app_name()
|
|
129
|
+
self.page.title = f"{app_name.title()} - G360"
|
|
130
|
+
self.page.theme_mode = ft.ThemeMode.DARK if self._theme_mode == "dark" else ft.ThemeMode.LIGHT
|
|
131
|
+
self.page.bgcolor = get_colors(self._theme_mode)["background"]
|
|
132
|
+
self.page.padding = 0
|
|
133
|
+
self.page.window_width = WINDOW_WIDTH
|
|
134
|
+
self.page.window_height = WINDOW_HEIGHT
|
|
135
|
+
self.page.window_min_width = WINDOW_MIN_WIDTH
|
|
136
|
+
self.page.window_min_height = WINDOW_MIN_HEIGHT
|
|
137
|
+
try:
|
|
138
|
+
self.page.window_center()
|
|
139
|
+
except AttributeError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
fonts_dir = Path(__file__).resolve().parent.parent.parent / "assets" / "fonts"
|
|
143
|
+
self.page.fonts = {
|
|
144
|
+
"Inter": str(fonts_dir / "Inter-Variable.ttf"),
|
|
145
|
+
"JetBrains Mono": str(fonts_dir / "JetBrainsMono-Variable.ttf"),
|
|
146
|
+
}
|
|
147
|
+
self.page.theme = ft.Theme(font_family="Inter")
|
|
148
|
+
|
|
149
|
+
def _build(self):
|
|
150
|
+
_log("_build: starting dashboard.build()...")
|
|
151
|
+
view = self.dashboard.build()
|
|
152
|
+
_log("_build: dashboard.build() OK")
|
|
153
|
+
self.page.add(view)
|
|
154
|
+
self.page.update()
|
|
155
|
+
_log("_build: page.add + update OK")
|
|
156
|
+
self.dashboard.register_overlay()
|
|
157
|
+
_log("_build: overlay (FilePickers) registrado")
|
|
158
|
+
|
|
159
|
+
sample_path = Path(__file__).resolve().parent.parent / "assets" / "data" / "sample_data.json"
|
|
160
|
+
cache, ts = _load_cache()
|
|
161
|
+
if cache:
|
|
162
|
+
_log(f"_build: loading cached data...")
|
|
163
|
+
self._raw_data = cache
|
|
164
|
+
self._cache_timestamp = ts
|
|
165
|
+
_, initial_hash = _data_changed(cache, None)
|
|
166
|
+
self._last_data_hash = initial_hash
|
|
167
|
+
self.dashboard.update_data(cache, cache_timestamp=ts)
|
|
168
|
+
self.page.update()
|
|
169
|
+
_log(f"_build: cache loaded OK ({len(cache)} items)")
|
|
170
|
+
elif sample_path.exists():
|
|
171
|
+
_log("_build: cache empty, loading sample_data.json...")
|
|
172
|
+
with open(sample_path, encoding="utf-8") as f:
|
|
173
|
+
raw = json.load(f)
|
|
174
|
+
self._raw_data = raw
|
|
175
|
+
self.dashboard.update_data(raw)
|
|
176
|
+
self.page.update()
|
|
177
|
+
_log("_build: sample_data loaded OK")
|
|
178
|
+
else:
|
|
179
|
+
_log("_build: neither cache nor sample_data.json found")
|
|
180
|
+
self.dashboard._show_empty_state("Sin datos disponibles")
|
|
181
|
+
self.dashboard._set_empty_state_status("Esperando datos", self.dashboard.c["warning"])
|
|
182
|
+
|
|
183
|
+
_log("_build: scheduling _delayed_load...")
|
|
184
|
+
self.page.run_task(self._delayed_load)
|
|
185
|
+
self._start_auto_refresh()
|
|
186
|
+
_log("_build: done")
|
|
187
|
+
|
|
188
|
+
def _toggle_theme(self):
|
|
189
|
+
self._theme_mode = "light" if self._theme_mode == "dark" else "dark"
|
|
190
|
+
save_theme_preference(self._theme_mode)
|
|
191
|
+
self.page.theme_mode = ft.ThemeMode.DARK if self._theme_mode == "dark" else ft.ThemeMode.LIGHT
|
|
192
|
+
self.page.bgcolor = get_colors(self._theme_mode)["background"]
|
|
193
|
+
self.dashboard.update_theme(self._theme_mode)
|
|
194
|
+
self.page.update()
|
|
195
|
+
|
|
196
|
+
async def _delayed_load(self):
|
|
197
|
+
import asyncio
|
|
198
|
+
await asyncio.sleep(0.5)
|
|
199
|
+
await self._load_data()
|
|
200
|
+
|
|
201
|
+
async def _on_refresh(self):
|
|
202
|
+
await self._load_data(is_manual=True)
|
|
203
|
+
|
|
204
|
+
def _start_auto_refresh(self):
|
|
205
|
+
try:
|
|
206
|
+
self._auto_refresh_stop = threading.Event()
|
|
207
|
+
t = threading.Thread(
|
|
208
|
+
target=self._auto_refresh_loop,
|
|
209
|
+
daemon=True,
|
|
210
|
+
name="auto-refresh",
|
|
211
|
+
)
|
|
212
|
+
t.start()
|
|
213
|
+
self._auto_refresh_thread = t
|
|
214
|
+
_log(f"_start_auto_refresh: hilo iniciado ({AUTO_REFRESH_INTERVAL}s)")
|
|
215
|
+
except Exception as ex:
|
|
216
|
+
_log(f"_start_auto_refresh: ERROR {ex}")
|
|
217
|
+
|
|
218
|
+
def shutdown(self):
|
|
219
|
+
"""Detiene el auto-refresh de forma limpia antes de cerrar."""
|
|
220
|
+
if self._auto_refresh_stop:
|
|
221
|
+
_log("_shutdown: deteniendo auto-refresh...")
|
|
222
|
+
self._auto_refresh_stop.set()
|
|
223
|
+
t = getattr(self, "_auto_refresh_thread", None)
|
|
224
|
+
if t and t.is_alive():
|
|
225
|
+
t.join(timeout=3)
|
|
226
|
+
_log("_shutdown: auto-refresh detenido")
|
|
227
|
+
|
|
228
|
+
def _auto_refresh_loop(self):
|
|
229
|
+
while not self._auto_refresh_stop.is_set():
|
|
230
|
+
time.sleep(1)
|
|
231
|
+
try:
|
|
232
|
+
self._on_auto_refresh_tick()
|
|
233
|
+
except Exception as ex:
|
|
234
|
+
_log(f"_auto_refresh_loop: ERROR {ex}")
|
|
235
|
+
|
|
236
|
+
def _on_auto_refresh_tick(self):
|
|
237
|
+
try:
|
|
238
|
+
if not self._cache_timestamp:
|
|
239
|
+
return
|
|
240
|
+
self.dashboard._update_refresh_status(self._cache_timestamp)
|
|
241
|
+
if time.time() - self._last_auto_refresh < AUTO_REFRESH_INTERVAL:
|
|
242
|
+
return
|
|
243
|
+
self._last_auto_refresh = time.time()
|
|
244
|
+
_log("_on_auto_refresh_tick: ejecutando auto-refresh")
|
|
245
|
+
self.page.run_task(self._load_data, is_manual=False)
|
|
246
|
+
except Exception as ex:
|
|
247
|
+
_log(f"_on_auto_refresh_tick: ERROR {ex}")
|
|
248
|
+
|
|
249
|
+
async def _load_data(self, is_manual=False):
|
|
250
|
+
import time
|
|
251
|
+
_t0 = time.time()
|
|
252
|
+
_log(f"_load_data: start is_manual={is_manual}")
|
|
253
|
+
self.dashboard.set_loading(True, "Descargando datos...")
|
|
254
|
+
|
|
255
|
+
if not self._download_lock.acquire(blocking=False):
|
|
256
|
+
_log("_load_data: otra descarga en curso, omitiendo")
|
|
257
|
+
self.dashboard.set_loading(False)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
import asyncio
|
|
262
|
+
loop = asyncio.get_running_loop()
|
|
263
|
+
_log("_load_data: calling _fetch_data in executor...")
|
|
264
|
+
raw = await loop.run_in_executor(None, self._fetch_data)
|
|
265
|
+
_log(f"_load_data: _fetch_data returned {type(raw).__name__}")
|
|
266
|
+
|
|
267
|
+
if not raw:
|
|
268
|
+
self.dashboard.set_loading(False)
|
|
269
|
+
self.dashboard.status_text.value = "No se obtuvieron datos"
|
|
270
|
+
self.dashboard.status_text.color = "#ef4444"
|
|
271
|
+
self.dashboard._show_snack("Error: No se obtuvieron datos", is_error=True)
|
|
272
|
+
if not self._raw_data:
|
|
273
|
+
self.dashboard._set_empty_state_status("Sin datos - API no disponible", self.dashboard.c["error"])
|
|
274
|
+
_log("_load_data: no data, showing error")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
changed, new_hash = _data_changed(raw, self._last_data_hash)
|
|
278
|
+
if not changed:
|
|
279
|
+
_log("_load_data: datos identicos, omitiendo UI refresh")
|
|
280
|
+
self.dashboard.set_loading(False)
|
|
281
|
+
self._download_lock.release()
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
self._raw_data = raw
|
|
285
|
+
self._last_data_hash = new_hash
|
|
286
|
+
_save_cache(raw)
|
|
287
|
+
self._cache_timestamp = datetime.now().isoformat()
|
|
288
|
+
self._last_auto_refresh = time.time()
|
|
289
|
+
_log("_load_data: cache saved")
|
|
290
|
+
self.dashboard.update_data(raw, cache_timestamp=self._cache_timestamp, stale=self._stale_data)
|
|
291
|
+
self.dashboard._hide_empty_state()
|
|
292
|
+
_log("_load_data: data updated in dashboard")
|
|
293
|
+
if is_manual:
|
|
294
|
+
self.dashboard._show_snack("Datos actualizados correctamente")
|
|
295
|
+
except Exception as ex:
|
|
296
|
+
_log(f"_load_data: EXCEPTION: {traceback.format_exc()}")
|
|
297
|
+
self.dashboard.status_text.value = f"Error: {str(ex)}"
|
|
298
|
+
self.dashboard.status_text.color = "#ef4444"
|
|
299
|
+
self.dashboard._show_snack(f"Fallo en la descarga: {str(ex)}", is_error=True)
|
|
300
|
+
self.dashboard.set_offline(True)
|
|
301
|
+
if not self._raw_data:
|
|
302
|
+
self.dashboard._set_empty_state_status("Error de conexión", self.dashboard.c["error"])
|
|
303
|
+
finally:
|
|
304
|
+
elapsed = time.time() - _t0
|
|
305
|
+
_log(f"_load_data: elapsed={elapsed:.1f}s")
|
|
306
|
+
self._download_lock.release()
|
|
307
|
+
if elapsed < 2:
|
|
308
|
+
self.dashboard.set_loading(True, "Actualizando vista...")
|
|
309
|
+
self.page.update()
|
|
310
|
+
import asyncio
|
|
311
|
+
await asyncio.sleep(2 - elapsed)
|
|
312
|
+
ts = self.dashboard.format_cache_timestamp(self._cache_timestamp) if not is_manual else ""
|
|
313
|
+
self.dashboard._ts_text.value = f"Ultima act. {ts}"
|
|
314
|
+
if self._stale_data:
|
|
315
|
+
self.dashboard._ts_text.color = self.dashboard.c["warning"]
|
|
316
|
+
self.dashboard._ts_text.value += " (caché)"
|
|
317
|
+
self.dashboard.status_text.value = "Datos en caché (fuera de horario)"
|
|
318
|
+
self.dashboard.status_text.color = self.dashboard.c["warning"]
|
|
319
|
+
self.dashboard._show_stale_warning()
|
|
320
|
+
else:
|
|
321
|
+
self.dashboard._ts_text.color = self.dashboard.c["accent"]
|
|
322
|
+
self.dashboard.status_text.value = "Datos actualizados"
|
|
323
|
+
self.dashboard.status_text.color = self.dashboard.c["accent"]
|
|
324
|
+
self.dashboard._hide_stale_warning()
|
|
325
|
+
self.dashboard._update_refresh_status(self._cache_timestamp)
|
|
326
|
+
self.dashboard.set_offline(False)
|
|
327
|
+
self.dashboard.set_loading(False)
|
|
328
|
+
self.page.update()
|
|
329
|
+
_log("_load_data: done")
|
|
330
|
+
|
|
331
|
+
def _fetch_data(self) -> dict | None:
|
|
332
|
+
"""
|
|
333
|
+
Metodo que debe ser sobrescrito por subclases.
|
|
334
|
+
Implementa la logica de descarga/fetch de datos.
|
|
335
|
+
Retorna dict con los datos crudos o None si falla.
|
|
336
|
+
"""
|
|
337
|
+
# Default: retorna None (no hay data)
|
|
338
|
+
# Subclases deben override这个方法
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main(page: ft.Page):
|
|
343
|
+
G360App(page)
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
ACCENT = "#10B981"
|
|
5
|
+
ACCENT_DARK = "#047857"
|
|
6
|
+
|
|
7
|
+
DARK_COLORS = {
|
|
8
|
+
"accent": "#10B981",
|
|
9
|
+
"accent_dark": "#047857",
|
|
10
|
+
"success": "#34D399",
|
|
11
|
+
"warning": "#F59E0B",
|
|
12
|
+
"error": "#EF4444",
|
|
13
|
+
"info": "#3B82F6",
|
|
14
|
+
"violet": "#8B5CF6",
|
|
15
|
+
"pink": "#EC4899",
|
|
16
|
+
"cyan": "#06B6D4",
|
|
17
|
+
"orange": "#F97316",
|
|
18
|
+
"kpis": {
|
|
19
|
+
"primary": "#06B6D4",
|
|
20
|
+
"secondary": "#8B5CF6",
|
|
21
|
+
"tertiary": "#0EA5E9",
|
|
22
|
+
"quaternary": "#6366F1",
|
|
23
|
+
"quinary": "#EC4899",
|
|
24
|
+
"senary": "#F59E0B",
|
|
25
|
+
"septenary": "#EF4444",
|
|
26
|
+
"octonary": "#F97316",
|
|
27
|
+
},
|
|
28
|
+
"surface": "#141D33",
|
|
29
|
+
"surface_variant": "#1B2740",
|
|
30
|
+
"surface_sunken": "#0E1627",
|
|
31
|
+
"background": "#0A0F1E",
|
|
32
|
+
"border": "#FFFFFF17",
|
|
33
|
+
"text_muted": "#8FA0BA",
|
|
34
|
+
"text_primary": "#F1F5FB",
|
|
35
|
+
"text_secondary": "#C9D4E6",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
LIGHT_COLORS = {
|
|
39
|
+
"accent": "#047857",
|
|
40
|
+
"accent_dark": "#065F46",
|
|
41
|
+
"success": "#15803D",
|
|
42
|
+
"warning": "#B45309",
|
|
43
|
+
"error": "#DC2626",
|
|
44
|
+
"info": "#2563EB",
|
|
45
|
+
"violet": "#7C3AED",
|
|
46
|
+
"pink": "#DB2777",
|
|
47
|
+
"cyan": "#0891B2",
|
|
48
|
+
"orange": "#EA580C",
|
|
49
|
+
"kpis": {
|
|
50
|
+
"primary": "#0891B2",
|
|
51
|
+
"secondary": "#7C3AED",
|
|
52
|
+
"tertiary": "#0284C7",
|
|
53
|
+
"quaternary": "#4F46E5",
|
|
54
|
+
"quinary": "#DB2777",
|
|
55
|
+
"senary": "#B45309",
|
|
56
|
+
"septenary": "#DC2626",
|
|
57
|
+
"octonary": "#EA580C",
|
|
58
|
+
},
|
|
59
|
+
"surface": "#FFFFFF",
|
|
60
|
+
"surface_variant": "#F7F9FC",
|
|
61
|
+
"surface_sunken": "#EEF1F6",
|
|
62
|
+
"background": "#F3F5F9",
|
|
63
|
+
"border": "#E3E8F0",
|
|
64
|
+
"text_muted": "#64748B",
|
|
65
|
+
"text_primary": "#0F172A",
|
|
66
|
+
"text_secondary": "#334155",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_colors(mode: str) -> dict:
|
|
71
|
+
return dict(DARK_COLORS if mode == "dark" else LIGHT_COLORS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def rgba(color: str, opacity: float) -> str:
|
|
75
|
+
if color.startswith("#") and len(color) == 9:
|
|
76
|
+
return color
|
|
77
|
+
if color.startswith("#") and len(color) == 7:
|
|
78
|
+
hex_color = color[1:]
|
|
79
|
+
alpha = int(max(0.0, min(1.0, opacity)) * 255)
|
|
80
|
+
return f"#{alpha:02x}{hex_color}"
|
|
81
|
+
return color
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_theme_file() -> Path:
|
|
85
|
+
return Path.home() / ".g360" / f"{_get_app_name()}_config.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_app_name() -> str:
|
|
89
|
+
try:
|
|
90
|
+
from pathlib import Path as _P
|
|
91
|
+
_root = _P(__file__).resolve().parent.parent.parent.parent
|
|
92
|
+
_skill = _root / "skill.json"
|
|
93
|
+
if _skill.exists():
|
|
94
|
+
with open(_skill, encoding="utf-8") as _f:
|
|
95
|
+
_data = json.load(_f)
|
|
96
|
+
return _data.get("name", "g360-app").replace("-", "_")
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return "g360_app"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_theme_preference() -> str:
|
|
103
|
+
try:
|
|
104
|
+
config_file = get_theme_file()
|
|
105
|
+
if config_file.exists():
|
|
106
|
+
with open(config_file, encoding="utf-8") as f:
|
|
107
|
+
config = json.load(f)
|
|
108
|
+
return config.get("theme_mode", "dark")
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
return "dark"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_theme_preference(mode: str):
|
|
115
|
+
try:
|
|
116
|
+
config_file = get_theme_file()
|
|
117
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
config = {"theme_mode": mode}
|
|
119
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
120
|
+
json.dump(config, f)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import tomllib
|
|
6
|
+
except ImportError:
|
|
7
|
+
import tomli as tomllib
|
|
8
|
+
|
|
9
|
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
|
10
|
+
ASSETS_DIR = BASE_DIR / "assets"
|
|
11
|
+
DATA_DIR = ASSETS_DIR / "data"
|
|
12
|
+
|
|
13
|
+
WINDOW_WIDTH = 1200
|
|
14
|
+
WINDOW_HEIGHT = 800
|
|
15
|
+
WINDOW_MIN_WIDTH = 900
|
|
16
|
+
WINDOW_MIN_HEIGHT = 600
|
|
17
|
+
|
|
18
|
+
AUTO_REFRESH_INTERVAL = 900
|
|
19
|
+
|
|
20
|
+
CACHE_FILE = DATA_DIR / ".last_raw.json"
|
|
21
|
+
VERSION_CACHE_FILE = DATA_DIR / ".version_check.json"
|
|
22
|
+
VERSION_CHECK_INTERVAL = 86400
|
|
23
|
+
|
|
24
|
+
LOG_PATH = BASE_DIR / "run_log.txt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_local_version() -> str:
|
|
28
|
+
pyproject = BASE_DIR / "pyproject.toml"
|
|
29
|
+
if not pyproject.exists():
|
|
30
|
+
return "0.0.0"
|
|
31
|
+
try:
|
|
32
|
+
with open(pyproject, "rb") as f:
|
|
33
|
+
data = tomllib.load(f)
|
|
34
|
+
return str(data.get("project", {}).get("version", "0.0.0"))
|
|
35
|
+
except Exception:
|
|
36
|
+
return "0.0.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_app_name() -> str:
|
|
40
|
+
skill_path = BASE_DIR / "skill.json"
|
|
41
|
+
if skill_path.exists():
|
|
42
|
+
try:
|
|
43
|
+
with open(skill_path, encoding="utf-8") as f:
|
|
44
|
+
import json
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
return data.get("name", "g360-app")
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return BASE_DIR.name
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
G360 Processor - Motor de logica de negocio.
|
|
5
|
+
|
|
6
|
+
Este modulo debe ser heredado y extendido por cada app G360.
|
|
7
|
+
Contiene los patrones base para:
|
|
8
|
+
- Calcular KPIs agregados
|
|
9
|
+
- Generar metricas por linea/categoria
|
|
10
|
+
- Sugiere transferencias entre almacenes
|
|
11
|
+
- Exporta reportes a Excel con formato profesional
|
|
12
|
+
|
|
13
|
+
Heredar y sobrescribir los metodos segun el dominio de cada app.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
APP_AUTHOR = "g360-app-polished"
|
|
21
|
+
APP_NAME = "G360"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_report_name(title: str) -> str:
|
|
25
|
+
"""Genera nombre de archivo con timestamp: G360_{slug}_{YYYYMMDD}_{HHMMSS}.xlsx"""
|
|
26
|
+
now = datetime.now()
|
|
27
|
+
ts = now.strftime("%Y%m%d_%H%M%S")
|
|
28
|
+
import re
|
|
29
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", title).strip("_").upper()
|
|
30
|
+
if len(slug) > 40:
|
|
31
|
+
slug = slug[:40]
|
|
32
|
+
if not slug:
|
|
33
|
+
slug = "REPORTE"
|
|
34
|
+
return f"{APP_NAME}_{slug}_{ts}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BaseProcessor:
|
|
38
|
+
"""
|
|
39
|
+
Procesador base que debe heredarse.
|
|
40
|
+
|
|
41
|
+
Ejemplo de uso:
|
|
42
|
+
class MiProcessor(BaseProcessor):
|
|
43
|
+
def calcular_kpis(self, raw_data):
|
|
44
|
+
# Implementar logica especifica
|
|
45
|
+
return kpis_dict
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._last_kpis = None
|
|
50
|
+
self._last_hash = None
|
|
51
|
+
|
|
52
|
+
def calcular_kpis(self, raw_data: dict) -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Calcula KPIs a partir de datos crudos.
|
|
55
|
+
Debe ser sobrescrito por subclases.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
raw_data: Datos crudos del dominio (dict por almacén/entidad)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
dict con KPIs calculados
|
|
62
|
+
"""
|
|
63
|
+
raise NotImplementedError("Subclase debe implementar calcular_kpis()")
|
|
64
|
+
|
|
65
|
+
def obtener_metricas(self, kpis: dict) -> tuple[list[dict], list[dict]]:
|
|
66
|
+
"""
|
|
67
|
+
Genera metricas agrupadas (por linea, categoria, etc).
|
|
68
|
+
Debe ser sobrescrito por subclases.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
(metricas_principales, metricas_secundarias)
|
|
72
|
+
"""
|
|
73
|
+
raise NotImplementedError("Subclase debe implementar obtener_metricas()")
|
|
74
|
+
|
|
75
|
+
def sugerir_acciones(self, raw_data: dict, kpis: dict) -> list[dict]:
|
|
76
|
+
"""
|
|
77
|
+
Sugiere acciones (transferencias, alertas, etc).
|
|
78
|
+
Debe ser sobrescrito por subclases.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
lista de dicts con sugerencias
|
|
82
|
+
"""
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
def export_to_excel(self, data: list, file_path: str, title: str = "Reporte"):
|
|
86
|
+
"""
|
|
87
|
+
Exporta datos a Excel con formato profesional.
|
|
88
|
+
Metodo base reutilizable.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
data: Lista de filas [(col1, col2, ...), ...]
|
|
92
|
+
file_path: Ruta del archivo de salida
|
|
93
|
+
title: Titulo del reporte
|
|
94
|
+
"""
|
|
95
|
+
from openpyxl import Workbook
|
|
96
|
+
from openpyxl.styles import PatternFill, Font, Alignment
|
|
97
|
+
|
|
98
|
+
wb = Workbook()
|
|
99
|
+
wb.properties.creator = APP_AUTHOR
|
|
100
|
+
wb.properties.description = f"Reporte generado por {APP_NAME} — {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
101
|
+
|
|
102
|
+
ws = wb.active
|
|
103
|
+
ws.title = title[:31]
|
|
104
|
+
|
|
105
|
+
# Headers
|
|
106
|
+
if data:
|
|
107
|
+
headers = [f"Col {i+1}" for i in range(len(data[0]))]
|
|
108
|
+
ws.append(headers)
|
|
109
|
+
for cell in ws[1]:
|
|
110
|
+
cell.fill = PatternFill(start_color="10B981", end_color="10B981", fill_type="solid")
|
|
111
|
+
cell.font = Font(color="FFFFFF", bold=True)
|
|
112
|
+
cell.alignment = Alignment(horizontal="center")
|
|
113
|
+
|
|
114
|
+
for row in data:
|
|
115
|
+
ws.append([str(v) if v is not None else "" for v in row])
|
|
116
|
+
|
|
117
|
+
ws.column_dimensions['A'].width = 35
|
|
118
|
+
for col in "BCD":
|
|
119
|
+
ws.column_dimensions[col].width = 16
|
|
120
|
+
|
|
121
|
+
wb.save(file_path)
|
|
122
|
+
|
|
123
|
+
def hash_data(self, data: dict) -> str:
|
|
124
|
+
"""Hash SHA-256 para deteccion de cambios."""
|
|
125
|
+
import hashlib
|
|
126
|
+
import json
|
|
127
|
+
serialized = json.dumps(data, sort_keys=True, ensure_ascii=False).encode()
|
|
128
|
+
return hashlib.sha256(serialized).hexdigest()[:16]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flet-desktop-polished",
|
|
3
|
+
"description": "G360 Flet Polished - Plantilla base con patrones de UI avanzada",
|
|
4
|
+
"framework": "flet",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"python": ">=3.11",
|
|
7
|
+
"colors": {
|
|
8
|
+
"bg": "#0b1220",
|
|
9
|
+
"surface": "#1a2333",
|
|
10
|
+
"accent": "#34d399",
|
|
11
|
+
"text": "#f0f6fc",
|
|
12
|
+
"muted": "#8b949e",
|
|
13
|
+
"success": "#34d399",
|
|
14
|
+
"warning": "#f59e0b",
|
|
15
|
+
"error": "#ef4444"
|
|
16
|
+
},
|
|
17
|
+
"effects": {
|
|
18
|
+
"glassmorphism": true,
|
|
19
|
+
"blur": "12px",
|
|
20
|
+
"rounded": "12px"
|
|
21
|
+
},
|
|
22
|
+
"signature": {
|
|
23
|
+
"mode": "powered",
|
|
24
|
+
"text": "powered by G360"
|
|
25
|
+
},
|
|
26
|
+
"build": {
|
|
27
|
+
"tool": "flet",
|
|
28
|
+
"target": "windows",
|
|
29
|
+
"name": "G360-App"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"flet": ">=0.28.3"
|
|
33
|
+
},
|
|
34
|
+
"guidelines": {
|
|
35
|
+
"architecture": "src/core (logica), src/ui (componentes), src/config (theme)",
|
|
36
|
+
"naming": "PascalCase para clases UI, camelCase para metodos",
|
|
37
|
+
"state_management": "Usar atributos de clase, hash diff para cache",
|
|
38
|
+
"threading": "Usar threading.Lock() para operaciones concurrentes"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
File without changes
|