"""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 ui_tables import style_treeview, zebra_tag from user_session import UserSession from version_info import module_version, versioned_title __version__ = module_version(__name__) 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" SHIPPED_IDCELLA = 9999 SHIPPED_BARCODE = "9000000" SHIPPED_LABEL = "7G.1.1" NON_SCAFF_IDCELLA = 1000 NON_SCAFF_BARCODE = "9001000" NON_SCAFF_LABEL = "5E1.1" 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 ), closed_plist 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, :idcella AS CurrentIDCella, CASE WHEN :idcella <> 9999 AND closed_plist.BarcodePallet IS NOT NULL THEN CAST(1 AS int) ELSE CAST(0 AS int) END AS IsMissingUnload 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 closed_plist ON closed_plist.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.py_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 current_idcella: int diagnostic_note: str selected: tk.BooleanVar def _build_diagnostic_note(is_missing_unload: int | bool = False) -> str: """Translate low-level anomaly flags into one operator-facing note.""" if bool(is_missing_unload): return "Motivo: spedita ma non scaricata dalla cella" return "" def _is_missing_unload_row(row: ScaricoRow) -> bool: """Return True when the row must be corrected only with shipped unload.""" return "spedita ma non scaricata dalla cella" in str(row.diagnostic_note or "").lower() 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(versioned_title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione), __name__)) 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 if self.winfo_exists(): 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) self.rows_tree = ttk.Treeview( tree_host, columns=("sel", "udc", "last", "diag"), show="headings", style="Scarico.Treeview", selectmode="none", ) style_treeview( self.rows_tree, style_name="Scarico.Treeview", rowheight=28, font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)), heading_font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")), ) 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.shipped", catalog=self._locale_catalog, default="Scarico come spedita"), command=self._on_scarica_spedita, 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.non_shelved", catalog=self._locale_catalog, default="Scarico come non scaff."), command=self._on_scarica_non_scaff, font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")), ).grid( row=0, column=2, 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=3, 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 "", ), tags=(zebra_tag(idx),), ) 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, current_idcella, is_missing_unload, ) 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, current_idcella=int(current_idcella or 0), diagnostic_note=_build_diagnostic_note(is_missing_unload), 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 _selected_rows_or_warn(self) -> list[ScaricoRow] | None: """Return selected rows or show the standard selection warning.""" 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 selected @_log_call() def _on_scarica_spedita(self): """Unload selected UDCs to the shipped conventional location.""" self._scarica_selected_to_target( target_idcella=SHIPPED_IDCELLA, target_barcode_cella=SHIPPED_BARCODE, target_label=SHIPPED_LABEL, title="Scarico come spedita", confirm_text="Scaricare come spedite {count} UDC da {ubicazione}?", busy_message="Scarico UDC come spedite...", block_shipped_for_non_scaff=False, ) @_log_call() def _on_scarica_non_scaff(self): """Unload selected UDCs to the non-shelved conventional location.""" self._scarica_selected_to_target( target_idcella=NON_SCAFF_IDCELLA, target_barcode_cella=NON_SCAFF_BARCODE, target_label=NON_SCAFF_LABEL, title="Scarico come non scaff.", confirm_text="Scaricare come non scaffalate {count} UDC da {ubicazione}?", busy_message="Scarico UDC come non scaffalate...", block_shipped_for_non_scaff=True, ) def _scarica_selected_to_target( self, *, target_idcella: int, target_barcode_cella: str, target_label: str, title: str, confirm_text: str, busy_message: str, block_shipped_for_non_scaff: bool, ): """Move selected UDCs to one conventional unload location.""" selected = self._selected_rows_or_warn() if not selected: return shipped_rows: list[ScaricoRow] = [] missing_unload_rows: list[ScaricoRow] = [] movable_rows = list(selected) if block_shipped_for_non_scaff: shipped_rows = [ row for row in selected if int(row.current_idcella or 0) == SHIPPED_IDCELLA ] missing_unload_rows = [row for row in selected if _is_missing_unload_row(row)] blocked_udcs = {row.udc for row in shipped_rows + missing_unload_rows} movable_rows = [row for row in selected if row.udc not in blocked_udcs] if (shipped_rows or missing_unload_rows) and not movable_rows: message_lines = ["Nessuna UDC movimentata."] if shipped_rows: message_lines.append(f"UDC gia' in {SHIPPED_LABEL}: " + ", ".join(row.udc for row in shipped_rows)) if missing_unload_rows: message_lines.append( "UDC spedite ma non scaricate: " + ", ".join(row.udc for row in missing_unload_rows) ) message_lines.append("Usare 'Scarico come spedita'.") messagebox.showwarning( title, "\n".join(message_lines), parent=self, ) return if not messagebox.askyesno( title, confirm_text.format(count=len(movable_rows), ubicazione=self.ubicazione), parent=self, ): return async def _job(): results: list[dict[str, Any]] = [] for row in movable_rows: result = await move_pallet_async( self.db_client, barcode_pallet=row.udc, target_idcella=target_idcella, target_barcode_cella=target_barcode_cella, 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( title, "Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.", parent=self, ) return if skipped: shipped_note = ( f"\nGia' in {SHIPPED_LABEL} non toccate: " + ", ".join(row.udc for row in shipped_rows) if shipped_rows else "" ) missing_unload_note = ( "\nSpedite ma non scaricate, usare 'Scarico come spedita': " + ", ".join(row.udc for row in missing_unload_rows) if missing_unload_rows else "" ) target_note = ( f"\nUDC in {target_label}: " + ", ".join(done) if done else "" ) messagebox.showwarning( title, "Scarico parziale.\nCompletate: " + ", ".join(done) + "\nNon scaricate: " + ", ".join(skipped) + shipped_note + missing_unload_note + target_note, parent=self, ) else: msg = f"Scarico completato verso {target_label} per:\n" + "\n".join(done) if shipped_rows: msg += f"\n\nUDC gia' in {SHIPPED_LABEL} non toccate:\n" + "\n".join(row.udc for row in shipped_rows) msg += f"\n\nUDC in {target_label}: " + ", ".join(done) if missing_unload_rows: msg += ( "\n\nUDC spedite ma non scaricate non toccate:\n" + "\n".join(row.udc for row in missing_unload_rows) + "\nUsare 'Scarico come spedita'." ) messagebox.showinfo(title, msg, parent=self) log_user_action( self.session, module=MODULE_LOG_NAME, action="layout.scarico.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff", outcome="ok", target=self.ubicazione, details={ "scaricate": done, "saltate": skipped, "target_idcella": target_idcella, "target_barcode_cella": target_barcode_cella, "gia_spedite": [row.udc for row in shipped_rows], "spedite_non_scaricate": [row.udc for row in missing_unload_rows], }, ) 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.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff", 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=busy_message, ) @_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, )