"""Modal dialog used to unload one or more UDCs from a multi-occupancy cell.""" from __future__ import annotations import json import logging import sys import tkinter as tk from dataclasses import dataclass from datetime import datetime from functools import wraps from pathlib import Path from typing import Any, Callable import customtkinter as ctk from tkinter import messagebox, ttk from gestione_aree import AsyncRunner from audit_log import log_user_action from busy_overlay import InlineBusyOverlay from locale_text import load_locale_catalog, text as loc_text from ui_theme import theme_color, theme_font, theme_section, theme_value from user_session import UserSession 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() SCARICO_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" MODULE_LOG_NAME = Path(__file__).stem MODULE_LOG_PATH = Path(__file__).with_suffix(".log") _MODULE_LOG_ENABLED = SCARICO_LOG_MODE.upper() != "OFF" _MODULE_LOG_LEVEL = "DEBUG" if SCARICO_LOG_MODE.upper() == "DEBUG" else "INFO" _MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) _MODULE_LOGGING_CONFIGURED = False DEFAULT_SCARICO_USER = "warehouse_ui" def _session_login(session: UserSession | None, fallback: str | None = None) -> str: """Return the current application login, falling back to a technical user.""" if session and str(session.login or "").strip(): return str(session.login).strip() return str((fallback or DEFAULT_SCARICO_USER) or DEFAULT_SCARICO_USER).strip() def _configure_module_logger(): """Configure console and file logging for this module.""" global _MODULE_LOGGING_CONFIGURED if _MODULE_LOGGING_CONFIGURED: return if not _MODULE_LOG_ENABLED: _MODULE_LOGGING_CONFIGURED = True return record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME logger.add( sys.stderr, level=_MODULE_LOG_LEVEL, colorize=True, filter=record_filter, format=( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " "{level: <8} | " "" + MODULE_LOG_NAME + " | " "{message}" ), ) logger.add( MODULE_LOG_PATH, level=_MODULE_LOG_LEVEL, colorize=False, encoding="utf-8", filter=record_filter, format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", ) _MODULE_LOGGING_CONFIGURED = True def _format_payload(payload: Any) -> str: """Serialize payloads for human-readable logging.""" try: return json.dumps(payload, ensure_ascii=False, indent=2, default=str) except Exception: return repr(payload) def _log_call(level: str | None = None): """Trace entry, exit and failure of selected high-level functions.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): effective_level = level or _MODULE_LOG_LEVEL _MODULE_LOGGER.log( effective_level, f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", ) try: result = func(*args, **kwargs) except Exception: _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") raise _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") return result return wrapper return decorator def _log_sql(query_name: str, sql: str, params: dict[str, Any]): """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[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 SCARICO_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={SCARICO_LOG_MODE.upper()}" ) SQL_UDC_IN_CELLA = """ WITH cell_pallets AS ( SELECT DISTINCT g.BarcodePallet FROM dbo.XMag_GiacenzaPallet g WHERE g.IDCella = :idcella ), last_in_cell AS ( SELECT mp.Attributo AS BarcodePallet, MAX(mp.ID) AS LastID FROM dbo.MagazziniPallet mp JOIN cell_pallets cp ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = mp.Attributo COLLATE Latin1_General_CI_AS WHERE mp.IDCella = :idcella AND mp.Tipo = 'V' GROUP BY mp.Attributo ), last_move AS ( SELECT mp.Attributo AS BarcodePallet, mp.ID, mp.DataMagazzino FROM dbo.MagazziniPallet mp JOIN last_in_cell lic ON lic.LastID = mp.ID ), 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 cell_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 cell_pallets cp ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = shipped.BarcodePallet COLLATE Latin1_General_CI_AS ) SELECT cp.BarcodePallet AS UDC, lm.ID AS SourceID, lm.DataMagazzino AS LastEventAt, 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 <> :idcella THEN CAST(1 AS int) ELSE CAST(0 AS int) END AS IsMovedGhost FROM cell_pallets cp LEFT JOIN last_move lm ON lm.BarcodePallet COLLATE Latin1_General_CI_AS = cp.BarcodePallet COLLATE Latin1_General_CI_AS 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 ORDER BY lm.ID DESC, cp.BarcodePallet DESC; """ SQL_SCARICA_UDC = """ SET NOCOUNT ON; DECLARE @Now datetime = GETDATE(); DECLARE @SourceID int = 0; DECLARE @NumeroPallet int = 0; DECLARE @PesoUnitario float = 1; DECLARE @Tara float = 0; DECLARE @SourceIDCella int = 0; SELECT TOP (1) @SourceID = src.ID, @NumeroPallet = ISNULL(src.NumeroPallet, 0), @PesoUnitario = ISNULL(NULLIF(src.PesoUnitario, 0), 1), @Tara = ISNULL(src.Tara, 0), @SourceIDCella = ISNULL(src.IDCella, 0) FROM dbo.MagazziniPallet src WHERE src.Attributo = :barcode_pallet AND src.Tipo = 'V' AND src.PesoUnitario > 0 ORDER BY src.ID DESC; IF @SourceID > 0 BEGIN UPDATE dbo.MagazziniPallet SET ModUtente = :utente, ModDataOra = @Now WHERE ID = @SourceID; INSERT INTO dbo.MagazziniPallet ( Tipo, IDRiferimento, NumeroPallet, Attributo, IDMagazzino, IDArea, IDCella, DataMagazzino, PesoUnitario, Tara, InsUtente, InsDataOra ) SELECT 'P', @SourceID, ISNULL(src.NumeroPallet, 0), src.Attributo, src.IDMagazzino, src.IDArea, src.IDCella, @Now, ISNULL(NULLIF(src.PesoUnitario, 0), 1), ISNULL(src.Tara, 0), :utente, @Now FROM dbo.MagazziniPallet src WHERE src.ID = @SourceID; UPDATE dbo.Celle SET IDStato = 0, ModUtente = :utente, ModDataOra = @Now WHERE ID = @SourceIDCella; END; INSERT INTO dbo.MagazziniPallet ( Tipo, IDRiferimento, NumeroPallet, Attributo, IDMagazzino, IDArea, IDCella, DataMagazzino, PesoUnitario, Tara, InsUtente, InsDataOra ) SELECT 'V', @SourceID, @NumeroPallet, :barcode_pallet, target.IDMagazzino, target.IDArea, target.ID, @Now, @PesoUnitario, @Tara, :utente, @Now FROM ( SELECT c.ID, c.IDArea, a.IDMagazzino FROM dbo.Celle c JOIN dbo.Aree a ON a.ID = c.IDArea WHERE c.ID = :target_idcella ) AS target; EXEC dbo.sp_ControllaPrenotazionePackingListPalletNew; SELECT CAST(1 AS int) AS Ok, @SourceID AS SourceID, :target_idcella AS TargetIDCella, :target_barcode_cella AS TargetBarcodeCella; """ async def move_pallet_async( db_client, *, barcode_pallet: str, target_idcella: int, target_barcode_cella: str, utente: str | None = None, ) -> dict[str, Any]: """Move one pallet to a target cell using the same movement semantics as the legacy app. The original C# application delegates load, transfer and unload to ``sp_xMagGestioneMagazziniPallet``. The Python app mirrors the same behavior with an explicit SQL batch that: 1. finds the latest positive row for the pallet, 2. registers a compensating ``P`` move on the source cell, 3. frees the previous cell reservation, 4. inserts a new ``V`` move on the target cell, 5. re-runs the packing-list reservation check. """ params = { "barcode_pallet": str(barcode_pallet or "").strip(), "target_idcella": int(target_idcella), "target_barcode_cella": str(target_barcode_cella or "").strip(), "utente": str((utente or DEFAULT_SCARICO_USER) or "warehouse_ui").strip(), } _log_sql("move_pallet", SQL_SCARICA_UDC, params) res = await db_client.query_json(SQL_SCARICA_UDC, params, commit=True) rows = res.get("rows", []) if isinstance(res, dict) else [] _log_dataset("move_pallet", rows) first = rows[0] if rows else [1, 0, params["target_idcella"], params["target_barcode_cella"]] return { "ok": int(first[0] or 0), "source_id": int(first[1] or 0), "target_idcella": int(first[2] or 0), "target_barcode_cella": str(first[3] or ""), "barcode_pallet": params["barcode_pallet"], } @dataclass class ScaricoRow: """View-model describing one unloadable UDC currently present in a cell.""" udc: str source_id: int | None last_event_at: str diagnostic_note: str selected: tk.BooleanVar def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str: """Translate low-level anomaly flags into one operator-facing note.""" notes: list[str] = [] if bool(is_shipped): notes.append("Mancato scarico: spedita") if bool(is_moved): notes.append("Mancato scarico: spostata") return " | ".join(notes) class ScaricoDialog(ctk.CTkToplevel): """Modal dialog that allows unloading selected UDCs from one cell.""" CHECKBOX_COL_W = 56 UDC_COL_W = 130 DATE_COL_W = 180 DIAG_COL_W = 320 @_log_call() def __init__( self, parent: tk.Misc, *, db_client, idcella: int, ubicazione: str, on_completed: Callable[[], None] | None = None, session: UserSession | None = None, ): super().__init__(parent) self.parent = parent self.db_client = db_client self.idcella = idcella self.ubicazione = ubicazione self.on_completed = on_completed self.session = session self.rows: list[ScaricoRow] = [] self._theme = theme_section("scarico_dialog", {}) self._locale_catalog = load_locale_catalog() self._busy = InlineBusyOverlay(self, self._theme) self._async = AsyncRunner(self) self.rows_tree: ttk.Treeview | None = None self.title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione)) self.resizable(False, False) self.transient(parent) self.protocol("WM_DELETE_WINDOW", self._close) self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) self._build_ui() self._load_rows() self.update_idletasks() self.grab_set() try: self.wait_visibility() except Exception: pass self.lift() self.focus_force() def _build_ui(self): """Build the compact modal layout.""" top = ctk.CTkFrame(self) top.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 6)) top.grid_columnconfigure(0, weight=1) ctk.CTkLabel( top, text=loc_text("scarico.label.location", catalog=self._locale_catalog, default="Ubicazione: {ubicazione}").format(ubicazione=self.ubicazione), font=theme_font(self._theme, "header_font", ("Segoe UI", 13, "bold")), ).grid( row=0, column=0, sticky="w" ) ctk.CTkLabel( top, text=loc_text("scarico.label.select", catalog=self._locale_catalog, default="Seleziona le UDC da scaricare"), anchor="w", font=theme_font(self._theme, "body_font", ("Segoe UI", 10)), ).grid( row=1, column=0, sticky="w", pady=(4, 0) ) table = ctk.CTkFrame(self) table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(0, 8)) table.grid_rowconfigure(0, weight=1) table.grid_columnconfigure(0, weight=1) tree_host = tk.Frame(table, bd=0, highlightthickness=0, background="#DBDBDB") tree_host.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 8)) tree_host.grid_rowconfigure(0, weight=1) tree_host.grid_columnconfigure(0, weight=1) style = ttk.Style(self) style.configure("Scarico.Treeview", rowheight=28, font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10))) style.configure("Scarico.Treeview.Heading", font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold"))) self.rows_tree = ttk.Treeview( tree_host, columns=("sel", "udc", "last", "diag"), show="headings", style="Scarico.Treeview", selectmode="none", ) self.rows_tree.heading("sel", text="Sel") self.rows_tree.heading("udc", text=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC")) self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento")) self.rows_tree.heading("diag", text=loc_text("scarico.col.diagnostic", catalog=self._locale_catalog, default="Diagnostica")) self.rows_tree.column("sel", width=self.CHECKBOX_COL_W, stretch=False, anchor="center") self.rows_tree.column("udc", width=self.UDC_COL_W, stretch=False, anchor="w") self.rows_tree.column("last", width=self.DATE_COL_W, stretch=False, anchor="w") self.rows_tree.column("diag", width=self.DIAG_COL_W, stretch=True, anchor="w") self.rows_tree.grid(row=0, column=0, sticky="nsew") self.rows_tree.bind("", self._on_tree_click, add="+") tree_scroll = ttk.Scrollbar(tree_host, orient="vertical", command=self.rows_tree.yview) tree_scroll.grid(row=0, column=1, sticky="ns") self.rows_tree.configure(yscrollcommand=tree_scroll.set) actions = ctk.CTkFrame(self) actions.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 10)) actions.grid_columnconfigure(0, weight=1) ctk.CTkButton( actions, text=loc_text("scarico.button.submit", catalog=self._locale_catalog, default="Scarica"), command=self._on_scarica, font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")), ).grid( row=0, column=1, padx=(8, 0), pady=8 ) ctk.CTkButton( actions, text=loc_text("scarico.button.close", catalog=self._locale_catalog, default="Chiudi"), command=self._close, font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")), ).grid( row=0, column=2, padx=(8, 8), pady=8 ) def _render_rows(self): """Recreate the compact list of unloadable UDC rows.""" if self.rows_tree is None: return for item in self.rows_tree.get_children(): self.rows_tree.delete(item) for idx, row in enumerate(self.rows): self.rows_tree.insert( "", "end", iid=str(idx), values=( "[x]" if row.selected.get() else "[ ]", row.udc, row.last_event_at, row.diagnostic_note or "", ), ) self.update_idletasks() width = max(900, min(1040, self.winfo_reqwidth() + 20)) total_height = max(210, min(460, self.winfo_reqheight() + 8)) self.geometry(f"{width}x{total_height}") def _on_tree_click(self, event): """Toggle the pseudo-checkbox when the operator clicks the first column.""" if self.rows_tree is None: return region = self.rows_tree.identify("region", event.x, event.y) if region != "cell": return column = self.rows_tree.identify_column(event.x) item_id = self.rows_tree.identify_row(event.y) if column != "#1" or not item_id: return try: row = self.rows[int(item_id)] except Exception: return row.selected.set(not row.selected.get()) self.rows_tree.set(item_id, "sel", "[x]" if row.selected.get() else "[ ]") return "break" @_log_call() def _load_rows(self): """Load the list of current UDCs ordered from newest to oldest.""" params = {"idcella": self.idcella} _log_sql("scarico_load_rows", SQL_UDC_IN_CELLA, params) def _ok(res): rows = res.get("rows", []) if isinstance(res, dict) else [] _log_dataset("scarico_load_rows", rows) self.rows = [] for udc, source_id, last_event_at, is_shipped, is_moved in rows: if isinstance(last_event_at, datetime): last_event = last_event_at.strftime("%d/%m/%Y %H:%M:%S") else: last_event = str(last_event_at or "") self.rows.append( ScaricoRow( udc=str(udc or ""), source_id=int(source_id) if source_id is not None else None, last_event_at=last_event, diagnostic_note=_build_diagnostic_note(is_shipped, is_moved), selected=tk.BooleanVar(value=False), ) ) self._render_rows() self._busy.hide() def _err(ex): self._busy.hide() _MODULE_LOGGER.exception(f"Errore caricamento righe scarico idcella={self.idcella}: {ex}") messagebox.showerror( loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"), loc_text("scarico.msg.load_error", catalog=self._locale_catalog, default="Caricamento UDC fallito:\n{error}").format(error=ex), parent=self, ) self._close() self._async.run( self.db_client.query_json(SQL_UDC_IN_CELLA, params), _ok, _err, busy=self._busy, message="Carico UDC...", ) @_log_call() def _on_scarica(self): """Unload the UDCs selected by the user from the current cell.""" selected = [row for row in self.rows if row.selected.get()] if not selected: messagebox.showinfo( loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"), loc_text("scarico.msg.select_one", catalog=self._locale_catalog, default="Seleziona almeno una UDC da scaricare."), parent=self, ) return if not messagebox.askyesno( "Conferma scarico", f"Scaricare {len(selected)} UDC da {self.ubicazione}?", parent=self, ): return async def _job(): results: list[dict[str, Any]] = [] for row in selected: result = await move_pallet_async( self.db_client, barcode_pallet=row.udc, target_idcella=9999, target_barcode_cella="9000000", utente=_session_login(self.session), ) results.append({"udc": row.udc, "affected": int(result.get("ok") or 0)}) return results def _ok(results): _log_dataset("scarica_udc", results) done = [item["udc"] for item in results if int(item.get("affected") or 0) > 0] skipped = [item["udc"] for item in results if int(item.get("affected") or 0) <= 0] if not done: messagebox.showwarning( "Scarica", "Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.", parent=self, ) return if skipped: messagebox.showwarning( "Scarica", "Scarico parziale.\nCompletate: " + ", ".join(done) + "\nNon scaricate: " + ", ".join(skipped), parent=self, ) else: messagebox.showinfo( "Scarica", "Scarico completato per:\n" + "\n".join(done), parent=self, ) log_user_action( self.session, module=MODULE_LOG_NAME, action="layout.scarico", outcome="ok", target=self.ubicazione, details={"scaricate": done, "saltate": skipped}, ) if self.on_completed: self.on_completed() self._close() def _err(ex): _MODULE_LOGGER.exception(f"Errore scarico idcella={self.idcella}: {ex}") log_user_action( self.session, module=MODULE_LOG_NAME, action="layout.scarico", outcome="error", target=self.ubicazione, details={"error": str(ex)}, ) messagebox.showerror( loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"), loc_text("scarico.msg.exec_error", catalog=self._locale_catalog, default="Scarico fallito:\n{error}").format(error=ex), parent=self, ) self._async.run( _job(), _ok, _err, busy=self._busy, message="Scarico UDC...", ) @_log_call() def _close(self): """Release the modal grab and close the dialog safely.""" try: self.grab_release() except Exception: pass try: self.destroy() except Exception: pass @_log_call() def open_scarico_dialog( parent: tk.Misc, *, db_client, idcella: int, ubicazione: str, on_completed: Callable[[], None] | None = None, session: UserSession | None = None, ) -> ScaricoDialog: """Create and return the modal unload dialog for one multi-UDC cell.""" return ScaricoDialog( parent, db_client=db_client, idcella=idcella, ubicazione=ubicazione, on_completed=on_completed, session=session, )