428 lines
16 KiB
Python
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
|