diff --git a/diagnostica.py b/diagnostica.py new file mode 100644 index 0000000..cca6627 --- /dev/null +++ b/diagnostica.py @@ -0,0 +1,274 @@ +"""Operational diagnostic checks for warehouse data quadrature.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk +from typing import Any + +import customtkinter as ctk + +from busy_overlay import InlineBusyOverlay +from gestione_aree import AsyncRunner +from locale_text import load_locale_catalog, text as loc_text +from runtime_support import log_exception +from ui_tables import style_treeview, zebra_tag +from ui_theme import theme_color, theme_font, theme_section, theme_value +from version_info import module_version, versioned_title +from window_placement import place_window_fullsize_below_parent_later + +__version__ = module_version(__name__) + + +SQL_SHIPPED_NOT_DISASSOCIATED = """ +SELECT DISTINCT + LTRIM(RTRIM(g.BarcodePallet)) AS UDC, + CAST('Spedita ma non disassociata' AS varchar(64)) AS Diagnostica, + CONCAT( + RTRIM(c.Corsia), + ' - ', + RTRIM(CAST(c.Colonna AS varchar(32))), + ' - ', + RTRIM(CAST(c.Fila AS varchar(32))) + ) AS NomeSimbolicoCella +FROM dbo.XMag_GiacenzaPallet AS g +INNER JOIN dbo.Celle AS c + ON c.ID = g.IDCella +INNER JOIN dbo.py_XMag_ViewPackingListStorico AS chiuse + ON LTRIM(RTRIM(chiuse.Pallet)) COLLATE Latin1_General_CI_AS = + LTRIM(RTRIM(g.BarcodePallet)) COLLATE Latin1_General_CI_AS +WHERE chiuse.StatoDocumento = 'D' + AND g.IDCella <> 9999 +ORDER BY NomeSimbolicoCella, UDC; +""" + + +def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(res, dict): + return [] + rows = res.get("rows") or [] + cols = res.get("columns") or [] + if rows and isinstance(rows[0], dict): + return [row for row in rows if isinstance(row, dict)] + + out: list[dict[str, Any]] = [] + for row in rows: + if isinstance(row, (list, tuple)) and cols: + out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))}) + return out + + +class DiagnosticaWindow(ctk.CTkToplevel): + """Window collecting read-only diagnostic queries.""" + + def __init__(self, parent: tk.Widget, db_client, session=None): + super().__init__(parent) + self.db_client = db_client + self.session = session + self._theme = theme_section("diagnostics_window", theme_section("search_window", {})) + self._locale_catalog = load_locale_catalog() + self._async = AsyncRunner(self) + self._busy = InlineBusyOverlay(self, self._theme) + self._load_in_progress = False + self._current_check = "" + self.var_status = tk.StringVar(value="") + + self.title( + versioned_title( + loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"), + __name__, + ) + ) + self.geometry(str(theme_value(self._theme, "window_geometry", "1100x680"))) + minsize = theme_value(self._theme, "window_minsize", [900, 560]) + self.minsize(int(minsize[0]), int(minsize[1])) + try: + self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f"))) + except Exception: + pass + + self._build_ui() + + def _is_alive(self) -> bool: + try: + return bool(self.winfo_exists()) + except tk.TclError: + return False + + def _hide_busy_safe(self) -> None: + try: + self._busy.hide() + except Exception as exc: + log_exception(__name__, exc, context="hide busy diagnostica") + + def _build_ui(self) -> None: + self.grid_rowconfigure(2, weight=1) + self.grid_columnconfigure(0, weight=1) + + top = ctk.CTkFrame( + self, + fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")), + ) + top.grid(row=0, column=0, sticky="ew", padx=8, pady=8) + top.grid_columnconfigure(1, weight=1) + + button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")) + ctk.CTkButton( + top, + text=loc_text( + "diagnostics.button.shipped_not_disassociated", + catalog=self._locale_catalog, + default="UDC spedite non disassociate", + ), + command=self._load_shipped_not_disassociated, + font=button_font, + ).grid(row=0, column=0, sticky="w", padx=(0, 8)) + + ctk.CTkLabel( + self, + textvariable=self.var_status, + anchor="w", + font=theme_font(self._theme, "status_font", ("Segoe UI", 10, "bold")), + ).grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 6)) + + wrap = ctk.CTkFrame(self) + wrap.grid(row=2, column=0, sticky="nsew", padx=8, pady=(0, 8)) + wrap.grid_rowconfigure(0, weight=1) + wrap.grid_columnconfigure(0, weight=1) + + cols = ("UDC", "Diagnostica", "NomeSimbolicoCella") + self.tree = ttk.Treeview(wrap, columns=cols, show="headings") + headings = { + "UDC": ( + loc_text("diagnostics.col.udc", catalog=self._locale_catalog, default="UDC"), + 130, + "w", + ), + "Diagnostica": ( + loc_text( + "diagnostics.col.shipped_not_disassociated", + catalog=self._locale_catalog, + default="Spedita ma non disassociata", + ), + 280, + "w", + ), + "NomeSimbolicoCella": ( + loc_text( + "diagnostics.col.symbolic_cell", + catalog=self._locale_catalog, + default="Nome simbolico cella", + ), + 240, + "w", + ), + } + for col in cols: + text, width, anchor = headings[col] + self.tree.heading(col, text=text) + self.tree.column(col, width=width, anchor=anchor, stretch=True) + style_treeview( + self.tree, + style_name="Diagnostics.Treeview", + rowheight=22, + font=("", 9), + heading_font=("", 9, "bold"), + ) + + 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") + + def _set_status(self, text: str) -> None: + self.var_status.set(text) + + def _load_shipped_not_disassociated(self) -> None: + self._run_check( + key="shipped_not_disassociated", + sql=SQL_SHIPPED_NOT_DISASSOCIATED, + busy_text=loc_text( + "diagnostics.busy.shipped_not_disassociated", + catalog=self._locale_catalog, + default="Cerco UDC spedite ma ancora in cella...", + ), + ) + + def _run_check(self, *, key: str, sql: str, busy_text: str) -> None: + if self._load_in_progress or not self._is_alive(): + return + + async def job(): + return await self.db_client.query_json(sql, as_dict_rows=True) + + self._load_in_progress = True + self._current_check = key + try: + self._busy.show(busy_text) + self._async.run(job(), self._on_loaded, self._on_error) + except Exception as exc: + self._load_in_progress = False + self._hide_busy_safe() + log_exception(__name__, exc, context=f"avvio diagnostica {key}") + messagebox.showerror( + loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"), + str(exc), + parent=self if self._is_alive() else None, + ) + + def _on_loaded(self, res: dict[str, Any] | None) -> None: + self._load_in_progress = False + self._hide_busy_safe() + if not self._is_alive(): + return + try: + rows = _rows_to_dicts(res) + self.tree.delete(*self.tree.get_children("")) + for index, row in enumerate(rows): + self.tree.insert( + "", + "end", + values=( + row.get("UDC") or "", + row.get("Diagnostica") or "", + row.get("NomeSimbolicoCella") or "", + ), + tags=(zebra_tag(index),), + ) + self._set_status( + loc_text( + "diagnostics.status.rows", + catalog=self._locale_catalog, + default="Righe trovate: {count}", + ).format(count=len(rows)) + ) + except Exception as exc: + log_exception(__name__, exc, context="render diagnostica") + messagebox.showerror( + loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"), + str(exc), + parent=self, + ) + + def _on_error(self, exc: BaseException) -> None: + self._load_in_progress = False + self._hide_busy_safe() + log_exception(__name__, exc, context=f"query diagnostica {self._current_check}") + if not self._is_alive(): + return + messagebox.showerror( + loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"), + str(exc), + parent=self, + ) + + +def open_diagnostica_window(parent: tk.Misc, db_client, session=None) -> tk.Misc: + """Open the diagnostics window.""" + + win = DiagnosticaWindow(parent, db_client, session=session) + place_window_fullsize_below_parent_later(parent, win) + win.bind("", lambda _e: win.destroy()) + return win diff --git a/locale.json b/locale.json index 35a4d61..379d042 100644 --- a/locale.json +++ b/locale.json @@ -10,6 +10,7 @@ "launcher.history_udc": "Storico movimenti UDC", "launcher.pickinglist": "Gestione Picking List", "launcher.history_pickinglist": "Storico Picking List", + "launcher.diagnostics": "Diagnostica", "launcher.arrange": "Ridisponi finestre", "launcher.exit": "Esci", "launcher.already_running_title": "Warehouse", @@ -75,6 +76,13 @@ "history.picking.msg.title": "Storico Picking List", "history.picking.msg.load_error": "Errore caricamento:\n{error}", "history.picking.msg.detail_error": "Errore dettaglio:\n{error}", + "diagnostics.title": "Diagnostica", + "diagnostics.button.shipped_not_disassociated": "UDC spedite non disassociate", + "diagnostics.busy.shipped_not_disassociated": "Cerco UDC spedite ma ancora in cella...", + "diagnostics.status.rows": "Righe trovate: {count}", + "diagnostics.col.udc": "UDC", + "diagnostics.col.shipped_not_disassociated": "Spedita ma non disassociata", + "diagnostics.col.symbolic_cell": "Nome simbolico cella", "reset.title": "Gestione Corsie - svuotamento celle per corsia", "reset.label.aisle": "Corsia:", "reset.button.refresh": "Carica", @@ -125,6 +133,7 @@ "launcher.history_udc": "UDC Movement History", "launcher.pickinglist": "Picking List Management", "launcher.history_pickinglist": "Picking List History", + "launcher.diagnostics": "Diagnostics", "launcher.arrange": "Arrange windows", "launcher.exit": "Exit", "launcher.already_running_title": "Warehouse", @@ -190,6 +199,13 @@ "history.picking.msg.title": "Picking List History", "history.picking.msg.load_error": "Load failed:\n{error}", "history.picking.msg.detail_error": "Detail load failed:\n{error}", + "diagnostics.title": "Diagnostics", + "diagnostics.button.shipped_not_disassociated": "Shipped UDC not disassociated", + "diagnostics.busy.shipped_not_disassociated": "Searching shipped UDC still assigned to cells...", + "diagnostics.status.rows": "Rows found: {count}", + "diagnostics.col.udc": "UDC", + "diagnostics.col.shipped_not_disassociated": "Shipped but not disassociated", + "diagnostics.col.symbolic_cell": "Symbolic cell name", "reset.title": "Aisle Management - empty cells by aisle", "reset.label.aisle": "Aisle:", "reset.button.refresh": "Load", diff --git a/main.py b/main.py index bdf0d71..889d467 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from async_loop_singleton import get_global_loop, stop_global_loop from async_msssql_query import AsyncMSSQLClient from audit_log import log_session_event from db_config import build_dsn_from_config, ensure_db_config +from diagnostica import open_diagnostica_window from gestione_layout import open_layout_window from gestione_pickinglist import open_pickinglist_window from login_window import prompt_login @@ -161,6 +162,7 @@ class Launcher(ctk.CTk): "storico_udc", "pickinglist", "storico_pickinglist", + "diagnostica", ] def __init__(self, session: UserSession, db_client: AsyncMSSQLClient): @@ -268,6 +270,15 @@ class Launcher(ctk.CTk): lambda: open_storico_pickinglist_window(self, self.db_client, session=self.session), ), ), + ( + "diagnostica", + loc_text("launcher.diagnostics", catalog=self._locale_catalog, default="Diagnostica"), + "launcher.open_diagnostics", + lambda: self._open_or_focus_child_window( + "diagnostica", + lambda: open_diagnostica_window(self, self.db_client, session=self.session), + ), + ), ( "arrange", loc_text("launcher.arrange", catalog=self._locale_catalog, default="Ridisponi finestre"), diff --git a/tooltip.json b/tooltip.json index bf1c837..f54f8ed 100644 --- a/tooltip.json +++ b/tooltip.json @@ -8,6 +8,7 @@ "launcher.open_history_udc": "Apre lo storico movimenti UDC per ricostruire carichi, scarichi, celle e utenti coinvolti.", "launcher.open_pickinglist": "Apre la gestione delle picking list per prenotare, controllare e aggiornare le liste di prelievo.", "launcher.open_history_pickinglist": "Apre lo storico picking list per consultare liste, stato operativo e dettaglio UDC.", + "launcher.open_diagnostics": "Apre la diagnostica operativa con quadrature e controlli di coerenza sui dati di magazzino.", "launcher.arrange_windows": "Dispone in cascata le finestre aperte seguendo l'ordine dei pulsanti del launcher.", "launcher.exit": "Chiude l'applicazione in modo pulito terminando la sessione utente e rilasciando la connessione condivisa al database.", "dbconfig.heading": "Spiega che qui si inseriscono i dati minimi per permettere al programma di collegarsi al database del magazzino al primo avvio.", @@ -42,6 +43,7 @@ "launcher.open_history_udc": "Open UDC movement history to review loads, unloads, cells and users involved.", "launcher.open_pickinglist": "Open picking list management to reserve, inspect and update picking lists.", "launcher.open_history_pickinglist": "Open picking-list history to inspect lists, operational status and UDC detail.", + "launcher.open_diagnostics": "Open operational diagnostics with data consistency checks and warehouse quadratures.", "launcher.arrange_windows": "Arrange open windows in cascade order following the launcher buttons.", "launcher.exit": "Close the application cleanly by ending the user session and releasing the shared database connection.", "dbconfig.heading": "Explain that this form collects the minimum data needed to connect the program to the warehouse database on first startup.", diff --git a/user_session.py b/user_session.py index cdaec01..87727fe 100644 --- a/user_session.py +++ b/user_session.py @@ -20,6 +20,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset( "launcher.open_history_udc", "launcher.open_pickinglist", "launcher.open_history_pickinglist", + "launcher.open_diagnostics", "launcher.arrange_windows", "launcher.exit", "reset_corsie.view", @@ -27,6 +28,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset( "non_shelved.view", "history_udc.view", "history_pickinglist.view", + "diagnostics.view", "multi_udc.view", "layout.view", "layout.carico", diff --git a/version_info.py b/version_info.py index 3401898..7eea97a 100644 --- a/version_info.py +++ b/version_info.py @@ -12,12 +12,13 @@ MODULE_VERSIONS: dict[str, str] = { "async_loop_singleton": "1.0.0", "async_msssql_query": "1.0.1", "audit_log": "1.0.0", - "main": "1.0.1", + "main": "1.0.2", "barcode_client": "1.0.26", "barcode_repository": "1.0.12", "barcode_service": "1.0.24", "busy_overlay": "1.0.0", "db_config": "1.0.0", + "diagnostica": "1.0.0", "gestione_aree": "1.0.1", "gestione_layout": "1.0.2", "gestione_pickinglist": "1.0.4", @@ -33,7 +34,7 @@ MODULE_VERSIONS: dict[str, str] = { "tooltips": "1.0.0", "udc_non_scaffalate": "1.0.1", "ui_theme": "1.0.0", - "user_session": "1.0.0", + "user_session": "1.0.1", "view_celle_multi_udc": "1.0.0", "window_placement": "1.0.0", }