Files
ware_house/search_pallets.py
2026-03-31 19:15:33 +02:00

428 lines
16 KiB
Python

"""Search window for pallets, lots and product codes across the warehouse."""
from __future__ import annotations
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk
from gestione_aree_frame_async import AsyncRunner, BusyOverlay
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
_HAS_XLSX = True
except Exception:
_HAS_XLSX = False
try:
from tksheet import Sheet
except Exception:
Sheet = None
SQL_SEARCH = r"""
WITH BASE AS (
SELECT
g.IDCella,
CONCAT(g.BarcodePallet, '') AS UDC,
c.Corsia,
c.Colonna,
c.Fila
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c ON c.ID = g.IDCella
),
JOINED AS (
SELECT
b.IDCella,
b.UDC,
b.Corsia,
b.Colonna,
b.Fila,
t.Lotto,
t.Prodotto,
t.Descrizione
FROM BASE b
LEFT JOIN dbo.vXTracciaProdotti AS t
ON t.Pallet COLLATE Latin1_General_CI_AS = LEFT(b.UDC, 6) COLLATE Latin1_General_CI_AS
)
SELECT
j.IDCella,
UPPER(
CONCAT(
COALESCE(LTRIM(RTRIM(j.Corsia)), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(j.Colonna AS varchar(32)))), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(j.Fila AS varchar(32)))), 'NA')
)
) AS Ubicazione,
j.UDC,
j.Lotto,
j.Prodotto,
j.Descrizione
FROM JOINED j
WHERE 1=1
AND ( :udc IS NULL OR j.UDC COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :udc, '%') )
AND ( :lotto IS NULL OR j.Lotto COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :lotto, '%') )
AND ( :codice IS NULL OR j.Prodotto COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :codice, '%') )
ORDER BY
CASE WHEN j.IDCella = 9999 THEN 1 ELSE 0 END,
j.Corsia, j.Colonna, j.Fila, j.UDC, j.Lotto, j.Prodotto;
"""
class SearchWindow(ctk.CTkToplevel):
"""Window that searches pallets by barcode, lot or product code."""
def __init__(self, parent: tk.Widget, db_app):
"""Initialize widgets and keep a reference to the shared DB client."""
super().__init__(parent)
self.title("Warehouse - Ricerca UDC/Lotto/Codice")
self.geometry("1100x720")
self.minsize(900, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
self._sort_state: dict[str, bool] = {}
self._build_ui()
def _build_ui(self):
"""Create the search form, result tree and scrollbars."""
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
for i in range(8):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(7, weight=1)
ctk.CTkLabel(top, text="UDC:").grid(row=0, column=0, sticky="w")
self.var_udc = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_udc, width=160).grid(row=0, column=1, sticky="w", padx=(4, 12))
ctk.CTkLabel(top, text="Lotto:").grid(row=0, column=2, sticky="w")
self.var_lotto = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_lotto, width=140).grid(row=0, column=3, sticky="w", padx=(4, 12))
ctk.CTkLabel(top, text="Codice prodotto:").grid(row=0, column=4, sticky="w")
self.var_codice = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_codice, width=160).grid(row=0, column=5, sticky="w", padx=(4, 12))
ctk.CTkButton(top, text="Cerca", command=self._do_search).grid(row=0, column=6, sticky="w")
ctk.CTkButton(top, text="Esporta XLSX", command=self._export_xlsx).grid(row=0, column=7, sticky="e")
wrap = ctk.CTkFrame(self)
wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
wrap.grid_rowconfigure(0, weight=1)
wrap.grid_columnconfigure(0, weight=1)
self.use_sheet = False
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
self._style = ttk.Style(self)
try:
self._style.theme_use(self._style.theme_use())
except Exception:
pass
self._style.configure("Search.Treeview", rowheight=22, font=("", 9))
self._style.configure("Search.Treeview.Heading", font=("", 9, "bold"), background="#F3F4F6")
self._style.map("Search.Treeview", background=[("selected", "#DCEBFF")])
self.tree.configure(style="Search.Treeview")
self.tree.tag_configure("even", background="#FFFFFF")
self.tree.tag_configure("odd", background="#F7F9FC")
self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020")
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
self.tree.grid(row=0, column=0, sticky="nsew")
sy.grid(row=0, column=1, sticky="ns")
sx.grid(row=1, column=0, sticky="ew")
self.tree.bind("<Double-1>", self._on_dclick)
self.tree.bind("<Button-1>", self._maybe_handle_heading_click, add=True)
self.tree.bind("<Double-1>", self._on_heading_double_click, add=True)
def _apply_zebra(self):
"""Reapply alternating row colors and special styling for cell 9999."""
for i, iid in enumerate(self.tree.get_children("")):
vals = self.tree.item(iid, "values")
zebra = "even" if i % 2 == 0 else "odd"
is9999 = False
if vals:
try:
is9999 = int(vals[0]) == 9999
except Exception:
is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,)
self.tree.item(iid, tags=tags)
def _export_xlsx(self):
"""Export the currently visible search results to an Excel file."""
rows = [self.tree.item(iid, "values") for iid in self.tree.get_children("")]
if not rows:
messagebox.showinfo("Esporta", "Non ci sono righe da esportare.", parent=self)
return
if not _HAS_XLSX:
messagebox.showerror("Esporta", "Per l'esportazione serve 'openpyxl' (pip install openpyxl).", parent=self)
return
from datetime import datetime
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default_name = f"esportazione_ricerca_{ts}.xlsx"
fname = filedialog.asksaveasfilename(
parent=self,
title="Esporta in Excel",
defaultextension=".xlsx",
filetypes=[("Excel Workbook", "*.xlsx")],
initialfile=default_name,
)
if not fname:
return
try:
wb = Workbook()
ws = wb.active
ws.title = "Risultati"
headers = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
for j, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=j, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
row_idx = 2
for row in rows:
for j, value in enumerate(row, start=1):
ws.cell(row=row_idx, column=j, value=value)
row_idx += 1
widths = {}
for row in ws.iter_rows(values_only=True):
for j, value in enumerate(row, start=1):
widths[j] = max(widths.get(j, 0), len("" if value is None else str(value)))
from openpyxl.utils import get_column_letter
for j, width in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(width + 2, 10), 60)
wb.save(fname)
messagebox.showinfo("Esporta", f"File creato:\n{fname}", parent=self)
except Exception as ex:
messagebox.showerror("Esporta", f"Errore durante l'esportazione:{ex}", parent=self)
def _on_dclick(self, evt):
"""Copy the selected pallet barcode when a result cell is double-clicked."""
region = self.tree.identify("region", evt.x, evt.y)
if region != "cell":
return
sel = self.tree.focus()
if not sel:
return
vals = self.tree.item(sel, "values")
if len(vals) >= 3 and vals[2]:
try:
self.clipboard_clear()
self.clipboard_append(vals[2])
except Exception:
pass
def _maybe_handle_heading_click(self, evt):
"""Prevent heading clicks from selecting phantom result rows."""
region = self.tree.identify("region", evt.x, evt.y)
if region == "heading":
return "break"
def _on_heading_double_click(self, evt):
"""Sort by the double-clicked heading."""
region = self.tree.identify("region", evt.x, evt.y)
if region != "heading":
return
col_id = self.tree.identify_column(evt.x)
try:
idx = int(col_id.replace("#", "")) - 1
except Exception:
return "break"
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
if 0 <= idx < len(cols):
self._sort_by_column(cols[idx])
return "break"
def _sort_key_for_col(self, col: str, val: str):
"""Return a stable sort key for the given column value."""
if val is None:
return (1, "")
s = str(val)
if col in ("IDCella",):
try:
return (0, int(s))
except Exception:
return (0, s.lower())
return (0, s.lower())
def _sort_by_column(self, col: str):
"""Sort the tree rows in place and update heading arrows."""
try:
rev = self._sort_state.get(col, False)
self._sort_state[col] = not rev
rows = []
for iid in self.tree.get_children(""):
vals = self.tree.item(iid, "values")
rows.append(
{
"iid": iid,
"IDCella": vals[0] if len(vals) > 0 else None,
"Ubicazione": vals[1] if len(vals) > 1 else None,
"UDC": vals[2] if len(vals) > 2 else None,
"Lotto": vals[3] if len(vals) > 3 else None,
"Codice": vals[4] if len(vals) > 4 else None,
"Descrizione": vals[5] if len(vals) > 5 else None,
}
)
rows.sort(key=lambda row: self._sort_key_for_col(col, row.get(col)), reverse=rev)
def _apply_moves():
for index, row in enumerate(rows):
self.tree.move(row["iid"], "", index)
titles = {
"IDCella": "IDCella",
"Ubicazione": "Ubicazione",
"UDC": "UDC / Barcode",
"Lotto": "Lotto",
"Codice": "Codice prodotto",
"Descrizione": "Descrizione prodotto",
}
for key, base in titles.items():
if key == col:
arrow = "" if not rev else ""
self.tree.heading(key, text=base + arrow)
else:
self.tree.heading(key, text=base)
self._apply_zebra()
self.after_idle(_apply_moves)
except Exception:
pass
def _on_sheet_header_double_click(self, event_dict):
"""Legacy sorter kept for the optional ``tksheet`` backend."""
try:
c = event_dict.get("column")
except Exception:
return
if c is None:
return
headers = ["IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione"]
if not (0 <= c < len(headers)):
return
colname = headers[c]
rev = self._sort_state.get(colname, False)
self._sort_state[colname] = not rev
data = self.sheet.get_sheet_data(return_copy=True)
def keyf(row):
val = row[c] if c < len(row) else None
if val is None:
return (1, "")
s = str(val)
if colname == "IDCella":
try:
return (0, int(s))
except Exception:
return (0, s.lower())
return (0, s.lower())
data.sort(key=keyf, reverse=rev)
self.sheet.set_sheet_data(data)
try:
arrow = "" if not rev else ""
hdrs = list(headers)
hdrs[c] = hdrs[c] + arrow
self.sheet.headers(hdrs)
except Exception:
pass
def _do_search(self):
"""Run the search query using the currently filled filters."""
udc = (self.var_udc.get() or "").strip()
lotto = (self.var_lotto.get() or "").strip()
codice = (self.var_codice.get() or "").strip()
if not (udc or lotto or codice):
if not messagebox.askyesno(
"Conferma",
"Nessun filtro impostato. Vuoi cercare su TUTTO il magazzino?",
parent=self,
):
return
params = {
"udc": (udc if udc else None),
"lotto": (lotto if lotto else None),
"codice": (codice if codice else None),
}
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
if self.use_sheet:
try:
data = []
for row in rows:
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v])
self.sheet.set_sheet_data(data)
self.sheet.set_all_cell_sizes_to_text()
except Exception:
self.use_sheet = False
if not self.use_sheet:
for iid in self.tree.get_children():
self.tree.delete(iid)
for idx, row in enumerate(rows):
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
zebra = "even" if idx % 2 == 0 else "odd"
try:
is9999 = int(idc) == 9999
except Exception:
is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,)
self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags)
if not rows:
messagebox.showinfo(
"Nessun risultato",
"Nessuna corrispondenza trovata con le chiavi di ricerca inserite.",
parent=self,
)
else:
self.var_udc.set("")
self.var_lotto.set("")
self.var_codice.set("")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore ricerca", str(ex), parent=self)
self._async.run(self.db.query_json(SQL_SEARCH, params), _ok, _err, busy=self._busy, message="Cerco...")
def open_search_window(parent, db_app):
"""Open a singleton-like search window tied to the launcher instance."""
key = "_search_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 = SearchWindow(parent, db_app)
setattr(parent, key, w)
return w