Checkpoint before ghost pallet cleanup workflow
This commit is contained in:
@@ -6,14 +6,68 @@ smooth by relying on deferred updates and lightweight progress indicators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from typing import Optional, Any, Dict, List, Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from audit_log import log_user_action
|
||||
try:
|
||||
from loguru import logger
|
||||
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
|
||||
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()
|
||||
|
||||
try:
|
||||
from tksheet import Sheet, natural_sort_key
|
||||
except Exception:
|
||||
Sheet = None # type: ignore[assignment]
|
||||
natural_sort_key = None # type: ignore[assignment]
|
||||
|
||||
# Usa overlay e runner "collaudati"
|
||||
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
|
||||
from gestione_aree import BusyOverlay, AsyncRunner
|
||||
from user_session import UserSession
|
||||
|
||||
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
||||
import asyncio
|
||||
@@ -27,6 +81,118 @@ except Exception:
|
||||
self.rc = rc; self.message = message; self.id_result = id_result
|
||||
|
||||
|
||||
PICKINGLIST_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
|
||||
PICKINGLIST_DETAIL_TEST_MULTIPLIER = 1 # 1 disables artificial row expansion for UI stress tests
|
||||
MODULE_LOG_NAME = Path(__file__).stem
|
||||
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
|
||||
_MODULE_LOG_ENABLED = PICKINGLIST_LOG_MODE.upper() != "OFF"
|
||||
_MODULE_LOG_LEVEL = "DEBUG" if PICKINGLIST_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: Optional[str] = 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]):
|
||||
"""Log one SQL statement and its parameters."""
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}")
|
||||
_MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}")
|
||||
|
||||
|
||||
def _log_dataset(query_name: str, rows: List[Dict[str, 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 PICKINGLIST_LOG_MODE.upper() == "DEBUG":
|
||||
_MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}")
|
||||
|
||||
|
||||
def _expand_detail_rows_for_test(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Artificially duplicate detail rows to stress-test the UI with larger datasets."""
|
||||
multiplier = max(1, int(PICKINGLIST_DETAIL_TEST_MULTIPLIER))
|
||||
if multiplier == 1 or not rows:
|
||||
return rows
|
||||
|
||||
expanded: List[Dict[str, Any]] = []
|
||||
for copy_idx in range(multiplier):
|
||||
for row_idx, row in enumerate(rows):
|
||||
cloned = dict(row)
|
||||
cloned["__test_copy__"] = copy_idx
|
||||
cloned["__test_row__"] = row_idx
|
||||
expanded.append(cloned)
|
||||
_MODULE_LOGGER.info(
|
||||
f"Dataset dettaglio espanso artificialmente da {len(rows)} a {len(expanded)} righe per test UI"
|
||||
)
|
||||
return expanded
|
||||
|
||||
|
||||
_configure_module_logger()
|
||||
if _MODULE_LOG_ENABLED:
|
||||
_MODULE_LOGGER.info(
|
||||
f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={PICKINGLIST_LOG_MODE.upper()}"
|
||||
)
|
||||
|
||||
|
||||
# -------------------- SQL --------------------
|
||||
SQL_PL = """
|
||||
SELECT
|
||||
@@ -205,10 +371,18 @@ class ScrollTable(ctk.CTkFrame):
|
||||
PADX_R = 8
|
||||
PADY = 2
|
||||
|
||||
def __init__(self, master, columns: List[ColSpec]):
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
columns: List[ColSpec],
|
||||
on_header_click: Optional[Callable[[ColSpec], None]] = None,
|
||||
):
|
||||
"""Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
|
||||
super().__init__(master)
|
||||
self.columns = columns
|
||||
self.on_header_click = on_header_click
|
||||
self._sort_key: Optional[str] = None
|
||||
self._sort_reverse = False
|
||||
self.total_w = sum(c.width for c in self.columns)
|
||||
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
@@ -243,6 +417,10 @@ class ScrollTable(ctk.CTkFrame):
|
||||
# bind
|
||||
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
|
||||
self.b_inner.bind("<Configure>", lambda e: self._on_body_configure())
|
||||
self.b_canvas.bind("<Enter>", self._bind_mousewheel)
|
||||
self.b_canvas.bind("<Leave>", self._unbind_mousewheel)
|
||||
self.b_inner.bind("<Enter>", self._bind_mousewheel)
|
||||
self.b_inner.bind("<Leave>", self._unbind_mousewheel)
|
||||
|
||||
self._build_header()
|
||||
|
||||
@@ -265,12 +443,27 @@ class ScrollTable(ctk.CTkFrame):
|
||||
holder.pack(side="left", fill="y")
|
||||
holder.pack_propagate(False)
|
||||
|
||||
lbl = ctk.CTkLabel(holder, text=col.title, anchor="w")
|
||||
header_text = col.title
|
||||
if col.key == self._sort_key:
|
||||
header_text = f"{col.title} {'↓' if self._sort_reverse else '↑'}"
|
||||
|
||||
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w")
|
||||
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
|
||||
|
||||
if self.on_header_click and col.key != "__check__":
|
||||
for widget in (holder, lbl):
|
||||
widget.bind("<Button-1>", lambda e, c=col: self.on_header_click(c))
|
||||
widget.configure(cursor="hand2")
|
||||
|
||||
self.h_inner.configure(width=self.total_w, height=ROW_H)
|
||||
self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
|
||||
|
||||
def set_sort_state(self, key: Optional[str], reverse: bool = False):
|
||||
"""Update the header labels so the active sort is visible."""
|
||||
self._sort_key = key
|
||||
self._sort_reverse = reverse
|
||||
self._build_header()
|
||||
|
||||
def _update_body_width(self):
|
||||
"""Keep the scroll region aligned with the current body content width."""
|
||||
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
|
||||
@@ -300,6 +493,22 @@ class ScrollTable(ctk.CTkFrame):
|
||||
self.h_canvas.xview_moveto(first)
|
||||
self.xbar.set(first, last)
|
||||
|
||||
def _bind_mousewheel(self, _event=None):
|
||||
"""Route mouse-wheel scrolling to the body canvas while the cursor is over the table."""
|
||||
self.b_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
|
||||
|
||||
def _unbind_mousewheel(self, _event=None):
|
||||
"""Stop routing global mouse-wheel events when the pointer leaves the table."""
|
||||
self.b_canvas.unbind_all("<MouseWheel>")
|
||||
|
||||
def _on_mousewheel(self, event):
|
||||
"""Scroll the body canvas vertically in response to wheel movement."""
|
||||
delta = getattr(event, "delta", 0)
|
||||
if delta == 0:
|
||||
return
|
||||
step = -1 if delta > 0 else 1
|
||||
self.b_canvas.yview_scroll(step, "units")
|
||||
|
||||
def clear_rows(self):
|
||||
"""Remove all rendered body rows."""
|
||||
for w in self.b_inner.winfo_children():
|
||||
@@ -369,18 +578,23 @@ class PLRow:
|
||||
|
||||
# -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
|
||||
class GestionePickingListFrame(ctk.CTkFrame):
|
||||
def __init__(self, master, *, db_client=None, conn_str=None):
|
||||
@_log_call()
|
||||
def __init__(self, master, *, db_client=None, conn_str=None, session: UserSession | None = None):
|
||||
"""Create the master/detail picking list frame."""
|
||||
super().__init__(master)
|
||||
if db_client is None:
|
||||
raise ValueError("GestionePickingListFrame richiede un db_client condiviso.")
|
||||
self.db_client = db_client
|
||||
self.session = session
|
||||
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
|
||||
self.busy = BusyOverlay(self) # overlay collaudato
|
||||
|
||||
self.rows_models: list[PLRow] = []
|
||||
self._detail_cache: Dict[Any, list] = {}
|
||||
self.detail_doc = None
|
||||
self._detail_sort_key: Optional[str] = None
|
||||
self._detail_sort_reverse = False
|
||||
self._detail_sorting = False
|
||||
|
||||
self._first_loading: bool = False # flag per cursore d'attesa solo al primo load
|
||||
self._render_job = None # Tracking del job di rendering in corso
|
||||
@@ -389,6 +603,16 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
|
||||
self.after_idle(self._first_show)
|
||||
|
||||
def _can(self, action: str) -> bool:
|
||||
"""Return whether the current user can execute one picking-list action."""
|
||||
|
||||
return self.session.can(action) if self.session else False
|
||||
|
||||
def _operator_id(self) -> int:
|
||||
"""Return the authenticated operator id or ``0`` if no session is present."""
|
||||
|
||||
return int(self.session.operator_id) if self.session else 0
|
||||
|
||||
def _first_show(self):
|
||||
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
|
||||
self._first_loading = True
|
||||
@@ -423,20 +647,171 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
self.pl_table = ScrollTable(self, PL_COLS)
|
||||
self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8))
|
||||
|
||||
self.det_table = ScrollTable(self, DET_COLS)
|
||||
self.det_table.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
|
||||
self.det_host = tk.Frame(self, bd=0, highlightthickness=0)
|
||||
self.det_host.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
|
||||
self.det_host.grid_rowconfigure(0, weight=1)
|
||||
self.det_host.grid_columnconfigure(0, weight=1)
|
||||
self._build_detail_sheet()
|
||||
|
||||
self._draw_details_hint()
|
||||
|
||||
def _build_detail_sheet(self):
|
||||
"""Create the high-volume detail table using tksheet."""
|
||||
if Sheet is None:
|
||||
raise RuntimeError("tksheet non disponibile: installa la dipendenza per usare la tabella dettagli.")
|
||||
|
||||
self.detail_sheet = Sheet(
|
||||
self.det_host,
|
||||
data=[],
|
||||
show_row_index=False,
|
||||
show_top_left=False,
|
||||
width=1000,
|
||||
height=320,
|
||||
sort_key=natural_sort_key,
|
||||
)
|
||||
self.detail_sheet.change_theme("light green")
|
||||
self.detail_sheet.enable_bindings("all")
|
||||
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
||||
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
|
||||
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
def _draw_details_hint(self):
|
||||
"""Render the placeholder row shown when no document is selected."""
|
||||
self.det_table.clear_rows()
|
||||
self.det_table.add_row(
|
||||
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
|
||||
row_index=0,
|
||||
anchors=["w"]*6
|
||||
self._load_detail_sheet_data(
|
||||
[["", "", "", "Seleziona una Picking List per vedere le UDC...", "", ""]]
|
||||
)
|
||||
|
||||
def _detail_headers(self) -> List[str]:
|
||||
"""Return detail headers with the active sort indicator, if any."""
|
||||
headers: List[str] = []
|
||||
for col in DET_COLS:
|
||||
title = col.title
|
||||
if col.key == self._detail_sort_key:
|
||||
title = f"{title} {'[desc]' if self._detail_sort_reverse else '[asc]'}"
|
||||
headers.append(title)
|
||||
return headers
|
||||
|
||||
def _detail_rows_to_sheet_data(self, rows: List[Dict[str, Any]]) -> List[List[str]]:
|
||||
"""Convert detail dictionaries to the row format expected by tksheet."""
|
||||
data: List[List[str]] = []
|
||||
for d in rows:
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip() == "") else str(ubi_raw).strip()
|
||||
data.append([pallet, lotto, articolo, descr, qta, loc])
|
||||
return data
|
||||
|
||||
def _load_detail_sheet_data(self, data: List[List[str]]):
|
||||
"""Push one full dataset into the tksheet detail widget."""
|
||||
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
||||
self.detail_sheet.set_sheet_data(
|
||||
data,
|
||||
reset_col_positions=True,
|
||||
reset_row_positions=True,
|
||||
redraw=True,
|
||||
)
|
||||
self.detail_sheet.set_all_column_widths()
|
||||
|
||||
def _detail_sort_value(self, row: Dict[str, Any], key: str):
|
||||
"""Return a normalized value used to sort detail rows by one logical column."""
|
||||
if key == "Ubicazione":
|
||||
value = _first(row, ["Ubicazione", "Cella", "PalletCella"])
|
||||
value = "Non scaffalata" if value in (None, "") else value
|
||||
elif key == "Qta":
|
||||
value = _first(row, ["Qta", "Quantita", "Qty", "QTY"], 0)
|
||||
try:
|
||||
return (0, float(value))
|
||||
except Exception:
|
||||
return (1, _s(value).lower())
|
||||
elif key == "Articolo":
|
||||
value = _first(row, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"])
|
||||
elif key == "Descrizione":
|
||||
value = _first(row, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"])
|
||||
elif key == "Pallet":
|
||||
value = _first(row, ["Pallet", "UDC", "PalletID"])
|
||||
else:
|
||||
value = row.get(key)
|
||||
|
||||
return (0, _s(value).lower())
|
||||
|
||||
def _sort_detail_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return detail rows sorted using the current header state."""
|
||||
if not self._detail_sort_key:
|
||||
return rows
|
||||
|
||||
return sorted(
|
||||
rows,
|
||||
key=lambda row: self._detail_sort_value(row, self._detail_sort_key),
|
||||
reverse=self._detail_sort_reverse,
|
||||
)
|
||||
|
||||
def _finish_detail_sort_feedback(self, root: tk.Misc):
|
||||
"""Dismiss busy feedback only after Tk has flushed the detail redraw."""
|
||||
try:
|
||||
root.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._detail_sorting = False
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
try:
|
||||
self.detail_sheet.configure(cursor="")
|
||||
root.configure(cursor="")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_detail_header_click(self, col: ColSpec):
|
||||
"""Toggle detail sorting when the user clicks a detail header."""
|
||||
if not self.detail_doc or self._detail_sorting:
|
||||
return
|
||||
|
||||
if self._detail_sort_key == col.key:
|
||||
self._detail_sort_reverse = not self._detail_sort_reverse
|
||||
else:
|
||||
self._detail_sort_key = col.key
|
||||
self._detail_sort_reverse = False
|
||||
|
||||
self._detail_sorting = True
|
||||
self.spinner.start(" Ordino dettagli...")
|
||||
self.busy.show(f"Ordinamento per {col.title}...")
|
||||
root = self.winfo_toplevel()
|
||||
try:
|
||||
root.configure(cursor="watch")
|
||||
self.detail_sheet.configure(cursor="watch")
|
||||
root.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_sort():
|
||||
try:
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
self._refresh_details()
|
||||
finally:
|
||||
# Wait one more UI turn so the redraw becomes visible before removing feedback.
|
||||
self.after_idle(lambda r=root: self.after(15, lambda: self._finish_detail_sort_feedback(r)))
|
||||
|
||||
self.after(25, _apply_sort)
|
||||
|
||||
def _on_detail_sheet_left_click(self, event):
|
||||
"""Sort detail rows when the user clicks a tksheet header cell."""
|
||||
try:
|
||||
region = self.detail_sheet.identify_region(event)
|
||||
column = self.detail_sheet.identify_column(event, exclude_header=False, allow_end=False)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if region != "header" or column is None or column < 0 or column >= len(DET_COLS):
|
||||
return
|
||||
|
||||
self._on_detail_header_click(DET_COLS[column])
|
||||
|
||||
def _apply_row_colors(self, rows: List[Dict[str, Any]]):
|
||||
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
|
||||
try:
|
||||
@@ -517,6 +892,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
break
|
||||
|
||||
# ----- eventi -----
|
||||
@_log_call()
|
||||
def on_row_checked(self, model: PLRow, is_checked: bool):
|
||||
"""Handle row selection changes and refresh the detail section."""
|
||||
# selezione esclusiva
|
||||
@@ -526,18 +902,23 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
m.set_checked(False)
|
||||
|
||||
self.detail_doc = model.pl.get("Documento")
|
||||
_MODULE_LOGGER.info(f"Documento selezionato per il dettaglio: {self.detail_doc}")
|
||||
self.spinner.start(" Carico dettagli…") # spinner ON
|
||||
|
||||
async def _job():
|
||||
_log_sql("SQL_PL_DETAILS", SQL_PL_DETAILS, {"Documento": self.detail_doc})
|
||||
return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
|
||||
|
||||
def _ok(res):
|
||||
# NON fermare lo spinner subito: lo farà _refresh_details_incremental
|
||||
self._detail_cache[self.detail_doc] = _rows_to_dicts(res)
|
||||
rows = _expand_detail_rows_for_test(_rows_to_dicts(res))
|
||||
_log_dataset("SQL_PL_DETAILS", rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
# Avvia il rendering incrementale che mantiene l'overlay attivo
|
||||
self._refresh_details_incremental()
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore durante il caricamento dettagli del documento {self.detail_doc}: {ex}")
|
||||
self.spinner.stop()
|
||||
self.busy.hide() # Chiudi l'overlay in caso di errore
|
||||
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
|
||||
@@ -555,16 +936,20 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
else:
|
||||
if not any(m.is_checked() for m in self.rows_models):
|
||||
self.detail_doc = None
|
||||
_MODULE_LOGGER.info("Nessun documento selezionato: ripristino placeholder del dettaglio.")
|
||||
self._refresh_details()
|
||||
|
||||
# ----- load PL -----
|
||||
@_log_call()
|
||||
def reload_from_db(self, first: bool = False):
|
||||
"""Load or reload the picking list summary table from the database."""
|
||||
self.spinner.start(" Carico…") # spinner ON
|
||||
async def _job():
|
||||
_log_sql("SQL_PL", SQL_PL, {})
|
||||
return await self.db_client.query_json(SQL_PL, {})
|
||||
def _on_success(res):
|
||||
rows = _rows_to_dicts(res)
|
||||
_log_dataset("SQL_PL", rows)
|
||||
self._refresh_mid_rows(rows)
|
||||
self.spinner.stop() # spinner OFF
|
||||
# se era il primo load, ripristina il cursore standard
|
||||
@@ -575,6 +960,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
pass
|
||||
self._first_loading = False
|
||||
def _on_error(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore durante il caricamento della picking list: {ex}")
|
||||
self.spinner.stop()
|
||||
if self._first_loading:
|
||||
try:
|
||||
@@ -592,102 +978,64 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
message="Caricamento Picking List…" if first else "Aggiornamento…"
|
||||
)
|
||||
|
||||
@_log_call("DEBUG")
|
||||
def _refresh_details(self):
|
||||
"""Render the detail table for the currently selected document."""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
return
|
||||
|
||||
rows = self._detail_cache.get(self.detail_doc, [])
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
_MODULE_LOGGER.debug(f"Ridisegno tabella dettaglio per documento={self.detail_doc} righe={len(rows)}")
|
||||
if not rows:
|
||||
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
||||
row_index=0, anchors=["w"]*6)
|
||||
self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
|
||||
return
|
||||
|
||||
for r, d in enumerate(rows):
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
|
||||
|
||||
self.det_table.add_row(
|
||||
values=[pallet, lotto, articolo, descr, qta, loc],
|
||||
row_index=r,
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
|
||||
|
||||
@_log_call("DEBUG")
|
||||
def _refresh_details_incremental(self, batch_size: int = 25):
|
||||
"""
|
||||
Render detail table incrementally in batches to keep UI responsive.
|
||||
Mantiene l'overlay visibile fino al completamento del rendering.
|
||||
Render detail table using tksheet while keeping busy feedback consistent.
|
||||
"""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
rows = self._detail_cache.get(self.detail_doc, [])
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Avvio rendering dettagli documento={self.detail_doc} righe={len(rows)}")
|
||||
if not rows:
|
||||
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
||||
row_index=0, anchors=["w"]*6)
|
||||
self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
# Inizia il rendering incrementale
|
||||
total_rows = len(rows)
|
||||
self.busy.show(f"Rendering {len(rows)} UDC...")
|
||||
self._render_batch(rows, batch_size, 0, total_rows)
|
||||
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Rendering dettagli completato documento={self.detail_doc} righe={len(rows)}")
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
|
||||
@_log_call("DEBUG")
|
||||
def _render_batch(self, rows: List[Dict[str, Any]], batch_size: int, start_idx: int, total_rows: int):
|
||||
"""
|
||||
Render a batch of rows and schedule the next batch.
|
||||
Mantiene lo spinner attivo fino all'ultimo batch.
|
||||
Legacy helper kept for compatibility after the move to tksheet.
|
||||
"""
|
||||
end_idx = min(start_idx + batch_size, total_rows)
|
||||
|
||||
# Aggiorna lo spinner con il progresso
|
||||
progress_pct = int((end_idx / total_rows) * 100)
|
||||
self.spinner.lbl.configure(text=f"◐ Rendering {progress_pct}%")
|
||||
|
||||
# Aggiorna anche il messaggio dell'overlay
|
||||
self.busy.show(f"Rendering {progress_pct}% ({end_idx}/{total_rows} UDC)...")
|
||||
|
||||
# Renderizza il batch corrente
|
||||
for r in range(start_idx, end_idx):
|
||||
d = rows[r]
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
|
||||
|
||||
self.det_table.add_row(
|
||||
values=[pallet, lotto, articolo, descr, qta, loc],
|
||||
row_index=r,
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
|
||||
# Se ci sono ancora righe da renderizzare, schedula il prossimo batch
|
||||
if end_idx < total_rows:
|
||||
# Lascia respirare Tk tra i batch (10ms)
|
||||
self.after(10, lambda: self._render_batch(rows, batch_size, end_idx, total_rows))
|
||||
else:
|
||||
# Ultimo batch completato: ferma lo spinner e chiudi l'overlay
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
del batch_size, start_idx, total_rows
|
||||
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
|
||||
|
||||
# ----- azioni -----
|
||||
@_log_call()
|
||||
def on_prenota(self):
|
||||
"""Reserve the selected picking list."""
|
||||
if not self._can("pickinglist.prenota"):
|
||||
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' prenotare picking list.", parent=self)
|
||||
return
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
|
||||
@@ -700,22 +1048,51 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
|
||||
return
|
||||
|
||||
id_operatore = 1 # TODO: recupera dal contesto reale
|
||||
id_operatore = self._operator_id()
|
||||
if id_operatore <= 0:
|
||||
messagebox.showerror("Prenota", "Sessione operatore non valida.", parent=self)
|
||||
return
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta prenotazione documento={documento} id_operatore={id_operatore}")
|
||||
self.spinner.start(" Prenoto…")
|
||||
|
||||
async def _job():
|
||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
||||
|
||||
def _ok(res: SPResult):
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
|
||||
self.spinner.stop()
|
||||
if res and res.rc == 0:
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="ok",
|
||||
target=documento,
|
||||
)
|
||||
self._recolor_row_by_documento(documento, desired)
|
||||
else:
|
||||
msg = (res.message if res else "Errore sconosciuto")
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="denied",
|
||||
target=documento,
|
||||
details={"message": msg},
|
||||
)
|
||||
messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore prenotazione documento={documento}: {ex}")
|
||||
self.spinner.stop()
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="error",
|
||||
target=documento,
|
||||
details={"error": str(ex)},
|
||||
)
|
||||
messagebox.showerror("Prenota", f"Errore:\n{ex}")
|
||||
|
||||
self.runner.run(
|
||||
@@ -726,8 +1103,12 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
message=f"Prenoto la Picking List {documento}…"
|
||||
)
|
||||
|
||||
@_log_call()
|
||||
def on_sprenota(self):
|
||||
"""Unreserve the selected picking list."""
|
||||
if not self._can("pickinglist.sprenota"):
|
||||
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' s-prenotare picking list.", parent=self)
|
||||
return
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
|
||||
@@ -740,22 +1121,51 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
|
||||
return
|
||||
|
||||
id_operatore = 1 # TODO: recupera dal contesto reale
|
||||
id_operatore = self._operator_id()
|
||||
if id_operatore <= 0:
|
||||
messagebox.showerror("S-prenota", "Sessione operatore non valida.", parent=self)
|
||||
return
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta s-prenotazione documento={documento} id_operatore={id_operatore}")
|
||||
self.spinner.start(" S-prenoto…")
|
||||
|
||||
async def _job():
|
||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
||||
|
||||
def _ok(res: SPResult):
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito s-prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
|
||||
self.spinner.stop()
|
||||
if res and res.rc == 0:
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="ok",
|
||||
target=documento,
|
||||
)
|
||||
self._recolor_row_by_documento(documento, desired)
|
||||
else:
|
||||
msg = (res.message if res else "Errore sconosciuto")
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="denied",
|
||||
target=documento,
|
||||
details={"message": msg},
|
||||
)
|
||||
messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore s-prenotazione documento={documento}: {ex}")
|
||||
self.spinner.stop()
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="error",
|
||||
target=documento,
|
||||
details={"error": str(ex)},
|
||||
)
|
||||
messagebox.showerror("S-prenota", f"Errore:\n{ex}")
|
||||
|
||||
self.runner.run(
|
||||
@@ -772,10 +1182,82 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
|
||||
# factory per main
|
||||
def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame':
|
||||
@_log_call()
|
||||
def create_frame(parent, *, db_client=None, conn_str=None, session: UserSession | None = None) -> 'GestionePickingListFrame':
|
||||
"""Factory used by the launcher to build the picking list frame."""
|
||||
ctk.set_appearance_mode("light")
|
||||
ctk.set_default_color_theme("green")
|
||||
return GestionePickingListFrame(parent, db_client=db_client)
|
||||
return GestionePickingListFrame(parent, db_client=db_client, session=session)
|
||||
|
||||
|
||||
@_log_call()
|
||||
def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | None = None) -> tk.Misc:
|
||||
"""Open the picking list window while minimizing the first paint flicker."""
|
||||
key = "_gestione_pickinglist_window_singleton"
|
||||
ex = getattr(parent, key, None)
|
||||
if ex and ex.winfo_exists():
|
||||
frame = getattr(ex, "_pickinglist_frame", None)
|
||||
if frame is not None:
|
||||
try:
|
||||
frame.session = session
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.lift()
|
||||
ex.focus_force()
|
||||
return ex
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
win = ctk.CTkToplevel(parent)
|
||||
win.title("Gestione Picking List")
|
||||
win.geometry("1200x700+0+100")
|
||||
win.minsize(1000, 560)
|
||||
setattr(parent, key, win)
|
||||
|
||||
# Keep the toplevel hidden until the child frame has built its initial layout.
|
||||
try:
|
||||
win.withdraw()
|
||||
win.attributes("-alpha", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frame = create_frame(win, db_client=db_client, session=session)
|
||||
try:
|
||||
frame.pack(fill="both", expand=True)
|
||||
except Exception:
|
||||
pass
|
||||
setattr(win, "_pickinglist_frame", frame)
|
||||
|
||||
# Reveal the fully-laid out window only after pending geometry work completes.
|
||||
try:
|
||||
win.update_idletasks()
|
||||
try:
|
||||
win.transient(parent)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
win.lift()
|
||||
try:
|
||||
win.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.attributes("-alpha", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
win.bind("<Escape>", lambda e: win.destroy())
|
||||
win.protocol("WM_DELETE_WINDOW", win.destroy)
|
||||
return win
|
||||
|
||||
# =================== /gestione_pickinglist.py ===================
|
||||
|
||||
Reference in New Issue
Block a user