"""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("", self._on_dclick) self.tree.bind("", self._maybe_handle_heading_click, add=True) self.tree.bind("", 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