Checkpoint before more window sizing work

This commit is contained in:
2026-05-10 16:29:49 +02:00
parent 6ab42a2303
commit 8489cd7459
15 changed files with 2071 additions and 156 deletions

View File

@@ -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())

86
busy_overlay.py Normal file
View File

@@ -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

View File

@@ -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("<<ListboxSelect>>", 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

View File

@@ -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:

View File

@@ -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

336
main.py
View File

@@ -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,60 +106,309 @@ 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(
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="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")
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."""
self.update_idletasks()
self._apply_dynamic_geometry()
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(
"<Destroy>",
lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<Activate>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<FocusIn>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<ButtonPress-1>",
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")
@@ -167,13 +420,6 @@ class Launcher(ctk.CTk):
finally:
self.destroy()
self.protocol("WM_DELETE_WINDOW", _on_close)
try:
self.lift()
self.focus_force()
except Exception:
pass
if __name__ == "__main__":
ctk.set_appearance_mode("light")

View File

@@ -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=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
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,20 +483,37 @@ 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]
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 _ok(payload):
try:
riepilogo = payload.get("riepilogo", {}) if isinstance(payload, dict) else {}
dettaglio = payload.get("dettaglio", {}) if isinstance(payload, dict) else {}
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)
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))
@@ -198,63 +524,127 @@ class ResetCorsieWindow(ctk.CTkToplevel):
self.var_dbl.set("0")
self.var_pallet.set("0")
def _err_sum(ex):
messagebox.showerror("Errore", f"Riepilogo fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia}), _ok_sum, _err_sum, busy=self._busy, message=f"Riepilogo {corsia}...")
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))
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)
def _err_det(ex):
messagebox.showerror("Errore", f"Dettaglio fallito:\n{ex}", parent=self)
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(self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia}), _ok_det, _err_det, busy=None, message=None)
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()

View File

@@ -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

43
tooltip.json Normal file
View File

@@ -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."
}
}

101
tooltips.py Normal file
View File

@@ -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("<Enter>", self._schedule_show, add="+")
self.widget.bind("<Leave>", self._hide, add="+")
self.widget.bind("<ButtonPress>", 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

165
ui_theme.json Normal file
View File

@@ -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"
}
}
}

97
ui_theme.py Normal file
View File

@@ -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)

View File

@@ -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",

View File

@@ -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("<<TreeviewOpen>>", self._on_open_node)
self.tree.bind("<Button-1>", 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()

335
window_placement.py Normal file
View File

@@ -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 "<window>"
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")