782 lines
30 KiB
Python
782 lines
30 KiB
Python
"""Picking list management window.
|
|
|
|
The module presents a master/detail UI for packing lists, supports reservation
|
|
and unreservation through an async stored-procedure port and keeps rendering
|
|
smooth by relying on deferred updates and lightweight progress indicators.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import tkinter as tk
|
|
import customtkinter as ctk
|
|
from tkinter import messagebox
|
|
from typing import Optional, Any, Dict, List, Callable
|
|
from dataclasses import dataclass
|
|
|
|
# Usa overlay e runner "collaudati"
|
|
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
|
|
|
|
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
|
import asyncio
|
|
try:
|
|
from prenota_sprenota_sql import sp_xExePackingListPallet_async, SPResult
|
|
except Exception:
|
|
async def sp_xExePackingListPallet_async(*args, **kwargs):
|
|
raise RuntimeError("sp_xExePackingListPallet_async non importabile: verifica prenota_sprenota_sql.py")
|
|
class SPResult:
|
|
def __init__(self, rc=-1, message="Procedura non disponibile", id_result=None):
|
|
self.rc = rc; self.message = message; self.id_result = id_result
|
|
|
|
|
|
# -------------------- SQL --------------------
|
|
SQL_PL = """
|
|
SELECT
|
|
COUNT(DISTINCT Pallet) AS Pallet,
|
|
COUNT(DISTINCT Lotto) AS Lotto,
|
|
COUNT(DISTINCT Articolo) AS Articolo,
|
|
COUNT(DISTINCT Descrizione) AS Descrizione,
|
|
SUM(Qta) AS Qta,
|
|
Documento,
|
|
CodNazione,
|
|
NAZIONE,
|
|
Stato,
|
|
MAX(PalletCella) AS PalletCella,
|
|
MAX(Magazzino) AS Magazzino,
|
|
MAX(Area) AS Area,
|
|
MAX(Cella) AS Cella,
|
|
MIN(Ordinamento) AS Ordinamento,
|
|
MAX(IDStato) AS IDStato
|
|
FROM dbo.XMag_ViewPackingList
|
|
GROUP BY Documento, CodNazione, NAZIONE, Stato
|
|
ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato;
|
|
"""
|
|
|
|
SQL_PL_DETAILS = """
|
|
SELECT *
|
|
FROM ViewPackingListRestante
|
|
WHERE Documento = :Documento
|
|
ORDER BY Ordinamento;
|
|
"""
|
|
|
|
# -------------------- helpers --------------------
|
|
def _rows_to_dicts(res: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""
|
|
Converte il payload ritornato da query_json in lista di dict.
|
|
Supporta:
|
|
- res = [ {..}, {..} ]
|
|
- res = { "rows": [..], "columns": [...] }
|
|
- res = { "data": [..], "columns": [...] }
|
|
- res = { "rows": [tuple,..], "columns": [...] }
|
|
"""
|
|
if res is None:
|
|
return []
|
|
|
|
if isinstance(res, list):
|
|
if not res:
|
|
return []
|
|
if isinstance(res[0], dict):
|
|
return res
|
|
return []
|
|
|
|
if isinstance(res, dict):
|
|
for rows_key in ("rows", "data", "result", "records"):
|
|
if rows_key in res and isinstance(res[rows_key], list):
|
|
rows = res[rows_key]
|
|
if not rows:
|
|
return []
|
|
if isinstance(rows[0], dict):
|
|
return rows
|
|
cols = res.get("columns") or res.get("cols") or []
|
|
out = []
|
|
for r in rows:
|
|
if cols and isinstance(r, (list, tuple)):
|
|
out.append({ (cols[i] if i < len(cols) else f"c{i}") : r[i]
|
|
for i in range(min(len(cols), len(r))) })
|
|
else:
|
|
if isinstance(r, (list, tuple)):
|
|
out.append({ f"c{i}": r[i] for i in range(len(r)) })
|
|
return out
|
|
if res and all(not isinstance(v, (list, tuple, dict)) for v in res.values()):
|
|
return [res]
|
|
|
|
return []
|
|
|
|
def _s(v) -> str:
|
|
"""Return a string representation, converting ``None`` to an empty string."""
|
|
return "" if v is None else str(v)
|
|
|
|
def _first(d: Dict[str, Any], keys: List[str], default: str = ""):
|
|
"""Return the first non-empty value found among the provided keys."""
|
|
for k in keys:
|
|
if k in d and d[k] not in (None, ""):
|
|
return d[k]
|
|
return default
|
|
|
|
# -------------------- column specs --------------------
|
|
@dataclass
|
|
class ColSpec:
|
|
"""Describe one logical column rendered in a ``ScrollTable``."""
|
|
|
|
title: str
|
|
key: str
|
|
width: int
|
|
anchor: str # 'w' | 'e' | 'center'
|
|
|
|
# Colonne PL (in alto) — include IDStato per la colorazione
|
|
PL_COLS: List[ColSpec] = [
|
|
ColSpec("", "__check__", 36, "w"),
|
|
ColSpec("Documento", "Documento", 120, "w"),
|
|
ColSpec("NAZIONE", "NAZIONE", 240, "w"),
|
|
ColSpec("Stato", "Stato", 110, "w"),
|
|
ColSpec("IDStato", "IDStato", 80, "e"), # nuova colonna
|
|
ColSpec("#Pallet", "Pallet", 100, "e"),
|
|
ColSpec("#Lotti", "Lotto", 100, "e"),
|
|
ColSpec("#Articoli", "Articolo", 110, "e"),
|
|
ColSpec("Qta", "Qta", 120, "e"),
|
|
]
|
|
|
|
DET_COLS: List[ColSpec] = [
|
|
ColSpec("UDC/Pallet", "Pallet", 150, "w"),
|
|
ColSpec("Lotto", "Lotto", 130, "w"),
|
|
ColSpec("Articolo", "Articolo", 150, "w"),
|
|
ColSpec("Descrizione","Descrizione", 320, "w"),
|
|
ColSpec("Qta", "Qta", 110, "e"),
|
|
ColSpec("Ubicazione", "Ubicazione", 320, "w"),
|
|
]
|
|
|
|
ROW_H = 28
|
|
|
|
|
|
# -------------------- Micro spinner (toolbar) --------------------
|
|
class ToolbarSpinner:
|
|
"""
|
|
Micro-animazione leggerissima per indicare attività:
|
|
mostra una label con frame: ◐ ◓ ◑ ◒ ... finché è attivo.
|
|
"""
|
|
FRAMES = ("◐", "◓", "◑", "◒")
|
|
def __init__(self, parent: tk.Widget):
|
|
"""Create the spinner label attached to the given parent widget."""
|
|
self.parent = parent
|
|
self.lbl = ctk.CTkLabel(parent, text="", width=28)
|
|
self._i = 0
|
|
self._active = False
|
|
self._job = None
|
|
|
|
def widget(self) -> ctk.CTkLabel:
|
|
"""Return the label widget hosting the spinner animation."""
|
|
return self.lbl
|
|
|
|
def start(self, text: str = ""):
|
|
"""Start the animation and optionally show a short status message."""
|
|
if self._active:
|
|
return
|
|
self._active = True
|
|
self.lbl.configure(text=f"{self.FRAMES[self._i]} {text}".strip())
|
|
self._tick()
|
|
|
|
def stop(self):
|
|
"""Stop the animation and clear the label text."""
|
|
self._active = False
|
|
if self._job is not None:
|
|
try:
|
|
self.parent.after_cancel(self._job)
|
|
except Exception:
|
|
pass
|
|
self._job = None
|
|
self.lbl.configure(text="")
|
|
|
|
def _tick(self):
|
|
"""Advance the spinner animation frame."""
|
|
if not self._active:
|
|
return
|
|
self._i = (self._i + 1) % len(self.FRAMES)
|
|
current = self.lbl.cget("text")
|
|
# Mantieni l'eventuale testo dopo il simbolo
|
|
txt_suffix = ""
|
|
if isinstance(current, str) and len(current) > 2:
|
|
txt_suffix = current[2:]
|
|
self.lbl.configure(text=f"{self.FRAMES[self._i]}{txt_suffix}")
|
|
self._job = self.parent.after(120, self._tick) # 8 fps soft
|
|
|
|
|
|
# -------------------- Scrollable table --------------------
|
|
class ScrollTable(ctk.CTkFrame):
|
|
GRID_COLOR = "#D0D5DD"
|
|
PADX_L = 8
|
|
PADX_R = 8
|
|
PADY = 2
|
|
|
|
def __init__(self, master, columns: List[ColSpec]):
|
|
"""Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
|
|
super().__init__(master)
|
|
self.columns = columns
|
|
self.total_w = sum(c.width for c in self.columns)
|
|
|
|
self.grid_rowconfigure(1, weight=1)
|
|
self.grid_columnconfigure(0, weight=1)
|
|
|
|
# header
|
|
self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0)
|
|
self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color="#f3f3f3",
|
|
height=ROW_H, width=self.total_w)
|
|
self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw",
|
|
width=self.total_w, height=ROW_H)
|
|
self.h_canvas.grid(row=0, column=0, sticky="ew")
|
|
|
|
# body
|
|
self.b_canvas = tk.Canvas(self, highlightthickness=0, bd=0)
|
|
self.b_inner = ctk.CTkFrame(self.b_canvas, fg_color="transparent",
|
|
width=self.total_w)
|
|
self.body_window = self.b_canvas.create_window((0,0), window=self.b_inner,
|
|
anchor="nw", width=self.total_w)
|
|
self.b_canvas.grid(row=1, column=0, sticky="nsew")
|
|
|
|
# scrollbars
|
|
self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview)
|
|
self.xbar = tk.Scrollbar(self, orient="horizontal", command=self._xscroll_both)
|
|
self.vbar.grid(row=1, column=1, sticky="ns")
|
|
self.xbar.grid(row=2, column=0, sticky="ew")
|
|
|
|
# link scroll
|
|
self.b_canvas.configure(yscrollcommand=self.vbar.set, xscrollcommand=self._xscroll_set_both)
|
|
self.h_canvas.configure(xscrollcommand=self.xbar.set)
|
|
|
|
# bind
|
|
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
|
|
self.b_inner.bind("<Configure>", lambda e: self._on_body_configure())
|
|
|
|
self._build_header()
|
|
|
|
def _build_header(self):
|
|
"""Build the static header row using the configured columns."""
|
|
for w in self.h_inner.winfo_children():
|
|
w.destroy()
|
|
|
|
row = ctk.CTkFrame(self.h_inner, fg_color="#f3f3f3",
|
|
height=ROW_H, width=self.total_w)
|
|
row.pack(fill="x", expand=False)
|
|
row.pack_propagate(False)
|
|
|
|
for col in self.columns:
|
|
holder = ctk.CTkFrame(
|
|
row, fg_color="#f3f3f3",
|
|
width=col.width, height=ROW_H,
|
|
border_width=1, border_color=self.GRID_COLOR
|
|
)
|
|
holder.pack(side="left", fill="y")
|
|
holder.pack_propagate(False)
|
|
|
|
lbl = ctk.CTkLabel(holder, text=col.title, anchor="w")
|
|
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
|
|
|
|
self.h_inner.configure(width=self.total_w, height=ROW_H)
|
|
self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
|
|
|
|
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)
|
|
sr = self.b_canvas.bbox("all")
|
|
if sr:
|
|
self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3]))
|
|
else:
|
|
self.b_canvas.configure(scrollregion=(0,0,self.total_w,0))
|
|
|
|
def _on_body_configure(self):
|
|
"""React to body resize events by syncing dimensions and header scroll."""
|
|
self._update_body_width()
|
|
self._sync_header_width()
|
|
|
|
def _sync_header_width(self):
|
|
"""Mirror the body horizontal scroll position on the header canvas."""
|
|
first, _ = self.b_canvas.xview()
|
|
self.h_canvas.xview_moveto(first)
|
|
|
|
def _xscroll_both(self, *args):
|
|
"""Scroll header and body together when the horizontal bar moves."""
|
|
self.h_canvas.xview(*args)
|
|
self.b_canvas.xview(*args)
|
|
|
|
def _xscroll_set_both(self, first, last):
|
|
"""Update the header viewport and scrollbar thumb in one place."""
|
|
self.h_canvas.xview_moveto(first)
|
|
self.xbar.set(first, last)
|
|
|
|
def clear_rows(self):
|
|
"""Remove all rendered body rows."""
|
|
for w in self.b_inner.winfo_children():
|
|
w.destroy()
|
|
self._update_body_width()
|
|
|
|
def add_row(
|
|
self,
|
|
values: List[str],
|
|
row_index: int,
|
|
anchors: Optional[List[str]] = None,
|
|
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
|
|
):
|
|
"""Append one row to the table body."""
|
|
row = ctk.CTkFrame(self.b_inner, fg_color="transparent",
|
|
height=ROW_H, width=self.total_w)
|
|
row.pack(fill="x", expand=False)
|
|
row.pack_propagate(False)
|
|
|
|
for i, col in enumerate(self.columns):
|
|
holder = ctk.CTkFrame(
|
|
row, fg_color="transparent",
|
|
width=col.width, height=ROW_H,
|
|
border_width=1, border_color=self.GRID_COLOR
|
|
)
|
|
holder.pack(side="left", fill="y")
|
|
holder.pack_propagate(False)
|
|
|
|
if col.key == "__check__":
|
|
if checkbox_builder:
|
|
cb = checkbox_builder(holder)
|
|
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
|
|
else:
|
|
ctk.CTkLabel(holder, text="").pack(fill="both")
|
|
else:
|
|
anchor = (anchors[i] if anchors else col.anchor)
|
|
ctk.CTkLabel(holder, text=values[i], anchor=anchor).pack(
|
|
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
|
|
)
|
|
|
|
self._update_body_width()
|
|
|
|
|
|
# -------------------- PL row model --------------------
|
|
class PLRow:
|
|
"""State holder for one picking list row and its selection checkbox."""
|
|
|
|
def __init__(self, pl: Dict[str, Any], on_check):
|
|
"""Bind a picking list payload to a ``BooleanVar`` and callback."""
|
|
self.pl = pl
|
|
self.var = ctk.BooleanVar(value=False)
|
|
self._callback = on_check
|
|
|
|
def is_checked(self) -> bool:
|
|
"""Return whether the row is currently selected."""
|
|
return self.var.get()
|
|
|
|
def set_checked(self, val: bool):
|
|
"""Programmatically update the checkbox state."""
|
|
self.var.set(val)
|
|
|
|
def build_checkbox(self, parent) -> ctk.CTkCheckBox:
|
|
"""Create the checkbox widget bound to this row model."""
|
|
return ctk.CTkCheckBox(parent, text="", variable=self.var,
|
|
command=lambda: self._callback(self, self.var.get()))
|
|
|
|
|
|
# -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
|
|
class GestionePickingListFrame(ctk.CTkFrame):
|
|
def __init__(self, master, *, db_client=None, conn_str=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.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._first_loading: bool = False # flag per cursore d'attesa solo al primo load
|
|
self._render_job = None # Tracking del job di rendering in corso
|
|
|
|
self._build_layout()
|
|
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
|
|
self.after_idle(self._first_show)
|
|
|
|
def _first_show(self):
|
|
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
|
|
self._first_loading = True
|
|
try:
|
|
self.winfo_toplevel().configure(cursor="watch")
|
|
except Exception:
|
|
pass
|
|
# spinner inizia
|
|
self.spinner.start(" Carico…")
|
|
self.reload_from_db(first=True)
|
|
|
|
# ---------- UI ----------
|
|
def _build_layout(self):
|
|
"""Build toolbar, master table and detail table."""
|
|
for r in (1, 3): self.grid_rowconfigure(r, weight=1)
|
|
self.grid_columnconfigure(0, weight=1)
|
|
|
|
top = ctk.CTkFrame(self)
|
|
top.grid(row=0, column=0, sticky="ew", padx=10, pady=(8,4))
|
|
for i, (text, cmd) in enumerate([
|
|
("Ricarica", self.reload_from_db),
|
|
("Prenota", self.on_prenota),
|
|
("S-prenota", self.on_sprenota),
|
|
("Esporta XLSX", self.on_export)
|
|
]):
|
|
ctk.CTkButton(top, text=text, command=cmd).grid(row=0, column=i, padx=6)
|
|
|
|
# --- micro spinner a destra della toolbar ---
|
|
self.spinner = ToolbarSpinner(top)
|
|
self.spinner.widget().grid(row=0, column=10, padx=(8,0)) # largo spazio a destra
|
|
|
|
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._draw_details_hint()
|
|
|
|
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
|
|
)
|
|
|
|
def _apply_row_colors(self, rows: List[Dict[str, Any]]):
|
|
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
|
|
try:
|
|
for idx, d in enumerate(rows):
|
|
row_widget = self.pl_table.b_inner.winfo_children()[idx]
|
|
if int(d.get("IDStato") or 0) == 1:
|
|
row_widget.configure(fg_color="#ffe6f2") # rosa tenue
|
|
else:
|
|
row_widget.configure(fg_color="transparent")
|
|
except Exception:
|
|
pass
|
|
|
|
def _refresh_mid_rows(self, rows: List[Dict[str, Any]]):
|
|
"""Rebuild the master table using the latest query results."""
|
|
self.pl_table.clear_rows()
|
|
self.rows_models.clear()
|
|
|
|
for r, d in enumerate(rows):
|
|
model = PLRow(d, self.on_row_checked)
|
|
self.rows_models.append(model)
|
|
values = [
|
|
"", # checkbox
|
|
_s(d.get("Documento")),
|
|
_s(d.get("NAZIONE")),
|
|
_s(d.get("Stato")),
|
|
_s(d.get("IDStato")), # nuova colonna visibile
|
|
_s(d.get("Pallet")),
|
|
_s(d.get("Lotto")),
|
|
_s(d.get("Articolo")),
|
|
_s(d.get("Qta")),
|
|
]
|
|
self.pl_table.add_row(
|
|
values=values,
|
|
row_index=r,
|
|
anchors=[c.anchor for c in PL_COLS],
|
|
checkbox_builder=model.build_checkbox
|
|
)
|
|
|
|
# 🎯 Colora dopo che la UI è resa → no balzi visivi
|
|
self.after_idle(lambda: self._apply_row_colors(rows))
|
|
|
|
# ----- helpers -----
|
|
def _get_selected_model(self) -> Optional[PLRow]:
|
|
"""Return the currently checked picking list row, if any."""
|
|
for m in self.rows_models:
|
|
if m.is_checked():
|
|
return m
|
|
return None
|
|
|
|
def _recolor_row_by_documento(self, documento: str, idstato: int):
|
|
"""Aggiorna colore riga e cella IDStato per il Documento indicato."""
|
|
for idx, m in enumerate(self.rows_models):
|
|
if _s(m.pl.get("Documento")) == _s(documento):
|
|
m.pl["IDStato"] = idstato
|
|
def _paint():
|
|
try:
|
|
row_widget = self.pl_table.b_inner.winfo_children()[idx]
|
|
row_widget.configure(fg_color="#ffe6f2" if idstato == 1 else "transparent")
|
|
row_children = row_widget.winfo_children()
|
|
if len(row_children) >= 5:
|
|
holder = row_children[4]
|
|
if holder.winfo_children():
|
|
lbl = holder.winfo_children()[0]
|
|
if hasattr(lbl, "configure"):
|
|
lbl.configure(text=str(idstato))
|
|
except Exception:
|
|
pass
|
|
# differisci la colorazione (smooth)
|
|
self.after_idle(_paint)
|
|
break
|
|
|
|
def _reselect_documento_after_reload(self, documento: str):
|
|
"""(Opzionale) Dopo un reload DB, riseleziona la PL con lo stesso Documento."""
|
|
for m in self.rows_models:
|
|
if _s(m.pl.get("Documento")) == _s(documento):
|
|
m.set_checked(True)
|
|
self.on_row_checked(m, True)
|
|
break
|
|
|
|
# ----- eventi -----
|
|
def on_row_checked(self, model: PLRow, is_checked: bool):
|
|
"""Handle row selection changes and refresh the detail section."""
|
|
# selezione esclusiva
|
|
if is_checked:
|
|
for m in self.rows_models:
|
|
if m is not model and m.is_checked():
|
|
m.set_checked(False)
|
|
|
|
self.detail_doc = model.pl.get("Documento")
|
|
self.spinner.start(" Carico dettagli…") # spinner ON
|
|
|
|
async def _job():
|
|
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)
|
|
# Avvia il rendering incrementale che mantiene l'overlay attivo
|
|
self._refresh_details_incremental()
|
|
|
|
def _err(ex):
|
|
self.spinner.stop()
|
|
self.busy.hide() # Chiudi l'overlay in caso di errore
|
|
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
|
|
|
|
self.runner.run(
|
|
_job(),
|
|
on_success=_ok,
|
|
on_error=_err,
|
|
busy=None, # NON usare busy automatico: lo gestiamo manualmente nel rendering
|
|
message=f"Carico UDC per Documento {self.detail_doc}…"
|
|
)
|
|
# Mostra manualmente l'overlay per la query
|
|
self.busy.show(f"Carico UDC per Documento {self.detail_doc}…")
|
|
|
|
else:
|
|
if not any(m.is_checked() for m in self.rows_models):
|
|
self.detail_doc = None
|
|
self._refresh_details()
|
|
|
|
# ----- load PL -----
|
|
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():
|
|
return await self.db_client.query_json(SQL_PL, {})
|
|
def _on_success(res):
|
|
rows = _rows_to_dicts(res)
|
|
self._refresh_mid_rows(rows)
|
|
self.spinner.stop() # spinner OFF
|
|
# se era il primo load, ripristina il cursore standard
|
|
if self._first_loading:
|
|
try:
|
|
self.winfo_toplevel().configure(cursor="")
|
|
except Exception:
|
|
pass
|
|
self._first_loading = False
|
|
def _on_error(ex):
|
|
self.spinner.stop()
|
|
if self._first_loading:
|
|
try:
|
|
self.winfo_toplevel().configure(cursor="")
|
|
except Exception:
|
|
pass
|
|
self._first_loading = False
|
|
messagebox.showerror("DB", f"Errore nel caricamento:\n{ex}")
|
|
|
|
self.runner.run(
|
|
_job(),
|
|
on_success=_on_success,
|
|
on_error=_on_error,
|
|
busy=self.busy,
|
|
message="Caricamento Picking List…" if first else "Aggiornamento…"
|
|
)
|
|
|
|
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, [])
|
|
if not rows:
|
|
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
|
row_index=0, anchors=["w"]*6)
|
|
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]
|
|
)
|
|
|
|
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.
|
|
"""
|
|
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, [])
|
|
if not rows:
|
|
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
|
row_index=0, anchors=["w"]*6)
|
|
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)
|
|
|
|
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.
|
|
"""
|
|
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()
|
|
|
|
# ----- azioni -----
|
|
def on_prenota(self):
|
|
"""Reserve the selected picking list."""
|
|
model = self._get_selected_model()
|
|
if not model:
|
|
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
|
|
return
|
|
|
|
documento = _s(model.pl.get("Documento"))
|
|
current = int(model.pl.get("IDStato") or 0)
|
|
desired = 1
|
|
if current == desired:
|
|
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
|
|
return
|
|
|
|
id_operatore = 1 # TODO: recupera dal contesto reale
|
|
self.spinner.start(" Prenoto…")
|
|
|
|
async def _job():
|
|
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
|
|
|
def _ok(res: SPResult):
|
|
self.spinner.stop()
|
|
if res and res.rc == 0:
|
|
self._recolor_row_by_documento(documento, desired)
|
|
else:
|
|
msg = (res.message if res else "Errore sconosciuto")
|
|
messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
|
|
|
|
def _err(ex):
|
|
self.spinner.stop()
|
|
messagebox.showerror("Prenota", f"Errore:\n{ex}")
|
|
|
|
self.runner.run(
|
|
_job(),
|
|
on_success=_ok,
|
|
on_error=_err,
|
|
busy=self.busy,
|
|
message=f"Prenoto la Picking List {documento}…"
|
|
)
|
|
|
|
def on_sprenota(self):
|
|
"""Unreserve the selected picking list."""
|
|
model = self._get_selected_model()
|
|
if not model:
|
|
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
|
|
return
|
|
|
|
documento = _s(model.pl.get("Documento"))
|
|
current = int(model.pl.get("IDStato") or 0)
|
|
desired = 0
|
|
if current == desired:
|
|
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
|
|
return
|
|
|
|
id_operatore = 1 # TODO: recupera dal contesto reale
|
|
self.spinner.start(" S-prenoto…")
|
|
|
|
async def _job():
|
|
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
|
|
|
def _ok(res: SPResult):
|
|
self.spinner.stop()
|
|
if res and res.rc == 0:
|
|
self._recolor_row_by_documento(documento, desired)
|
|
else:
|
|
msg = (res.message if res else "Errore sconosciuto")
|
|
messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
|
|
|
|
def _err(ex):
|
|
self.spinner.stop()
|
|
messagebox.showerror("S-prenota", f"Errore:\n{ex}")
|
|
|
|
self.runner.run(
|
|
_job(),
|
|
on_success=_ok,
|
|
on_error=_err,
|
|
busy=self.busy,
|
|
message=f"S-prenoto la Picking List {documento}…"
|
|
)
|
|
|
|
def on_export(self):
|
|
"""Placeholder for a future export implementation."""
|
|
messagebox.showinfo("Esporta", "Stub esportazione.")
|
|
|
|
|
|
# factory per main
|
|
def create_frame(parent, *, db_client=None, conn_str=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)
|
|
|
|
# =================== /gestione_pickinglist.py ===================
|