migrazione verso gitea

This commit is contained in:
2026-03-31 19:15:33 +02:00
parent 8806d598eb
commit f6a5b1b29f
118 changed files with 17197 additions and 459 deletions

33
trash/async_runner.py Normal file
View File

@@ -0,0 +1,33 @@
"""Minimal bridge between Tkinter and an external asyncio loop."""
import asyncio
from typing import Callable
class AsyncRunner:
"""Run coroutines on a background loop and marshal results back to Tk."""
def __init__(self, tk_root, loop: asyncio.AbstractEventLoop):
"""Store the Tk root widget and the loop used for background work."""
self.tk = tk_root
self.loop = loop
def run(self, awaitable, on_ok: Callable, on_err: Callable, busy=None, message: str | None = None):
"""Schedule an awaitable and optionally show a busy indicator."""
if busy:
busy.show(message or "Lavoro in corso...")
fut = asyncio.run_coroutine_threadsafe(awaitable, self.loop)
self._poll(fut, on_ok, on_err, busy)
def _poll(self, fut, on_ok, on_err, busy):
"""Poll the future until completion and invoke the proper callback."""
if fut.done():
if busy:
busy.hide()
try:
res = fut.result()
on_ok(res)
except Exception as ex:
on_err(ex)
return
self.tk.after(50, lambda: self._poll(fut, on_ok, on_err, busy))

View File

@@ -0,0 +1,712 @@
"""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._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):
self.spinner.stop() # spinner OFF
self._detail_cache[self.detail_doc] = _rows_to_dicts(res)
# differisci il render dei dettagli (più fluido)
self.after_idle(self._refresh_details)
def _err(ex):
self.spinner.stop()
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
self.runner.run(
_job(),
on_success=_ok,
on_error=_err,
busy=self.busy,
message=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]
)
# ----- 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 ===================

View File

@@ -0,0 +1,633 @@
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0, border_color="transparent"
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0, border_color="transparent"
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0, border_color="transparent"
)
def _clear_highlight(self):
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0, border_color="transparent")
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0, border_color="transparent")
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6,
)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}…")
# ---------------- SEARCH ----------------
def _search_udc(self):
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def open_layout_window(parent, db_app):
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w

View File

@@ -0,0 +1,632 @@
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0
)
def _clear_highlight(self):
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0)
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0)
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}…")
# ---------------- SEARCH ----------------
def _search_udc(self):
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def open_layout_window(parent, db_app):
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w