"""Editor for the per-document picking-list pickup sequence. The barcode client remains unaware of the editor: it reads the effective order from ``dbo.py_ViewPackingListPrelievo``. This module only manages the Python- specific configuration rows used by that view. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable import tkinter as tk from tkinter import messagebox import customtkinter as ctk from busy_overlay import InlineBusyOverlay from gestione_aree import AsyncRunner from locale_text import load_locale_catalog, text as loc_text from ui_theme import theme_color, theme_font, theme_section from user_session import UserSession from version_info import module_version, versioned_title from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text __version__ = module_version(__name__) SQL_LOAD_GROUPS = """ SELECT GruppoPrelievoChiave, MAX(GruppoPrelievo) AS GruppoPrelievo, MIN(OrdineGruppoDefault) AS OrdineGruppoDefault, MIN(OrdineGruppoConfigurato) AS OrdineGruppoConfigurato FROM dbo.py_ViewPackingListPrelievo WHERE CAST(Documento AS varchar(50)) = :documento AND Cella <> 9999 GROUP BY GruppoPrelievoChiave ORDER BY CASE WHEN MIN(OrdineGruppoConfigurato) IS NULL THEN 1 ELSE 0 END, MIN(OrdineGruppoConfigurato), MIN(OrdineGruppoDefault), MAX(GruppoPrelievo); """ SQL_RESET_SEQUENCE = """ SET NOCOUNT ON; SET XACT_ABORT ON; UPDATE dbo.py_PickingListPrelievoSequenza SET DelUtente = :utente, DelDataOra = SYSDATETIME(), ModUtente = :utente, ModDataOra = SYSDATETIME() WHERE Documento = :documento AND DelDataOra IS NULL; SELECT CAST(0 AS int) AS ElementiSalvati; """ def _rows_to_dicts(result: dict[str, Any] | None) -> list[dict[str, Any]]: """Convert the shared database payload to dictionaries.""" if not isinstance(result, dict): return [] rows = result.get("rows") or [] columns = result.get("columns") or [] if rows and isinstance(rows[0], dict): return [dict(row) for row in rows] return [ {str(columns[index]): row[index] for index in range(min(len(columns), len(row)))} for row in rows if isinstance(row, (list, tuple)) ] @dataclass(frozen=True) class PickupGroup: """One rack group, or the conventional non-shelved group.""" key: str label: str default_order: int class PickingListSequenceWindow(ctk.CTkToplevel): """Drag-and-drop editor for one picking-list pickup sequence.""" def __init__( self, parent: tk.Misc, db_client, *, documento: str, session: UserSession | None, on_saved: Callable[[], None] | None = None, ): super().__init__(parent) self.db_client = db_client self.documento = str(documento or "").strip() self.session = session self.on_saved = on_saved self._catalog = load_locale_catalog() self._tooltip_catalog = load_tooltip_catalog() self._theme = theme_section("picking_sequence_window", {}) self.runner = AsyncRunner(self) self.busy = InlineBusyOverlay(self, self._theme) self.available: list[PickupGroup] = [] self.selected: list[PickupGroup] = [] self._selected_key: str | None = None self._selected_side: str | None = None self._drag_group: PickupGroup | None = None self._drag_side: str | None = None self.title(versioned_title(self._t("picking.sequence.title", "Sequenza di prelievo"), __name__)) self.geometry("780x560") self.minsize(680, 480) self.transient(parent) self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f"))) self.protocol("WM_DELETE_WINDOW", self.destroy) self.bind("", lambda _event: self.destroy()) self._build_layout() self.withdraw() self.after_idle(self._show_and_load) def _t(self, key: str, default: str, **values: Any) -> str: """Return one localized and formatted label.""" value = loc_text(key, catalog=self._catalog, default=default) try: return value.format(**values) except Exception: return value def _build_layout(self) -> None: """Build the two lists and the explicit keyboard-friendly controls.""" self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(2, weight=1) self.grid_rowconfigure(2, weight=1) ctk.CTkLabel( self, text=self._t("picking.sequence.document", "Picking List {documento}", documento=self.documento), font=theme_font(self._theme, "title_font", ("Segoe UI", 16, "bold")), ).grid(row=0, column=0, columnspan=3, padx=16, pady=(14, 4), sticky="w") ctk.CTkLabel( self, text=self._t( "picking.sequence.hint", "Trascina gli scaffali a destra. Quelli non inseriti saranno accodati secondo l'ordine predefinito.", ), anchor="w", justify="left", wraplength=720, ).grid(row=1, column=0, columnspan=3, padx=16, pady=(0, 10), sticky="ew") self.available_panel, self.available_list = self._build_list_panel( 0, self._t("picking.sequence.available", "Scaffali disponibili") ) controls = ctk.CTkFrame(self, fg_color="transparent") controls.grid(row=2, column=1, padx=10, pady=4) for label, command in ( (self._t("picking.sequence.button.add", "Aggiungi >"), self._add_selected), (self._t("picking.sequence.button.remove", "< Rimuovi"), self._remove_selected), (self._t("picking.sequence.button.up", "Su"), lambda: self._move_selected(-1)), (self._t("picking.sequence.button.down", "Giu'"), lambda: self._move_selected(1)), ): button = ctk.CTkButton(controls, text=label, width=120, command=command) button.pack(pady=5) tooltip_key = { self._t("picking.sequence.button.add", "Aggiungi >"): "picking.sequence.add", self._t("picking.sequence.button.remove", "< Rimuovi"): "picking.sequence.remove", self._t("picking.sequence.button.up", "Su"): "picking.sequence.up", self._t("picking.sequence.button.down", "Giu'"): "picking.sequence.down", }.get(label, "") self._attach_tooltip(button, tooltip_key) self.selected_panel, self.selected_list = self._build_list_panel( 2, self._t("picking.sequence.selected", "Ordine personalizzato") ) ctk.CTkLabel( self, text=self._t( "picking.sequence.default_hint", "Default: prima Non scaff., poi gli scaffali secondo l'ordine WMS.", ), anchor="w", text_color=theme_color(self._theme, "secondary_text_color", "#4b5563"), ).grid(row=3, column=0, columnspan=3, padx=16, pady=(8, 4), sticky="ew") bottom = ctk.CTkFrame(self) bottom.grid(row=4, column=0, columnspan=3, padx=16, pady=(6, 14), sticky="ew") bottom.grid_columnconfigure(0, weight=1) reset_button = ctk.CTkButton( bottom, text=self._t("picking.sequence.button.reset", "Ripristina default"), command=self._reset_default, fg_color=theme_color(self._theme, "reset_button_fg_color", "#6b7280"), ) reset_button.grid(row=0, column=1, padx=5, pady=8) self._attach_tooltip(reset_button, "picking.sequence.reset") save_button = ctk.CTkButton( bottom, text=self._t("picking.sequence.button.save", "Salva"), command=self._save, ) save_button.grid(row=0, column=2, padx=5, pady=8) self._attach_tooltip(save_button, "picking.sequence.save") close_button = ctk.CTkButton( bottom, text=self._t("picking.sequence.button.close", "Chiudi"), command=self.destroy, ) close_button.grid(row=0, column=3, padx=5, pady=8) self._attach_tooltip(close_button, "picking.sequence.close") def _attach_tooltip(self, widget: tk.Misc, key: str) -> None: """Attach a localized tooltip when the catalog contains one.""" value = tooltip_text(key, catalog=self._tooltip_catalog) if key else "" if value: WidgetToolTip(widget, value) def _build_list_panel(self, column: int, title: str): """Create one titled, scrollable group list.""" panel = ctk.CTkFrame(self) panel.grid(row=2, column=column, padx=(16 if column == 0 else 0, 16 if column == 2 else 0), pady=4, sticky="nsew") panel.grid_columnconfigure(0, weight=1) panel.grid_rowconfigure(1, weight=1) ctk.CTkLabel(panel, text=title, font=("Segoe UI", 12, "bold")).grid( row=0, column=0, padx=10, pady=(10, 6), sticky="w" ) content = ctk.CTkScrollableFrame(panel, fg_color=theme_color(self._theme, "list_fg_color", "#f8fafc")) content.grid(row=1, column=0, padx=8, pady=(0, 8), sticky="nsew") content.grid_columnconfigure(0, weight=1) return panel, content def _show_and_load(self) -> None: """Center the ready window and start its asynchronous load.""" try: self.update_idletasks() parent = self.master x = max(0, parent.winfo_rootx() + (parent.winfo_width() - self.winfo_width()) // 2) y = max(0, parent.winfo_rooty() + (parent.winfo_height() - self.winfo_height()) // 2) self.geometry(f"+{x}+{y}") except Exception: pass self.deiconify() self.lift() self.focus_force() self._load() def _load(self) -> None: """Load available groups and the optional saved sequence.""" async def _job(): return await self.db_client.query_json(SQL_LOAD_GROUPS, {"documento": self.documento}) def _ok(result): rows = _rows_to_dicts(result) configured: list[tuple[int, PickupGroup]] = [] available: list[PickupGroup] = [] for row in rows: group = PickupGroup( key=str(row.get("GruppoPrelievoChiave") or "").strip(), label=str(row.get("GruppoPrelievo") or "").strip(), default_order=int(row.get("OrdineGruppoDefault") or 0), ) configured_order = row.get("OrdineGruppoConfigurato") if configured_order is None: available.append(group) else: configured.append((int(configured_order), group)) self.available = sorted(available, key=lambda item: (item.default_order, item.label)) self.selected = [item for _order, item in sorted(configured, key=lambda pair: pair[0])] self._render_lists() def _error(exc): messagebox.showerror( self._t("picking.sequence.title", "Sequenza di prelievo"), self._t("picking.sequence.msg.load_error", "Errore durante il caricamento:\n{error}", error=exc), parent=self, ) self.runner.run( _job(), on_success=_ok, on_error=_error, busy=self.busy, message=self._t("picking.sequence.busy.load", "Carico gli scaffali..."), ) def _render_lists(self) -> None: """Redraw list buttons after a move or a database load.""" for host in (self.available_list, self.selected_list): for child in host.winfo_children(): child.destroy() for row, group in enumerate(self.available): self._render_group_button(self.available_list, group, "available", row) for row, group in enumerate(self.selected): self._render_group_button(self.selected_list, group, "selected", row) def _render_group_button(self, host, group: PickupGroup, side: str, row: int) -> None: """Render one selectable and draggable group button.""" is_selected = self._selected_key == group.key and self._selected_side == side prefix = f"{row + 1}. " if side == "selected" else "" button = ctk.CTkButton( host, text=f"{prefix}{group.label}", anchor="w", height=34, fg_color="#1976d2" if is_selected else theme_color(self._theme, "item_fg_color", "#2ebf74"), command=lambda g=group, s=side: self._select(g, s), ) button.grid(row=row, column=0, padx=4, pady=4, sticky="ew") button.bind("", lambda _event, g=group, s=side: self._toggle_group(g, s), add="+") drag_widgets = [button] for attr in ("_canvas", "_text_label", "_image_label"): widget = getattr(button, attr, None) if widget is not None: drag_widgets.append(widget) for widget in drag_widgets: widget.bind("", lambda _event, g=group, s=side: self._start_drag(g, s), add="+") widget.bind("", self._finish_drag, add="+") self._attach_tooltip(button, "picking.sequence.item") def _select(self, group: PickupGroup, side: str) -> None: """Select one group for the explicit move controls.""" self._selected_key = group.key self._selected_side = side self._render_lists() def _toggle_group(self, group: PickupGroup, side: str) -> None: """Move a double-clicked group to the opposite side.""" self._selected_key = group.key self._selected_side = side self._add_selected() if side == "available" else self._remove_selected() def _selected_group(self) -> PickupGroup | None: """Return the group selected in the active side.""" source = self.available if self._selected_side == "available" else self.selected return next((item for item in source if item.key == self._selected_key), None) def _add_selected(self) -> None: """Append the selected available group to the custom sequence.""" if self._selected_side != "available": return group = self._selected_group() if group is None: return self.available = [item for item in self.available if item.key != group.key] self.selected.append(group) self._selected_side = "selected" self._render_lists() def _remove_selected(self) -> None: """Return the selected configured group to the available list.""" if self._selected_side != "selected": return group = self._selected_group() if group is None: return self.selected = [item for item in self.selected if item.key != group.key] self.available.append(group) self.available.sort(key=lambda item: (item.default_order, item.label)) self._selected_side = "available" self._render_lists() def _move_selected(self, offset: int) -> None: """Move one configured group up or down.""" if self._selected_side != "selected": return index = next((i for i, item in enumerate(self.selected) if item.key == self._selected_key), -1) target = index + int(offset) if index < 0 or target < 0 or target >= len(self.selected): return self.selected[index], self.selected[target] = self.selected[target], self.selected[index] self._render_lists() def _start_drag(self, group: PickupGroup, side: str) -> None: """Remember the item being dragged.""" self._drag_group = group self._drag_side = side @staticmethod def _is_descendant(widget: tk.Misc | None, ancestor: tk.Misc) -> bool: """Return whether a widget belongs to one list panel.""" current = widget while current is not None: if current == ancestor: return True current = getattr(current, "master", None) return False def _finish_drag(self, event) -> None: """Drop the dragged group into a list and place it near the pointer.""" group = self._drag_group if group is None: return self._drag_group = None try: target_widget = self.winfo_containing(event.x_root, event.y_root) except Exception: return if self._is_descendant(target_widget, self.selected_panel): self.available = [item for item in self.available if item.key != group.key] self.selected = [item for item in self.selected if item.key != group.key] target_y = event.y_root - self.selected_list.winfo_rooty() row_height = 42 target_index = max(0, min(len(self.selected), target_y // row_height)) self.selected.insert(target_index, group) self._selected_side = "selected" elif self._is_descendant(target_widget, self.available_panel): self.selected = [item for item in self.selected if item.key != group.key] if not any(item.key == group.key for item in self.available): self.available.append(group) self.available.sort(key=lambda item: (item.default_order, item.label)) self._selected_side = "available" else: return self._selected_key = group.key self._render_lists() def _save_sql(self) -> tuple[str, dict[str, Any]]: """Build one atomic soft-delete/insert batch using bound parameters.""" params: dict[str, Any] = { "documento": self.documento, "utente": self.session.login if self.session else "SYSTEM", } values: list[str] = [] for index, group in enumerate(self.selected): params[f"key_{index}"] = group.key params[f"label_{index}"] = group.label params[f"order_{index}"] = index values.append( f"(:documento, :key_{index}, :label_{index}, :order_{index}, :utente, SYSDATETIME(), :utente, SYSDATETIME())" ) insert = "" if values: insert = """ INSERT INTO dbo.py_PickingListPrelievoSequenza ( Documento, GruppoChiave, GruppoEtichetta, Ordine, InsUtente, InsDataOra, ModUtente, ModDataOra ) VALUES """ + ",\n".join(values) + ";" sql = f""" SET NOCOUNT ON; SET XACT_ABORT ON; UPDATE dbo.py_PickingListPrelievoSequenza SET DelUtente = :utente, DelDataOra = SYSDATETIME(), ModUtente = :utente, ModDataOra = SYSDATETIME() WHERE Documento = :documento AND DelDataOra IS NULL; {insert} SELECT CAST({len(values)} AS int) AS ElementiSalvati; """ return sql, params def _save(self) -> None: """Persist the configured prefix atomically.""" sql, params = self._save_sql() async def _job(): return await self.db_client.query_json(sql, params, commit=True) self.runner.run( _job(), on_success=lambda _result: self._save_completed("saved"), on_error=self._save_error, busy=self.busy, message=self._t("picking.sequence.busy.save", "Salvo la sequenza..."), ) def _reset_default(self) -> None: """Delete the custom sequence so the shared default becomes active.""" async def _job(): return await self.db_client.query_json( SQL_RESET_SEQUENCE, { "documento": self.documento, "utente": self.session.login if self.session else "SYSTEM", }, commit=True, ) self.runner.run( _job(), on_success=lambda _result: self._save_completed("reset"), on_error=self._save_error, busy=self.busy, message=self._t("picking.sequence.busy.save", "Salvo la sequenza..."), ) def _save_completed(self, mode: str) -> None: """Refresh the editor and notify the parent after a successful save.""" message_key = "picking.sequence.msg.reset" if mode == "reset" else "picking.sequence.msg.saved" default = ( "Sequenza personalizzata rimossa. E' attivo l'ordine predefinito." if mode == "reset" else "Sequenza salvata." ) messagebox.showinfo(self._t("picking.sequence.title", "Sequenza di prelievo"), self._t(message_key, default), parent=self) if self.on_saved: self.on_saved() self._selected_key = None self._selected_side = None self._load() def _save_error(self, exc: BaseException) -> None: """Show a localized save error.""" messagebox.showerror( self._t("picking.sequence.title", "Sequenza di prelievo"), self._t("picking.sequence.msg.save_error", "Errore durante il salvataggio:\n{error}", error=exc), parent=self, ) def open_pickinglist_sequence_window( parent: tk.Misc, db_client, *, documento: str, session: UserSession | None, on_saved: Callable[[], None] | None = None, ) -> PickingListSequenceWindow: """Open at most one sequence editor for the picking-list window.""" key = "_pickinglist_sequence_window_singleton" existing = getattr(parent, key, None) if existing is not None: try: if existing.winfo_exists() and existing.documento == str(documento).strip(): existing.on_saved = on_saved existing.deiconify() existing.lift() existing.focus_force() return existing if existing.winfo_exists(): existing.destroy() except Exception: pass window = PickingListSequenceWindow( parent, db_client, documento=documento, session=session, on_saved=on_saved, ) setattr(parent, key, window) def _clear_reference() -> None: try: if getattr(parent, key, None) is window: setattr(parent, key, None) finally: window.destroy() window.protocol("WM_DELETE_WINDOW", _clear_reference) return window