From 8489cd74590196ad02c3825ffcdfc8d94ec7f5e4 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Sun, 10 May 2026 16:29:49 +0200 Subject: [PATCH] Checkpoint before more window sizing work --- async_msssql_query.py | 6 +- busy_overlay.py | 86 +++++++ gestione_layout.py | 80 +++++- gestione_pickinglist.py | 8 +- gestione_scarico.py | 9 +- main.py | 346 ++++++++++++++++++++++---- reset_corsie.py | 531 +++++++++++++++++++++++++++++++++++----- search_pallets.py | 2 + tooltip.json | 43 ++++ tooltips.py | 101 ++++++++ ui_theme.json | 165 +++++++++++++ ui_theme.py | 97 ++++++++ user_session.py | 2 + view_celle_multi_udc.py | 416 +++++++++++++++++++++++++++++-- window_placement.py | 335 +++++++++++++++++++++++++ 15 files changed, 2071 insertions(+), 156 deletions(-) create mode 100644 busy_overlay.py create mode 100644 tooltip.json create mode 100644 tooltips.py create mode 100644 ui_theme.json create mode 100644 ui_theme.py create mode 100644 window_placement.py diff --git a/async_msssql_query.py b/async_msssql_query.py index 843cf0a..59ff1a7 100644 --- a/async_msssql_query.py +++ b/async_msssql_query.py @@ -120,6 +120,7 @@ class AsyncMSSQLClient: params: Optional[Dict[str, Any]] = None, *, as_dict_rows: bool = False, + commit: bool = False, ) -> Dict[str, Any]: """Execute a query and return a JSON-friendly payload. @@ -128,6 +129,9 @@ class AsyncMSSQLClient: params: Optional named parameters bound to the statement. as_dict_rows: When ``True`` returns rows as dictionaries keyed by column name; otherwise rows are returned as lists. + commit: When ``True`` the statement runs in a transaction that is + committed on success. Useful for SQL batches that both mutate + data and return a final result set. Returns: A dictionary containing column names, rows and elapsed execution @@ -135,7 +139,7 @@ class AsyncMSSQLClient: """ await self._ensure_engine() t0 = time.perf_counter() - async with self._engine.connect() as conn: + async with (self._engine.begin() if commit else self._engine.connect()) as conn: res = await conn.execute(text(sql), params or {}) rows = res.fetchall() cols = list(res.keys()) diff --git a/busy_overlay.py b/busy_overlay.py new file mode 100644 index 0000000..6d49479 --- /dev/null +++ b/busy_overlay.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any +import tkinter as tk + +import customtkinter as ctk + +from ui_theme import theme_color, theme_font, theme_padding, theme_value + + +class InlineBusyOverlay: + """Busy overlay rendered inside the same window, avoiding extra toplevels.""" + + def __init__(self, parent: tk.Misc, theme_cfg: dict[str, Any] | None = None): + self.parent = parent + self.theme_cfg = theme_cfg or {} + self._cover: ctk.CTkFrame | None = None + self._label: ctk.CTkLabel | None = None + self._bar: ctk.CTkProgressBar | None = None + + def show(self, message: str = "Attendere..."): + if self._cover and self._cover.winfo_exists(): + if self._label: + self._label.configure(text=message) + try: + self._cover.lift() + except Exception: + pass + return + + cover = ctk.CTkFrame( + self.parent, + corner_radius=0, + fg_color=theme_color(self.theme_cfg, "overlay_cover_fg_color", ("#d9d9d9", "#4a4a4a")), + ) + cover.place(relx=0, rely=0, relwidth=1, relheight=1) + try: + cover.lift() + except Exception: + pass + + wrap = ctk.CTkFrame( + cover, + corner_radius=int(theme_value(self.theme_cfg, "overlay_panel_corner_radius", 10)), + fg_color=theme_color(self.theme_cfg, "overlay_panel_fg_color", ("#f2f2f2", "#353535")), + ) + wrap.place(relx=0.5, rely=0.5, anchor="center") + + label = ctk.CTkLabel( + wrap, + text=message, + font=theme_font(self.theme_cfg, "overlay_label_font", ("Segoe UI", 11, "bold")), + ) + label_pad = theme_padding(self.theme_cfg, "overlay_label_padding", (18, 14, 18, 8)) + label.pack(padx=(label_pad[0], label_pad[2]), pady=(label_pad[1], label_pad[3])) + + bar = ctk.CTkProgressBar( + wrap, + mode="indeterminate", + width=int(theme_value(self.theme_cfg, "overlay_progress_width", 220)), + ) + bar_pad = theme_padding(self.theme_cfg, "overlay_progress_padding", (18, 0, 18, 14)) + bar.pack(padx=(bar_pad[0], bar_pad[2]), pady=(bar_pad[1], bar_pad[3])) + try: + bar.start() + except Exception: + pass + + self._cover = cover + self._label = label + self._bar = bar + + def hide(self): + if self._bar: + try: + self._bar.stop() + except Exception: + pass + self._bar = None + self._label = None + if self._cover and self._cover.winfo_exists(): + try: + self._cover.destroy() + except Exception: + pass + self._cover = None diff --git a/gestione_layout.py b/gestione_layout.py index 190e760..54f7532 100644 --- a/gestione_layout.py +++ b/gestione_layout.py @@ -18,10 +18,14 @@ from pathlib import Path from typing import Any from audit_log import log_user_action -from gestione_aree import BusyOverlay, AsyncRunner +from busy_overlay import InlineBusyOverlay +from gestione_aree import AsyncRunner from gestione_scarico import DEFAULT_SCARICO_USER, move_pallet_async, open_scarico_dialog from tksheet import Sheet, natural_sort_key +from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text +from ui_theme import theme_color, theme_font, theme_section, theme_value from user_session import UserSession +from window_placement import place_window_fullsize_below_parent_later try: from loguru import logger @@ -204,14 +208,21 @@ class LayoutWindow(ctk.CTkToplevel): def __init__(self, parent: tk.Widget, db_app, session: UserSession | None = None): """Create the window and initialize the state used by the matrix view.""" super().__init__(parent) + self._theme = theme_section("layout_window", {}) + self._tooltip_catalog = load_tooltip_catalog() self.title("Warehouse - Layout corsie") - self.geometry("1200x740") - self.minsize(980, 560) + self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740"))) + minsize = theme_value(self._theme, "window_minsize", [980, 560]) + self.minsize(int(minsize[0]), int(minsize[1])) self.resizable(True, True) + try: + self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f"))) + except Exception: + pass self.db = db_app self.session = session - self._busy = BusyOverlay(self) + self._busy = InlineBusyOverlay(self, self._theme) self._async = AsyncRunner(self) # layout principale 5% / 80% / 15% @@ -254,6 +265,10 @@ class LayoutWindow(ctk.CTkToplevel): """Create the top toolbar with aisle selection and search controls.""" top = ctk.CTkFrame(self) top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6) + try: + top.configure(fg_color=theme_color(self._theme, "top_frame_fg_color", ("#d7d7d7", "#3b3b3b"))) + except Exception: + pass for i in range(4): top.grid_columnconfigure(i, weight=0) top.grid_columnconfigure(1, weight=1) @@ -261,8 +276,16 @@ class LayoutWindow(ctk.CTkToplevel): # lista corsie lf = ctk.CTkFrame(top) lf.grid(row=0, column=0, sticky="nsw") + try: + lf.configure(fg_color=theme_color(self._theme, "panel_frame_fg_color", ("#dcdcdc", "#363636"))) + except Exception: + pass lf.grid_columnconfigure(0, weight=1) - ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2)) + ctk.CTkLabel( + lf, + text="Corsie", + font=theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 12, "bold")), + ).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2)) self.lb = tk.Listbox(lf, height=6, exportselection=False) self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6)) self.lb.bind("<>", self._on_select) @@ -271,16 +294,50 @@ class LayoutWindow(ctk.CTkToplevel): srch = ctk.CTkFrame(top) srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10)) self.search_var = tk.StringVar() - self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260) + try: + srch.configure(fg_color=theme_color(self._theme, "panel_frame_fg_color", ("#dcdcdc", "#363636"))) + except Exception: + pass + self.search_entry = ctk.CTkEntry( + srch, + textvariable=self.search_var, + width=260, + font=theme_font(self._theme, "entry_font", ("Segoe UI", 10)), + ) self.search_entry.grid(row=0, column=0, sticky="w") - ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0)) + btn_search = ctk.CTkButton( + srch, + text="Cerca per barcode UDC", + command=self._search_udc, + font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")), + ) + btn_search.grid(row=0, column=1, padx=(8, 0)) srch.grid_columnconfigure(0, weight=1) + WidgetToolTip(btn_search, tooltip_text("layout.search_udc", catalog=self._tooltip_catalog)) # toolbar tb = ctk.CTkFrame(top) tb.grid(row=0, column=3, sticky="ne") - ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4) - ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4) + try: + tb.configure(fg_color=theme_color(self._theme, "panel_frame_fg_color", ("#dcdcdc", "#363636"))) + except Exception: + pass + btn_refresh = ctk.CTkButton( + tb, + text="Aggiorna", + command=self._refresh_current, + font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")), + ) + btn_refresh.grid(row=0, column=0, padx=4) + btn_export = ctk.CTkButton( + tb, + text="Export XLSX", + command=self._export_xlsx, + font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")), + ) + btn_export.grid(row=0, column=1, padx=4) + WidgetToolTip(btn_refresh, tooltip_text("layout.refresh", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_export, tooltip_text("layout.export_xlsx", catalog=self._tooltip_catalog)) # ---------------- MATRIX HOST ---------------- def _build_matrix_host(self): @@ -1361,6 +1418,10 @@ def open_layout_window(parent, db_app, session: UserSession | None = None): ex = getattr(parent, key, None) if ex and ex.winfo_exists(): ex.session = session + try: + ex.deiconify() + except Exception: + pass try: ex.lift() ex.focus_force() @@ -1369,4 +1430,5 @@ def open_layout_window(parent, db_app, session: UserSession | None = None): pass w = LayoutWindow(parent, db_app, session=session) setattr(parent, key, w) + place_window_fullsize_below_parent_later(parent, w) return w diff --git a/gestione_pickinglist.py b/gestione_pickinglist.py index aae7239..ba00eeb 100644 --- a/gestione_pickinglist.py +++ b/gestione_pickinglist.py @@ -68,6 +68,7 @@ except Exception: # Usa overlay e runner "collaudati" from gestione_aree import BusyOverlay, AsyncRunner from user_session import UserSession +from window_placement import place_window_fullsize_below_parent_later # === IMPORT procedura async prenota/s-prenota (no pyodbc qui) === import asyncio @@ -1215,7 +1216,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N win = ctk.CTkToplevel(parent) win.title("Gestione Picking List") - win.geometry("1200x700+0+100") + win.geometry("1200x700") win.minsize(1000, 560) setattr(parent, key, win) @@ -1236,10 +1237,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N # Reveal the fully-laid out window only after pending geometry work completes. try: win.update_idletasks() - try: - win.transient(parent) - except Exception: - pass + place_window_fullsize_below_parent_later(parent, win) try: win.deiconify() except Exception: diff --git a/gestione_scarico.py b/gestione_scarico.py index 7852383..067ad06 100644 --- a/gestione_scarico.py +++ b/gestione_scarico.py @@ -15,8 +15,9 @@ from typing import Any, Callable import customtkinter as ctk from tkinter import messagebox, ttk -from gestione_aree import BusyOverlay, AsyncRunner +from gestione_aree import AsyncRunner from audit_log import log_user_action +from busy_overlay import InlineBusyOverlay from user_session import UserSession try: @@ -251,6 +252,8 @@ ORDER BY SQL_SCARICA_UDC = """ +SET NOCOUNT ON; + DECLARE @Now datetime = GETDATE(); DECLARE @SourceID int = 0; DECLARE @NumeroPallet int = 0; @@ -384,7 +387,7 @@ async def move_pallet_async( "utente": str((utente or DEFAULT_SCARICO_USER) or "warehouse_ui").strip(), } _log_sql("move_pallet", SQL_SCARICA_UDC, params) - res = await db_client.query_json(SQL_SCARICA_UDC, params) + res = await db_client.query_json(SQL_SCARICA_UDC, params, commit=True) rows = res.get("rows", []) if isinstance(res, dict) else [] _log_dataset("move_pallet", rows) first = rows[0] if rows else [1, 0, params["target_idcella"], params["target_barcode_cella"]] @@ -446,7 +449,7 @@ class ScaricoDialog(ctk.CTkToplevel): self.on_completed = on_completed self.session = session self.rows: list[ScaricoRow] = [] - self._busy = BusyOverlay(self) + self._busy = InlineBusyOverlay(self) self._async = AsyncRunner(self) self.rows_tree: ttk.Treeview | None = None diff --git a/main.py b/main.py index d01dd24..9f51eca 100644 --- a/main.py +++ b/main.py @@ -9,20 +9,24 @@ import asyncio import ctypes import sys import tkinter as tk +import time import customtkinter as ctk from tkinter import messagebox from async_loop_singleton import get_global_loop from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn +from audit_log import log_session_event from gestione_layout import open_layout_window from gestione_pickinglist import open_pickinglist_window from login_window import prompt_login from reset_corsie import open_reset_corsie_window from search_pallets import open_search_window -from audit_log import log_session_event -from view_celle_multi_udc import open_celle_multiple_window +from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text +from ui_theme import theme_font, theme_section, theme_value from user_session import UserSession, create_user_session +from view_celle_multi_udc import open_celle_multiple_window +from window_placement import cascade_children_below_parent, place_window_fullsize_below_parent_later # ---- Config ---- @@ -102,78 +106,320 @@ def _build_bypass_session() -> UserSession: class Launcher(ctk.CTk): """Main launcher window that exposes the available warehouse tools.""" + _WINDOW_ORDER = [ + "reset_corsie", + "layout", + "multi_udc", + "search", + "pickinglist", + ] + def __init__(self, session: UserSession): """Create the launcher toolbar and wire every button to a feature window.""" super().__init__() self.session: UserSession = session + self._theme = theme_section("launcher", {}) + self._tooltip_catalog = load_tooltip_catalog() + self._child_windows: list[tk.Misc] = [] + self._child_windows_by_key: dict[str, tk.Misc] = {} + self._is_cascading = False + self._focus_restore_pending: set[str] = set() + self._restore_suppressed_until = 0.0 self.title(f"Warehouse 1.0.0 - {self.session.display_name}") - self.geometry("1280x96+0+0") + self._apply_dynamic_geometry() + + outer_pady = int(theme_value(self._theme, "outer_pady", 10)) + outer_padx = int(theme_value(self._theme, "outer_padx", 0)) + info_padx = int(theme_value(self._theme, "info_padx", 6)) + info_pady = theme_value(self._theme, "info_pady", [4, 2]) + button_padx = int(theme_value(self._theme, "button_padx", 6)) + button_pady = int(theme_value(self._theme, "button_pady", 6)) + max_buttons_per_row = max(1, int(theme_value(self._theme, "max_buttons_per_row", 7))) wrap = ctk.CTkFrame(self) - wrap.pack(pady=10, fill="x") + wrap.pack(padx=outer_padx, pady=outer_pady, fill="x") + + actions = [ + ( + "reset_corsie", + "Gestione Corsie", + "launcher.open_reset_corsie", + lambda: self._open_child_window( + "reset_corsie", + open_reset_corsie_window(self, db_app, session=self.session), + ), + ), + ( + "layout", + "Gestione Layout", + "launcher.open_layout", + lambda: self._open_child_window( + "layout", + open_layout_window(self, db_app, session=self.session), + ), + ), + ( + "multi_udc", + "UDC Fantasma", + "launcher.open_multi_udc", + lambda: self._open_child_window( + "multi_udc", + open_celle_multiple_window(self, db_app, session=self.session), + ), + ), + ( + "search", + "Ricerca UDC", + "launcher.open_search", + lambda: self._open_child_window( + "search", + open_search_window(self, db_app, session=self.session), + ), + ), + ( + "pickinglist", + "Gestione Picking List", + "launcher.open_pickinglist", + lambda: self._open_child_window( + "pickinglist", + open_pickinglist_window(self, db_app, session=self.session), + ), + ), + ( + "arrange", + "Ridisponi finestre", + "launcher.arrange_windows", + self._cascade_open_windows, + ), + ( + "exit", + "Esci", + "launcher.exit", + self._shutdown, + ), + ] + + used_columns = max(1, min(len(actions), max_buttons_per_row)) info = ctk.CTkLabel( wrap, text=f"Operatore: {self.session.display_name} ({self.session.login})", anchor="w", - font=("", 12, "bold"), + font=theme_font(self._theme, "info_font", default=("Segoe UI", 12, "bold")), ) - info.grid(row=0, column=0, columnspan=5, padx=6, pady=(4, 2), sticky="ew") + info.grid(row=0, column=0, columnspan=used_columns, padx=info_padx, pady=tuple(info_pady), sticky="ew") - ctk.CTkButton( - wrap, - text="Gestione Corsie", - state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled", - command=lambda: open_reset_corsie_window(self, db_app, session=self.session), - ).grid(row=1, column=0, padx=6, pady=6, sticky="ew") - ctk.CTkButton( - wrap, - text="Gestione Layout", - state="normal" if self.session.can("launcher.open_layout") else "disabled", - command=lambda: open_layout_window(self, db_app, session=self.session), - ).grid(row=1, column=1, padx=6, pady=6, sticky="ew") - ctk.CTkButton( - wrap, - text="UDC Fantasma", - state="normal" if self.session.can("launcher.open_multi_udc") else "disabled", - command=lambda: open_celle_multiple_window(self, db_app, session=self.session), - ).grid(row=1, column=2, padx=6, pady=6, sticky="ew") - ctk.CTkButton( - wrap, - text="Ricerca UDC", - state="normal" if self.session.can("launcher.open_search") else "disabled", - command=lambda: open_search_window(self, db_app, session=self.session), - ).grid(row=1, column=3, padx=6, pady=6, sticky="ew") - ctk.CTkButton( - wrap, - text="Gestione Picking List", - state="normal" if self.session.can("launcher.open_pickinglist") else "disabled", - command=lambda: open_pickinglist_window(self, db_app, session=self.session), - ).grid(row=1, column=4, padx=6, pady=6, sticky="ew") + for idx, (_key, label, permission, callback) in enumerate(actions): + row = 1 + (idx // max_buttons_per_row) + column = idx % max_buttons_per_row + button = ctk.CTkButton( + wrap, + text=label, + font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")), + state="normal" if self.session.can(permission) else "disabled", + command=callback, + ) + button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew") + tip = tooltip_text(permission, catalog=self._tooltip_catalog) + if tip: + WidgetToolTip(button, tip) - for i in range(5): + for i in range(max_buttons_per_row): wrap.grid_columnconfigure(i, weight=1) - def _on_close(): - """Dispose shared resources before closing the launcher.""" - try: - if self.session is not None: - log_session_event(self.session, action="logout", outcome="ok") - fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) - try: - fut.result(timeout=2) - except Exception: - pass - finally: - self.destroy() + self.update_idletasks() + self._apply_dynamic_geometry() - self.protocol("WM_DELETE_WINDOW", _on_close) + self.protocol("WM_DELETE_WINDOW", self._shutdown) try: self.lift() self.focus_force() except Exception: pass + def _apply_dynamic_geometry(self) -> None: + """Size the launcher around its current content and keep it docked at the top.""" + + top_x = int(theme_value(self._theme, "window_top_x", 0)) + top_y = int(theme_value(self._theme, "window_top_y", 0)) + min_width = int(theme_value(self._theme, "window_min_width", 960)) + target_width = int(theme_value(self._theme, "window_width", 1280)) + + requested_width = max(min_width, self.winfo_reqwidth()) + width = max(min_width, target_width, requested_width) + height = max(80, self.winfo_reqheight()) + self.geometry(f"{width}x{height}+{top_x}+{top_y}") + + def _open_child_window(self, key: str, window: tk.Misc | None) -> None: + """Track child windows opened from the launcher.""" + + if window is None: + return + self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()] + if window not in self._child_windows: + self._child_windows.append(window) + self._child_windows_by_key = { + child_key: child + for child_key, child in self._child_windows_by_key.items() + if getattr(child, "winfo_exists", lambda: False)() + } + self._child_windows_by_key[key] = window + try: + window.bind( + "", + lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget), + add="+", + ) + except Exception: + pass + try: + window.bind( + "", + lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget), + add="+", + ) + except Exception: + pass + try: + window.bind( + "", + lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget), + add="+", + ) + except Exception: + pass + try: + window.bind( + "", + lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget), + add="+", + ) + except Exception: + pass + try: + window.lift() + except Exception: + pass + + def _forget_child_window(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None: + """Remove stale references to child windows that have been closed.""" + + if event_widget is not None and event_widget is not window: + return + try: + tracked = self._child_windows_by_key.get(key) + if tracked is window: + self._child_windows_by_key.pop(key, None) + except Exception: + pass + try: + self._child_windows = [w for w in self._child_windows if w is not window and getattr(w, "winfo_exists", lambda: False)()] + except Exception: + pass + + def _widget_belongs_to_window(self, window: tk.Misc, widget: tk.Misc | None) -> bool: + """Return True when an event widget is the toplevel itself or one of its descendants.""" + + if widget is None: + return True + if widget is window: + return True + try: + current = widget + while current is not None: + if current is window: + return True + parent_name = current.winfo_parent() + if not parent_name: + break + current = current.nametowidget(parent_name) + except Exception: + pass + return False + + def _on_child_activate(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None: + """Restore an activated child to the primary slot below the launcher.""" + + if self._is_cascading: + return + if time.monotonic() < self._restore_suppressed_until: + return + if not self._widget_belongs_to_window(window, event_widget): + return + tracked = self._child_windows_by_key.get(key) + if tracked is not window or not getattr(window, "winfo_exists", lambda: False)(): + return + if key in self._focus_restore_pending: + return + self._focus_restore_pending.add(key) + + def _restore() -> None: + try: + tracked_now = self._child_windows_by_key.get(key) + if tracked_now is window and getattr(window, "winfo_exists", lambda: False)(): + place_window_fullsize_below_parent_later(self, window) + finally: + try: + self.after(250, lambda: self._focus_restore_pending.discard(key)) + except Exception: + self._focus_restore_pending.discard(key) + + try: + self.after(0, _restore) + except Exception: + self._focus_restore_pending.discard(key) + + def _cascade_open_windows(self) -> None: + """Cascade all open child windows following launcher button order.""" + + self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()] + self._child_windows_by_key = { + key: window + for key, window in self._child_windows_by_key.items() + if getattr(window, "winfo_exists", lambda: False)() + } + ordered_windows = [ + self._child_windows_by_key[key] + for key in self._WINDOW_ORDER + if key in self._child_windows_by_key + ] + self._is_cascading = True + self._focus_restore_pending.clear() + self._restore_suppressed_until = time.monotonic() + 1.2 + cascade_children_below_parent( + self, + ordered_windows, + x_offset_step=int(theme_value(self._theme, "cascade_x_offset", 28)), + y_offset_step=int(theme_value(self._theme, "cascade_y_offset", 28)), + margin_left=int(theme_value(self._theme, "cascade_margin_left", 0)), + margin_top=int(theme_value(self._theme, "cascade_margin_top", 0)), + ) + def _finish_cascade() -> None: + self._is_cascading = False + try: + self.lift() + self.focus_force() + except Exception: + pass + try: + self.after(250, _finish_cascade) + except Exception: + self._is_cascading = False + + def _shutdown(self) -> None: + """Dispose session and shared DB resources before closing the launcher.""" + + try: + if self.session is not None: + log_session_event(self.session, action="logout", outcome="ok") + fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) + try: + fut.result(timeout=2) + except Exception: + pass + finally: + self.destroy() + if __name__ == "__main__": ctk.set_appearance_mode("light") diff --git a/reset_corsie.py b/reset_corsie.py index 896a108..07ba5fd 100644 --- a/reset_corsie.py +++ b/reset_corsie.py @@ -1,16 +1,166 @@ -"""Window used to inspect and empty an entire warehouse aisle. +"""Window used to inspect and logically empty an entire warehouse aisle. -The module exposes a destructive maintenance tool: it summarizes the occupancy -state of a selected aisle and, after explicit confirmation, deletes matching -rows from ``MagazziniPallet``. +The tool summarizes the current occupancy of one aisle and, after explicit +confirmation, unloads every active UDC through the same logical movement +semantics used by the rest of the WMS. """ +from __future__ import annotations + +import json +import logging +import sys import tkinter as tk +from functools import wraps +from pathlib import Path from tkinter import messagebox, simpledialog, ttk +from typing import Any import customtkinter as ctk -from gestione_aree import AsyncRunner, BusyOverlay +from busy_overlay import InlineBusyOverlay +from gestione_aree import AsyncRunner +from gestione_scarico import move_pallet_async +from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text +from ui_theme import theme_color, theme_font, theme_padding, theme_section, theme_value +from window_placement import place_window_fullsize_below_parent_later + +try: + from loguru import logger +except Exception: # pragma: no cover - fallback used only when Loguru is not available + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + + +RESET_CORSIE_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = RESET_CORSIE_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if RESET_CORSIE_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: str | None = None): + """Trace entry, exit and failure of selected high-level functions.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + + return wrapper + + return decorator + + +def _log_sql(query_name: str, sql: str, params: dict[str, Any] | None = None): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params or {})}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: list[Any]): + """Log query results at summary or full-debug level depending on the flag.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if RESET_CORSIE_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={RESET_CORSIE_LOG_MODE.upper()}" + ) + SQL_CORSIE = """ WITH C AS ( @@ -76,60 +226,191 @@ WHERE COALESCE(s.n,0) > 0 ORDER BY TRY_CONVERT(int,c.Colonna), c.Colonna, TRY_CONVERT(int,c.Fila), c.Fila; """ -SQL_COUNT_DELETE = """ -SELECT COUNT(*) AS RowsToDelete -FROM dbo.MagazziniPallet mp -JOIN dbo.Celle c ON c.ID = mp.IDCella -WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia; +SQL_COUNT_RESET = """ +SELECT + COUNT(DISTINCT g.BarcodePallet) AS TotUDC, + COUNT(DISTINCT g.IDCella) AS TotCelle +FROM dbo.XMag_GiacenzaPallet g +JOIN dbo.Celle c ON c.ID = g.IDCella +WHERE c.ID <> 9999 + AND LTRIM(RTRIM(c.Corsia)) = :corsia; """ -SQL_DELETE = """ -DELETE mp -FROM dbo.MagazziniPallet mp -JOIN dbo.Celle c ON c.ID = mp.IDCella -WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia; +SQL_UDC_RESET = """ +WITH U AS ( + SELECT DISTINCT + g.BarcodePallet AS BarcodePallet, + g.IDCella AS IDCella, + CONCAT(LTRIM(RTRIM(c.Corsia)), '.', LTRIM(RTRIM(c.Colonna)), '.', LTRIM(RTRIM(c.Fila))) AS Ubicazione, + TRY_CONVERT(int, c.Colonna) AS SortColNum, + LTRIM(RTRIM(c.Colonna)) AS SortColTxt, + TRY_CONVERT(int, c.Fila) AS SortFilaNum, + LTRIM(RTRIM(c.Fila)) AS SortFilaTxt + FROM dbo.XMag_GiacenzaPallet g + JOIN dbo.Celle c ON c.ID = g.IDCella + WHERE c.ID <> 9999 + AND LTRIM(RTRIM(c.Corsia)) = :corsia +) +SELECT + BarcodePallet, + IDCella, + Ubicazione +FROM U +ORDER BY + SortColNum, + SortColTxt, + SortFilaNum, + SortFilaTxt, + BarcodePallet; """ class ResetCorsieWindow(ctk.CTkToplevel): """Toplevel used to inspect and clear the pallets assigned to an aisle.""" + @_log_call() def __init__(self, parent, db_client, session=None): """Create the window and immediately load the list of aisles.""" super().__init__(parent) + self._theme = theme_section("reset_corsie", {}) self.title("Reset Corsie - svuotamento celle per corsia") - self.geometry("1000x680") - self.minsize(880, 560) + self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680"))) + minsize = theme_value(self._theme, "window_minsize", [880, 560]) + self.minsize(int(minsize[0]), int(minsize[1])) self.resizable(True, True) + try: + self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f"))) + except Exception: + pass self.db = db_client self.session = session - self._busy = BusyOverlay(self) + self._busy = InlineBusyOverlay(self, self._theme) self._async = AsyncRunner(self) + self._refresh_token = 0 + self._tooltip_catalog = load_tooltip_catalog() self._build_ui() self._load_corsie() + def _setup_tree_style(self): + """Apply a denser, spreadsheet-like style to the main result grid.""" + + style = ttk.Style(self) + style.configure( + "ResetCorsie.Treeview.Heading", + font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")), + background=theme_value(self._theme, "tree_heading_bg", "#b8c7db"), + foreground=theme_value(self._theme, "tree_heading_fg", "#10243e"), + relief="flat", + padding=theme_padding(self._theme, "tree_heading_padding", (8, 6)), + ) + style.map( + "ResetCorsie.Treeview.Heading", + background=[("active", theme_value(self._theme, "tree_heading_bg_active", "#aebfd6"))], + relief=[("pressed", "groove"), ("!pressed", "flat")], + ) + style.configure( + "ResetCorsie.Treeview", + font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)), + rowheight=int(theme_value(self._theme, "tree_row_height", 30)), + background=theme_value(self._theme, "tree_body_bg", "#ffffff"), + fieldbackground=theme_value(self._theme, "tree_body_bg", "#ffffff"), + foreground=theme_value(self._theme, "tree_body_fg", "#1f1f1f"), + borderwidth=0, + ) + style.map( + "ResetCorsie.Treeview", + background=[("selected", theme_value(self._theme, "tree_selected_bg", "#cfe4ff"))], + foreground=[("selected", theme_value(self._theme, "tree_selected_fg", "#10243e"))], + ) + + @_log_call() def _build_ui(self): """Create selectors, summary widgets and the occupied-cell grid.""" + self._setup_tree_style() top = ctk.CTkFrame(self) - top.pack(fill="x", padx=8, pady=8) - ctk.CTkLabel(top, text="Corsia:").pack(side="left") - self.cmb = ctk.CTkComboBox(top, width=140, values=[]) + top.pack( + fill="x", + padx=int(theme_value(self._theme, "frame_padx", 8)), + pady=int(theme_value(self._theme, "frame_pady", 8)), + ) + try: + top.configure(fg_color=theme_color(self._theme, "top_frame_fg_color", ("#d7d7d7", "#3b3b3b"))) + except Exception: + pass + ctk.CTkLabel( + top, + text="Corsia:", + font=theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10)), + ).pack(side="left") + self.cmb = ctk.CTkComboBox( + top, + width=int(theme_value(self._theme, "combobox_width", 140)), + height=int(theme_value(self._theme, "combobox_height", 28)), + values=[], + font=theme_font(self._theme, "combobox_font", ("Segoe UI", 10)), + dropdown_font=theme_font(self._theme, "combobox_font", ("Segoe UI", 10)), + ) self.cmb.pack(side="left", padx=(6, 10)) - ctk.CTkButton(top, text="Carica", command=self.refresh).pack(side="left") - ctk.CTkButton(top, text="Svuota corsia...", command=self._ask_reset).pack(side="right") + btn_refresh = ctk.CTkButton( + top, + text="Carica", + command=self.refresh, + width=int(theme_value(self._theme, "toolbar_button_width", 140)), + height=int(theme_value(self._theme, "toolbar_button_height", 28)), + corner_radius=int(theme_value(self._theme, "toolbar_button_corner_radius", 6)), + font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")), + ) + btn_refresh.pack(side="left") + btn_reset = ctk.CTkButton( + top, + text="Svuota corsia...", + command=self._ask_reset, + width=int(theme_value(self._theme, "toolbar_button_width", 140)), + height=int(theme_value(self._theme, "toolbar_button_height", 28)), + corner_radius=int(theme_value(self._theme, "toolbar_button_corner_radius", 6)), + font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")), + ) + btn_reset.pack(side="right") + WidgetToolTip(btn_refresh, tooltip_text("reset_corsie.refresh", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_reset, tooltip_text("reset_corsie.empty_aisle", catalog=self._tooltip_catalog)) mid = ctk.CTkFrame(self) - mid.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + mid.pack( + fill="both", + expand=True, + padx=int(theme_value(self._theme, "frame_padx", 8)), + pady=(0, int(theme_value(self._theme, "frame_pady", 8))), + ) + try: + mid.configure(fg_color=theme_color(self._theme, "mid_frame_fg_color", ("#e5e5e5", "#383838"))) + except Exception: + pass mid.grid_columnconfigure(0, weight=1) mid.grid_rowconfigure(0, weight=1) - self.tree = ttk.Treeview(mid, columns=("Ubicazione", "NumUDC"), show="headings", selectmode="browse") + self.tree = ttk.Treeview( + mid, + columns=("Ubicazione", "NumUDC"), + show="headings", + selectmode="browse", + style="ResetCorsie.Treeview", + ) self.tree.heading("Ubicazione", text="Ubicazione") self.tree.heading("NumUDC", text="UDC in cella") - self.tree.column("Ubicazione", width=240, anchor="w") - self.tree.column("NumUDC", width=120, anchor="e") + self.tree.column( + "Ubicazione", + width=int(theme_value(self._theme, "tree_col_ubicazione_width", 340)), + anchor=str(theme_value(self._theme, "tree_col_ubicazione_anchor", "center")), + ) + self.tree.column( + "NumUDC", + width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)), + anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")), + ) + self.tree.tag_configure("odd", background=theme_value(self._theme, "tree_row_odd_bg", "#ffffff")) + self.tree.tag_configure("even", background=theme_value(self._theme, "tree_row_even_bg", "#f3f6fb")) sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview) sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview) @@ -139,11 +420,27 @@ class ResetCorsieWindow(ctk.CTkToplevel): sx.grid(row=1, column=0, sticky="ew") bottom = ctk.CTkFrame(self) - bottom.pack(fill="x", padx=8, pady=(0, 8)) - ctk.CTkLabel(bottom, text="Riepilogo", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0)) + bottom.pack( + fill="x", + padx=int(theme_value(self._theme, "frame_padx", 8)), + pady=(0, int(theme_value(self._theme, "frame_pady", 8))), + ) + try: + bottom.configure(fg_color=theme_color(self._theme, "bottom_frame_fg_color", ("#dcdcdc", "#363636"))) + except Exception: + pass + ctk.CTkLabel( + bottom, + text="Riepilogo", + font=theme_font(self._theme, "summary_title_font", ("Segoe UI", 12, "bold")), + ).pack(anchor="w", padx=8, pady=(8, 0)) g = ctk.CTkFrame(bottom) g.pack(fill="x", padx=8, pady=8) + try: + g.configure(fg_color=theme_color(self._theme, "inner_summary_frame_fg_color", ("#d4d4d4", "#404040"))) + except Exception: + pass self.var_tot_celle = tk.StringVar(value="0") self.var_occ = tk.StringVar(value="0") self.var_dbl = tk.StringVar(value="0") @@ -151,8 +448,16 @@ class ResetCorsieWindow(ctk.CTkToplevel): def _kv(parent_widget, label, var, col): """Build a compact summary label/value pair.""" - ctk.CTkLabel(parent_widget, text=label, font=("Segoe UI", 9, "bold")).grid(row=0, column=col * 2, sticky="w", padx=(0, 6)) - ctk.CTkLabel(parent_widget, textvariable=var).grid(row=0, column=col * 2 + 1, sticky="w", padx=(0, 18)) + ctk.CTkLabel( + parent_widget, + text=label, + font=theme_font(self._theme, "summary_label_font", ("Segoe UI", 9, "bold")), + ).grid(row=0, column=col * 2, sticky="w", padx=(0, 6)) + ctk.CTkLabel( + parent_widget, + textvariable=var, + font=theme_font(self._theme, "summary_value_font", ("Segoe UI", 9)), + ).grid(row=0, column=col * 2 + 1, sticky="w", padx=(0, 18)) g.grid_columnconfigure(7, weight=1) _kv(g, "Tot. celle:", self.var_tot_celle, 0) @@ -160,10 +465,14 @@ class ResetCorsieWindow(ctk.CTkToplevel): _kv(g, "Celle doppie:", self.var_dbl, 2) _kv(g, "Tot. pallet:", self.var_pallet, 3) + @_log_call() def _load_corsie(self): """Load available aisles and preselect ``1A`` when present.""" + _log_sql("reset_corsie_corsie", SQL_CORSIE, {}) + def _ok(res): rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset("reset_corsie_corsie", rows) items = [r[0] for r in rows] self.cmb.configure(values=items) if items: @@ -174,87 +483,168 @@ class ResetCorsieWindow(ctk.CTkToplevel): messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self) def _err(ex): + _MODULE_LOGGER.exception(f"Errore caricamento corsie reset corsie: {ex}") messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}", parent=self) self._async.run(self.db.query_json(SQL_CORSIE, {}), _ok, _err, busy=self._busy, message="Carico corsie...") + @_log_call() def refresh(self): """Refresh both the summary counters and the occupied-cell list.""" corsia = self.cmb.get().strip() if not corsia: return + _log_sql("reset_corsie_riepilogo", SQL_RIEPILOGO, {"corsia": corsia}) + _log_sql("reset_corsie_dettaglio", SQL_DETTAGLIO, {"corsia": corsia}) - def _ok_sum(res): - rows = res.get("rows", []) if isinstance(res, dict) else [] - if rows: - tot, occ, dbl, pallet = rows[0] - self.var_tot_celle.set(str(tot or 0)) - self.var_occ.set(str(occ or 0)) - self.var_dbl.set(str(dbl or 0)) - self.var_pallet.set(str(pallet or 0)) - else: - self.var_tot_celle.set("0") - self.var_occ.set("0") - self.var_dbl.set("0") - self.var_pallet.set("0") + async def _q(): + riepilogo = await self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia}) + dettaglio = await self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia}) + return {"riepilogo": riepilogo, "dettaglio": dettaglio} - def _err_sum(ex): - messagebox.showerror("Errore", f"Riepilogo fallito:\n{ex}", parent=self) + def _ok(payload): + try: + riepilogo = payload.get("riepilogo", {}) if isinstance(payload, dict) else {} + dettaglio = payload.get("dettaglio", {}) if isinstance(payload, dict) else {} - self._async.run(self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia}), _ok_sum, _err_sum, busy=self._busy, message=f"Riepilogo {corsia}...") + sum_rows = riepilogo.get("rows", []) if isinstance(riepilogo, dict) else [] + det_rows = dettaglio.get("rows", []) if isinstance(dettaglio, dict) else [] + _log_dataset("reset_corsie_riepilogo", sum_rows) + _log_dataset("reset_corsie_dettaglio", det_rows) - def _ok_det(res): - rows = res.get("rows", []) if isinstance(res, dict) else [] - for item in self.tree.get_children(): - self.tree.delete(item) - for _idc, ubi, n in rows: - self.tree.insert("", "end", values=(ubi, n)) + if sum_rows: + tot, occ, dbl, pallet = sum_rows[0] + self.var_tot_celle.set(str(tot or 0)) + self.var_occ.set(str(occ or 0)) + self.var_dbl.set(str(dbl or 0)) + self.var_pallet.set(str(pallet or 0)) + else: + self.var_tot_celle.set("0") + self.var_occ.set("0") + self.var_dbl.set("0") + self.var_pallet.set("0") - def _err_det(ex): - messagebox.showerror("Errore", f"Dettaglio fallito:\n{ex}", parent=self) + for item in self.tree.get_children(): + self.tree.delete(item) + for idx, (_idc, ubi, n) in enumerate(det_rows): + tag = "even" if idx % 2 else "odd" + self.tree.insert("", "end", values=(ubi, n), tags=(tag,)) + except Exception as ex: + _MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}") + messagebox.showerror("Errore", f"Aggiornamento interfaccia fallito:\n{ex}", parent=self) - self._async.run(self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia}), _ok_det, _err_det, busy=None, message=None) + def _err(ex): + _MODULE_LOGGER.exception(f"Errore refresh reset corsie corsia={corsia}: {ex}") + messagebox.showerror("Errore", f"Refresh fallito:\n{ex}", parent=self) + self._async.run(_q(), _ok, _err, busy=self._busy, message=f"Riepilogo {corsia}...") + + @_log_call() def _ask_reset(self): - """Ask for confirmation and start the delete flow for the selected aisle.""" + """Ask for confirmation and start the logical unload flow for the selected aisle.""" corsia = self.cmb.get().strip() if not corsia: return + _log_sql("reset_corsie_count_reset", SQL_COUNT_RESET, {"corsia": corsia}) def _ok_count(res): rows = res.get("rows", []) if isinstance(res, dict) else [] - n = int(rows[0][0]) if rows else 0 - if n <= 0: - messagebox.showinfo("Svuota corsia", f"Nessun pallet da rimuovere per la corsia {corsia}.", parent=self) + _log_dataset("reset_corsie_count_reset", rows) + tot_udc = int(rows[0][0] or 0) if rows else 0 + tot_celle = int(rows[0][1] or 0) if rows else 0 + if tot_udc <= 0: + messagebox.showinfo("Svuota corsia", f"Nessuna UDC attiva da scaricare per la corsia {corsia}.", parent=self) return msg = ( - f"Verranno cancellati {n} record da MagazziniPallet per la corsia {corsia}.", - "Questa operazione e' irreversibile.", + f"Verranno scaricate logicamente {tot_udc} UDC attive distribuite su {tot_celle} celle della corsia {corsia}.", + "L'operazione verra' eseguita come scarico verso 9000000 / 9999, senza cancellazioni fisiche dirette.", "Digitare il nome della corsia per confermare:", ) confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self) if confirm is None: + _MODULE_LOGGER.info(f"Reset corsia {corsia}: conferma annullata dall'utente") return if confirm.strip().upper() != corsia.upper(): + _MODULE_LOGGER.info(f"Reset corsia {corsia}: testo conferma non corrispondente ({confirm!r})") messagebox.showwarning("Annullato", "Testo di conferma non corrispondente.", parent=self) return self._do_reset(corsia) def _err_count(ex): - messagebox.showerror("Errore", f"Conteggio righe da cancellare fallito:\n{ex}", parent=self) + _MODULE_LOGGER.exception(f"Errore conteggio reset corsie corsia={corsia}: {ex}") + messagebox.showerror("Errore", f"Conteggio UDC da scaricare fallito:\n{ex}", parent=self) - self._async.run(self.db.query_json(SQL_COUNT_DELETE, {"corsia": corsia}), _ok_count, _err_count, busy=self._busy, message="Verifico...") + self._async.run(self.db.query_json(SQL_COUNT_RESET, {"corsia": corsia}), _ok_count, _err_count, busy=self._busy, message="Verifico...") + @_log_call() def _do_reset(self, corsia: str): - """Execute the actual delete and refresh the window afterwards.""" - def _ok_del(_): - messagebox.showinfo("Completato", f"Corsia {corsia}: svuotamento completato.", parent=self) + """Execute the logical unload of every active UDC in the selected aisle.""" + _log_sql("reset_corsie_udc_reset", SQL_UDC_RESET, {"corsia": corsia}) + + async def _q(): + payload = await self.db.query_json(SQL_UDC_RESET, {"corsia": corsia}) + rows = payload.get("rows", []) if isinstance(payload, dict) else [] + _log_dataset("reset_corsie_udc_reset", rows) + + success = 0 + failed: list[dict[str, Any]] = [] + utente = str(getattr(self.session, "login", "") or "warehouse_ui").strip() + + for barcode_pallet, idcella, ubicazione in rows: + try: + await move_pallet_async( + self.db, + barcode_pallet=str(barcode_pallet or "").strip(), + target_idcella=9999, + target_barcode_cella="9000000", + utente=utente, + ) + success += 1 + except Exception as ex: + failed.append( + { + "barcode_pallet": str(barcode_pallet or ""), + "idcella": int(idcella or 0), + "ubicazione": str(ubicazione or ""), + "error": str(ex), + } + ) + + return { + "total": len(rows), + "success": success, + "failed": failed, + } + + def _ok_del(result): + total = int((result or {}).get("total", 0)) + success = int((result or {}).get("success", 0)) + failed = list((result or {}).get("failed", [])) + _MODULE_LOGGER.info( + f"Reset corsia {corsia}: scarico logico completato success={success} total={total} failed={len(failed)}" + ) + if failed: + messagebox.showwarning( + "Completato con errori", + ( + f"Corsia {corsia}: scaricate {success} UDC su {total}.\n" + f"Errori su {len(failed)} UDC. Controllare {MODULE_LOG_PATH.name}." + ), + parent=self, + ) + else: + messagebox.showinfo( + "Completato", + f"Corsia {corsia}: svuotamento logico completato su {success} UDC.", + parent=self, + ) self.refresh() def _err_del(ex): + _MODULE_LOGGER.exception(f"Errore reset logico corsie corsia={corsia}: {ex}") messagebox.showerror("Errore", f"Svuotamento fallito:\n{ex}", parent=self) - self._async.run(self.db.query_json(SQL_DELETE, {"corsia": corsia}), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...") + self._async.run(_q(), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...") def open_reset_corsie_window(parent, db_app, session=None): @@ -262,6 +652,10 @@ def open_reset_corsie_window(parent, db_app, session=None): key = "_reset_corsie_window_singleton" ex = getattr(parent, key, None) if ex and ex.winfo_exists(): + try: + ex.deiconify() + except Exception: + pass try: ex.lift() ex.focus_force() @@ -270,6 +664,7 @@ def open_reset_corsie_window(parent, db_app, session=None): pass win = ResetCorsieWindow(parent, db_app, session=session) setattr(parent, key, win) + place_window_fullsize_below_parent_later(parent, win) try: win.lift() win.focus_force() diff --git a/search_pallets.py b/search_pallets.py index 8c25d16..f6d47fa 100644 --- a/search_pallets.py +++ b/search_pallets.py @@ -8,6 +8,7 @@ from tkinter import filedialog, messagebox, ttk import customtkinter as ctk from gestione_aree import AsyncRunner, BusyOverlay +from window_placement import place_window_fullsize_below_parent_later try: from openpyxl import Workbook @@ -425,4 +426,5 @@ def open_search_window(parent, db_app, session=None): pass w = SearchWindow(parent, db_app, session=session) setattr(parent, key, w) + place_window_fullsize_below_parent_later(parent, w) return w diff --git a/tooltip.json b/tooltip.json new file mode 100644 index 0000000..08f29ef --- /dev/null +++ b/tooltip.json @@ -0,0 +1,43 @@ +{ + "default_language": "IT", + "IT": { + "launcher.open_reset_corsie": "Apre la finestra di gestione corsie per visualizzare il contenuto di una corsia e svuotarla in modo controllato.", + "launcher.open_layout": "Apre la vista layout delle corsie con celle, UDC presenti e menu operativo contestuale.", + "launcher.open_multi_udc": "Apre la vista UDC fantasma per analizzare celle con piu' pallet e bonificare i candidati fantasma.", + "launcher.open_search": "Apre la ricerca UDC per trovare rapidamente una unita' di carico e verificarne la posizione.", + "launcher.open_pickinglist": "Apre la gestione delle picking list per prenotare, controllare e aggiornare le liste di prelievo.", + "launcher.arrange_windows": "Dispone in cascata le finestre aperte seguendo l'ordine dei pulsanti del launcher.", + "launcher.exit": "Chiude l'applicazione in modo pulito terminando la sessione utente e rilasciando la connessione condivisa al database.", + "reset_corsie.refresh": "Ricarica il riepilogo e l'elenco delle celle occupate per la corsia selezionata.", + "reset_corsie.empty_aisle": "Scarica logicamente tutte le UDC attive della corsia selezionata verso l'ubicazione di uscita.", + "layout.search_udc": "Cerca una UDC per barcode, cambia automaticamente corsia e porta in evidenza la cella trovata.", + "layout.refresh": "Ricarica la corsia selezionata e aggiorna matrice, colori e statistiche.", + "layout.export_xlsx": "Esporta la vista corrente del layout corsia in un file Excel.", + "multi_udc.refresh": "Ricarica l'albero delle celle con UDC multiple e il riepilogo percentuale per corsia.", + "multi_udc.expand_all": "Espande tutti i nodi gia' caricati nell'albero.", + "multi_udc.collapse_all": "Comprime tutti i nodi dell'albero.", + "multi_udc.preselect": "Espande la corsia selezionata e preseleziona automaticamente le UDC con causale fantasma.", + "multi_udc.remove_ghosts": "Scarica logicamente le UDC selezionate della corsia attiva verso l'ubicazione di uscita.", + "multi_udc.export_xlsx": "Esporta in Excel il contenuto corrente della vista UDC fantasma." + }, + "ENG": { + "launcher.open_reset_corsie": "Open the aisle management window to inspect an aisle and empty it in a controlled way.", + "launcher.open_layout": "Open the aisle layout view with cells, present UDCs and the operational context menu.", + "launcher.open_multi_udc": "Open the ghost UDC view to inspect cells with multiple pallets and clean ghost candidates.", + "launcher.open_search": "Open the UDC search window to quickly locate a load unit and verify its position.", + "launcher.open_pickinglist": "Open picking list management to reserve, inspect and update picking lists.", + "launcher.arrange_windows": "Arrange open windows in cascade order following the launcher buttons.", + "launcher.exit": "Close the application cleanly by ending the user session and releasing the shared database connection.", + "reset_corsie.refresh": "Reload the summary and the list of occupied cells for the selected aisle.", + "reset_corsie.empty_aisle": "Logically unload all active UDCs in the selected aisle to the outbound location.", + "layout.search_udc": "Search a UDC by barcode, switch aisle automatically and highlight the matching cell.", + "layout.refresh": "Reload the selected aisle and refresh matrix, colors and statistics.", + "layout.export_xlsx": "Export the current aisle layout view to an Excel file.", + "multi_udc.refresh": "Reload the tree of cells with multiple UDCs and the percentage summary by aisle.", + "multi_udc.expand_all": "Expand all nodes currently loaded in the tree.", + "multi_udc.collapse_all": "Collapse all tree nodes.", + "multi_udc.preselect": "Expand the selected aisle and automatically preselect UDCs classified as ghost candidates.", + "multi_udc.remove_ghosts": "Logically unload the selected UDCs of the active aisle to the outbound location.", + "multi_udc.export_xlsx": "Export the current ghost UDC view to Excel." + } +} diff --git a/tooltips.py b/tooltips.py new file mode 100644 index 0000000..c1052fa --- /dev/null +++ b/tooltips.py @@ -0,0 +1,101 @@ +"""Tooltip catalog and widget helper utilities.""" + +from __future__ import annotations + +import json +from pathlib import Path +import tkinter as tk + + +_TOOLTIP_FILE = Path(__file__).with_name("tooltip.json") + + +def load_tooltip_catalog() -> dict: + """Load the tooltip catalog from JSON, returning a safe default on errors.""" + + try: + return json.loads(_TOOLTIP_FILE.read_text(encoding="utf-8")) + except Exception: + return {"default_language": "IT", "IT": {}, "ENG": {}} + + +def tooltip_text(key: str, *, language: str | None = None, catalog: dict | None = None) -> str: + """Return the localized tooltip text for ``key``.""" + + data = catalog or load_tooltip_catalog() + lang = str(language or data.get("default_language") or "IT").upper() + texts = data.get(lang, {}) or {} + if key in texts: + return str(texts[key]) + fallback = data.get("IT", {}) or {} + return str(fallback.get(key, "")) + + +class WidgetToolTip: + """Simple delayed tooltip for Tk/customtkinter widgets.""" + + def __init__(self, widget: tk.Misc, text: str, *, delay_ms: int = 400, wraplength: int = 320): + self.widget = widget + self.text = text.strip() + self.delay_ms = int(delay_ms) + self.wraplength = int(wraplength) + self._after_id: str | None = None + self._tip: tk.Toplevel | None = None + if self.text: + self.widget.bind("", self._schedule_show, add="+") + self.widget.bind("", self._hide, add="+") + self.widget.bind("", self._hide, add="+") + + def _schedule_show(self, _event=None): + self._cancel_schedule() + self._after_id = self.widget.after(self.delay_ms, self._show) + + def _cancel_schedule(self): + if self._after_id is not None: + try: + self.widget.after_cancel(self._after_id) + except Exception: + pass + self._after_id = None + + def _show(self): + self._after_id = None + if self._tip is not None or not self.text: + return + try: + x = self.widget.winfo_rootx() + 18 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8 + tip = tk.Toplevel(self.widget) + tip.withdraw() + tip.overrideredirect(True) + tip.attributes("-topmost", True) + frame = tk.Frame(tip, bg="#fff7c7", bd=1, relief="solid") + frame.pack(fill="both", expand=True) + label = tk.Label( + frame, + text=self.text, + justify="left", + anchor="w", + wraplength=self.wraplength, + bg="#fff7c7", + fg="#1f1f1f", + font=("Segoe UI", 9), + padx=8, + pady=6, + ) + label.pack(fill="both", expand=True) + tip.geometry(f"+{x}+{y}") + tip.deiconify() + self._tip = tip + except Exception: + self._tip = None + + def _hide(self, _event=None): + self._cancel_schedule() + tip = self._tip + self._tip = None + if tip is not None: + try: + tip.destroy() + except Exception: + pass diff --git a/ui_theme.json b/ui_theme.json new file mode 100644 index 0000000..a05f7c3 --- /dev/null +++ b/ui_theme.json @@ -0,0 +1,165 @@ +{ + "global": { + "window_bg": ["#f1f1f1", "#2b2b2b"], + "panel_bg": ["#d9d9d9", "#3a3a3a"], + "panel_alt_bg": ["#cfcfcf", "#454545"], + "text_primary": "#1f1f1f", + "text_secondary": "#4b4b4b", + "accent": "#2ebf74", + "accent_hover": "#28a766", + "danger": "#ca3d3d", + "danger_hover": "#aa2f2f", + "border": "#bdbdbd" + }, + "launcher": { + "window_width": 1280, + "window_min_width": 960, + "window_top_x": 0, + "window_top_y": 0, + "outer_pady": 10, + "outer_padx": 0, + "max_buttons_per_row": 7, + "button_padx": 6, + "button_pady": 6, + "info_padx": 6, + "info_pady": [4, 2], + "info_font": { + "family": "Segoe UI", + "size": 12, + "weight": "bold" + }, + "cascade_x_offset": 42, + "cascade_y_offset": 34, + "cascade_margin_left": 0, + "cascade_margin_top": 0, + "button_font": { + "family": "Segoe UI", + "size": 11, + "weight": "bold" + } + }, + "reset_corsie": { + "window_geometry": "1000x680", + "window_minsize": [880, 560], + "window_fg_color": ["#efefef", "#2f2f2f"], + "top_frame_fg_color": ["#d7d7d7", "#3b3b3b"], + "mid_frame_fg_color": ["#e5e5e5", "#383838"], + "bottom_frame_fg_color": ["#dcdcdc", "#363636"], + "inner_summary_frame_fg_color": ["#d4d4d4", "#404040"], + "frame_padx": 8, + "frame_pady": 8, + "toolbar_button_width": 140, + "toolbar_button_height": 28, + "toolbar_button_corner_radius": 6, + "toolbar_button_font": { + "family": "Segoe UI", + "size": 10, + "weight": "bold" + }, + "toolbar_label_font": { + "family": "Segoe UI", + "size": 10, + "weight": "normal" + }, + "combobox_width": 140, + "combobox_height": 28, + "combobox_font": { + "family": "Segoe UI", + "size": 10, + "weight": "normal" + }, + "summary_title_font": { + "family": "Segoe UI", + "size": 12, + "weight": "bold" + }, + "summary_label_font": { + "family": "Segoe UI", + "size": 9, + "weight": "bold" + }, + "summary_value_font": { + "family": "Segoe UI", + "size": 9, + "weight": "normal" + }, + "tree_heading_font": { + "family": "Segoe UI", + "size": 10, + "weight": "bold" + }, + "tree_body_font": { + "family": "Segoe UI", + "size": 10, + "weight": "normal" + }, + "tree_row_height": 30, + "tree_heading_bg": "#9fb2cb", + "tree_heading_bg_active": "#90a5c0", + "tree_heading_fg": "#10243e", + "tree_heading_padding": [8, 6], + "tree_body_bg": "#ffffff", + "tree_body_fg": "#1f1f1f", + "tree_row_odd_bg": "#ffffff", + "tree_row_even_bg": "#edf3fb", + "tree_selected_bg": "#cfe4ff", + "tree_selected_fg": "#10243e", + "tree_col_ubicazione_width": 360, + "tree_col_ubicazione_anchor": "center", + "tree_col_num_udc_width": 180, + "tree_col_num_udc_anchor": "center", + "tree_show_grid_hint": true, + "overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"], + "overlay_panel_fg_color": ["#f2f2f2", "#353535"], + "overlay_panel_corner_radius": 10, + "overlay_label_font": { + "family": "Segoe UI", + "size": 11, + "weight": "bold" + }, + "overlay_progress_width": 220, + "overlay_label_padding": [18, 14, 18, 8], + "overlay_progress_padding": [18, 0, 18, 14] + }, + "layout_window": { + "window_geometry": "1200x740", + "window_minsize": [980, 560], + "window_fg_color": ["#efefef", "#2f2f2f"], + "top_frame_fg_color": ["#d7d7d7", "#3b3b3b"], + "panel_frame_fg_color": ["#dcdcdc", "#363636"], + "toolbar_button_font": { + "family": "Segoe UI", + "size": 10, + "weight": "bold" + }, + "toolbar_label_font": { + "family": "Segoe UI", + "size": 12, + "weight": "bold" + }, + "entry_font": { + "family": "Segoe UI", + "size": 10, + "weight": "normal" + } + }, + "multi_udc": { + "window_geometry": "1100x700", + "window_minsize": [900, 550], + "window_fg_color": ["#efefef", "#2f2f2f"], + "toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"], + "content_frame_fg_color": ["#e5e5e5", "#383838"], + "summary_frame_fg_color": ["#dcdcdc", "#363636"], + "inner_summary_frame_fg_color": ["#d4d4d4", "#404040"], + "toolbar_button_font": { + "family": "Segoe UI", + "size": 10, + "weight": "bold" + }, + "summary_title_font": { + "family": "Segoe UI", + "size": 12, + "weight": "bold" + } + } +} diff --git a/ui_theme.py b/ui_theme.py new file mode 100644 index 0000000..622e927 --- /dev/null +++ b/ui_theme.py @@ -0,0 +1,97 @@ +"""External UI theme loader for the warehouse desktop application. + +The module reads ``ui_theme.json`` from the workspace root and exposes a few +helpers to resolve fonts, colors, paddings and section-specific configuration +without hardcoding presentation details inside each window module. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +THEME_PATH = Path(__file__).with_name("ui_theme.json") + + +@lru_cache(maxsize=1) +def load_theme() -> dict[str, Any]: + """Load the external JSON theme, returning an empty dict on failure.""" + + try: + return json.loads(THEME_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + + +def reload_theme() -> dict[str, Any]: + """Clear cache and reload the theme from disk.""" + + load_theme.cache_clear() + return load_theme() + + +def theme_section(name: str, default: dict[str, Any] | None = None) -> dict[str, Any]: + """Return one top-level theme section as a dictionary.""" + + theme = load_theme() + value = theme.get(name) + if isinstance(value, dict): + return value + return dict(default or {}) + + +def theme_value(section: dict[str, Any], key: str, default: Any = None) -> Any: + """Return a scalar theme value from a section.""" + + return section.get(key, default) + + +def theme_color(section: dict[str, Any], key: str, default: Any = None) -> Any: + """Return a CTk-compatible color value (string or light/dark tuple).""" + + value = section.get(key, default) + if isinstance(value, list) and len(value) == 2: + return tuple(value) + return value + + +def theme_padding(section: dict[str, Any], key: str, default: tuple[int, ...]) -> tuple[int, ...]: + """Return tuple-like padding values from a JSON list.""" + + value = section.get(key) + if isinstance(value, list): + try: + return tuple(int(v) for v in value) + except Exception: + return default + return default + + +def theme_font(section: dict[str, Any], key: str, default: tuple[Any, ...]) -> tuple[Any, ...]: + """Resolve a Tk font tuple from JSON.""" + + spec = section.get(key) + if not isinstance(spec, dict): + return default + + family = spec.get("family", default[0] if default else "Segoe UI") + size = int(spec.get("size", default[1] if len(default) > 1 else 10)) + + parts: list[Any] = [family, size] + + weight = str(spec.get("weight", "")).strip().lower() + if weight in {"bold", "normal"} and weight != "normal": + parts.append(weight) + + slant = str(spec.get("slant", "")).strip().lower() + if slant == "italic": + parts.append(slant) + + if bool(spec.get("underline", False)): + parts.append("underline") + if bool(spec.get("overstrike", False)): + parts.append("overstrike") + + return tuple(parts) diff --git a/user_session.py b/user_session.py index a60d201..a786adf 100644 --- a/user_session.py +++ b/user_session.py @@ -14,6 +14,8 @@ ALL_ACTIONS: FrozenSet[str] = frozenset( "launcher.open_multi_udc", "launcher.open_search", "launcher.open_pickinglist", + "launcher.arrange_windows", + "launcher.exit", "reset_corsie.view", "search.view", "multi_udc.view", diff --git a/view_celle_multi_udc.py b/view_celle_multi_udc.py index 13a8166..52bda4e 100644 --- a/view_celle_multi_udc.py +++ b/view_celle_multi_udc.py @@ -16,7 +16,12 @@ import customtkinter as ctk from openpyxl import Workbook from openpyxl.styles import Alignment, Font +from busy_overlay import InlineBusyOverlay from gestione_aree import AsyncRunner +from gestione_scarico import move_pallet_async +from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text +from ui_theme import theme_color, theme_font, theme_section, theme_value +from window_placement import place_window_fullsize_below_parent_later try: from loguru import logger @@ -288,6 +293,90 @@ GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto, shipped.BarcodePallet, la.ID ORDER BY b.BarcodePallet; """ +SQL_PALLET_IN_CORSIA = BASE_CTE + f""" +, dup_celle AS ( + SELECT b.IDCella + FROM base b + WHERE b.Corsia = RTRIM(:corsia) + GROUP BY b.IDCella + HAVING COUNT(DISTINCT b.BarcodePallet) > 1 +), +corsia_pallets AS ( + SELECT DISTINCT b.IDCella, b.BarcodePallet + FROM base b + JOIN dup_celle dc ON dc.IDCella = b.IDCella +), +latest_any AS ( + SELECT + ranked.BarcodePallet, + ranked.IDCella + FROM ( + SELECT + mp.Attributo AS BarcodePallet, + mp.IDCella, + ROW_NUMBER() OVER ( + PARTITION BY mp.Attributo + ORDER BY mp.ID DESC + ) AS rn + FROM dbo.MagazziniPallet mp + JOIN (SELECT DISTINCT BarcodePallet FROM corsia_pallets) cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + mp.Attributo COLLATE Latin1_General_CI_AS + WHERE mp.Tipo = 'V' + AND mp.PesoUnitario > 0 + ) ranked + WHERE ranked.rn = 1 +), +shipped AS ( + SELECT DISTINCT shipped.BarcodePallet + FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped + JOIN (SELECT DISTINCT BarcodePallet FROM corsia_pallets) cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + shipped.BarcodePallet COLLATE Latin1_General_CI_AS +) +SELECT + cp.IDCella, + {UBI_B} AS Ubicazione, + cp.BarcodePallet AS Pallet, + ta.Descrizione, + ta.Lotto, + CASE + WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsShippedGhost, + CASE + WHEN la.IDCella IS NOT NULL + AND la.IDCella <> cp.IDCella + THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsMovedGhost +FROM corsia_pallets cp +JOIN base b + ON b.IDCella = cp.IDCella + AND b.BarcodePallet = cp.BarcodePallet +OUTER APPLY ( + SELECT TOP (1) t.Descrizione, t.Lotto + FROM dbo.vXTracciaProdotti AS t + WHERE t.Pallet = cp.BarcodePallet COLLATE Latin1_General_CI_AS + ORDER BY t.Lotto +) AS ta +LEFT JOIN latest_any la + ON la.BarcodePallet COLLATE Latin1_General_CI_AS = + cp.BarcodePallet COLLATE Latin1_General_CI_AS +LEFT JOIN shipped + ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS = + cp.BarcodePallet COLLATE Latin1_General_CI_AS +GROUP BY + cp.IDCella, + {UBI_B}, + cp.BarcodePallet, + ta.Descrizione, + ta.Lotto, + shipped.BarcodePallet, + la.IDCella +ORDER BY cp.IDCella, cp.BarcodePallet; +""" + def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str: """Translate anomaly flags into the operator-facing ghost cause.""" @@ -343,14 +432,25 @@ class CelleMultipleWindow(ctk.CTkToplevel): def __init__(self, root, db_client, runner: AsyncRunner | None = None, session=None): """Bind the shared DB client and immediately load the tree summary.""" super().__init__(root) + self._theme = theme_section("multi_udc", {}) + self._tooltip_catalog = load_tooltip_catalog() self.title("Celle con piu' pallet") self.session = session - self.geometry("1100x700") - self.minsize(900, 550) + self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700"))) + minsize = theme_value(self._theme, "window_minsize", [900, 550]) + self.minsize(int(minsize[0]), int(minsize[1])) self.resizable(True, True) + try: + self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f"))) + except Exception: + pass self.db = db_client self.runner = runner or AsyncRunner(self) + self._busy = InlineBusyOverlay(self, self._theme) + self.selected_corsia_id: str | None = None + self.selected_udc_keys: set[str] = set() + self.udc_meta_by_key: dict[str, dict[str, Any]] = {} self._build_layout() self._bind_events() @@ -365,13 +465,35 @@ class CelleMultipleWindow(ctk.CTkToplevel): toolbar = ctk.CTkFrame(self) toolbar.grid(row=0, column=0, sticky="nsew") - ctk.CTkButton(toolbar, text="Aggiorna", command=self.refresh_all).pack(side="left", padx=6, pady=4) - ctk.CTkButton(toolbar, text="Espandi tutto", command=self.expand_all).pack(side="left", padx=6, pady=4) - ctk.CTkButton(toolbar, text="Comprimi tutto", command=self.collapse_all).pack(side="left", padx=6, pady=4) - ctk.CTkButton(toolbar, text="Esporta in XLSX", command=self.export_to_xlsx).pack(side="left", padx=6, pady=4) + try: + toolbar.configure(fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b"))) + except Exception: + pass + btn_refresh = ctk.CTkButton(toolbar, text="Aggiorna", command=self.refresh_all, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_refresh.pack(side="left", padx=6, pady=4) + btn_expand = ctk.CTkButton(toolbar, text="Espandi tutto", command=self.expand_all, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_expand.pack(side="left", padx=6, pady=4) + btn_collapse = ctk.CTkButton(toolbar, text="Comprimi tutto", command=self.collapse_all, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_collapse.pack(side="left", padx=6, pady=4) + btn_preselect = ctk.CTkButton(toolbar, text="Preselezione fantasmi corsia", command=self._preselect_selected_corsia, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_preselect.pack(side="left", padx=6, pady=4) + btn_remove = ctk.CTkButton(toolbar, text="Rimuovi fantasmi corsia", command=self._remove_selected_ghosts_for_corsia, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_remove.pack(side="left", padx=6, pady=4) + btn_export = ctk.CTkButton(toolbar, text="Esporta in XLSX", command=self.export_to_xlsx, font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))) + btn_export.pack(side="left", padx=6, pady=4) + WidgetToolTip(btn_refresh, tooltip_text("multi_udc.refresh", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_expand, tooltip_text("multi_udc.expand_all", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_collapse, tooltip_text("multi_udc.collapse_all", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_preselect, tooltip_text("multi_udc.preselect", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_remove, tooltip_text("multi_udc.remove_ghosts", catalog=self._tooltip_catalog)) + WidgetToolTip(btn_export, tooltip_text("multi_udc.export_xlsx", catalog=self._tooltip_catalog)) frame = ctk.CTkFrame(self) frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6)) + try: + frame.configure(fg_color=theme_color(self._theme, "content_frame_fg_color", ("#e5e5e5", "#383838"))) + except Exception: + pass frame.grid_rowconfigure(0, weight=1) frame.grid_columnconfigure(0, weight=1) self.tree = ttk.Treeview(frame, columns=("col2", "col3", "col4"), show="tree headings", selectmode="browse") @@ -392,9 +514,21 @@ class CelleMultipleWindow(ctk.CTkToplevel): sumf = ctk.CTkFrame(self) sumf.grid(row=2, column=0, sticky="nsew", padx=6, pady=(0, 6)) - ctk.CTkLabel(sumf, text="Riepilogo % celle multiple per corsia", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0)) + try: + sumf.configure(fg_color=theme_color(self._theme, "summary_frame_fg_color", ("#dcdcdc", "#363636"))) + except Exception: + pass + ctk.CTkLabel( + sumf, + text="Riepilogo % celle multiple per corsia", + font=theme_font(self._theme, "summary_title_font", ("Segoe UI", 12, "bold")), + ).pack(anchor="w", padx=8, pady=(8, 0)) inner = ctk.CTkFrame(sumf) inner.pack(fill="both", expand=True, padx=6, pady=6) + try: + inner.configure(fg_color=theme_color(self._theme, "inner_summary_frame_fg_color", ("#d4d4d4", "#404040"))) + except Exception: + pass inner.grid_rowconfigure(0, weight=1) inner.grid_columnconfigure(0, weight=1) self.sum_tbl = ttk.Treeview(inner, columns=("Corsia", "TotCelle", "CelleMultiple", "Percentuale"), show="headings") @@ -416,10 +550,85 @@ class CelleMultipleWindow(ctk.CTkToplevel): def _bind_events(self): """Attach lazy-load behavior when nodes are expanded.""" self.tree.bind("<>", self._on_open_node) + self.tree.bind("", self._on_tree_click, add="+") + + def _format_corsia_text(self, corsia: str) -> str: + """Render one aisle root with its exclusive selection marker.""" + + return f"[{'x' if self.selected_corsia_id == f'corsia:{corsia}' else ' '}] Corsia {corsia}" + + def _format_pallet_text(self, pallet: str, selected: bool) -> str: + """Render one pallet leaf with its selection marker.""" + + return f"[{'x' if selected else ' '}] {pallet}" + + def _selected_corsia_value(self) -> str | None: + """Return the code of the currently active aisle.""" + + if self.selected_corsia_id and self.selected_corsia_id.startswith("corsia:"): + return self.selected_corsia_id.split(":", 1)[1] + return None + + def _set_selected_corsia(self, node_id: str | None): + """Keep exactly one selected aisle and refresh root labels.""" + + previous = self.selected_corsia_id + self.selected_corsia_id = node_id + for iid in self.tree.get_children(""): + if not iid.startswith("corsia:"): + continue + self.tree.item(iid, text=self._format_corsia_text(iid.split(":", 1)[1])) + if previous and previous != node_id and previous.startswith("corsia:"): + self._clear_leaf_selection_for_corsia(previous.split(":", 1)[1]) + + def _set_leaf_selected(self, leaf_id: str, selected: bool): + """Toggle one selected pallet leaf and refresh its label if visible.""" + + meta = self.udc_meta_by_key.get(leaf_id) + if selected: + self.selected_udc_keys.add(leaf_id) + else: + self.selected_udc_keys.discard(leaf_id) + if meta and self.tree.exists(leaf_id): + self.tree.item(leaf_id, text=self._format_pallet_text(str(meta.get("pallet", "")), selected)) + + def _clear_leaf_selection_for_corsia(self, corsia: str): + """Clear all selected pallet leaves for one aisle.""" + + for leaf_id, meta in list(self.udc_meta_by_key.items()): + if meta.get("corsia") == corsia: + self._set_leaf_selected(leaf_id, False) + + def _on_tree_click(self, event): + """Handle custom selection on aisle roots and pallet leaves.""" + + try: + element = self.tree.identify_element(event.x, event.y) + if "indicator" in str(element).lower(): + return + item_id = self.tree.identify_row(event.y) + except Exception: + return + if not item_id: + return + tags = self.tree.item(item_id, "tags") or () + if "corsia" in tags: + self._set_selected_corsia(item_id) + self.tree.selection_set(item_id) + return "break" + if "pallet" in tags: + corsia = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "") + self._set_selected_corsia(f"corsia:{corsia}") + self._set_leaf_selected(item_id, item_id not in self.selected_udc_keys) + self.tree.selection_set(item_id) + return "break" @_log_call() def refresh_all(self): """Reload both the duplication tree and the summary percentage table.""" + self.selected_corsia_id = None + self.selected_udc_keys.clear() + self.udc_meta_by_key.clear() self._load_corsie() self._load_riepilogo() @@ -448,8 +657,16 @@ class CelleMultipleWindow(ctk.CTkToplevel): if not corsia: continue node_id = f"corsia:{corsia}" - self.tree.insert("", "end", iid=node_id, text=f"Corsia {corsia}", values=("", ""), open=False, tags=("corsia",)) - self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "")) + self.tree.insert( + "", + "end", + iid=node_id, + text=self._format_corsia_text(corsia), + values=("", "", ""), + open=False, + tags=("corsia",), + ) + self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", "")) def _on_open_node(self, _evt): """Lazy-load children when a tree node is expanded.""" @@ -491,7 +708,7 @@ class CelleMultipleWindow(ctk.CTkToplevel): rows = _json_obj(res).get("rows", []) _log_dataset("multi_udc_celle_per_corsia", rows) if not rows: - self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "")) + self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", "")) return for row in rows: idc = row["IDCella"] @@ -501,11 +718,19 @@ class CelleMultipleWindow(ctk.CTkToplevel): node_id = f"cella:{idc}" label = f"{ubi} [x{num}]" if self.tree.exists(node_id): - self.tree.item(node_id, text=label, values=(f"IDCella {idc}", "")) + self.tree.item(node_id, text=label, values=(f"IDCella {idc}", "", "")) else: - self.tree.insert(parent_iid, "end", iid=node_id, text=label, values=(f"IDCella {idc}", ""), open=False, tags=("cella", f"corsia:{corsia}")) + self.tree.insert( + parent_iid, + "end", + iid=node_id, + text=label, + values=(f"IDCella {idc}", "", ""), + open=False, + tags=("cella", f"corsia:{corsia}"), + ) if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)): - self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "")) + self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", "")) @_log_call() def _load_pallet_for_cella(self, parent_iid, idcella: int): @@ -540,20 +765,169 @@ class CelleMultipleWindow(ctk.CTkToplevel): pallet = row.get("Pallet", "") desc = row.get("Descrizione", "") lotto = row.get("Lotto", "") - causale = _build_diagnostic_note(row.get("IsShippedGhost", 0), row.get("IsMovedGhost", 0)) + is_shipped = int(row.get("IsShippedGhost", 0) or 0) + is_moved = int(row.get("IsMovedGhost", 0) or 0) + causale = _build_diagnostic_note(is_shipped, is_moved) leaf_id = f"pallet:{idcella_num}:{pallet}" + self.udc_meta_by_key[leaf_id] = { + "corsia": corsia_val, + "ubicazione": cella_ubi, + "idcella": idcella_num, + "pallet": str(pallet), + "descrizione": desc, + "lotto": lotto, + "causale": causale, + "is_shipped": is_shipped, + "is_moved": is_moved, + } if self.tree.exists(leaf_id): - self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto, causale)) + self.tree.item( + leaf_id, + text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys), + values=(desc, lotto, causale), + ) continue self.tree.insert( parent_iid, "end", iid=leaf_id, - text=str(pallet), + text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys), values=(desc, lotto, causale), tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"), ) + @_log_call() + def _preselect_selected_corsia(self): + """Expand one aisle and preselect only shipped/moved ghost pallets.""" + + corsia = self._selected_corsia_value() + if not corsia: + messagebox.showinfo("Preselezione", "Seleziona prima una corsia.", parent=self) + return + + corsia_node = f"corsia:{corsia}" + self.tree.item(corsia_node, open=True) + self._clear_leaf_selection_for_corsia(corsia) + self._busy.show(f"Preselezione fantasmi corsia {corsia.strip()}...") + + _log_sql("multi_udc_celle_per_corsia.preselect", SQL_CELLE_DUP_PER_CORSIA, {"corsia": corsia}) + _log_sql("multi_udc_pallet_in_corsia.preselect", SQL_PALLET_IN_CORSIA, {"corsia": corsia}) + + async def _q(db): + celle_res = _json_obj(await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True)) + celle_rows = celle_res.get("rows", []) + pallet_res = _json_obj(await db.query_json(SQL_PALLET_IN_CORSIA, params={"corsia": corsia}, as_dict_rows=True)) + return {"cells": celle_rows, "pallets": pallet_res.get("rows", [])} + + def _ok(res): + self._busy.hide() + payload = _json_obj(res) + cell_rows = payload.get("cells", []) + pallet_rows = payload.get("pallets", []) + selected_count = 0 + grouped_pallets: dict[int, list[dict[str, Any]]] = {} + for pallet_row in pallet_rows: + grouped_pallets.setdefault(int(pallet_row["IDCella"]), []).append(pallet_row) + + if self.tree.exists(f"{corsia_node}::lazy"): + self.tree.delete(f"{corsia_node}::lazy") + + for cell_row in cell_rows: + idcella = int(cell_row["IDCella"]) + cell_node = f"cella:{idcella}" + if not self.tree.exists(cell_node): + self._fill_celle(corsia_node, {"rows": [cell_row]}) + self.tree.item(cell_node, open=True) + for child in list(self.tree.get_children(cell_node)): + self.tree.delete(child) + self._fill_pallet(cell_node, {"rows": grouped_pallets.get(idcella, [])}) + for leaf_id, meta in list(self.udc_meta_by_key.items()): + if meta.get("corsia") != corsia or meta.get("idcella") != idcella: + continue + if meta.get("is_shipped") or meta.get("is_moved"): + self._set_leaf_selected(leaf_id, True) + selected_count += 1 + + messagebox.showinfo( + "Preselezione completata", + f"Corsia {corsia}\nUDC candidate selezionate automaticamente: {selected_count}", + parent=self, + ) + + def _err(ex): + self._busy.hide() + _MODULE_LOGGER.exception(f"Errore preselezione fantasmi corsia={corsia}: {ex}") + messagebox.showerror("Errore", str(ex), parent=self) + + self.runner.run(_q(self.db), _ok, _err) + + @_log_call() + def _remove_selected_ghosts_for_corsia(self): + """Remove the selected ghost pallets only for the active aisle.""" + + corsia = self._selected_corsia_value() + if not corsia: + messagebox.showinfo("Bonifica fantasmi", "Seleziona prima una corsia.", parent=self) + return + + selected_meta = [ + meta + for leaf_id, meta in self.udc_meta_by_key.items() + if leaf_id in self.selected_udc_keys and meta.get("corsia") == corsia + ] + if not selected_meta: + messagebox.showinfo("Bonifica fantasmi", "Nessuna UDC selezionata nella corsia attiva.", parent=self) + return + + shipped_count = sum(1 for meta in selected_meta if meta.get("is_shipped")) + moved_count = sum(1 for meta in selected_meta if meta.get("is_moved")) + cell_count = len({meta.get("idcella") for meta in selected_meta}) + if not messagebox.askyesno( + "Conferma bonifica corsia", + ( + f"Corsia {corsia}\n" + f"UDC selezionate: {len(selected_meta)}\n" + f"Celle coinvolte: {cell_count}\n" + f"Spedite: {shipped_count}\n" + f"Spostate: {moved_count}\n\n" + "Procedere con la rimozione delle sole UDC selezionate?" + ), + parent=self, + ): + return + self._busy.show(f"Rimozione fantasmi corsia {corsia.strip()}...") + + async def _q(_db): + results: list[dict[str, Any]] = [] + for meta in selected_meta: + result = await move_pallet_async( + self.db, + barcode_pallet=str(meta.get("pallet", "")).strip(), + target_idcella=9999, + target_barcode_cella="9000000", + utente=str(getattr(self.session, "login", "") or "warehouse_ui").strip(), + ) + results.append({"meta": meta, "result": result}) + return {"rows": results} + + def _ok(res): + self._busy.hide() + rows = _json_obj(res).get("rows", []) + removed = sum(1 for row in rows if row.get("result")) + self.refresh_all() + messagebox.showinfo( + "Bonifica completata", + f"Corsia {corsia}\nUDC rimosse: {removed}", + parent=self, + ) + + def _err(ex): + self._busy.hide() + _MODULE_LOGGER.exception(f"Errore bonifica fantasmi corsia={corsia}: {ex}") + messagebox.showerror("Errore bonifica", str(ex), parent=self) + + self.runner.run(_q(self.db), _ok, _err) + @_log_call() def _load_riepilogo(self): """Load the percentage summary by aisle.""" @@ -635,10 +1009,11 @@ class CelleMultipleWindow(ctk.CTkToplevel): tags = self.tree.item(pallet_node, "tags") or () if "pallet" not in tags: continue - corsia = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "") - ubi = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "") - idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "") - pallet = self.tree.item(pallet_node, "text") + meta = self.udc_meta_by_key.get(pallet_node, {}) + corsia = meta.get("corsia") or next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "") + ubi = meta.get("ubicazione") or next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "") + idcella = meta.get("idcella") or next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "") + pallet = meta.get("pallet") or self.tree.item(pallet_node, "text") desc, lotto, causale = self.tree.item(pallet_node, "values") for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1): ws_det.cell(row=row_idx, column=j, value=value) @@ -690,6 +1065,7 @@ def open_celle_multiple_window( pass win = CelleMultipleWindow(root, db_client, runner=runner, session=session) setattr(root, key, win) + place_window_fullsize_below_parent_later(root, win) try: win.lift() win.focus_force() diff --git a/window_placement.py b/window_placement.py new file mode 100644 index 0000000..d967eb4 --- /dev/null +++ b/window_placement.py @@ -0,0 +1,335 @@ +"""Helpers to place child windows consistently relative to the launcher.""" + +from __future__ import annotations + +import ctypes +import logging +import math +import tkinter as tk +from pathlib import Path + + +MODULE_LOG_NAME = "window_placement" +MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log") +_MODULE_LOGGER = logging.getLogger(MODULE_LOG_NAME) +if not _MODULE_LOGGER.handlers: + _MODULE_LOGGER.setLevel(logging.DEBUG) + _MODULE_LOGGER.propagate = False + _handler = logging.FileHandler(MODULE_LOG_PATH, encoding="utf-8") + _handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) + _MODULE_LOGGER.addHandler(_handler) + + +def _safe_xy(window: tk.Misc) -> tuple[int | None, int | None]: + """Return current window coordinates without raising.""" + + try: + return int(window.winfo_x()), int(window.winfo_y()) + except Exception: + return None, None + + +def _safe_wh(window: tk.Misc) -> tuple[int | None, int | None]: + """Return current window size without raising.""" + + try: + return int(window.winfo_width()), int(window.winfo_height()) + except Exception: + return None, None + + +def _window_label(window: tk.Misc) -> str: + """Return a readable label for log messages.""" + + try: + title = str(window.title()).strip() + if title: + return title + except Exception: + pass + try: + return str(window) + except Exception: + return "" + + +def _work_area_bounds(window: tk.Misc) -> tuple[int, int, int, int]: + """Return the desktop work area excluding the taskbar when available.""" + + try: + if hasattr(ctypes, "windll"): + class RECT(ctypes.Structure): + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] + + rect = RECT() + SPI_GETWORKAREA = 0x0030 + if ctypes.windll.user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0): + return int(rect.left), int(rect.top), int(rect.right), int(rect.bottom) + except Exception: + pass + + return 0, 0, int(window.winfo_screenwidth()), int(window.winfo_screenheight()) + + +def _set_window_bounds(child: tk.Misc, x: int, y: int, width: int | None = None, height: int | None = None) -> None: + """Move a toplevel to the requested bounds, resizing it when dimensions are provided.""" + + before_x, before_y = _safe_xy(child) + before_w, before_h = _safe_wh(child) + _MODULE_LOGGER.debug( + "set_bounds.start window=%s from=(%s,%s,%s,%s) target=(%s,%s,%s,%s)", + _window_label(child), + before_x, + before_y, + before_w, + before_h, + x, + y, + width, + height, + ) + try: + child.state("normal") + except Exception: + pass + try: + child.deiconify() + except Exception: + pass + try: + if hasattr(ctypes, "windll"): + hwnd = int(child.winfo_id()) + SWP_NOZORDER = 0x0004 + SWP_NOACTIVATE = 0x0010 + flags = SWP_NOZORDER | SWP_NOACTIVATE + move_w = 0 if width is None else int(width) + move_h = 0 if height is None else int(height) + if width is None or height is None: + flags |= 0x0001 # SWP_NOSIZE + ctypes.windll.user32.SetWindowPos(hwnd, 0, int(x), int(y), move_w, move_h, flags) + if width is None or height is None: + child.geometry(f"+{x}+{y}") + else: + child.geometry(f"{int(width)}x{int(height)}+{x}+{y}") + except Exception: + fallback_w = max(child.winfo_width(), child.winfo_reqwidth()) if width is None else int(width) + fallback_h = max(child.winfo_height(), child.winfo_reqheight()) if height is None else int(height) + child.geometry(f"{fallback_w}x{fallback_h}+{x}+{y}") + try: + child.update_idletasks() + except Exception: + pass + after_x, after_y = _safe_xy(child) + after_w, after_h = _safe_wh(child) + _MODULE_LOGGER.debug( + "set_bounds.end window=%s final=(%s,%s,%s,%s) target=(%s,%s,%s,%s)", + _window_label(child), + after_x, + after_y, + after_w, + after_h, + x, + y, + width, + height, + ) + + +def _set_window_position(child: tk.Misc, x: int, y: int) -> None: + """Move a toplevel to the requested screen coordinates without resizing it.""" + + _set_window_bounds(child, x, y, None, None) + + +def _batch_set_window_positions(windows: list[tk.Misc], positions: list[tuple[int, int]]) -> bool: + """Try to reposition multiple windows in one Win32 batch to reduce flicker.""" + + if not hasattr(ctypes, "windll"): + return False + if len(windows) != len(positions) or not windows: + return False + + try: + user32 = ctypes.windll.user32 + hdwp = user32.BeginDeferWindowPos(len(windows)) + if not hdwp: + return False + + SWP_NOSIZE = 0x0001 + SWP_NOZORDER = 0x0004 + SWP_NOACTIVATE = 0x0010 + flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE + + for child, (x, y) in zip(windows, positions): + try: + child.state("normal") + except Exception: + pass + try: + child.deiconify() + except Exception: + pass + hwnd = int(child.winfo_id()) + hdwp = user32.DeferWindowPos(hdwp, hwnd, 0, int(x), int(y), 0, 0, flags) + if not hdwp: + return False + + return bool(user32.EndDeferWindowPos(hdwp)) + except Exception: + return False + + +def place_window_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): + """Place ``child`` so its outer top edge sits just below ``parent``. + + The placement uses root-window coordinates and preserves the child's + computed width/height. Call it after the child has a geometry. + """ + + try: + parent.update_idletasks() + child.update_idletasks() + # On Windows/Tk, ``winfo_rootx`` starts at the inner client area, + # while ``winfo_x`` tracks the outer window frame. Using ``winfo_x`` + # keeps child windows flush with the launcher's external left border. + x = parent.winfo_x() + int(x_offset) + y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap) + _set_window_position(child, x, y) + except Exception: + pass + + +def place_window_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): + """Schedule child placement on the Tk queue after geometry settles.""" + + try: + child.after(0, lambda: place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)) + except Exception: + place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap) + + +def place_window_fullsize_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): + """Place a child below the launcher and size it to the full remaining work area.""" + + try: + parent.update_idletasks() + child.update_idletasks() + work_left, _work_top, work_right, work_bottom = _work_area_bounds(parent) + x = parent.winfo_x() + int(x_offset) + y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap) + width = max(320, int(work_right) - int(x)) + height = max(240, int(work_bottom) - int(y)) + _set_window_bounds(child, x, y, width, height) + except Exception: + pass + + +def place_window_fullsize_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0): + """Schedule full-size placement below the launcher after geometry settles.""" + + try: + child.after(0, lambda: place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)) + except Exception: + place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap) + + +def tile_children_below_parent(parent: tk.Misc, children: list[tk.Misc], *, gap: int = 8, margin: int = 8): + """Arrange open children in a compact grid below the launcher.""" + + windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()] + if not windows: + return + + try: + parent.update_idletasks() + start_x = parent.winfo_rootx() + int(margin) + start_y = parent.winfo_rooty() + parent.winfo_height() + int(margin) + screen_w = parent.winfo_screenwidth() + screen_h = parent.winfo_screenheight() + avail_w = max(320, screen_w - start_x - int(margin)) + avail_h = max(240, screen_h - start_y - int(margin)) + + count = len(windows) + cols = max(1, math.ceil(math.sqrt(count))) + rows = max(1, math.ceil(count / cols)) + cell_w = max(320, (avail_w - (cols - 1) * int(gap)) // cols) + cell_h = max(240, (avail_h - (rows - 1) * int(gap)) // rows) + + for idx, child in enumerate(windows): + row = idx // cols + col = idx % cols + x = start_x + col * (cell_w + int(gap)) + y = start_y + row * (cell_h + int(gap)) + child.geometry(f"{cell_w}x{cell_h}+{x}+{y}") + try: + child.lift() + except Exception: + pass + except Exception: + pass + + +def cascade_children_below_parent( + parent: tk.Misc, + children: list[tk.Misc], + *, + x_offset_step: int = 20, + y_offset_step: int = 20, + margin_left: int = 0, + margin_top: int = 0, +): + """Arrange open children in cascade order below the launcher.""" + + windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()] + if not windows: + return + + try: + parent.update_idletasks() + base_x = parent.winfo_x() + int(margin_left) + base_y = parent.winfo_rooty() + parent.winfo_height() + int(margin_top) + positions: list[tuple[int, int]] = [] + _MODULE_LOGGER.info( + "cascade.start parent=%s base=(%s,%s) count=%s x_step=%s y_step=%s", + _window_label(parent), + base_x, + base_y, + len(windows), + x_offset_step, + y_offset_step, + ) + + for idx, child in enumerate(windows): + child.update_idletasks() + x = base_x + idx * int(x_offset_step) + y = base_y + idx * int(y_offset_step) + positions.append((x, y)) + _MODULE_LOGGER.info( + "cascade.window index=%s window=%s target=(%s,%s)", + idx, + _window_label(child), + x, + y, + ) + + batched = _batch_set_window_positions(windows, positions) + _MODULE_LOGGER.info("cascade.batch_applied=%s", batched) + + for child, (x, y) in zip(windows, positions): + try: + if not batched: + _set_window_position(child, x, y) + child.after(110, lambda w=child, px=x, py=y: _set_window_position(w, px, py) if getattr(w, "winfo_exists", lambda: False)() else None) + except Exception: + pass + try: + child.lift() + except Exception: + pass + except Exception: + _MODULE_LOGGER.exception("cascade.error")