Checkpoint before more window sizing work
This commit is contained in:
@@ -120,6 +120,7 @@ class AsyncMSSQLClient:
|
|||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
*,
|
*,
|
||||||
as_dict_rows: bool = False,
|
as_dict_rows: bool = False,
|
||||||
|
commit: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a query and return a JSON-friendly payload.
|
"""Execute a query and return a JSON-friendly payload.
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ class AsyncMSSQLClient:
|
|||||||
params: Optional named parameters bound to the statement.
|
params: Optional named parameters bound to the statement.
|
||||||
as_dict_rows: When ``True`` returns rows as dictionaries keyed by
|
as_dict_rows: When ``True`` returns rows as dictionaries keyed by
|
||||||
column name; otherwise rows are returned as lists.
|
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:
|
Returns:
|
||||||
A dictionary containing column names, rows and elapsed execution
|
A dictionary containing column names, rows and elapsed execution
|
||||||
@@ -135,7 +139,7 @@ class AsyncMSSQLClient:
|
|||||||
"""
|
"""
|
||||||
await self._ensure_engine()
|
await self._ensure_engine()
|
||||||
t0 = time.perf_counter()
|
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 {})
|
res = await conn.execute(text(sql), params or {})
|
||||||
rows = res.fetchall()
|
rows = res.fetchall()
|
||||||
cols = list(res.keys())
|
cols = list(res.keys())
|
||||||
|
|||||||
86
busy_overlay.py
Normal file
86
busy_overlay.py
Normal 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
|
||||||
@@ -18,10 +18,14 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from audit_log import log_user_action
|
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 gestione_scarico import DEFAULT_SCARICO_USER, move_pallet_async, open_scarico_dialog
|
||||||
from tksheet import Sheet, natural_sort_key
|
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 user_session import UserSession
|
||||||
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -204,14 +208,21 @@ class LayoutWindow(ctk.CTkToplevel):
|
|||||||
def __init__(self, parent: tk.Widget, db_app, session: UserSession | None = None):
|
def __init__(self, parent: tk.Widget, db_app, session: UserSession | None = None):
|
||||||
"""Create the window and initialize the state used by the matrix view."""
|
"""Create the window and initialize the state used by the matrix view."""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._theme = theme_section("layout_window", {})
|
||||||
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
self.title("Warehouse - Layout corsie")
|
self.title("Warehouse - Layout corsie")
|
||||||
self.geometry("1200x740")
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740")))
|
||||||
self.minsize(980, 560)
|
minsize = theme_value(self._theme, "window_minsize", [980, 560])
|
||||||
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
self.resizable(True, True)
|
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.db = db_app
|
||||||
self.session = session
|
self.session = session
|
||||||
self._busy = BusyOverlay(self)
|
self._busy = InlineBusyOverlay(self, self._theme)
|
||||||
self._async = AsyncRunner(self)
|
self._async = AsyncRunner(self)
|
||||||
|
|
||||||
# layout principale 5% / 80% / 15%
|
# layout principale 5% / 80% / 15%
|
||||||
@@ -254,6 +265,10 @@ class LayoutWindow(ctk.CTkToplevel):
|
|||||||
"""Create the top toolbar with aisle selection and search controls."""
|
"""Create the top toolbar with aisle selection and search controls."""
|
||||||
top = ctk.CTkFrame(self)
|
top = ctk.CTkFrame(self)
|
||||||
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
|
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):
|
for i in range(4):
|
||||||
top.grid_columnconfigure(i, weight=0)
|
top.grid_columnconfigure(i, weight=0)
|
||||||
top.grid_columnconfigure(1, weight=1)
|
top.grid_columnconfigure(1, weight=1)
|
||||||
@@ -261,8 +276,16 @@ class LayoutWindow(ctk.CTkToplevel):
|
|||||||
# lista corsie
|
# lista corsie
|
||||||
lf = ctk.CTkFrame(top)
|
lf = ctk.CTkFrame(top)
|
||||||
lf.grid(row=0, column=0, sticky="nsw")
|
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)
|
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 = tk.Listbox(lf, height=6, exportselection=False)
|
||||||
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
|
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
|
||||||
self.lb.bind("<<ListboxSelect>>", self._on_select)
|
self.lb.bind("<<ListboxSelect>>", self._on_select)
|
||||||
@@ -271,16 +294,50 @@ class LayoutWindow(ctk.CTkToplevel):
|
|||||||
srch = ctk.CTkFrame(top)
|
srch = ctk.CTkFrame(top)
|
||||||
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
|
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
|
||||||
self.search_var = tk.StringVar()
|
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")
|
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)
|
srch.grid_columnconfigure(0, weight=1)
|
||||||
|
WidgetToolTip(btn_search, tooltip_text("layout.search_udc", catalog=self._tooltip_catalog))
|
||||||
|
|
||||||
# toolbar
|
# toolbar
|
||||||
tb = ctk.CTkFrame(top)
|
tb = ctk.CTkFrame(top)
|
||||||
tb.grid(row=0, column=3, sticky="ne")
|
tb.grid(row=0, column=3, sticky="ne")
|
||||||
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
|
try:
|
||||||
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
|
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 ----------------
|
# ---------------- MATRIX HOST ----------------
|
||||||
def _build_matrix_host(self):
|
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)
|
ex = getattr(parent, key, None)
|
||||||
if ex and ex.winfo_exists():
|
if ex and ex.winfo_exists():
|
||||||
ex.session = session
|
ex.session = session
|
||||||
|
try:
|
||||||
|
ex.deiconify()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
ex.lift()
|
ex.lift()
|
||||||
ex.focus_force()
|
ex.focus_force()
|
||||||
@@ -1369,4 +1430,5 @@ def open_layout_window(parent, db_app, session: UserSession | None = None):
|
|||||||
pass
|
pass
|
||||||
w = LayoutWindow(parent, db_app, session=session)
|
w = LayoutWindow(parent, db_app, session=session)
|
||||||
setattr(parent, key, w)
|
setattr(parent, key, w)
|
||||||
|
place_window_fullsize_below_parent_later(parent, w)
|
||||||
return w
|
return w
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ except Exception:
|
|||||||
# Usa overlay e runner "collaudati"
|
# Usa overlay e runner "collaudati"
|
||||||
from gestione_aree import BusyOverlay, AsyncRunner
|
from gestione_aree import BusyOverlay, AsyncRunner
|
||||||
from user_session import UserSession
|
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 procedura async prenota/s-prenota (no pyodbc qui) ===
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -1215,7 +1216,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N
|
|||||||
|
|
||||||
win = ctk.CTkToplevel(parent)
|
win = ctk.CTkToplevel(parent)
|
||||||
win.title("Gestione Picking List")
|
win.title("Gestione Picking List")
|
||||||
win.geometry("1200x700+0+100")
|
win.geometry("1200x700")
|
||||||
win.minsize(1000, 560)
|
win.minsize(1000, 560)
|
||||||
setattr(parent, key, win)
|
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.
|
# Reveal the fully-laid out window only after pending geometry work completes.
|
||||||
try:
|
try:
|
||||||
win.update_idletasks()
|
win.update_idletasks()
|
||||||
try:
|
place_window_fullsize_below_parent_later(parent, win)
|
||||||
win.transient(parent)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
win.deiconify()
|
win.deiconify()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from typing import Any, Callable
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
from gestione_aree import BusyOverlay, AsyncRunner
|
from gestione_aree import AsyncRunner
|
||||||
from audit_log import log_user_action
|
from audit_log import log_user_action
|
||||||
|
from busy_overlay import InlineBusyOverlay
|
||||||
from user_session import UserSession
|
from user_session import UserSession
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -251,6 +252,8 @@ ORDER BY
|
|||||||
|
|
||||||
|
|
||||||
SQL_SCARICA_UDC = """
|
SQL_SCARICA_UDC = """
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
DECLARE @Now datetime = GETDATE();
|
DECLARE @Now datetime = GETDATE();
|
||||||
DECLARE @SourceID int = 0;
|
DECLARE @SourceID int = 0;
|
||||||
DECLARE @NumeroPallet 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(),
|
"utente": str((utente or DEFAULT_SCARICO_USER) or "warehouse_ui").strip(),
|
||||||
}
|
}
|
||||||
_log_sql("move_pallet", SQL_SCARICA_UDC, params)
|
_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 []
|
rows = res.get("rows", []) if isinstance(res, dict) else []
|
||||||
_log_dataset("move_pallet", rows)
|
_log_dataset("move_pallet", rows)
|
||||||
first = rows[0] if rows else [1, 0, params["target_idcella"], params["target_barcode_cella"]]
|
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.on_completed = on_completed
|
||||||
self.session = session
|
self.session = session
|
||||||
self.rows: list[ScaricoRow] = []
|
self.rows: list[ScaricoRow] = []
|
||||||
self._busy = BusyOverlay(self)
|
self._busy = InlineBusyOverlay(self)
|
||||||
self._async = AsyncRunner(self)
|
self._async = AsyncRunner(self)
|
||||||
self.rows_tree: ttk.Treeview | None = None
|
self.rows_tree: ttk.Treeview | None = None
|
||||||
|
|
||||||
|
|||||||
346
main.py
346
main.py
@@ -9,20 +9,24 @@ import asyncio
|
|||||||
import ctypes
|
import ctypes
|
||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
import time
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
from async_loop_singleton import get_global_loop
|
from async_loop_singleton import get_global_loop
|
||||||
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
|
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_layout import open_layout_window
|
||||||
from gestione_pickinglist import open_pickinglist_window
|
from gestione_pickinglist import open_pickinglist_window
|
||||||
from login_window import prompt_login
|
from login_window import prompt_login
|
||||||
from reset_corsie import open_reset_corsie_window
|
from reset_corsie import open_reset_corsie_window
|
||||||
from search_pallets import open_search_window
|
from search_pallets import open_search_window
|
||||||
from audit_log import log_session_event
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
from view_celle_multi_udc import open_celle_multiple_window
|
from ui_theme import theme_font, theme_section, theme_value
|
||||||
from user_session import UserSession, create_user_session
|
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 ----
|
# ---- Config ----
|
||||||
@@ -102,78 +106,320 @@ def _build_bypass_session() -> UserSession:
|
|||||||
class Launcher(ctk.CTk):
|
class Launcher(ctk.CTk):
|
||||||
"""Main launcher window that exposes the available warehouse tools."""
|
"""Main launcher window that exposes the available warehouse tools."""
|
||||||
|
|
||||||
|
_WINDOW_ORDER = [
|
||||||
|
"reset_corsie",
|
||||||
|
"layout",
|
||||||
|
"multi_udc",
|
||||||
|
"search",
|
||||||
|
"pickinglist",
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, session: UserSession):
|
def __init__(self, session: UserSession):
|
||||||
"""Create the launcher toolbar and wire every button to a feature window."""
|
"""Create the launcher toolbar and wire every button to a feature window."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.session: UserSession = session
|
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.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 = 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(
|
info = ctk.CTkLabel(
|
||||||
wrap,
|
wrap,
|
||||||
text=f"Operatore: {self.session.display_name} ({self.session.login})",
|
text=f"Operatore: {self.session.display_name} ({self.session.login})",
|
||||||
anchor="w",
|
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):
|
||||||
wrap,
|
row = 1 + (idx // max_buttons_per_row)
|
||||||
text="Gestione Corsie",
|
column = idx % max_buttons_per_row
|
||||||
state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled",
|
button = ctk.CTkButton(
|
||||||
command=lambda: open_reset_corsie_window(self, db_app, session=self.session),
|
wrap,
|
||||||
).grid(row=1, column=0, padx=6, pady=6, sticky="ew")
|
text=label,
|
||||||
ctk.CTkButton(
|
font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")),
|
||||||
wrap,
|
state="normal" if self.session.can(permission) else "disabled",
|
||||||
text="Gestione Layout",
|
command=callback,
|
||||||
state="normal" if self.session.can("launcher.open_layout") else "disabled",
|
)
|
||||||
command=lambda: open_layout_window(self, db_app, session=self.session),
|
button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew")
|
||||||
).grid(row=1, column=1, padx=6, pady=6, sticky="ew")
|
tip = tooltip_text(permission, catalog=self._tooltip_catalog)
|
||||||
ctk.CTkButton(
|
if tip:
|
||||||
wrap,
|
WidgetToolTip(button, tip)
|
||||||
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 i in range(5):
|
for i in range(max_buttons_per_row):
|
||||||
wrap.grid_columnconfigure(i, weight=1)
|
wrap.grid_columnconfigure(i, weight=1)
|
||||||
|
|
||||||
def _on_close():
|
self.update_idletasks()
|
||||||
"""Dispose shared resources before closing the launcher."""
|
self._apply_dynamic_geometry()
|
||||||
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.protocol("WM_DELETE_WINDOW", _on_close)
|
self.protocol("WM_DELETE_WINDOW", self._shutdown)
|
||||||
try:
|
try:
|
||||||
self.lift()
|
self.lift()
|
||||||
self.focus_force()
|
self.focus_force()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
|
||||||
|
try:
|
||||||
|
fut.result(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
ctk.set_appearance_mode("light")
|
ctk.set_appearance_mode("light")
|
||||||
|
|||||||
531
reset_corsie.py
531
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
|
The tool summarizes the current occupancy of one aisle and, after explicit
|
||||||
state of a selected aisle and, after explicit confirmation, deletes matching
|
confirmation, unloads every active UDC through the same logical movement
|
||||||
rows from ``MagazziniPallet``.
|
semantics used by the rest of the WMS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
from tkinter import messagebox, simpledialog, ttk
|
from tkinter import messagebox, simpledialog, ttk
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import customtkinter as ctk
|
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 = """
|
SQL_CORSIE = """
|
||||||
WITH C AS (
|
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;
|
ORDER BY TRY_CONVERT(int,c.Colonna), c.Colonna, TRY_CONVERT(int,c.Fila), c.Fila;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SQL_COUNT_DELETE = """
|
SQL_COUNT_RESET = """
|
||||||
SELECT COUNT(*) AS RowsToDelete
|
SELECT
|
||||||
FROM dbo.MagazziniPallet mp
|
COUNT(DISTINCT g.BarcodePallet) AS TotUDC,
|
||||||
JOIN dbo.Celle c ON c.ID = mp.IDCella
|
COUNT(DISTINCT g.IDCella) AS TotCelle
|
||||||
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
|
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 = """
|
SQL_UDC_RESET = """
|
||||||
DELETE mp
|
WITH U AS (
|
||||||
FROM dbo.MagazziniPallet mp
|
SELECT DISTINCT
|
||||||
JOIN dbo.Celle c ON c.ID = mp.IDCella
|
g.BarcodePallet AS BarcodePallet,
|
||||||
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
|
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):
|
class ResetCorsieWindow(ctk.CTkToplevel):
|
||||||
"""Toplevel used to inspect and clear the pallets assigned to an aisle."""
|
"""Toplevel used to inspect and clear the pallets assigned to an aisle."""
|
||||||
|
|
||||||
|
@_log_call()
|
||||||
def __init__(self, parent, db_client, session=None):
|
def __init__(self, parent, db_client, session=None):
|
||||||
"""Create the window and immediately load the list of aisles."""
|
"""Create the window and immediately load the list of aisles."""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._theme = theme_section("reset_corsie", {})
|
||||||
self.title("Reset Corsie - svuotamento celle per corsia")
|
self.title("Reset Corsie - svuotamento celle per corsia")
|
||||||
self.geometry("1000x680")
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680")))
|
||||||
self.minsize(880, 560)
|
minsize = theme_value(self._theme, "window_minsize", [880, 560])
|
||||||
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
self.resizable(True, True)
|
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.db = db_client
|
||||||
self.session = session
|
self.session = session
|
||||||
self._busy = BusyOverlay(self)
|
self._busy = InlineBusyOverlay(self, self._theme)
|
||||||
self._async = AsyncRunner(self)
|
self._async = AsyncRunner(self)
|
||||||
|
self._refresh_token = 0
|
||||||
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load_corsie()
|
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):
|
def _build_ui(self):
|
||||||
"""Create selectors, summary widgets and the occupied-cell grid."""
|
"""Create selectors, summary widgets and the occupied-cell grid."""
|
||||||
|
self._setup_tree_style()
|
||||||
top = ctk.CTkFrame(self)
|
top = ctk.CTkFrame(self)
|
||||||
top.pack(fill="x", padx=8, pady=8)
|
top.pack(
|
||||||
ctk.CTkLabel(top, text="Corsia:").pack(side="left")
|
fill="x",
|
||||||
self.cmb = ctk.CTkComboBox(top, width=140, values=[])
|
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))
|
self.cmb.pack(side="left", padx=(6, 10))
|
||||||
ctk.CTkButton(top, text="Carica", command=self.refresh).pack(side="left")
|
btn_refresh = ctk.CTkButton(
|
||||||
ctk.CTkButton(top, text="Svuota corsia...", command=self._ask_reset).pack(side="right")
|
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 = 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_columnconfigure(0, weight=1)
|
||||||
mid.grid_rowconfigure(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("Ubicazione", text="Ubicazione")
|
||||||
self.tree.heading("NumUDC", text="UDC in cella")
|
self.tree.heading("NumUDC", text="UDC in cella")
|
||||||
self.tree.column("Ubicazione", width=240, anchor="w")
|
self.tree.column(
|
||||||
self.tree.column("NumUDC", width=120, anchor="e")
|
"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)
|
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
|
||||||
sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
|
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")
|
sx.grid(row=1, column=0, sticky="ew")
|
||||||
|
|
||||||
bottom = ctk.CTkFrame(self)
|
bottom = ctk.CTkFrame(self)
|
||||||
bottom.pack(fill="x", padx=8, pady=(0, 8))
|
bottom.pack(
|
||||||
ctk.CTkLabel(bottom, text="Riepilogo", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0))
|
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 = ctk.CTkFrame(bottom)
|
||||||
g.pack(fill="x", padx=8, pady=8)
|
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_tot_celle = tk.StringVar(value="0")
|
||||||
self.var_occ = tk.StringVar(value="0")
|
self.var_occ = tk.StringVar(value="0")
|
||||||
self.var_dbl = 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):
|
def _kv(parent_widget, label, var, col):
|
||||||
"""Build a compact summary label/value pair."""
|
"""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(
|
||||||
ctk.CTkLabel(parent_widget, textvariable=var).grid(row=0, column=col * 2 + 1, sticky="w", padx=(0, 18))
|
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)
|
g.grid_columnconfigure(7, weight=1)
|
||||||
_kv(g, "Tot. celle:", self.var_tot_celle, 0)
|
_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, "Celle doppie:", self.var_dbl, 2)
|
||||||
_kv(g, "Tot. pallet:", self.var_pallet, 3)
|
_kv(g, "Tot. pallet:", self.var_pallet, 3)
|
||||||
|
|
||||||
|
@_log_call()
|
||||||
def _load_corsie(self):
|
def _load_corsie(self):
|
||||||
"""Load available aisles and preselect ``1A`` when present."""
|
"""Load available aisles and preselect ``1A`` when present."""
|
||||||
|
_log_sql("reset_corsie_corsie", SQL_CORSIE, {})
|
||||||
|
|
||||||
def _ok(res):
|
def _ok(res):
|
||||||
rows = res.get("rows", []) if isinstance(res, dict) else []
|
rows = res.get("rows", []) if isinstance(res, dict) else []
|
||||||
|
_log_dataset("reset_corsie_corsie", rows)
|
||||||
items = [r[0] for r in rows]
|
items = [r[0] for r in rows]
|
||||||
self.cmb.configure(values=items)
|
self.cmb.configure(values=items)
|
||||||
if items:
|
if items:
|
||||||
@@ -174,87 +483,168 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
|||||||
messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self)
|
messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self)
|
||||||
|
|
||||||
def _err(ex):
|
def _err(ex):
|
||||||
|
_MODULE_LOGGER.exception(f"Errore caricamento corsie reset corsie: {ex}")
|
||||||
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}", parent=self)
|
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...")
|
self._async.run(self.db.query_json(SQL_CORSIE, {}), _ok, _err, busy=self._busy, message="Carico corsie...")
|
||||||
|
|
||||||
|
@_log_call()
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""Refresh both the summary counters and the occupied-cell list."""
|
"""Refresh both the summary counters and the occupied-cell list."""
|
||||||
corsia = self.cmb.get().strip()
|
corsia = self.cmb.get().strip()
|
||||||
if not corsia:
|
if not corsia:
|
||||||
return
|
return
|
||||||
|
_log_sql("reset_corsie_riepilogo", SQL_RIEPILOGO, {"corsia": corsia})
|
||||||
|
_log_sql("reset_corsie_dettaglio", SQL_DETTAGLIO, {"corsia": corsia})
|
||||||
|
|
||||||
def _ok_sum(res):
|
async def _q():
|
||||||
rows = res.get("rows", []) if isinstance(res, dict) else []
|
riepilogo = await self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia})
|
||||||
if rows:
|
dettaglio = await self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia})
|
||||||
tot, occ, dbl, pallet = rows[0]
|
return {"riepilogo": riepilogo, "dettaglio": dettaglio}
|
||||||
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_sum(ex):
|
def _ok(payload):
|
||||||
messagebox.showerror("Errore", f"Riepilogo fallito:\n{ex}", parent=self)
|
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):
|
if sum_rows:
|
||||||
rows = res.get("rows", []) if isinstance(res, dict) else []
|
tot, occ, dbl, pallet = sum_rows[0]
|
||||||
for item in self.tree.get_children():
|
self.var_tot_celle.set(str(tot or 0))
|
||||||
self.tree.delete(item)
|
self.var_occ.set(str(occ or 0))
|
||||||
for _idc, ubi, n in rows:
|
self.var_dbl.set(str(dbl or 0))
|
||||||
self.tree.insert("", "end", values=(ubi, n))
|
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):
|
for item in self.tree.get_children():
|
||||||
messagebox.showerror("Errore", f"Dettaglio fallito:\n{ex}", parent=self)
|
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):
|
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()
|
corsia = self.cmb.get().strip()
|
||||||
if not corsia:
|
if not corsia:
|
||||||
return
|
return
|
||||||
|
_log_sql("reset_corsie_count_reset", SQL_COUNT_RESET, {"corsia": corsia})
|
||||||
|
|
||||||
def _ok_count(res):
|
def _ok_count(res):
|
||||||
rows = res.get("rows", []) if isinstance(res, dict) else []
|
rows = res.get("rows", []) if isinstance(res, dict) else []
|
||||||
n = int(rows[0][0]) if rows else 0
|
_log_dataset("reset_corsie_count_reset", rows)
|
||||||
if n <= 0:
|
tot_udc = int(rows[0][0] or 0) if rows else 0
|
||||||
messagebox.showinfo("Svuota corsia", f"Nessun pallet da rimuovere per la corsia {corsia}.", parent=self)
|
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
|
return
|
||||||
msg = (
|
msg = (
|
||||||
f"Verranno cancellati {n} record da MagazziniPallet per la corsia {corsia}.",
|
f"Verranno scaricate logicamente {tot_udc} UDC attive distribuite su {tot_celle} celle della corsia {corsia}.",
|
||||||
"Questa operazione e' irreversibile.",
|
"L'operazione verra' eseguita come scarico verso 9000000 / 9999, senza cancellazioni fisiche dirette.",
|
||||||
"Digitare il nome della corsia per confermare:",
|
"Digitare il nome della corsia per confermare:",
|
||||||
)
|
)
|
||||||
confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self)
|
confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self)
|
||||||
if confirm is None:
|
if confirm is None:
|
||||||
|
_MODULE_LOGGER.info(f"Reset corsia {corsia}: conferma annullata dall'utente")
|
||||||
return
|
return
|
||||||
if confirm.strip().upper() != corsia.upper():
|
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)
|
messagebox.showwarning("Annullato", "Testo di conferma non corrispondente.", parent=self)
|
||||||
return
|
return
|
||||||
self._do_reset(corsia)
|
self._do_reset(corsia)
|
||||||
|
|
||||||
def _err_count(ex):
|
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):
|
def _do_reset(self, corsia: str):
|
||||||
"""Execute the actual delete and refresh the window afterwards."""
|
"""Execute the logical unload of every active UDC in the selected aisle."""
|
||||||
def _ok_del(_):
|
_log_sql("reset_corsie_udc_reset", SQL_UDC_RESET, {"corsia": corsia})
|
||||||
messagebox.showinfo("Completato", f"Corsia {corsia}: svuotamento completato.", parent=self)
|
|
||||||
|
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()
|
self.refresh()
|
||||||
|
|
||||||
def _err_del(ex):
|
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)
|
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):
|
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"
|
key = "_reset_corsie_window_singleton"
|
||||||
ex = getattr(parent, key, None)
|
ex = getattr(parent, key, None)
|
||||||
if ex and ex.winfo_exists():
|
if ex and ex.winfo_exists():
|
||||||
|
try:
|
||||||
|
ex.deiconify()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
ex.lift()
|
ex.lift()
|
||||||
ex.focus_force()
|
ex.focus_force()
|
||||||
@@ -270,6 +664,7 @@ def open_reset_corsie_window(parent, db_app, session=None):
|
|||||||
pass
|
pass
|
||||||
win = ResetCorsieWindow(parent, db_app, session=session)
|
win = ResetCorsieWindow(parent, db_app, session=session)
|
||||||
setattr(parent, key, win)
|
setattr(parent, key, win)
|
||||||
|
place_window_fullsize_below_parent_later(parent, win)
|
||||||
try:
|
try:
|
||||||
win.lift()
|
win.lift()
|
||||||
win.focus_force()
|
win.focus_force()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from tkinter import filedialog, messagebox, ttk
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from gestione_aree import AsyncRunner, BusyOverlay
|
from gestione_aree import AsyncRunner, BusyOverlay
|
||||||
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
@@ -425,4 +426,5 @@ def open_search_window(parent, db_app, session=None):
|
|||||||
pass
|
pass
|
||||||
w = SearchWindow(parent, db_app, session=session)
|
w = SearchWindow(parent, db_app, session=session)
|
||||||
setattr(parent, key, w)
|
setattr(parent, key, w)
|
||||||
|
place_window_fullsize_below_parent_later(parent, w)
|
||||||
return w
|
return w
|
||||||
|
|||||||
43
tooltip.json
Normal file
43
tooltip.json
Normal 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
101
tooltips.py
Normal 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
165
ui_theme.json
Normal 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
97
ui_theme.py
Normal 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)
|
||||||
@@ -14,6 +14,8 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
|
|||||||
"launcher.open_multi_udc",
|
"launcher.open_multi_udc",
|
||||||
"launcher.open_search",
|
"launcher.open_search",
|
||||||
"launcher.open_pickinglist",
|
"launcher.open_pickinglist",
|
||||||
|
"launcher.arrange_windows",
|
||||||
|
"launcher.exit",
|
||||||
"reset_corsie.view",
|
"reset_corsie.view",
|
||||||
"search.view",
|
"search.view",
|
||||||
"multi_udc.view",
|
"multi_udc.view",
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import customtkinter as ctk
|
|||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Alignment, Font
|
from openpyxl.styles import Alignment, Font
|
||||||
|
|
||||||
|
from busy_overlay import InlineBusyOverlay
|
||||||
from gestione_aree import AsyncRunner
|
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:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -288,6 +293,90 @@ GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto, shipped.BarcodePallet, la.ID
|
|||||||
ORDER BY b.BarcodePallet;
|
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:
|
def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str:
|
||||||
"""Translate anomaly flags into the operator-facing ghost cause."""
|
"""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):
|
def __init__(self, root, db_client, runner: AsyncRunner | None = None, session=None):
|
||||||
"""Bind the shared DB client and immediately load the tree summary."""
|
"""Bind the shared DB client and immediately load the tree summary."""
|
||||||
super().__init__(root)
|
super().__init__(root)
|
||||||
|
self._theme = theme_section("multi_udc", {})
|
||||||
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
self.title("Celle con piu' pallet")
|
self.title("Celle con piu' pallet")
|
||||||
self.session = session
|
self.session = session
|
||||||
self.geometry("1100x700")
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700")))
|
||||||
self.minsize(900, 550)
|
minsize = theme_value(self._theme, "window_minsize", [900, 550])
|
||||||
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
self.resizable(True, True)
|
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.db = db_client
|
||||||
self.runner = runner or AsyncRunner(self)
|
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._build_layout()
|
||||||
self._bind_events()
|
self._bind_events()
|
||||||
@@ -365,13 +465,35 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
|
|
||||||
toolbar = ctk.CTkFrame(self)
|
toolbar = ctk.CTkFrame(self)
|
||||||
toolbar.grid(row=0, column=0, sticky="nsew")
|
toolbar.grid(row=0, column=0, sticky="nsew")
|
||||||
ctk.CTkButton(toolbar, text="Aggiorna", command=self.refresh_all).pack(side="left", padx=6, pady=4)
|
try:
|
||||||
ctk.CTkButton(toolbar, text="Espandi tutto", command=self.expand_all).pack(side="left", padx=6, pady=4)
|
toolbar.configure(fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")))
|
||||||
ctk.CTkButton(toolbar, text="Comprimi tutto", command=self.collapse_all).pack(side="left", padx=6, pady=4)
|
except Exception:
|
||||||
ctk.CTkButton(toolbar, text="Esporta in XLSX", command=self.export_to_xlsx).pack(side="left", padx=6, pady=4)
|
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 = ctk.CTkFrame(self)
|
||||||
frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6))
|
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_rowconfigure(0, weight=1)
|
||||||
frame.grid_columnconfigure(0, weight=1)
|
frame.grid_columnconfigure(0, weight=1)
|
||||||
self.tree = ttk.Treeview(frame, columns=("col2", "col3", "col4"), show="tree headings", selectmode="browse")
|
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 = ctk.CTkFrame(self)
|
||||||
sumf.grid(row=2, column=0, sticky="nsew", padx=6, pady=(0, 6))
|
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 = ctk.CTkFrame(sumf)
|
||||||
inner.pack(fill="both", expand=True, padx=6, pady=6)
|
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_rowconfigure(0, weight=1)
|
||||||
inner.grid_columnconfigure(0, weight=1)
|
inner.grid_columnconfigure(0, weight=1)
|
||||||
self.sum_tbl = ttk.Treeview(inner, columns=("Corsia", "TotCelle", "CelleMultiple", "Percentuale"), show="headings")
|
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):
|
def _bind_events(self):
|
||||||
"""Attach lazy-load behavior when nodes are expanded."""
|
"""Attach lazy-load behavior when nodes are expanded."""
|
||||||
self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
|
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()
|
@_log_call()
|
||||||
def refresh_all(self):
|
def refresh_all(self):
|
||||||
"""Reload both the duplication tree and the summary percentage table."""
|
"""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_corsie()
|
||||||
self._load_riepilogo()
|
self._load_riepilogo()
|
||||||
|
|
||||||
@@ -448,8 +657,16 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
if not corsia:
|
if not corsia:
|
||||||
continue
|
continue
|
||||||
node_id = f"corsia:{corsia}"
|
node_id = f"corsia:{corsia}"
|
||||||
self.tree.insert("", "end", iid=node_id, text=f"Corsia {corsia}", values=("", ""), open=False, tags=("corsia",))
|
self.tree.insert(
|
||||||
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", ""))
|
"",
|
||||||
|
"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):
|
def _on_open_node(self, _evt):
|
||||||
"""Lazy-load children when a tree node is expanded."""
|
"""Lazy-load children when a tree node is expanded."""
|
||||||
@@ -491,7 +708,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
rows = _json_obj(res).get("rows", [])
|
rows = _json_obj(res).get("rows", [])
|
||||||
_log_dataset("multi_udc_celle_per_corsia", rows)
|
_log_dataset("multi_udc_celle_per_corsia", rows)
|
||||||
if not 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
|
return
|
||||||
for row in rows:
|
for row in rows:
|
||||||
idc = row["IDCella"]
|
idc = row["IDCella"]
|
||||||
@@ -501,11 +718,19 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
node_id = f"cella:{idc}"
|
node_id = f"cella:{idc}"
|
||||||
label = f"{ubi} [x{num}]"
|
label = f"{ubi} [x{num}]"
|
||||||
if self.tree.exists(node_id):
|
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:
|
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)):
|
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()
|
@_log_call()
|
||||||
def _load_pallet_for_cella(self, parent_iid, idcella: int):
|
def _load_pallet_for_cella(self, parent_iid, idcella: int):
|
||||||
@@ -540,20 +765,169 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
pallet = row.get("Pallet", "")
|
pallet = row.get("Pallet", "")
|
||||||
desc = row.get("Descrizione", "")
|
desc = row.get("Descrizione", "")
|
||||||
lotto = row.get("Lotto", "")
|
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}"
|
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):
|
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
|
continue
|
||||||
self.tree.insert(
|
self.tree.insert(
|
||||||
parent_iid,
|
parent_iid,
|
||||||
"end",
|
"end",
|
||||||
iid=leaf_id,
|
iid=leaf_id,
|
||||||
text=str(pallet),
|
text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys),
|
||||||
values=(desc, lotto, causale),
|
values=(desc, lotto, causale),
|
||||||
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
|
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()
|
@_log_call()
|
||||||
def _load_riepilogo(self):
|
def _load_riepilogo(self):
|
||||||
"""Load the percentage summary by aisle."""
|
"""Load the percentage summary by aisle."""
|
||||||
@@ -635,10 +1009,11 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
tags = self.tree.item(pallet_node, "tags") or ()
|
tags = self.tree.item(pallet_node, "tags") or ()
|
||||||
if "pallet" not in tags:
|
if "pallet" not in tags:
|
||||||
continue
|
continue
|
||||||
corsia = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "")
|
meta = self.udc_meta_by_key.get(pallet_node, {})
|
||||||
ubi = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "")
|
corsia = meta.get("corsia") or next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "")
|
||||||
idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "")
|
ubi = meta.get("ubicazione") or next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "")
|
||||||
pallet = self.tree.item(pallet_node, "text")
|
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")
|
desc, lotto, causale = self.tree.item(pallet_node, "values")
|
||||||
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1):
|
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1):
|
||||||
ws_det.cell(row=row_idx, column=j, value=value)
|
ws_det.cell(row=row_idx, column=j, value=value)
|
||||||
@@ -690,6 +1065,7 @@ def open_celle_multiple_window(
|
|||||||
pass
|
pass
|
||||||
win = CelleMultipleWindow(root, db_client, runner=runner, session=session)
|
win = CelleMultipleWindow(root, db_client, runner=runner, session=session)
|
||||||
setattr(root, key, win)
|
setattr(root, key, win)
|
||||||
|
place_window_fullsize_below_parent_later(root, win)
|
||||||
try:
|
try:
|
||||||
win.lift()
|
win.lift()
|
||||||
win.focus_force()
|
win.focus_force()
|
||||||
|
|||||||
335
window_placement.py
Normal file
335
window_placement.py
Normal 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")
|
||||||
Reference in New Issue
Block a user