From 6ab42a23035ff3d0b992ae5345c7930b61e60b9a Mon Sep 17 00:00:00 2001 From: allebonvi Date: Sat, 9 May 2026 12:18:59 +0200 Subject: [PATCH] Checkpoint before ghost pallet cleanup workflow --- audit_log.py | 102 ++ docs/api_reference.rst | 16 +- docs/architecture.md | 6 +- docs/flows/README.md | 5 +- docs/flows/async_loop_singleton_flow.md | 2 +- ...me_async_flow.md => gestione_aree_flow.md} | 2 +- ...window_flow.md => gestione_layout_flow.md} | 11 +- docs/flows/gestione_pickinglist_flow.md | 2 +- docs/flows/index.rst | 7 +- docs/flows/main_flow.md | 8 +- ...e_flow.md => view_celle_multi_udc_flow.md} | 2 +- docs/flows/warehouse_operational_flow.md | 167 ++ fix_layout_window.py | 4 +- fix_query.py | 4 +- ...ne_aree_frame_async.py => gestione_aree.py | 58 +- gestione_layout.py | 1372 +++++++++++++++++ gestione_pickinglist.py | 640 +++++++- gestione_scarico.py | 734 +++++++++ layout_window.py | 698 --------- login_window.py | 251 +++ main.py | 210 ++- prenota_sprenota_sql.py | 189 ++- pyproject.toml | 2 + reset_corsie.py | 26 +- search_pallets.py | 9 +- user_session.py | 88 ++ ...lle_multiple.py => view_celle_multi_udc.py | 305 +++- 27 files changed, 3947 insertions(+), 973 deletions(-) create mode 100644 audit_log.py rename docs/flows/{gestione_aree_frame_async_flow.md => gestione_aree_flow.md} (97%) rename docs/flows/{layout_window_flow.md => gestione_layout_flow.md} (82%) rename docs/flows/{view_celle_multiple_flow.md => view_celle_multi_udc_flow.md} (98%) create mode 100644 docs/flows/warehouse_operational_flow.md rename gestione_aree_frame_async.py => gestione_aree.py (73%) create mode 100644 gestione_layout.py create mode 100644 gestione_scarico.py delete mode 100644 layout_window.py create mode 100644 login_window.py create mode 100644 user_session.py rename view_celle_multiple.py => view_celle_multi_udc.py (62%) diff --git a/audit_log.py b/audit_log.py new file mode 100644 index 0000000..ab6232d --- /dev/null +++ b/audit_log.py @@ -0,0 +1,102 @@ +"""Central textual audit log for user-driven warehouse operations.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from user_session import UserSession + + +AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log") +_LOGGER = logging.getLogger("warehouse_audit") +_LOGGER_CONFIGURED = False + + +def _configure_logger() -> None: + """Configure the append-only textual audit logger once.""" + + global _LOGGER_CONFIGURED + if _LOGGER_CONFIGURED: + return + + _LOGGER.setLevel(logging.INFO) + _LOGGER.propagate = False + handler = logging.FileHandler(AUDIT_LOG_PATH, encoding="utf-8") + handler.setFormatter(logging.Formatter("%(asctime)s | %(message)s")) + _LOGGER.addHandler(handler) + _LOGGER_CONFIGURED = True + + +def _user_label(session: UserSession | None) -> str: + """Render the user identity consistently in the audit trail.""" + + if session is None: + return "anonymous" + return f"{session.login or 'anonymous'}#{session.operator_id}" + + +def _payload( + *, + session: UserSession | None, + module: str, + action: str, + outcome: str, + target: str = "", + details: dict[str, Any] | None = None, +) -> str: + """Serialize one audit event as a compact text line.""" + + base = { + "user": _user_label(session), + "module": module, + "action": action, + "outcome": outcome, + "target": target, + "details": details or {}, + } + return json.dumps(base, ensure_ascii=False, default=str) + + +def log_user_action( + session: UserSession | None, + *, + module: str, + action: str, + outcome: str, + target: str = "", + details: dict[str, Any] | None = None, +) -> None: + """Write one user action event to the textual audit file.""" + + _configure_logger() + _LOGGER.info( + _payload( + session=session, + module=module, + action=action, + outcome=outcome, + target=target, + details=details, + ) + ) + + +def log_session_event( + session: UserSession | None, + *, + action: str, + outcome: str, + details: dict[str, Any] | None = None, +) -> None: + """Write one session lifecycle event to the textual audit file.""" + + log_user_action( + session, + module="session", + action=action, + outcome=outcome, + details=details, + ) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index d790b83..54fd578 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -20,18 +20,18 @@ async_msssql_query.py :undoc-members: :show-inheritance: -gestione_aree_frame_async.py +gestione_aree.py ---------------------------- -.. automodule:: gestione_aree_frame_async +.. automodule:: gestione_aree :members: :undoc-members: :show-inheritance: -layout_window.py ----------------- +gestione_layout.py +------------------ -.. automodule:: layout_window +.. automodule:: gestione_layout :members: :undoc-members: :show-inheritance: @@ -44,10 +44,10 @@ reset_corsie.py :undoc-members: :show-inheritance: -view_celle_multiple.py ----------------------- +view_celle_multi_udc.py +----------------------- -.. automodule:: view_celle_multiple +.. automodule:: view_celle_multi_udc :members: :undoc-members: :show-inheritance: diff --git a/docs/architecture.md b/docs/architecture.md index 4681e09..a474962 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,12 +12,12 @@ flowchart TD Main --> DB["AsyncMSSQLClient"] Launcher --> Reset["reset_corsie.py"] - Launcher --> Layout["layout_window.py"] - Launcher --> Ghost["view_celle_multiple.py"] + Launcher --> Layout["gestione_layout.py"] + Launcher --> Ghost["view_celle_multi_udc.py"] Launcher --> Search["search_pallets.py"] Launcher --> Picking["gestione_pickinglist.py"] - Reset --> Runner["gestione_aree_frame_async.AsyncRunner"] + Reset --> Runner["gestione_aree.AsyncRunner"] Layout --> Runner Ghost --> Runner Search --> Runner diff --git a/docs/flows/README.md b/docs/flows/README.md index 8b1c017..495b63c 100644 --- a/docs/flows/README.md +++ b/docs/flows/README.md @@ -12,12 +12,13 @@ I diagrammi sono scritti in Mermaid, quindi possono essere: ## Indice - [main](./main_flow.md) -- [layout_window](./layout_window_flow.md) +- [gestione_layout](./gestione_layout_flow.md) - [reset_corsie](./reset_corsie_flow.md) -- [view_celle_multiple](./view_celle_multiple_flow.md) +- [view_celle_multi_udc](./view_celle_multi_udc_flow.md) - [search_pallets](./search_pallets_flow.md) - [gestione_pickinglist](./gestione_pickinglist_flow.md) - [infrastruttura async/db](./async_db_flow.md) +- [warehouse operational flow](./warehouse_operational_flow.md) ## Convenzioni diff --git a/docs/flows/async_loop_singleton_flow.md b/docs/flows/async_loop_singleton_flow.md index 395dfbb..6a76c50 100644 --- a/docs/flows/async_loop_singleton_flow.md +++ b/docs/flows/async_loop_singleton_flow.md @@ -35,5 +35,5 @@ flowchart TD ## Note - E un helper minimale usato da `main.py`. -- Il modulo esiste separato da `gestione_aree_frame_async.py`, ma concettualmente +- Il modulo esiste separato da `gestione_aree.py`, ma concettualmente svolge lo stesso ruolo di gestione del loop condiviso. diff --git a/docs/flows/gestione_aree_frame_async_flow.md b/docs/flows/gestione_aree_flow.md similarity index 97% rename from docs/flows/gestione_aree_frame_async_flow.md rename to docs/flows/gestione_aree_flow.md index cb10953..112c3ab 100644 --- a/docs/flows/gestione_aree_frame_async_flow.md +++ b/docs/flows/gestione_aree_flow.md @@ -1,4 +1,4 @@ -# `gestione_aree_frame_async.py` +# `gestione_aree.py` ## Scopo diff --git a/docs/flows/layout_window_flow.md b/docs/flows/gestione_layout_flow.md similarity index 82% rename from docs/flows/layout_window_flow.md rename to docs/flows/gestione_layout_flow.md index 818d079..58bbf5b 100644 --- a/docs/flows/layout_window_flow.md +++ b/docs/flows/gestione_layout_flow.md @@ -1,10 +1,11 @@ -# `layout_window.py` +# `gestione_layout.py` ## Scopo Questo modulo visualizza il layout delle corsie come matrice di celle, mostra lo stato di occupazione, consente di cercare una UDC e permette l'export della -matrice. +matrice. La griglia ad alte prestazioni e' resa con `tksheet`, mantenendo la +stessa semantica visiva delle celle operative. ## Flusso operativo @@ -57,5 +58,7 @@ flowchart LR - Il modulo usa un token `_req_counter` per evitare che risposte async vecchie aggiornino la UI fuori ordine. - La statistica globale viene ricalcolata da query SQL, mentre quella della - corsia corrente usa la matrice già caricata in memoria. -- `destroy()` marca la finestra come non più attiva per evitare callback tardive. + corsia corrente usa la matrice gia' caricata in memoria. +- Il click destro su una cella riusa lo stesso menu contestuale della versione + precedente basata su pulsanti CTk. +- `destroy()` marca la finestra come non piu' attiva per evitare callback tardive. diff --git a/docs/flows/gestione_pickinglist_flow.md b/docs/flows/gestione_pickinglist_flow.md index e5aca94..c652351 100644 --- a/docs/flows/gestione_pickinglist_flow.md +++ b/docs/flows/gestione_pickinglist_flow.md @@ -13,7 +13,7 @@ Questo modulo gestisce la vista master/detail delle picking list e permette di: ```{mermaid} flowchart TD - A["open_pickinglist_window() da main.py"] --> B["create_pickinglist_frame()"] + A["open_pickinglist_window() in gestione_pickinglist.py"] --> B["create_frame()"] B --> C["GestionePickingListFrame.__init__()"] C --> D["_build_layout()"] D --> E["after_idle(_first_show)"] diff --git a/docs/flows/index.rst b/docs/flows/index.rst index 9c2f9b0..d95ef65 100644 --- a/docs/flows/index.rst +++ b/docs/flows/index.rst @@ -9,12 +9,13 @@ infrastrutturali. README.md main_flow.md - layout_window_flow.md + gestione_layout_flow.md reset_corsie_flow.md - view_celle_multiple_flow.md + view_celle_multi_udc_flow.md search_pallets_flow.md gestione_pickinglist_flow.md + warehouse_operational_flow.md async_db_flow.md async_msssql_query_flow.md - gestione_aree_frame_async_flow.md + gestione_aree_flow.md async_loop_singleton_flow.md diff --git a/docs/flows/main_flow.md b/docs/flows/main_flow.md index cca4eb6..d892308 100644 --- a/docs/flows/main_flow.md +++ b/docs/flows/main_flow.md @@ -22,7 +22,7 @@ flowchart TD I --> K["open_layout_window()"] I --> L["open_celle_multiple_window()"] I --> M["open_search_window()"] - I --> N["open_pickinglist_window()"] + I --> N["gestione_pickinglist.open_pickinglist_window()"] ``` ## Schema di chiamata @@ -33,13 +33,13 @@ flowchart LR Launcher --> Layout["open_layout_window"] Launcher --> Ghost["open_celle_multiple_window"] Launcher --> Search["open_search_window"] - Launcher --> Pick["open_pickinglist_window"] - Pick --> PickFactory["create_pickinglist_frame"] + Launcher --> Pick["gestione_pickinglist.open_pickinglist_window"] + Pick --> PickFactory["gestione_pickinglist.create_frame"] ``` ## Note - `db_app` viene creato una sola volta e poi passato a tutte le finestre. - Alla chiusura del launcher viene chiamato `db_app.dispose()` sul loop globale. -- `open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende +- `gestione_pickinglist.open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende visibile solo a layout pronto, per ridurre lo sfarfallio iniziale. diff --git a/docs/flows/view_celle_multiple_flow.md b/docs/flows/view_celle_multi_udc_flow.md similarity index 98% rename from docs/flows/view_celle_multiple_flow.md rename to docs/flows/view_celle_multi_udc_flow.md index 5dd4187..58e23b9 100644 --- a/docs/flows/view_celle_multiple_flow.md +++ b/docs/flows/view_celle_multi_udc_flow.md @@ -1,4 +1,4 @@ -# `view_celle_multiple.py` +# `view_celle_multi_udc.py` ## Scopo diff --git a/docs/flows/warehouse_operational_flow.md b/docs/flows/warehouse_operational_flow.md new file mode 100644 index 0000000..9e75051 --- /dev/null +++ b/docs/flows/warehouse_operational_flow.md @@ -0,0 +1,167 @@ +# Warehouse Operational Flow + +Questo diagramma descrive il flusso operativo integrato tra: + +- layout di magazzino; +- gestione picking list; +- movimentazione fisica dei pallet; +- viste SQL e stored procedure coinvolte; +- aggiornamento dell'interfaccia dopo ogni operazione. + +## Vista d'insieme + +```{mermaid} +flowchart TD + UI["Operatore su interfaccia"] --> LAY["Gestione Layout"] + UI --> PL["Gestione Picking List"] + + subgraph Layout["Flusso Layout"] + LAY --> LC1["Caricamento corsie e metadati layout"] + LC1 --> LC2["vViewMappaturaDescrizioneCorsia"] + LC1 --> LC3["vViewMappaturaPosizCorsia"] + LC1 --> LC4["MagLayout"] + LC1 --> LC5["Celle / Magazzini"] + LC2 --> GRID["Rendering griglia scaffale"] + LC3 --> GRID + LC4 --> GRID + LC5 --> GRID + GRID --> LX["Click su cella / ricerca UDC / menu contestuale"] + end + + subgraph Picking["Flusso Picking List"] + PL --> PC1["Caricamento elenco documenti"] + PC1 --> PV1["XMag_ViewPackingList"] + PV1 --> PGRID["Griglia documenti aggregata"] + PGRID --> PSEL["Selezione documento"] + PSEL --> PV2["vViewPackingListRestante"] + PV2 --> PDET["Griglia dettaglio documento"] + PDET --> PACT["Prenota / S-prenota / consultazione"] + end + + subgraph Mov["Movimentazione pallet"] + LX --> MOVE{"Operazione fisica?"} + MOVE -->|Carico o spostamento| SP1["sp_xMagGestioneMagazziniPallet"] + MOVE -->|Scarico pallet| SP1 + PACT -->|Prenota o s-prenota| SP2["sp_xExePackingListPallet"] + SP1 --> M1["Aggiorna movimenti MagazziniPallet"] + SP1 --> M2["Aggiorna cella destinazione / origine"] + SP1 --> M3["Controlla prenotazione automatica"] + M3 --> SP3["sp_ControllaPrenotazionePackingListPalletNew"] + SP3 --> SP4["sp_xExePackingListPalletPrenota"] + SP2 --> M4["Aggiorna Celle.IDStato"] + SP2 --> M5["Scrive LogPackingList"] + SP4 --> M4 + end + + subgraph Views["Ricostruzione contesto"] + M1 --> GV["XMag_GiacenzaPallet"] + M2 --> CV["Celle"] + GV --> XP["XMag_ViewPackingList"] + GV --> TP["vXTracciaProdotti"] + CV --> XP + XP --> RL1["Stato documento / pallet / ubicazione"] + TP --> RL2["Articolo / lotto / descrizione"] + end + + RL1 --> LREF["Refresh Layout / Picking List"] + RL2 --> LREF + LREF --> GRID + LREF --> PGRID + LREF --> PDET +``` + +## Flusso del layout + +```{mermaid} +flowchart TD + A["Apertura Gestione Layout"] --> B["Legge mappatura corsie"] + B --> B1["vViewMappaturaDescrizioneCorsia"] + B --> B2["vViewMappaturaPosizCorsia"] + B --> B3["MagLayout"] + B --> B4["Celle / Magazzini"] + B1 --> C["Costruisce geometria scaffale"] + B2 --> C + B3 --> C + B4 --> C + C --> D["Query giacenza pallet per corsia"] + D --> E["Colora celle: vuota / piena / multipla"] + E --> F["Mostra UDC piu recente nella cella"] + F --> G{"Interazione utente"} + G -->|Ricerca UDC| H["Evidenzia cella in blu"] + G -->|Tasto destro su cella rossa| I["Menu contestuale"] + I --> J["Dialog scarico / analisi multi UDC"] + J --> K["Movimentazione fisica pallet"] + K --> L["Refresh layout"] +``` + +## Flusso della picking list + +```{mermaid} +flowchart TD + A["Apertura Gestione Picking List"] --> B["Query aggregata documenti"] + B --> C["XMag_ViewPackingList"] + C --> D["Una riga per documento"] + D --> E["Utente seleziona il documento"] + E --> F["Query dettaglio documento"] + F --> G["vViewPackingListRestante"] + G --> H["Mostra pallet ancora rilevanti per il documento"] + H --> I{"Azione utente"} + I -->|Prenota o s-prenota| J["sp_xExePackingListPallet"] + I -->|Consulta dettaglio| H + J --> K["Aggiorna IDStato delle celle del documento"] + K --> L["LogPackingList"] + K --> M["Refresh lista documenti"] + K --> N["Refresh dettaglio documento"] +``` + +## Flusso della movimentazione pallet + +```{mermaid} +flowchart TD + A["Utente legge barcode pallet e cella"] --> B["sp_xMagGestioneMagazziniPallet"] + B --> C{"Il pallet esiste gia in giacenza?"} + C -->|No| D["Inserisce movimento V sulla nuova cella"] + C -->|Si| E["Inserisce movimento P sulla vecchia cella"] + E --> F["Inserisce movimento V sulla nuova cella"] + D --> G["Aggiorna ricostruzione giacenza"] + F --> G + G --> H["XMag_GiacenzaPallet"] + H --> I["XMag_ViewPackingList"] + H --> J["vXTracciaProdotti"] + I --> K["Ubicazione e stato documento"] + J --> L["Dati articolo e lotto"] + K --> M["Refresh interfaccia"] + L --> M +``` + +## Significato delle viste e delle stored procedure + +- `XMag_ViewPackingList`: + ricostruisce il collegamento tra pallet, documento, ubicazione e stato + logistico. E' la vista principale per la schermata picking list. +- `vViewPackingListRestante`: + mostra il dettaglio operativo del documento, cioe' le righe ancora visibili + e ordinate per ubicazione. +- `vXTracciaProdotti`: + arricchisce il pallet con lotto, codice articolo e descrizione. +- `sp_xMagGestioneMagazziniPallet`: + esegue il movimento fisico del pallet nel magazzino. +- `sp_xExePackingListPallet`: + fa il toggle di prenotazione delle celle coinvolte in una picking list. +- `sp_xExePackingListPalletPrenota`: + forza la prenotazione a `IDStato = 1`. +- `sp_ControllaPrenotazionePackingListPalletNew`: + controlla se, dopo una movimentazione fisica, debba essere riapplicata una + prenotazione automatica sulle celle coinvolte. + +## Lettura pratica del sistema + +- Il layout risponde alla domanda: + "dove sono i pallet e come sono distribuiti nello scaffale?" +- La picking list risponde alla domanda: + "quali pallet fanno parte di un documento e quali celle sono prenotate?" +- La movimentazione pallet risponde alla domanda: + "cosa succede nel tracciato quando un pallet viene caricato, spostato o + scaricato?" +- Le viste vengono rilette dopo l'operazione per riportare la UI in uno stato + coerente con il database. diff --git a/fix_layout_window.py b/fix_layout_window.py index acc9619..df86cc6 100644 --- a/fix_layout_window.py +++ b/fix_layout_window.py @@ -1,4 +1,4 @@ -"""One-off maintenance script to sanitize ``border_color`` usage in ``layout_window``. +"""One-off maintenance script to sanitize ``border_color`` usage in ``gestione_layout``. The script removes incompatible ``border_color='transparent'`` assignments from widget configuration calls while preserving explicit highlight colors that are @@ -9,7 +9,7 @@ import re from pathlib import Path # Path default (modifica se serve) -p = Path("./layout_window.py") +p = Path("./gestione_layout.py") if not p.exists(): raise SystemExit(f"File non trovato: {p}") diff --git a/fix_query.py b/fix_query.py index 377b2fc..dfbd94d 100644 --- a/fix_query.py +++ b/fix_query.py @@ -1,4 +1,4 @@ -"""One-off maintenance script to patch performance issues in ``layout_window``. +"""One-off maintenance script to patch performance issues in ``gestione_layout``. The script was used during development to remove an expensive resize-triggered refresh and to inject some lifecycle guards into the window implementation. @@ -8,7 +8,7 @@ It is kept in the repository as an auditable patch recipe. from pathlib import Path import re -p = Path("./layout_window.py") +p = Path("./gestione_layout.py") src = p.read_text(encoding="utf-8") backup = p.with_suffix(".py.bak_perf") diff --git a/gestione_aree_frame_async.py b/gestione_aree.py similarity index 73% rename from gestione_aree_frame_async.py rename to gestione_aree.py index a865525..52eb131 100644 --- a/gestione_aree_frame_async.py +++ b/gestione_aree.py @@ -1,22 +1,26 @@ """Shared Tk/async helpers used by multiple warehouse windows. -The module bundles three concerns used throughout the GUI: +The module bundles two concerns used throughout the GUI: -* lifecycle of the shared background asyncio loop; * a modal-like busy overlay shown during long-running tasks; -* an ``AsyncRunner`` that schedules coroutines and re-enters Tk safely. +* an ``AsyncRunner`` that schedules coroutines on the shared loop and + re-enters Tk safely. + +The shared loop itself is defined only in :mod:`async_loop_singleton` and is +reused here instead of being recreated locally. """ from __future__ import annotations import asyncio -import threading import tkinter as tk from typing import Any, Callable, Optional import customtkinter as ctk -__VERSION__ = "GestioneAreeFrame v3.2.5-singleloop" +from async_loop_singleton import get_global_loop + +__VERSION__ = "GestioneAreeFrame v3.3.0-singleloop" try: from async_msssql_query import AsyncMSSQLClient # noqa: F401 @@ -24,50 +28,6 @@ except Exception: AsyncMSSQLClient = object # type: ignore -class _LoopHolder: - """Keep references to the shared event loop and its worker thread.""" - - def __init__(self): - self.loop: Optional[asyncio.AbstractEventLoop] = None - self.thread: Optional[threading.Thread] = None - self.ready = threading.Event() - - -_GLOBAL = _LoopHolder() - - -def _run_loop(): - """Create and run the shared event loop inside the worker thread.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - _GLOBAL.loop = loop - _GLOBAL.ready.set() - loop.run_forever() - - -def get_global_loop() -> asyncio.AbstractEventLoop: - """Return the shared background event loop, creating it if needed.""" - if _GLOBAL.loop is not None: - return _GLOBAL.loop - _GLOBAL.thread = threading.Thread(target=_run_loop, name="warehouse-asyncio", daemon=True) - _GLOBAL.thread.start() - _GLOBAL.ready.wait(timeout=5.0) - if _GLOBAL.loop is None: - raise RuntimeError("Impossibile avviare l'event loop globale") - return _GLOBAL.loop - - -def stop_global_loop(): - """Stop the shared event loop and release thread references.""" - if _GLOBAL.loop and _GLOBAL.loop.is_running(): - _GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop) - if _GLOBAL.thread: - _GLOBAL.thread.join(timeout=2.0) - _GLOBAL.loop = None - _GLOBAL.thread = None - _GLOBAL.ready.clear() - - class BusyOverlay: """Semi-transparent overlay used to block interaction during async tasks.""" diff --git a/gestione_layout.py b/gestione_layout.py new file mode 100644 index 0000000..190e760 --- /dev/null +++ b/gestione_layout.py @@ -0,0 +1,1372 @@ +"""Warehouse layout management window. + +This module renders one aisle as a high-volume matrix, exposes search and +statistics helpers, and relies on ``tksheet`` to keep the layout responsive +even when the number of cells grows significantly. +""" + +from __future__ import annotations +import json +import logging +import sys +import tkinter as tk +from tkinter import Menu, messagebox, filedialog, simpledialog +import customtkinter as ctk +from datetime import datetime +from functools import wraps +from pathlib import Path +from typing import Any + +from audit_log import log_user_action +from gestione_aree import BusyOverlay, AsyncRunner +from gestione_scarico import DEFAULT_SCARICO_USER, move_pallet_async, open_scarico_dialog +from tksheet import Sheet, natural_sort_key +from user_session import UserSession + +try: + from loguru import logger +except Exception: # pragma: no cover - fallback used only when Loguru is not available + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + +# ---- 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" +HEADER_BG = "#E8EDF5" +HEADER_FG = "#1A1A1A" +STATE_PRENOTATA = 1 +STATE_DISABILITATA = 3 +PRENOTATA_BG = "#F6E26B" +DISABLED_BG = "#555555" +DISABLED_FG = "#F4F4F4" + +SQL_SET_CELL_PRENOTAZIONE = """ +UPDATE dbo.Celle + SET IDStato = :stato, + ModUtente = :utente, + ModDataOra = GETDATE() + WHERE ID = :idcella; +""" + +LAYOUT_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = LAYOUT_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if LAYOUT_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: str | None = None): + """Trace entry, exit and failure of selected high-level functions.""" + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + return wrapper + return decorator + + +def _log_sql(query_name: str, sql: str, params: dict[str, Any]): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: list[Any]): + """Log query results at summary or full-debug level depending on the flag.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if LAYOUT_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={LAYOUT_LOG_MODE.upper()}" + ) + + +def pct_text(p_full: float, p_double: float | None = None) -> str: + """Format occupancy percentages for the progress-bar labels.""" + 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 è resa in una griglia ad alte prestazioni (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 + """ + @_log_call() + def __init__(self, parent: tk.Widget, db_app, session: UserSession | None = None): + """Create the window and initialize the state used by the matrix view.""" + super().__init__(parent) + self.title("Warehouse - Layout corsie") + self.geometry("1200x740") + self.minsize(980, 560) + self.resizable(True, True) + + self.db = db_app + self.session = session + 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.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 "") + self.cell_ids: list[list[int]] = [] + self.cell_logical_state: list[list[int]] = [] + self.sheet_to_matrix_rows: list[int] = [] + + # 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._alive = True + self._stats_after_id = None # se mai userai un refresh periodico, potremo cancellarlo qui + + self._build_top() + self._build_matrix_host() + self._build_stats() + + self._load_corsie() + # disabilitato: il refresh ad ogni generava molte query/lag + # self.bind("", lambda e: self.after_idle(self._refresh_stats)) + + # ---------------- TOP BAR ---------------- + def _build_top(self): + """Create the top toolbar with aisle selection and search controls.""" + 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("<>", 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): + """Create the container that hosts the high-volume warehouse matrix.""" + 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) + self.host.grid_rowconfigure(0, weight=1) + self.host.grid_columnconfigure(0, weight=1) + + self.sheet = Sheet( + self.host, + show_header=True, + show_row_index=True, + show_top_left=True, + width=1000, + height=520, + default_column_width=118, + default_row_height=44, + set_all_heights_and_widths=True, + align="center", + table_wrap="c", + show_default_header_for_empty=False, + show_default_index_for_empty=False, + sort_key=natural_sort_key, + font=("Segoe UI", 10, "normal"), + header_font=("Segoe UI", 11, "bold"), + index_font=("Segoe UI", 11, "bold"), + table_bg="#F6F7F9", + table_fg=FG_DARK, + table_grid_fg="#FFFFFF", + header_bg=HEADER_BG, + header_fg=HEADER_FG, + header_grid_fg="#D5DCE7", + header_selected_cells_bg=HEADER_BG, + header_selected_cells_fg=HEADER_FG, + index_bg=HEADER_BG, + index_fg=HEADER_FG, + index_grid_fg="#D5DCE7", + index_selected_cells_bg=HEADER_BG, + index_selected_cells_fg=HEADER_FG, + table_selected_cells_border_fg="#0B57D0", + table_selected_cells_bg="#DCE9FF", + table_selected_cells_fg="#0B57D0", + frame_bg="#F6F7F9", + outline_thickness=0, + ) + self.sheet.enable_bindings( + "single_select", + "drag_select", + "arrowkeys", + "prior", + "next", + "copy", + "select_all", + "rc_select", + ) + self.sheet.readonly("all") + self.sheet.set_index_width(92, redraw=False) + self.sheet.bind("", self._on_sheet_right_click, add="+") + self.sheet.grid(row=0, column=0, sticky="nsew") + + # The top-left corner of the grid acts as the visual "scaffale" marker. + self.sheet_corner = ctk.CTkLabel( + self.host, + text="", + width=92, + height=28, + corner_radius=0, + fg_color=HEADER_BG, + text_color=HEADER_FG, + font=("Segoe UI", 12, "bold"), + ) + self.sheet_corner.place(x=1, y=1) + self.sheet_corner.lift() + + def _column_header_labels(self, cols: int, col_txt: list[list[str]]) -> list[str]: + """Build fixed column headers using the physical stack numbers.""" + labels: list[str] = [] + for c in range(cols): + value = "" + for r in range(len(col_txt)): + if c < len(col_txt[r]) and str(col_txt[r][c] or "").strip(): + value = str(col_txt[r][c]).strip() + break + try: + normalized = str(int(value)) + except Exception: + normalized = value or str(c + 1) + labels.append(f"Pila {normalized}") + return labels + + def _row_header_labels(self, rows: int, fila_txt: list[list[str]]) -> list[str]: + """Build fixed row headers using the lowercase physical level labels.""" + labels: list[str] = [] + for sheet_row in range(rows): + matrix_row = (rows - 1) - sheet_row + value = "" + if 0 <= matrix_row < len(fila_txt): + for cell_value in fila_txt[matrix_row]: + if str(cell_value or "").strip(): + value = str(cell_value).strip() + break + labels.append(f"Livello {(value or str(matrix_row + 1)).lower()}") + return labels + + def _scaffale_corner_label(self, corsia: str) -> str: + """Return the uppercase scaffale letter shown in the top-left corner.""" + letters = "".join(ch for ch in str(corsia or "") if ch.isalpha()).upper() + if letters: + return letters[-1] + return str(corsia or "").strip().upper() + + def _state_colors(self, state: int) -> tuple[str, str]: + """Return background and foreground colors for one occupancy state.""" + if state == 0: + return COLOR_EMPTY, FG_DARK + if state == 1: + return COLOR_FULL, FG_DARK + return COLOR_DOUBLE, FG_LIGHT + + def _effective_cell_style(self, occupancy_state: int, logical_state: int) -> tuple[str, str]: + """Combine occupancy and logistic states into one rendered style.""" + + if int(logical_state or 0) == STATE_DISABILITATA: + return DISABLED_BG, DISABLED_FG + if int(logical_state or 0) == STATE_PRENOTATA: + return PRENOTATA_BG, FG_DARK + return self._state_colors(occupancy_state) + + def _sheet_row_for_matrix_row(self, matrix_row: int) -> int: + """Map one logical matrix row to the displayed tksheet row.""" + return (len(self.matrix_state) - 1) - matrix_row + + def _apply_sheet_cell_style(self, matrix_row: int, matrix_col: int, state: int, redraw: bool = False): + """Apply the visual state associated with a cell occupancy level.""" + if not self.matrix_state: + return + logical_state = 0 + if self.cell_logical_state and matrix_row < len(self.cell_logical_state): + if matrix_col < len(self.cell_logical_state[matrix_row]): + logical_state = int(self.cell_logical_state[matrix_row][matrix_col] or 0) + bg, fg = self._effective_cell_style(state, logical_state) + sheet_row = self._sheet_row_for_matrix_row(matrix_row) + self.sheet.highlight_cells(cells=[(sheet_row, matrix_col)], bg=bg, fg=fg, redraw=redraw, overwrite=True) + + def _apply_sheet_styles(self): + """Paint all visible cells according to the current matrix state.""" + self.sheet.dehighlight_cells(all_=True, redraw=False) + for r, row in enumerate(self.matrix_state): + for c, state in enumerate(row): + self._apply_sheet_cell_style(r, c, state, redraw=False) + self.sheet.refresh() + + def _clear_highlight(self): + """Remove the temporary highlight from the previously focused cell.""" + if self._highlighted and self.matrix_state: + r, c = self._highlighted + try: + self._apply_sheet_cell_style(r, c, self.matrix_state[r][c], redraw=True) + except Exception: + pass + self._highlighted = None + + def _rebuild_matrix(self, rows: int, cols: int, state, logical_state, fila_txt, col_txt, desc, udc1, corsia): + """Recreate the visible cell matrix from the latest query result.""" + _MODULE_LOGGER.log( + _MODULE_LOG_LEVEL, + f"Rendering matrice corsia={corsia} rows={rows} cols={cols}", + ) + self._clear_highlight() + self.matrix_state, self.cell_logical_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = ( + state, + logical_state, + fila_txt, + col_txt, + desc, + udc1, + ) + self.sheet_to_matrix_rows = list(reversed(range(rows))) + + data = [["" for _ in range(cols)] for _ in range(rows)] + for r in range(rows): + for c in range(cols): + sheet_r = (rows - 1) - r + code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" + udc = udc1[r][c] or "" + logical = int(logical_state[r][c] or 0) if logical_state else 0 + marker = " [PREN]" if logical == STATE_PRENOTATA else (" [DIS]" if logical == STATE_DISABILITATA else "") + data[sheet_r][c] = f"{code}{marker}\n{udc}" + + self.sheet.set_sheet_data(data, reset_col_positions=True, reset_row_positions=True, redraw=False) + self.sheet.headers(self._column_header_labels(cols, col_txt), redraw=False) + self.sheet.row_index(self._row_header_labels(rows, fila_txt), redraw=False) + self.sheet.readonly("all") + self.sheet.set_all_column_widths(width=118, redraw=False) + self.sheet.set_all_row_heights(height=44, redraw=False) + self.sheet.set_index_width(92, redraw=False) + self.sheet_corner.configure(text=self._scaffale_corner_label(corsia)) + self._apply_sheet_styles() + + if getattr(self, '_alive', True) and 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) + + def _sheet_cell_to_matrix(self, sheet_row: int, sheet_col: int) -> tuple[int, int] | None: + """Map one displayed tksheet coordinate back to the logical matrix coordinate.""" + if not self.matrix_state or sheet_row < 0 or sheet_col < 0: + return None + if sheet_row >= len(self.sheet_to_matrix_rows): + return None + matrix_row = self.sheet_to_matrix_rows[sheet_row] + if sheet_col >= len(self.matrix_state[matrix_row]): + return None + return matrix_row, sheet_col + + def _on_sheet_right_click(self, event): + """Open the existing context menu for the cell under the pointer.""" + try: + if self.sheet.identify_region(event) != "table": + return + sheet_row = self.sheet.identify_row(event, exclude_index=True, allow_end=False) + sheet_col = self.sheet.identify_column(event, exclude_header=True, allow_end=False) + except Exception: + return + if sheet_row is None or sheet_col is None: + return + mapped = self._sheet_cell_to_matrix(sheet_row, sheet_col) + if not mapped: + return + self.sheet.select_cell(sheet_row, sheet_col, redraw=True, run_binding_func=False) + self._open_menu(event, mapped[0], mapped[1]) + + # ---------------- CONTEXT MENU ---------------- + def _open_menu(self, event, r, c): + """Open the context menu for a single matrix cell.""" + label = self._cell_label(r, c) + m = Menu(self, tearoff=0) + m.add_command( + label="Carico", + state="normal" if self._can("layout.carico") else "disabled", + command=lambda: self._prompt_carico(r, c), + ) + m.add_command( + label="Scarico", + state="normal" if self._can("layout.scarico") else "disabled", + command=lambda: self._scarica_from_menu(r, c), + ) + m.add_command( + label="Abilita cella", + state="normal" if self._can("layout.abilita_cella") else "disabled", + command=lambda: self._set_logical_state(r, c, 0), + ) + m.add_command( + label="Disabilita cella", + state="normal" if self._can("layout.disabilita_cella") else "disabled", + command=lambda: self._set_logical_state(r, c, STATE_DISABILITATA), + ) + 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 _can(self, action: str) -> bool: + """Return whether the current session can execute one layout action.""" + + return self.session.can(action) if self.session else False + + def _actor_login(self) -> str: + """Return the current application login or the technical fallback.""" + + if self.session and str(self.session.login or "").strip(): + return str(self.session.login).strip() + return DEFAULT_SCARICO_USER + + def _guard_action(self, action: str, message: str) -> bool: + """Gate one action and inform the user if it is not currently allowed.""" + + if self._can(action): + return True + messagebox.showwarning("Permesso negato", message, parent=self) + return False + + def _cell_label(self, r: int, c: int) -> str: + """Return the human-readable cell label used across the layout UI.""" + return f"{self.corsia_selezionata.get()}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}" + + def _cell_barcode(self, r: int, c: int) -> str: + """Return the barcode-style cell description used by the legacy movement logic.""" + return str(self.desc[r][c] or "").strip() + + def _cell_id(self, r: int, c: int) -> int: + """Return the internal cell id, or ``0`` when the matrix slot is not mapped.""" + if not self.cell_ids or r >= len(self.cell_ids) or c >= len(self.cell_ids[r]): + return 0 + try: + return int(self.cell_ids[r][c] or 0) + except Exception: + return 0 + + @_log_call() + def _prompt_carico(self, r: int, c: int): + """Prompt the operator for a pallet barcode and move it to the selected cell.""" + if not self._guard_action("layout.carico", "L'operatore corrente non puo' eseguire carichi."): + return + barcode = simpledialog.askstring( + "Carico", + f"Inserisci il barcode UDC da caricare in {self._cell_label(r, c)}:", + parent=self, + ) + barcode = str(barcode or "").strip() + if not barcode: + return + self._run_pallet_move( + barcode_pallet=barcode, + target_idcella=self._cell_id(r, c), + target_barcode_cella=self._cell_barcode(r, c), + success_message=f"Carico completato su {self._cell_label(r, c)}.", + busy_message=f"Carico UDC su {self._cell_label(r, c)}...", + ) + + @_log_call() + def _scarica_from_menu(self, r: int, c: int): + """Unload the current cell using the same logical semantics as the original app.""" + if not self._guard_action("layout.scarico", "L'operatore corrente non puo' eseguire scarichi."): + return + if self._logical_state_at(r, c) == STATE_DISABILITATA: + self._toast("La cella e' disabilitata.") + return + stato = int(self.matrix_state[r][c] or 0) + if stato <= 0: + self._toast("La cella selezionata non contiene alcuna UDC da scaricare.") + return + if stato >= 2: + self._open_scarico_dialog(r, c) + return + + barcode = str(self.udc1[r][c] or "").strip() + if not barcode: + self._toast("UDC non disponibile per lo scarico.") + return + if not messagebox.askyesno( + "Scarico", + f"Scaricare l'UDC {barcode} da {self._cell_label(r, c)}?", + parent=self, + ): + return + self._run_pallet_move( + barcode_pallet=barcode, + target_idcella=9999, + target_barcode_cella="9000000", + success_message=f"Scarico completato per {barcode}.", + busy_message=f"Scarico {barcode}...", + ) + + @_log_call() + def _run_pallet_move( + self, + *, + barcode_pallet: str, + target_idcella: int, + target_barcode_cella: str, + success_message: str, + busy_message: str, + ): + """Execute one pallet movement and refresh the current aisle afterwards.""" + barcode_pallet = str(barcode_pallet or "").strip() + if not barcode_pallet: + self._toast("Barcode UDC non valido.") + return + if int(target_idcella or 0) <= 0: + self._toast("Cella di destinazione non valida.") + return + + def _ok(result): + _MODULE_LOGGER.log( + _MODULE_LOG_LEVEL, + f"Movimento completato udc={barcode_pallet} target={target_barcode_cella} result={_format_payload(result)}", + ) + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="layout.scarico" if int(target_idcella) == 9999 else "layout.carico", + outcome="ok", + target=target_barcode_cella, + details={"barcode_pallet": barcode_pallet}, + ) + self._refresh_current() + self._toast(success_message) + + def _err(ex): + _MODULE_LOGGER.exception( + f"Errore movimento udc={barcode_pallet} target={target_barcode_cella}: {ex}" + ) + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="layout.scarico" if int(target_idcella) == 9999 else "layout.carico", + outcome="error", + target=target_barcode_cella, + details={"barcode_pallet": barcode_pallet, "error": str(ex)}, + ) + messagebox.showerror("Movimento UDC", f"Operazione fallita:\n{ex}", parent=self) + + self._async.run( + move_pallet_async( + self.db, + barcode_pallet=barcode_pallet, + target_idcella=int(target_idcella), + target_barcode_cella=str(target_barcode_cella or ""), + utente=self._actor_login(), + ), + _ok, + _err, + busy=self._busy, + message=busy_message, + ) + + @_log_call() + def _open_scarico_dialog(self, r: int, c: int): + """Open the modal unload dialog for one multi-UDC cell.""" + if not self._guard_action("layout.scarico", "L'operatore corrente non puo' eseguire scarichi."): + return + if not self.cell_ids or r >= len(self.cell_ids) or c >= len(self.cell_ids[r]): + _MODULE_LOGGER.info(f"Scarico non disponibile per cella r={r} c={c}: IDCella assente") + self._toast("IDCella non disponibile per lo scarico.") + return + idcella = self._cell_id(r, c) + if idcella <= 0: + _MODULE_LOGGER.info(f"Scarico non disponibile per cella r={r} c={c}: IDCella non valida ({idcella})") + self._toast("IDCella non valida per lo scarico.") + return + ubicazione = self._cell_label(r, c) + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Apro dialog di scarico per ubicazione={ubicazione} idcella={idcella}") + open_scarico_dialog( + self, + db_client=self.db, + idcella=idcella, + ubicazione=ubicazione, + on_completed=self._refresh_current, + session=self.session, + ) + + @_log_call() + def _set_prenotazione(self, r: int, c: int, stato: int): + """Apply the original reservation toggle semantics to the selected cell.""" + action = "layout.prenota" if int(stato) == 1 else "layout.libera_prenotazione" + if not self._guard_action(action, "L'operatore corrente non puo' modificare la prenotazione."): + return + idcella = self._cell_id(r, c) + if idcella <= 0: + self._toast("IDCella non valida per la prenotazione.") + return + params = { + "idcella": idcella, + "stato": int(stato), + "utente": self._actor_login(), + } + _log_sql("set_prenotazione", SQL_SET_CELL_PRENOTAZIONE, params) + + def _ok(_affected): + _MODULE_LOGGER.log( + _MODULE_LOG_LEVEL, + f"Prenotazione aggiornata idcella={idcella} stato={stato} ubicazione={self._cell_label(r, c)}", + ) + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action=action, + outcome="ok", + target=self._cell_label(r, c), + details={"idcella": idcella, "stato": int(stato)}, + ) + self._refresh_current() + self._toast( + "Prenotazione impostata." if stato == 1 else "Prenotazione liberata." + ) + + def _err(ex): + _MODULE_LOGGER.exception( + f"Errore aggiornamento prenotazione idcella={idcella} stato={stato}: {ex}" + ) + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action=action, + outcome="error", + target=self._cell_label(r, c), + details={"idcella": idcella, "stato": int(stato), "error": str(ex)}, + ) + messagebox.showerror("Prenotazione", f"Aggiornamento fallito:\n{ex}", parent=self) + + self._async.run( + self.db.exec(SQL_SET_CELL_PRENOTAZIONE, params, commit=True), + _ok, + _err, + busy=self._busy, + message="Aggiorno prenotazione...", + ) + + def _logical_state_at(self, r: int, c: int) -> int: + """Return the logical state stored for one cell.""" + + if not self.cell_logical_state or r >= len(self.cell_logical_state): + return 0 + if c >= len(self.cell_logical_state[r]): + return 0 + try: + return int(self.cell_logical_state[r][c] or 0) + except Exception: + return 0 + + @_log_call() + def _set_logical_state(self, r: int, c: int, stato: int): + """Set one logical warehouse state directly on the selected cell.""" + + action = "layout.disabilita_cella" if int(stato) == STATE_DISABILITATA else "layout.abilita_cella" + if not self._guard_action(action, "L'operatore corrente non puo' modificare lo stato della cella."): + return + idcella = self._cell_id(r, c) + if idcella <= 0: + self._toast("IDCella non valida.") + return + params = { + "idcella": idcella, + "stato": int(stato), + "utente": self._actor_login(), + } + _log_sql("set_logical_state", SQL_SET_CELL_PRENOTAZIONE, params) + + def _ok(_affected): + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action=action, + outcome="ok", + target=self._cell_label(r, c), + details={"idcella": idcella, "stato": int(stato)}, + ) + self._refresh_current() + self._toast("Cella disabilitata." if int(stato) == STATE_DISABILITATA else "Cella abilitata.") + + def _err(ex): + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action=action, + outcome="error", + target=self._cell_label(r, c), + details={"idcella": idcella, "stato": int(stato), "error": str(ex)}, + ) + messagebox.showerror("Stato cella", f"Aggiornamento fallito:\n{ex}", parent=self) + + self._async.run( + self.db.exec(SQL_SET_CELL_PRENOTAZIONE, params, commit=True), + _ok, + _err, + busy=self._busy, + message="Aggiorno stato cella...", + ) + + @_log_call() + def _set_cell(self, r, c, val): + """Update a cell state in memory and refresh the local statistics.""" + self.matrix_state[r][c] = val + self._apply_sheet_cell_style(r, c, val, redraw=True) + self._refresh_stats() + + # ---------------- STATS ---------------- + def _build_stats(self): + """Create progress bars, labels and legend for occupancy statistics.""" + 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) + self._legend(leg, 7, "Prenotata", PRENOTATA_BG) + self._legend(leg, 9, "Disabilitata", DISABLED_BG) + + def _legend(self, parent, col, text, color): + """Add a legend entry describing one matrix 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 ---------------- + @_log_call() + def _load_corsie(self): + """Load the list of aisles available for visualization.""" + 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; + """ + _log_sql("load_corsie", sql, {}) + + def _ok(res): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset("load_corsie", rows) + 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._busy.hide() + self.after_idle(lambda: self._on_select(None)) + else: + self._toast("Nessuna corsia trovata.") + self._busy.hide() + def _err(ex): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + self._busy.hide() + _MODULE_LOGGER.exception(f"Errore caricamento corsie: {ex}") + 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…") + + @_log_call() + def _on_select(self, _): + """Load the selected aisle when the listbox selection changes.""" + 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): + """Select a given aisle inside the listbox if it is present.""" + 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 + + @_log_call() + def _load_matrix(self, corsia: str): + """Query and render the matrix for the selected aisle.""" + # 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, + IDStato + 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 + ), + P AS ( + SELECT g.IDCella, g.BarcodePallet + FROM dbo.XMag_GiacenzaPallet g + JOIN C c ON c.ID = g.IDCella + ), + U AS ( + SELECT ranked.IDCella AS ID, ranked.BarcodePallet AS LastUDC + FROM ( + SELECT + p.IDCella, + p.BarcodePallet, + ROW_NUMBER() OVER ( + PARTITION BY p.IDCella + ORDER BY + COALESCE(last_move.ModDataOra, last_move.InsDataOra, last_move.DataMagazzino) DESC, + last_move.ID DESC, + p.BarcodePallet DESC + ) AS rn + FROM P p + OUTER APPLY ( + SELECT TOP (1) + mp.ID, + mp.ModDataOra, + mp.InsDataOra, + mp.DataMagazzino + FROM dbo.MagazziniPallet mp + WHERE mp.IDCella = p.IDCella + AND mp.Attributo = p.BarcodePallet + AND mp.Tipo = 'V' + ORDER BY + COALESCE(mp.ModDataOra, mp.InsDataOra, mp.DataMagazzino) DESC, + mp.ID DESC + ) AS last_move + ) ranked + WHERE ranked.rn = 1 + ) + 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, + ISNULL(c.IDStato, 0) AS StatoLogico, + c.ID AS IDCella, + c.Descrizione, + LTRIM(RTRIM(c.Fila)) AS FilaTxt, + LTRIM(RTRIM(c.Colonna)) AS ColTxt, + U.LastUDC + 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; + """ + _log_sql("load_matrix", sql, {"corsia": corsia}) + + def _ok(res): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + # ignora risposte superate + if req_id < self._last_req: + return + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset(f"load_matrix[{corsia}]", rows) + 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)] + logical = [[0] * max_c for _ in range(max_r)] + cell_ids = [[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, stato_logico, idcella, descr, fila_txt, col_txt, first_udc = row + r = int(rown) - 1 + c = int(coln) - 1 + mat[r][c] = int(stato) + logical[r][c] = int(stato_logico or 0) + cell_ids[r][c] = int(idcella or 0) + 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 "") + + def _finish_render(): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + if req_id < self._last_req: + return + self.cell_ids = cell_ids + self._rebuild_matrix(max_r, max_c, mat, logical, fila, col, desc, udc, corsia) + self._refresh_stats() + self._busy.hide() + + self.update_idletasks() + self.after_idle(_finish_render) + def _err(ex): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + if req_id < self._last_req: + return + self._busy.hide() + _MODULE_LOGGER.exception(f"Errore caricamento matrice corsia={corsia}: {ex}") + 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 ---------------- + @_log_call() + def _search_udc(self): + """Find a pallet barcode and navigate to the aisle and cell that contain it.""" + 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' + """ + _log_sql("search_udc", sql, {"barcode": barcode}) + + def _ok(res): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + if search_req_id < self._last_req: + return + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset(f"search_udc[{barcode}]", rows) + 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 not getattr(self, '_alive', True) or not self.winfo_exists(): + return + if search_req_id < self._last_req: + return + _MODULE_LOGGER.exception(f"Errore ricerca UDC barcode={barcode}: {ex}") + 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: + """Highlight a cell by its textual row and column labels.""" + 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() + sheet_row = self._sheet_row_for_matrix_row(r) + self.sheet.highlight_cells(cells=[(sheet_row, c)], bg="#0B57D0", fg="#FFFFFF", redraw=True, overwrite=True) + self.sheet.set_currently_selected(row=sheet_row, column=c) + self._highlighted = (r, c) + return True + return False + + def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str): + """Show a toast when a searched cell cannot be highlighted.""" + if not self._try_highlight(col_txt, fila_txt): + self._toast("Cella trovata ma non evidenziabile nella griglia.") + + # ---------------- COMMANDS ---------------- + @_log_call() + def _refresh_current(self): + """Reload the matrix of the currently selected aisle.""" + if self.corsia_selezionata.get(): + self._load_matrix(self.corsia_selezionata.get()) + + @_log_call() + def _export_xlsx(self): + """Export both matrix metadata and the rendered grid to Excel.""" + 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): + """Refresh global and local occupancy statistics shown in the footer.""" + # 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; + """ + _log_sql("refresh_stats_global", sql_tot, {}) + + def _ok(res): + if not getattr(self, '_alive', True) or not self.winfo_exists(): + return + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset("refresh_stats_global", rows) + 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): + """Draw a horizontal occupancy bar on the given canvas.""" + 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): + """Show a transient status message at the bottom of the window.""" + 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): + """Copy a string to the clipboard and inform the user.""" + self.clipboard_clear() + self.clipboard_append(txt) + self._toast(f"Copiato: {txt}") + + + + @_log_call() + def destroy(self): + """Mark the window as closed and release dynamic widgets safely.""" + # evita nuovi refresh/async dopo destroy + self._alive = False + # cancella eventuali timer + try: + if self._stats_after_id is not None: + self.after_cancel(self._stats_after_id) + except Exception: + pass + # pulizia UI leggera + try: + for w in list(self.host.winfo_children()): + w.destroy() + except Exception: + pass + try: + super().destroy() + except Exception: + pass + +@_log_call() +def open_layout_window(parent, db_app, session: UserSession | None = None): + """Open the layout window as a singleton-like child of ``parent``.""" + key = "_gestione_layout_window_singleton" + ex = getattr(parent, key, None) + if ex and ex.winfo_exists(): + ex.session = session + try: + ex.lift() + ex.focus_force() + return ex + except Exception: + pass + w = LayoutWindow(parent, db_app, session=session) + setattr(parent, key, w) + return w diff --git a/gestione_pickinglist.py b/gestione_pickinglist.py index 9549fd0..aae7239 100644 --- a/gestione_pickinglist.py +++ b/gestione_pickinglist.py @@ -6,14 +6,68 @@ smooth by relying on deferred updates and lightweight progress indicators. """ from __future__ import annotations +import json +import sys import tkinter as tk import customtkinter as ctk from tkinter import messagebox from typing import Optional, Any, Dict, List, Callable from dataclasses import dataclass +from functools import wraps +from pathlib import Path +import logging + +from audit_log import log_user_action +try: + from loguru import logger +except Exception: # pragma: no cover - safety fallback if dependency is missing locally + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + +try: + from tksheet import Sheet, natural_sort_key +except Exception: + Sheet = None # type: ignore[assignment] + natural_sort_key = None # type: ignore[assignment] # Usa overlay e runner "collaudati" -from gestione_aree_frame_async import BusyOverlay, AsyncRunner +from gestione_aree import BusyOverlay, AsyncRunner +from user_session import UserSession # === IMPORT procedura async prenota/s-prenota (no pyodbc qui) === import asyncio @@ -27,6 +81,118 @@ except Exception: self.rc = rc; self.message = message; self.id_result = id_result +PICKINGLIST_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +PICKINGLIST_DETAIL_TEST_MULTIPLIER = 1 # 1 disables artificial row expansion for UI stress tests +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = PICKINGLIST_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if PICKINGLIST_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: Optional[str] = None): + """Trace entry, exit and failure of selected high-level functions.""" + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + return wrapper + return decorator + + +def _log_sql(query_name: str, sql: str, params: Dict[str, Any]): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: List[Dict[str, Any]]): + """Log query results at summary or full-debug level depending on the flag.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if PICKINGLIST_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + + +def _expand_detail_rows_for_test(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Artificially duplicate detail rows to stress-test the UI with larger datasets.""" + multiplier = max(1, int(PICKINGLIST_DETAIL_TEST_MULTIPLIER)) + if multiplier == 1 or not rows: + return rows + + expanded: List[Dict[str, Any]] = [] + for copy_idx in range(multiplier): + for row_idx, row in enumerate(rows): + cloned = dict(row) + cloned["__test_copy__"] = copy_idx + cloned["__test_row__"] = row_idx + expanded.append(cloned) + _MODULE_LOGGER.info( + f"Dataset dettaglio espanso artificialmente da {len(rows)} a {len(expanded)} righe per test UI" + ) + return expanded + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={PICKINGLIST_LOG_MODE.upper()}" + ) + + # -------------------- SQL -------------------- SQL_PL = """ SELECT @@ -205,10 +371,18 @@ class ScrollTable(ctk.CTkFrame): PADX_R = 8 PADY = 2 - def __init__(self, master, columns: List[ColSpec]): + def __init__( + self, + master, + columns: List[ColSpec], + on_header_click: Optional[Callable[[ColSpec], None]] = None, + ): """Create a fixed-header scrollable table rendered with Tk/CTk widgets.""" super().__init__(master) self.columns = columns + self.on_header_click = on_header_click + self._sort_key: Optional[str] = None + self._sort_reverse = False self.total_w = sum(c.width for c in self.columns) self.grid_rowconfigure(1, weight=1) @@ -243,6 +417,10 @@ class ScrollTable(ctk.CTkFrame): # bind self.h_inner.bind("", lambda e: self._sync_header_width()) self.b_inner.bind("", lambda e: self._on_body_configure()) + self.b_canvas.bind("", self._bind_mousewheel) + self.b_canvas.bind("", self._unbind_mousewheel) + self.b_inner.bind("", self._bind_mousewheel) + self.b_inner.bind("", self._unbind_mousewheel) self._build_header() @@ -265,12 +443,27 @@ class ScrollTable(ctk.CTkFrame): holder.pack(side="left", fill="y") holder.pack_propagate(False) - lbl = ctk.CTkLabel(holder, text=col.title, anchor="w") + header_text = col.title + if col.key == self._sort_key: + header_text = f"{col.title} {'↓' if self._sort_reverse else '↑'}" + + lbl = ctk.CTkLabel(holder, text=header_text, anchor="w") lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY) + if self.on_header_click and col.key != "__check__": + for widget in (holder, lbl): + widget.bind("", lambda e, c=col: self.on_header_click(c)) + widget.configure(cursor="hand2") + self.h_inner.configure(width=self.total_w, height=ROW_H) self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H)) + def set_sort_state(self, key: Optional[str], reverse: bool = False): + """Update the header labels so the active sort is visible.""" + self._sort_key = key + self._sort_reverse = reverse + self._build_header() + 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) @@ -300,6 +493,22 @@ class ScrollTable(ctk.CTkFrame): self.h_canvas.xview_moveto(first) self.xbar.set(first, last) + def _bind_mousewheel(self, _event=None): + """Route mouse-wheel scrolling to the body canvas while the cursor is over the table.""" + self.b_canvas.bind_all("", self._on_mousewheel) + + def _unbind_mousewheel(self, _event=None): + """Stop routing global mouse-wheel events when the pointer leaves the table.""" + self.b_canvas.unbind_all("") + + def _on_mousewheel(self, event): + """Scroll the body canvas vertically in response to wheel movement.""" + delta = getattr(event, "delta", 0) + if delta == 0: + return + step = -1 if delta > 0 else 1 + self.b_canvas.yview_scroll(step, "units") + def clear_rows(self): """Remove all rendered body rows.""" for w in self.b_inner.winfo_children(): @@ -369,18 +578,23 @@ class PLRow: # -------------------- main frame (no-flicker + UX tuning + spinner) -------------------- class GestionePickingListFrame(ctk.CTkFrame): - def __init__(self, master, *, db_client=None, conn_str=None): + @_log_call() + def __init__(self, master, *, db_client=None, conn_str=None, session: UserSession | None = 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.session = session 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._detail_sort_key: Optional[str] = None + self._detail_sort_reverse = False + self._detail_sorting = False self._first_loading: bool = False # flag per cursore d'attesa solo al primo load self._render_job = None # Tracking del job di rendering in corso @@ -389,6 +603,16 @@ class GestionePickingListFrame(ctk.CTkFrame): # 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa) self.after_idle(self._first_show) + def _can(self, action: str) -> bool: + """Return whether the current user can execute one picking-list action.""" + + return self.session.can(action) if self.session else False + + def _operator_id(self) -> int: + """Return the authenticated operator id or ``0`` if no session is present.""" + + return int(self.session.operator_id) if self.session else 0 + def _first_show(self): """Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor.""" self._first_loading = True @@ -423,20 +647,171 @@ class GestionePickingListFrame(ctk.CTkFrame): 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.det_host = tk.Frame(self, bd=0, highlightthickness=0) + self.det_host.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10)) + self.det_host.grid_rowconfigure(0, weight=1) + self.det_host.grid_columnconfigure(0, weight=1) + self._build_detail_sheet() self._draw_details_hint() + def _build_detail_sheet(self): + """Create the high-volume detail table using tksheet.""" + if Sheet is None: + raise RuntimeError("tksheet non disponibile: installa la dipendenza per usare la tabella dettagli.") + + self.detail_sheet = Sheet( + self.det_host, + data=[], + show_row_index=False, + show_top_left=False, + width=1000, + height=320, + sort_key=natural_sort_key, + ) + self.detail_sheet.change_theme("light green") + self.detail_sheet.enable_bindings("all") + self.detail_sheet.headers(self._detail_headers(), redraw=False) + self.detail_sheet.bind("", self._on_detail_sheet_left_click, add="+") + self.detail_sheet.grid(row=0, column=0, sticky="nsew") + 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 + self._load_detail_sheet_data( + [["", "", "", "Seleziona una Picking List per vedere le UDC...", "", ""]] ) + def _detail_headers(self) -> List[str]: + """Return detail headers with the active sort indicator, if any.""" + headers: List[str] = [] + for col in DET_COLS: + title = col.title + if col.key == self._detail_sort_key: + title = f"{title} {'[desc]' if self._detail_sort_reverse else '[asc]'}" + headers.append(title) + return headers + + def _detail_rows_to_sheet_data(self, rows: List[Dict[str, Any]]) -> List[List[str]]: + """Convert detail dictionaries to the row format expected by tksheet.""" + data: List[List[str]] = [] + for d in 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() + data.append([pallet, lotto, articolo, descr, qta, loc]) + return data + + def _load_detail_sheet_data(self, data: List[List[str]]): + """Push one full dataset into the tksheet detail widget.""" + self.detail_sheet.headers(self._detail_headers(), redraw=False) + self.detail_sheet.set_sheet_data( + data, + reset_col_positions=True, + reset_row_positions=True, + redraw=True, + ) + self.detail_sheet.set_all_column_widths() + + def _detail_sort_value(self, row: Dict[str, Any], key: str): + """Return a normalized value used to sort detail rows by one logical column.""" + if key == "Ubicazione": + value = _first(row, ["Ubicazione", "Cella", "PalletCella"]) + value = "Non scaffalata" if value in (None, "") else value + elif key == "Qta": + value = _first(row, ["Qta", "Quantita", "Qty", "QTY"], 0) + try: + return (0, float(value)) + except Exception: + return (1, _s(value).lower()) + elif key == "Articolo": + value = _first(row, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]) + elif key == "Descrizione": + value = _first(row, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]) + elif key == "Pallet": + value = _first(row, ["Pallet", "UDC", "PalletID"]) + else: + value = row.get(key) + + return (0, _s(value).lower()) + + def _sort_detail_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Return detail rows sorted using the current header state.""" + if not self._detail_sort_key: + return rows + + return sorted( + rows, + key=lambda row: self._detail_sort_value(row, self._detail_sort_key), + reverse=self._detail_sort_reverse, + ) + + def _finish_detail_sort_feedback(self, root: tk.Misc): + """Dismiss busy feedback only after Tk has flushed the detail redraw.""" + try: + root.update_idletasks() + except Exception: + pass + + self._detail_sorting = False + self.spinner.stop() + self.busy.hide() + try: + self.detail_sheet.configure(cursor="") + root.configure(cursor="") + except Exception: + pass + + def _on_detail_header_click(self, col: ColSpec): + """Toggle detail sorting when the user clicks a detail header.""" + if not self.detail_doc or self._detail_sorting: + return + + if self._detail_sort_key == col.key: + self._detail_sort_reverse = not self._detail_sort_reverse + else: + self._detail_sort_key = col.key + self._detail_sort_reverse = False + + self._detail_sorting = True + self.spinner.start(" Ordino dettagli...") + self.busy.show(f"Ordinamento per {col.title}...") + root = self.winfo_toplevel() + try: + root.configure(cursor="watch") + self.detail_sheet.configure(cursor="watch") + root.update_idletasks() + except Exception: + pass + + def _apply_sort(): + try: + rows = list(self._detail_cache.get(self.detail_doc, [])) + rows = self._sort_detail_rows(rows) + self._detail_cache[self.detail_doc] = rows + self._refresh_details() + finally: + # Wait one more UI turn so the redraw becomes visible before removing feedback. + self.after_idle(lambda r=root: self.after(15, lambda: self._finish_detail_sort_feedback(r))) + + self.after(25, _apply_sort) + + def _on_detail_sheet_left_click(self, event): + """Sort detail rows when the user clicks a tksheet header cell.""" + try: + region = self.detail_sheet.identify_region(event) + column = self.detail_sheet.identify_column(event, exclude_header=False, allow_end=False) + except Exception: + return + + if region != "header" or column is None or column < 0 or column >= len(DET_COLS): + return + + self._on_detail_header_click(DET_COLS[column]) + def _apply_row_colors(self, rows: List[Dict[str, Any]]): """Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe.""" try: @@ -517,6 +892,7 @@ class GestionePickingListFrame(ctk.CTkFrame): break # ----- eventi ----- + @_log_call() def on_row_checked(self, model: PLRow, is_checked: bool): """Handle row selection changes and refresh the detail section.""" # selezione esclusiva @@ -526,18 +902,23 @@ class GestionePickingListFrame(ctk.CTkFrame): m.set_checked(False) self.detail_doc = model.pl.get("Documento") + _MODULE_LOGGER.info(f"Documento selezionato per il dettaglio: {self.detail_doc}") self.spinner.start(" Carico dettagli…") # spinner ON async def _job(): + _log_sql("SQL_PL_DETAILS", SQL_PL_DETAILS, {"Documento": self.detail_doc}) return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc}) def _ok(res): # NON fermare lo spinner subito: lo farà _refresh_details_incremental - self._detail_cache[self.detail_doc] = _rows_to_dicts(res) + rows = _expand_detail_rows_for_test(_rows_to_dicts(res)) + _log_dataset("SQL_PL_DETAILS", rows) + self._detail_cache[self.detail_doc] = rows # Avvia il rendering incrementale che mantiene l'overlay attivo self._refresh_details_incremental() def _err(ex): + _MODULE_LOGGER.exception(f"Errore durante il caricamento dettagli del documento {self.detail_doc}: {ex}") self.spinner.stop() self.busy.hide() # Chiudi l'overlay in caso di errore messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}") @@ -555,16 +936,20 @@ class GestionePickingListFrame(ctk.CTkFrame): else: if not any(m.is_checked() for m in self.rows_models): self.detail_doc = None + _MODULE_LOGGER.info("Nessun documento selezionato: ripristino placeholder del dettaglio.") self._refresh_details() # ----- load PL ----- + @_log_call() 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(): + _log_sql("SQL_PL", SQL_PL, {}) return await self.db_client.query_json(SQL_PL, {}) def _on_success(res): rows = _rows_to_dicts(res) + _log_dataset("SQL_PL", rows) self._refresh_mid_rows(rows) self.spinner.stop() # spinner OFF # se era il primo load, ripristina il cursore standard @@ -575,6 +960,7 @@ class GestionePickingListFrame(ctk.CTkFrame): pass self._first_loading = False def _on_error(ex): + _MODULE_LOGGER.exception(f"Errore durante il caricamento della picking list: {ex}") self.spinner.stop() if self._first_loading: try: @@ -592,102 +978,64 @@ class GestionePickingListFrame(ctk.CTkFrame): message="Caricamento Picking List…" if first else "Aggiornamento…" ) + @_log_call("DEBUG") 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, []) + rows = list(self._detail_cache.get(self.detail_doc, [])) + rows = self._sort_detail_rows(rows) + _MODULE_LOGGER.debug(f"Ridisegno tabella dettaglio per documento={self.detail_doc} righe={len(rows)}") if not rows: - self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], - row_index=0, anchors=["w"]*6) + self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]]) 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] - ) + self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows)) + @_log_call("DEBUG") def _refresh_details_incremental(self, batch_size: int = 25): """ - Render detail table incrementally in batches to keep UI responsive. - Mantiene l'overlay visibile fino al completamento del rendering. + Render detail table using tksheet while keeping busy feedback consistent. """ - self.det_table.clear_rows() if not self.detail_doc: self._draw_details_hint() self.spinner.stop() self.busy.hide() return - rows = self._detail_cache.get(self.detail_doc, []) + rows = list(self._detail_cache.get(self.detail_doc, [])) + rows = self._sort_detail_rows(rows) + self._detail_cache[self.detail_doc] = rows + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Avvio rendering dettagli documento={self.detail_doc} righe={len(rows)}") if not rows: - self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], - row_index=0, anchors=["w"]*6) + self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]]) self.spinner.stop() self.busy.hide() return - # Inizia il rendering incrementale - total_rows = len(rows) self.busy.show(f"Rendering {len(rows)} UDC...") - self._render_batch(rows, batch_size, 0, total_rows) + self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows)) + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Rendering dettagli completato documento={self.detail_doc} righe={len(rows)}") + self.spinner.stop() + self.busy.hide() + @_log_call("DEBUG") def _render_batch(self, rows: List[Dict[str, Any]], batch_size: int, start_idx: int, total_rows: int): """ - Render a batch of rows and schedule the next batch. - Mantiene lo spinner attivo fino all'ultimo batch. + Legacy helper kept for compatibility after the move to tksheet. """ - end_idx = min(start_idx + batch_size, total_rows) - - # Aggiorna lo spinner con il progresso - progress_pct = int((end_idx / total_rows) * 100) - self.spinner.lbl.configure(text=f"◐ Rendering {progress_pct}%") - - # Aggiorna anche il messaggio dell'overlay - self.busy.show(f"Rendering {progress_pct}% ({end_idx}/{total_rows} UDC)...") - - # Renderizza il batch corrente - for r in range(start_idx, end_idx): - d = rows[r] - 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] - ) - - # Se ci sono ancora righe da renderizzare, schedula il prossimo batch - if end_idx < total_rows: - # Lascia respirare Tk tra i batch (10ms) - self.after(10, lambda: self._render_batch(rows, batch_size, end_idx, total_rows)) - else: - # Ultimo batch completato: ferma lo spinner e chiudi l'overlay - self.spinner.stop() - self.busy.hide() + del batch_size, start_idx, total_rows + self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows)) # ----- azioni ----- + @_log_call() def on_prenota(self): """Reserve the selected picking list.""" + if not self._can("pickinglist.prenota"): + messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' prenotare picking list.", parent=self) + return model = self._get_selected_model() if not model: messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.") @@ -700,22 +1048,51 @@ class GestionePickingListFrame(ctk.CTkFrame): messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.") return - id_operatore = 1 # TODO: recupera dal contesto reale + id_operatore = self._operator_id() + if id_operatore <= 0: + messagebox.showerror("Prenota", "Sessione operatore non valida.", parent=self) + return + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta prenotazione documento={documento} id_operatore={id_operatore}") self.spinner.start(" Prenoto…") async def _job(): return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) def _ok(res: SPResult): + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}") self.spinner.stop() if res and res.rc == 0: + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.prenota", + outcome="ok", + target=documento, + ) self._recolor_row_by_documento(documento, desired) else: msg = (res.message if res else "Errore sconosciuto") + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.prenota", + outcome="denied", + target=documento, + details={"message": msg}, + ) messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}") def _err(ex): + _MODULE_LOGGER.exception(f"Errore prenotazione documento={documento}: {ex}") self.spinner.stop() + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.prenota", + outcome="error", + target=documento, + details={"error": str(ex)}, + ) messagebox.showerror("Prenota", f"Errore:\n{ex}") self.runner.run( @@ -726,8 +1103,12 @@ class GestionePickingListFrame(ctk.CTkFrame): message=f"Prenoto la Picking List {documento}…" ) + @_log_call() def on_sprenota(self): """Unreserve the selected picking list.""" + if not self._can("pickinglist.sprenota"): + messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' s-prenotare picking list.", parent=self) + return model = self._get_selected_model() if not model: messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.") @@ -740,22 +1121,51 @@ class GestionePickingListFrame(ctk.CTkFrame): messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.") return - id_operatore = 1 # TODO: recupera dal contesto reale + id_operatore = self._operator_id() + if id_operatore <= 0: + messagebox.showerror("S-prenota", "Sessione operatore non valida.", parent=self) + return + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta s-prenotazione documento={documento} id_operatore={id_operatore}") self.spinner.start(" S-prenoto…") async def _job(): return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) def _ok(res: SPResult): + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito s-prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}") self.spinner.stop() if res and res.rc == 0: + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.sprenota", + outcome="ok", + target=documento, + ) self._recolor_row_by_documento(documento, desired) else: msg = (res.message if res else "Errore sconosciuto") + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.sprenota", + outcome="denied", + target=documento, + details={"message": msg}, + ) messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}") def _err(ex): + _MODULE_LOGGER.exception(f"Errore s-prenotazione documento={documento}: {ex}") self.spinner.stop() + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="pickinglist.sprenota", + outcome="error", + target=documento, + details={"error": str(ex)}, + ) messagebox.showerror("S-prenota", f"Errore:\n{ex}") self.runner.run( @@ -772,10 +1182,82 @@ class GestionePickingListFrame(ctk.CTkFrame): # factory per main -def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame': +@_log_call() +def create_frame(parent, *, db_client=None, conn_str=None, session: UserSession | None = 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) + return GestionePickingListFrame(parent, db_client=db_client, session=session) + + +@_log_call() +def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | None = None) -> tk.Misc: + """Open the picking list window while minimizing the first paint flicker.""" + key = "_gestione_pickinglist_window_singleton" + ex = getattr(parent, key, None) + if ex and ex.winfo_exists(): + frame = getattr(ex, "_pickinglist_frame", None) + if frame is not None: + try: + frame.session = session + except Exception: + pass + try: + ex.deiconify() + except Exception: + pass + try: + ex.lift() + ex.focus_force() + return ex + except Exception: + pass + + win = ctk.CTkToplevel(parent) + win.title("Gestione Picking List") + win.geometry("1200x700+0+100") + win.minsize(1000, 560) + setattr(parent, key, win) + + # Keep the toplevel hidden until the child frame has built its initial layout. + try: + win.withdraw() + win.attributes("-alpha", 0.0) + except Exception: + pass + + frame = create_frame(win, db_client=db_client, session=session) + try: + frame.pack(fill="both", expand=True) + except Exception: + pass + setattr(win, "_pickinglist_frame", frame) + + # Reveal the fully-laid out window only after pending geometry work completes. + try: + win.update_idletasks() + try: + win.transient(parent) + except Exception: + pass + try: + win.deiconify() + except Exception: + pass + win.lift() + try: + win.focus_force() + except Exception: + pass + try: + win.attributes("-alpha", 1.0) + except Exception: + pass + except Exception: + pass + + win.bind("", lambda e: win.destroy()) + win.protocol("WM_DELETE_WINDOW", win.destroy) + return win # =================== /gestione_pickinglist.py =================== diff --git a/gestione_scarico.py b/gestione_scarico.py new file mode 100644 index 0000000..7852383 --- /dev/null +++ b/gestione_scarico.py @@ -0,0 +1,734 @@ +"""Modal dialog used to unload one or more UDCs from a multi-occupancy cell.""" + +from __future__ import annotations + +import json +import logging +import sys +import tkinter as tk +from dataclasses import dataclass +from datetime import datetime +from functools import wraps +from pathlib import Path +from typing import Any, Callable + +import customtkinter as ctk +from tkinter import messagebox, ttk + +from gestione_aree import BusyOverlay, AsyncRunner +from audit_log import log_user_action +from user_session import UserSession + +try: + from loguru import logger +except Exception: # pragma: no cover - fallback used only when Loguru is not available + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + + +SCARICO_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = SCARICO_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if SCARICO_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False +DEFAULT_SCARICO_USER = "warehouse_ui" + + +def _session_login(session: UserSession | None, fallback: str | None = None) -> str: + """Return the current application login, falling back to a technical user.""" + + if session and str(session.login or "").strip(): + return str(session.login).strip() + return str((fallback or DEFAULT_SCARICO_USER) or DEFAULT_SCARICO_USER).strip() + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: str | None = None): + """Trace entry, exit and failure of selected high-level functions.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + + return wrapper + + return decorator + + +def _log_sql(query_name: str, sql: str, params: dict[str, Any]): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: list[Any]): + """Log query results at summary or full-debug level depending on the flag.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if SCARICO_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={SCARICO_LOG_MODE.upper()}" + ) + + +SQL_UDC_IN_CELLA = """ +WITH cell_pallets AS ( + SELECT DISTINCT + g.BarcodePallet + FROM dbo.XMag_GiacenzaPallet g + WHERE g.IDCella = :idcella +), +last_in_cell AS ( + SELECT + mp.Attributo AS BarcodePallet, + MAX(mp.ID) AS LastID + FROM dbo.MagazziniPallet mp + JOIN cell_pallets cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + mp.Attributo COLLATE Latin1_General_CI_AS + WHERE mp.IDCella = :idcella + AND mp.Tipo = 'V' + GROUP BY mp.Attributo +), +last_move AS ( + SELECT + mp.Attributo AS BarcodePallet, + mp.ID, + mp.DataMagazzino + FROM dbo.MagazziniPallet mp + JOIN last_in_cell lic ON lic.LastID = mp.ID +), +latest_any AS ( + SELECT + ranked.BarcodePallet, + ranked.IDCella + FROM ( + SELECT + mp.Attributo AS BarcodePallet, + mp.IDCella, + ROW_NUMBER() OVER ( + PARTITION BY mp.Attributo + ORDER BY mp.ID DESC + ) AS rn + FROM dbo.MagazziniPallet mp + JOIN cell_pallets cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + mp.Attributo COLLATE Latin1_General_CI_AS + WHERE mp.Tipo = 'V' + AND mp.PesoUnitario > 0 + ) ranked + WHERE ranked.rn = 1 +), +shipped AS ( + SELECT DISTINCT + shipped.BarcodePallet + FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped + JOIN cell_pallets cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + shipped.BarcodePallet COLLATE Latin1_General_CI_AS +) +SELECT + cp.BarcodePallet AS UDC, + lm.ID AS SourceID, + lm.DataMagazzino AS LastEventAt, + CASE + WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsShippedGhost, + CASE + WHEN la.IDCella IS NOT NULL + AND la.IDCella <> :idcella + THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsMovedGhost +FROM cell_pallets cp +LEFT JOIN last_move lm + ON lm.BarcodePallet COLLATE Latin1_General_CI_AS = + cp.BarcodePallet COLLATE Latin1_General_CI_AS +LEFT JOIN latest_any la + ON la.BarcodePallet COLLATE Latin1_General_CI_AS = + cp.BarcodePallet COLLATE Latin1_General_CI_AS +LEFT JOIN shipped + ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS = + cp.BarcodePallet COLLATE Latin1_General_CI_AS +ORDER BY + lm.ID DESC, + cp.BarcodePallet DESC; +""" + + +SQL_SCARICA_UDC = """ +DECLARE @Now datetime = GETDATE(); +DECLARE @SourceID int = 0; +DECLARE @NumeroPallet int = 0; +DECLARE @PesoUnitario float = 1; +DECLARE @Tara float = 0; +DECLARE @SourceIDCella int = 0; + +SELECT TOP (1) + @SourceID = src.ID, + @NumeroPallet = ISNULL(src.NumeroPallet, 0), + @PesoUnitario = ISNULL(NULLIF(src.PesoUnitario, 0), 1), + @Tara = ISNULL(src.Tara, 0), + @SourceIDCella = ISNULL(src.IDCella, 0) +FROM dbo.MagazziniPallet src +WHERE src.Attributo = :barcode_pallet + AND src.Tipo = 'V' + AND src.PesoUnitario > 0 +ORDER BY src.ID DESC; + +IF @SourceID > 0 +BEGIN + UPDATE dbo.MagazziniPallet + SET ModUtente = :utente, + ModDataOra = @Now + WHERE ID = @SourceID; + + INSERT INTO dbo.MagazziniPallet ( + Tipo, + IDRiferimento, + NumeroPallet, + Attributo, + IDMagazzino, + IDArea, + IDCella, + DataMagazzino, + PesoUnitario, + Tara, + InsUtente, + InsDataOra + ) + SELECT + 'P', + @SourceID, + ISNULL(src.NumeroPallet, 0), + src.Attributo, + src.IDMagazzino, + src.IDArea, + src.IDCella, + @Now, + ISNULL(NULLIF(src.PesoUnitario, 0), 1), + ISNULL(src.Tara, 0), + :utente, + @Now + FROM dbo.MagazziniPallet src + WHERE src.ID = @SourceID; + + UPDATE dbo.Celle + SET IDStato = 0, + ModUtente = :utente, + ModDataOra = @Now + WHERE ID = @SourceIDCella; +END; + +INSERT INTO dbo.MagazziniPallet ( + Tipo, + IDRiferimento, + NumeroPallet, + Attributo, + IDMagazzino, + IDArea, + IDCella, + DataMagazzino, + PesoUnitario, + Tara, + InsUtente, + InsDataOra +) +SELECT + 'V', + @SourceID, + @NumeroPallet, + :barcode_pallet, + target.IDMagazzino, + target.IDArea, + target.ID, + @Now, + @PesoUnitario, + @Tara, + :utente, + @Now +FROM ( + SELECT c.ID, c.IDArea, a.IDMagazzino + FROM dbo.Celle c + JOIN dbo.Aree a ON a.ID = c.IDArea + WHERE c.ID = :target_idcella +) AS target; + +EXEC dbo.sp_ControllaPrenotazionePackingListPalletNew; + +SELECT + CAST(1 AS int) AS Ok, + @SourceID AS SourceID, + :target_idcella AS TargetIDCella, + :target_barcode_cella AS TargetBarcodeCella; +""" + + +async def move_pallet_async( + db_client, + *, + barcode_pallet: str, + target_idcella: int, + target_barcode_cella: str, + utente: str | None = None, +) -> dict[str, Any]: + """Move one pallet to a target cell using the same movement semantics as the legacy app. + + The original C# application delegates load, transfer and unload to + ``sp_xMagGestioneMagazziniPallet``. The Python app mirrors the same + behavior with an explicit SQL batch that: + 1. finds the latest positive row for the pallet, + 2. registers a compensating ``P`` move on the source cell, + 3. frees the previous cell reservation, + 4. inserts a new ``V`` move on the target cell, + 5. re-runs the packing-list reservation check. + """ + params = { + "barcode_pallet": str(barcode_pallet or "").strip(), + "target_idcella": int(target_idcella), + "target_barcode_cella": str(target_barcode_cella or "").strip(), + "utente": str((utente or DEFAULT_SCARICO_USER) or "warehouse_ui").strip(), + } + _log_sql("move_pallet", SQL_SCARICA_UDC, params) + res = await db_client.query_json(SQL_SCARICA_UDC, params) + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset("move_pallet", rows) + first = rows[0] if rows else [1, 0, params["target_idcella"], params["target_barcode_cella"]] + return { + "ok": int(first[0] or 0), + "source_id": int(first[1] or 0), + "target_idcella": int(first[2] or 0), + "target_barcode_cella": str(first[3] or ""), + "barcode_pallet": params["barcode_pallet"], + } + + +@dataclass +class ScaricoRow: + """View-model describing one unloadable UDC currently present in a cell.""" + + udc: str + source_id: int | None + last_event_at: str + diagnostic_note: str + selected: tk.BooleanVar + + +def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str: + """Translate low-level anomaly flags into one operator-facing note.""" + + notes: list[str] = [] + if bool(is_shipped): + notes.append("Mancato scarico: spedita") + if bool(is_moved): + notes.append("Mancato scarico: spostata") + return " | ".join(notes) + + +class ScaricoDialog(ctk.CTkToplevel): + """Modal dialog that allows unloading selected UDCs from one cell.""" + + CHECKBOX_COL_W = 56 + UDC_COL_W = 130 + DATE_COL_W = 180 + DIAG_COL_W = 320 + + @_log_call() + def __init__( + self, + parent: tk.Misc, + *, + db_client, + idcella: int, + ubicazione: str, + on_completed: Callable[[], None] | None = None, + session: UserSession | None = None, + ): + super().__init__(parent) + self.parent = parent + self.db_client = db_client + self.idcella = idcella + self.ubicazione = ubicazione + self.on_completed = on_completed + self.session = session + self.rows: list[ScaricoRow] = [] + self._busy = BusyOverlay(self) + self._async = AsyncRunner(self) + self.rows_tree: ttk.Treeview | None = None + + self.title(f"Scarica {ubicazione}") + self.resizable(False, False) + self.transient(parent) + self.protocol("WM_DELETE_WINDOW", self._close) + + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + self._build_ui() + self._load_rows() + + self.update_idletasks() + self.grab_set() + try: + self.wait_visibility() + except Exception: + pass + self.lift() + self.focus_force() + + def _build_ui(self): + """Build the compact modal layout.""" + top = ctk.CTkFrame(self) + top.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 6)) + top.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(top, text=f"Ubicazione: {self.ubicazione}", font=("", 13, "bold")).grid( + row=0, column=0, sticky="w" + ) + ctk.CTkLabel(top, text="Seleziona le UDC da scaricare", anchor="w").grid( + row=1, column=0, sticky="w", pady=(4, 0) + ) + + table = ctk.CTkFrame(self) + table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(0, 8)) + table.grid_rowconfigure(0, weight=1) + table.grid_columnconfigure(0, weight=1) + + tree_host = tk.Frame(table, bd=0, highlightthickness=0, background="#DBDBDB") + tree_host.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 8)) + tree_host.grid_rowconfigure(0, weight=1) + tree_host.grid_columnconfigure(0, weight=1) + + style = ttk.Style(self) + style.configure("Scarico.Treeview", rowheight=28, font=("Segoe UI", 10)) + style.configure("Scarico.Treeview.Heading", font=("Segoe UI", 10, "bold")) + + self.rows_tree = ttk.Treeview( + tree_host, + columns=("sel", "udc", "last", "diag"), + show="headings", + style="Scarico.Treeview", + selectmode="none", + ) + self.rows_tree.heading("sel", text="Sel") + self.rows_tree.heading("udc", text="UDC") + self.rows_tree.heading("last", text="Ultimo inserimento") + self.rows_tree.heading("diag", text="Diagnostica") + self.rows_tree.column("sel", width=self.CHECKBOX_COL_W, stretch=False, anchor="center") + self.rows_tree.column("udc", width=self.UDC_COL_W, stretch=False, anchor="w") + self.rows_tree.column("last", width=self.DATE_COL_W, stretch=False, anchor="w") + self.rows_tree.column("diag", width=self.DIAG_COL_W, stretch=True, anchor="w") + self.rows_tree.grid(row=0, column=0, sticky="nsew") + self.rows_tree.bind("", self._on_tree_click, add="+") + + tree_scroll = ttk.Scrollbar(tree_host, orient="vertical", command=self.rows_tree.yview) + tree_scroll.grid(row=0, column=1, sticky="ns") + self.rows_tree.configure(yscrollcommand=tree_scroll.set) + + actions = ctk.CTkFrame(self) + actions.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 10)) + actions.grid_columnconfigure(0, weight=1) + ctk.CTkButton(actions, text="scarica", command=self._on_scarica).grid( + row=0, column=1, padx=(8, 0), pady=8 + ) + ctk.CTkButton(actions, text="close", command=self._close).grid( + row=0, column=2, padx=(8, 8), pady=8 + ) + + def _render_rows(self): + """Recreate the compact list of unloadable UDC rows.""" + if self.rows_tree is None: + return + for item in self.rows_tree.get_children(): + self.rows_tree.delete(item) + + for idx, row in enumerate(self.rows): + self.rows_tree.insert( + "", + "end", + iid=str(idx), + values=( + "[x]" if row.selected.get() else "[ ]", + row.udc, + row.last_event_at, + row.diagnostic_note or "", + ), + ) + + self.update_idletasks() + width = max(900, min(1040, self.winfo_reqwidth() + 20)) + total_height = max(210, min(460, self.winfo_reqheight() + 8)) + self.geometry(f"{width}x{total_height}") + + def _on_tree_click(self, event): + """Toggle the pseudo-checkbox when the operator clicks the first column.""" + + if self.rows_tree is None: + return + region = self.rows_tree.identify("region", event.x, event.y) + if region != "cell": + return + column = self.rows_tree.identify_column(event.x) + item_id = self.rows_tree.identify_row(event.y) + if column != "#1" or not item_id: + return + try: + row = self.rows[int(item_id)] + except Exception: + return + row.selected.set(not row.selected.get()) + self.rows_tree.set(item_id, "sel", "[x]" if row.selected.get() else "[ ]") + return "break" + + @_log_call() + def _load_rows(self): + """Load the list of current UDCs ordered from newest to oldest.""" + params = {"idcella": self.idcella} + _log_sql("scarico_load_rows", SQL_UDC_IN_CELLA, params) + + def _ok(res): + rows = res.get("rows", []) if isinstance(res, dict) else [] + _log_dataset("scarico_load_rows", rows) + self.rows = [] + for udc, source_id, last_event_at, is_shipped, is_moved in rows: + if isinstance(last_event_at, datetime): + last_event = last_event_at.strftime("%d/%m/%Y %H:%M:%S") + else: + last_event = str(last_event_at or "") + self.rows.append( + ScaricoRow( + udc=str(udc or ""), + source_id=int(source_id) if source_id is not None else None, + last_event_at=last_event, + diagnostic_note=_build_diagnostic_note(is_shipped, is_moved), + selected=tk.BooleanVar(value=False), + ) + ) + self._render_rows() + self._busy.hide() + + def _err(ex): + self._busy.hide() + _MODULE_LOGGER.exception(f"Errore caricamento righe scarico idcella={self.idcella}: {ex}") + messagebox.showerror("Scarica", f"Caricamento UDC fallito:\n{ex}", parent=self) + self._close() + + self._async.run( + self.db_client.query_json(SQL_UDC_IN_CELLA, params), + _ok, + _err, + busy=self._busy, + message="Carico UDC...", + ) + + @_log_call() + def _on_scarica(self): + """Unload the UDCs selected by the user from the current cell.""" + selected = [row for row in self.rows if row.selected.get()] + if not selected: + messagebox.showinfo("Scarica", "Seleziona almeno una UDC da scaricare.", parent=self) + return + + if not messagebox.askyesno( + "Conferma scarico", + f"Scaricare {len(selected)} UDC da {self.ubicazione}?", + parent=self, + ): + return + + async def _job(): + results: list[dict[str, Any]] = [] + for row in selected: + result = await move_pallet_async( + self.db_client, + barcode_pallet=row.udc, + target_idcella=9999, + target_barcode_cella="9000000", + utente=_session_login(self.session), + ) + results.append({"udc": row.udc, "affected": int(result.get("ok") or 0)}) + return results + + def _ok(results): + _log_dataset("scarica_udc", results) + done = [item["udc"] for item in results if int(item.get("affected") or 0) > 0] + skipped = [item["udc"] for item in results if int(item.get("affected") or 0) <= 0] + if not done: + messagebox.showwarning( + "Scarica", + "Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.", + parent=self, + ) + return + if skipped: + messagebox.showwarning( + "Scarica", + "Scarico parziale.\nCompletate: " + + ", ".join(done) + + "\nNon scaricate: " + + ", ".join(skipped), + parent=self, + ) + else: + messagebox.showinfo( + "Scarica", + "Scarico completato per:\n" + "\n".join(done), + parent=self, + ) + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="layout.scarico", + outcome="ok", + target=self.ubicazione, + details={"scaricate": done, "saltate": skipped}, + ) + if self.on_completed: + self.on_completed() + self._close() + + def _err(ex): + _MODULE_LOGGER.exception(f"Errore scarico idcella={self.idcella}: {ex}") + log_user_action( + self.session, + module=MODULE_LOG_NAME, + action="layout.scarico", + outcome="error", + target=self.ubicazione, + details={"error": str(ex)}, + ) + messagebox.showerror("Scarica", f"Scarico fallito:\n{ex}", parent=self) + + self._async.run( + _job(), + _ok, + _err, + busy=self._busy, + message="Scarico UDC...", + ) + + @_log_call() + def _close(self): + """Release the modal grab and close the dialog safely.""" + try: + self.grab_release() + except Exception: + pass + try: + self.destroy() + except Exception: + pass + + +@_log_call() +def open_scarico_dialog( + parent: tk.Misc, + *, + db_client, + idcella: int, + ubicazione: str, + on_completed: Callable[[], None] | None = None, + session: UserSession | None = None, +) -> ScaricoDialog: + """Create and return the modal unload dialog for one multi-UDC cell.""" + return ScaricoDialog( + parent, + db_client=db_client, + idcella=idcella, + ubicazione=ubicazione, + on_completed=on_completed, + session=session, + ) diff --git a/layout_window.py b/layout_window.py deleted file mode 100644 index bf7425d..0000000 --- a/layout_window.py +++ /dev/null @@ -1,698 +0,0 @@ -"""Graphical aisle layout viewer for warehouse cells and pallet occupancy.""" - -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: - """Format occupancy percentages for the progress-bar labels.""" - 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): - """Create the window and initialize the state used by the matrix view.""" - 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._alive = True - self._stats_after_id = None # se mai userai un refresh periodico, potremo cancellarlo qui - - self._build_top() - self._build_matrix_host() - self._build_stats() - - self._load_corsie() - # disabilitato: il refresh ad ogni generava molte query/lag - # self.bind("", lambda e: self.after_idle(self._refresh_stats)) - - # ---------------- TOP BAR ---------------- - def _build_top(self): - """Create the top toolbar with aisle selection and search controls.""" - 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("<>", 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): - """Create the container that will host the dynamically rebuilt matrix.""" - 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): - """Apply the visual state associated with a cell occupancy level.""" - 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): - """Remove the temporary highlight from the previously focused cell.""" - 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): - """Recreate the visible cell matrix from the latest query result.""" - # 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("", 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 getattr(self, '_alive', True) and 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): - """Open the context menu for a single matrix cell.""" - 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): - """Update a cell state in memory and refresh the local statistics.""" - 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): - """Create progress bars, labels and legend for occupancy statistics.""" - 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): - """Add a legend entry describing one matrix 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): - """Load the list of aisles available for visualization.""" - 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): - if not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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): - if not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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, _): - """Load the selected aisle when the listbox selection changes.""" - 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): - """Select a given aisle inside the listbox if it is present.""" - 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): - """Query and render the matrix for the selected aisle.""" - # 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): - if not getattr(self, '_alive', True) or not self.winfo_exists(): - return - # 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 not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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): - """Find a pallet barcode and navigate to the aisle and cell that contain it.""" - 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 not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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 not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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: - """Highlight a cell by its textual row and column labels.""" - 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): - """Show a toast when a searched cell cannot be highlighted.""" - if not self._try_highlight(col_txt, fila_txt): - self._toast("Cella trovata ma non mappabile a pulsante.") - - # ---------------- COMMANDS ---------------- - def _refresh_current(self): - """Reload the matrix of the currently selected aisle.""" - if self.corsia_selezionata.get(): - self._load_matrix(self.corsia_selezionata.get()) - - def _export_xlsx(self): - """Export both matrix metadata and the rendered grid to Excel.""" - 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): - """Refresh global and local occupancy statistics shown in the footer.""" - # 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): - if not getattr(self, '_alive', True) or not self.winfo_exists(): - return - 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): - """Draw a horizontal occupancy bar on the given canvas.""" - 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): - """Show a transient status message at the bottom of the window.""" - 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): - """Copy a string to the clipboard and inform the user.""" - self.clipboard_clear() - self.clipboard_append(txt) - self._toast(f"Copiato: {txt}") - - - - def destroy(self): - """Mark the window as closed and release dynamic widgets safely.""" - # evita nuovi refresh/async dopo destroy - self._alive = False - # cancella eventuali timer - try: - if self._stats_after_id is not None: - self.after_cancel(self._stats_after_id) - except Exception: - pass - # pulizia UI leggera - try: - for w in list(self.host.winfo_children()): - w.destroy() - except Exception: - pass - try: - super().destroy() - except Exception: - pass - -def open_layout_window(parent, db_app): - """Open the layout window as a singleton-like child of ``parent``.""" - 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 diff --git a/login_window.py b/login_window.py new file mode 100644 index 0000000..40a4a7e --- /dev/null +++ b/login_window.py @@ -0,0 +1,251 @@ +"""Application login dialog backed by the ``Operatori`` table.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk +from typing import Any + +from audit_log import log_session_event +from gestione_aree import AsyncRunner +from user_session import UserSession, create_user_session + + +SQL_LOGIN = """ +SELECT TOP (1) + ID, + Login, + Nominativo, + Privilegio, + CodiceUnita +FROM dbo.Operatori +WHERE LTRIM(RTRIM(Login)) = :login + AND LTRIM(RTRIM([Password])) = :password +ORDER BY ID; +""" + + +def _rows_to_dicts(res: Any) -> list[dict[str, Any]]: + """Normalize DB responses into a list of row dictionaries.""" + + if res is None: + return [] + if isinstance(res, list): + return [row for row in res if isinstance(row, dict)] + if isinstance(res, dict): + rows = res.get("rows") or res.get("data") or res.get("records") or [] + if rows and isinstance(rows[0], dict): + return rows + cols = res.get("columns") or [] + 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 + return [] + + +class LoginWindow(tk.Toplevel): + """Small modal dialog used to authenticate one warehouse operator.""" + + def __init__(self, parent: tk.Misc, db_client): + super().__init__(parent) + self.db_client = db_client + self.result_session: UserSession | None = None + self._async = AsyncRunner(self) + self._login_button: ttk.Button | None = None + self._cancel_button: ttk.Button | None = None + self._status_var = tk.StringVar(value="") + + self.title("Login Warehouse") + self.geometry("420x250") + self.resizable(False, False) + try: + if parent is not None and parent.winfo_viewable(): + self.transient(parent) + except Exception: + pass + self.protocol("WM_DELETE_WINDOW", self._on_cancel) + + self.login_var = tk.StringVar() + self.password_var = tk.StringVar() + + self._build_ui() + self.update_idletasks() + self.grab_set() + self.deiconify() + self.lift() + self.attributes("-topmost", True) + self.after(50, self._show_ready) + + def _build_ui(self) -> None: + """Build the compact operator login form.""" + + body = ttk.Frame(self, padding=12) + body.pack(fill="both", expand=True) + body.columnconfigure(1, weight=1) + + ttk.Label( + body, + text="Autenticazione operatore", + font=("Segoe UI", 11, "bold"), + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(4, 14)) + + ttk.Label(body, text="Login").grid(row=1, column=0, sticky="w", padx=(0, 10), pady=6) + self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=28) + self.login_entry.grid(row=1, column=1, sticky="ew", pady=6) + + ttk.Label(body, text="Password").grid(row=2, column=0, sticky="w", padx=(0, 10), pady=6) + self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=28, show="*") + self.password_entry.grid(row=2, column=1, sticky="ew", pady=6) + + self.info_label = ttk.Label( + body, + text="Per ora tutti gli operatori autenticati possono usare tutte le funzioni.", + justify="left", + wraplength=320, + ) + self.info_label.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 8)) + + self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555") + self.status_label.grid(row=4, column=0, columnspan=2, sticky="w", pady=(2, 2)) + + actions = ttk.Frame(body) + actions.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(6, 0)) + actions.columnconfigure(0, weight=1) + self._cancel_button = ttk.Button(actions, text="Annulla", command=self._on_cancel) + self._cancel_button.grid(row=0, column=1, padx=(0, 8), pady=8) + self._login_button = ttk.Button(actions, text="Accedi", command=self._on_login) + self._login_button.grid(row=0, column=2, pady=8) + + self.bind("", lambda _e: self._on_login()) + self.bind("", lambda _e: self._on_cancel()) + + def _set_busy(self, busy: bool, message: str = "") -> None: + """Enable or disable user interaction during async authentication.""" + + state = "disabled" if busy else "normal" + try: + self.login_entry.configure(state=state) + self.password_entry.configure(state=state) + if self._login_button is not None: + self._login_button.configure(state=state) + if self._cancel_button is not None: + self._cancel_button.configure(state=state) + self.configure(cursor="watch" if busy else "") + self._status_var.set(message) + self.update_idletasks() + except Exception: + pass + + def _focus_login(self) -> None: + """Focus the login entry as soon as the modal becomes visible.""" + + try: + self.login_entry.focus_force() + except Exception: + pass + + def _show_ready(self) -> None: + """Make the login visible and ready even when the bootstrap root is hidden.""" + + try: + self.attributes("-topmost", True) + self.deiconify() + self.lift() + self.focus_force() + self._focus_login() + finally: + try: + self.after(250, lambda: self.attributes("-topmost", False)) + except Exception: + pass + + def _on_login(self) -> None: + """Validate credentials against the Operatori table.""" + + login = str(self.login_var.get() or "").strip() + password = str(self.password_var.get() or "").strip() + if not login or not password: + messagebox.showwarning("Login", "Inserisci login e password.", parent=self) + return + + params = {"login": login, "password": password} + self._set_busy(True, "Verifico credenziali...") + + def _ok(res: Any) -> None: + rows = _rows_to_dicts(res) + self._set_busy(False, "") + if not rows: + log_session_event( + None, + action="login.failed", + outcome="denied", + details={"login": login}, + ) + messagebox.showerror("Login", "Credenziali non valide.", parent=self) + try: + self.password_var.set("") + self.password_entry.focus_force() + except Exception: + pass + return + + row = rows[0] + self.result_session = create_user_session( + operator_id=int(row.get("ID") or 0), + login=str(row.get("Login") or login).strip(), + nominativo=str(row.get("Nominativo") or "").strip(), + privilegio=int(row["Privilegio"]) if row.get("Privilegio") is not None else None, + codice_unita=str(row.get("CodiceUnita") or "").strip(), + ) + log_session_event( + self.result_session, + action="login.success", + outcome="ok", + details={"display_name": self.result_session.display_name}, + ) + self._close() + + def _err(ex: Exception) -> None: + self._set_busy(False, "") + log_session_event( + None, + action="login.error", + outcome="error", + details={"login": login, "error": str(ex)}, + ) + messagebox.showerror("Login", f"Verifica credenziali fallita:\n{ex}", parent=self) + + self._async.run( + self.db_client.query_json(SQL_LOGIN, params), + _ok, + _err, + ) + + def _on_cancel(self) -> None: + """Abort the login flow and close the modal.""" + + log_session_event(None, action="login.cancel", outcome="cancelled") + self.result_session = None + self._close() + + def _close(self) -> None: + """Release the modal grab and destroy the login window.""" + + try: + self.grab_release() + except Exception: + pass + try: + self.destroy() + except Exception: + pass + + +def prompt_login(parent: tk.Misc, db_client) -> UserSession | None: + """Open the login modal and return the authenticated user session, if any.""" + + dialog = LoginWindow(parent, db_client) + dialog.wait_window() + return dialog.result_session diff --git a/main.py b/main.py index 9021149..d01dd24 100644 --- a/main.py +++ b/main.py @@ -6,38 +6,23 @@ project. """ import asyncio +import ctypes import sys import tkinter as tk import customtkinter as ctk +from tkinter import messagebox from async_loop_singleton import get_global_loop from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn -from layout_window import open_layout_window +from gestione_layout import open_layout_window +from gestione_pickinglist import open_pickinglist_window +from login_window import prompt_login from reset_corsie import open_reset_corsie_window from search_pallets import open_search_window -from view_celle_multiple import open_celle_multiple_window - -# Try factory, else frame, else app (senza passare conn_str all'App) -try: - from gestione_pickinglist import create_frame as create_pickinglist_frame -except Exception: - try: - from gestione_pickinglist import GestionePickingListFrame as _PLFrame - - def create_pickinglist_frame(parent, db_client=None, conn_str=None): - """Build the picking list UI using the frame-based fallback.""" - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("green") - return _PLFrame(parent, db_client=db_client, conn_str=conn_str) - except Exception: - from gestione_pickinglist import GestionePickingListApp as _PLApp - - def create_pickinglist_frame(parent, db_client=None, conn_str=None): - """Fallback used only by legacy app-style picking list implementations.""" - app = _PLApp() - app.mainloop() - return tk.Frame(parent) +from audit_log import log_session_event +from view_celle_multi_udc import open_celle_multiple_window +from user_session import UserSession, create_user_session # ---- Config ---- @@ -46,6 +31,17 @@ DBNAME = "Mediseawall" USER = "sa" PASSWORD = "1Password1" +# Development shortcut: skip the login dialog and boot directly as MAG1. +# Set to False when you want to restore normal authentication. +BYPASS_LOGIN = True +BYPASS_LOGIN_USER = { + "operator_id": 4, + "login": "MAG1", + "nominativo": "MAG1", + "privilegio": 3, + "codice_unita": "U1", +} + if sys.platform.startswith("win"): try: asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -69,93 +65,91 @@ if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"): dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD) db_app = AsyncMSSQLClient(dsn_app) +_APP_MUTEX = None +_APP_MUTEX_NAME = "Local\\WarehousePythonAppSingleton" -def open_pickinglist_window(parent: tk.Misc, db_client: AsyncMSSQLClient): - """Open the picking list window while minimizing initial flicker.""" - win = ctk.CTkToplevel(parent) - win.title("Gestione Picking List") - win.geometry("1200x700+0+100") - win.minsize(1000, 560) +def _acquire_single_instance_mutex() -> bool: + """Return ``True`` only for the first running instance of the application.""" - # Keep the toplevel hidden while its content is being created. - try: - win.withdraw() - win.attributes("-alpha", 0.0) - except Exception: - pass + global _APP_MUTEX + if not sys.platform.startswith("win"): + return True - frame = create_pickinglist_frame(win, db_client=db_client) - try: - frame.pack(fill="both", expand=True) - except Exception: - pass + kernel32 = ctypes.windll.kernel32 + mutex = kernel32.CreateMutexW(None, False, _APP_MUTEX_NAME) + if not mutex: + return True - # Show the window only when the layout is ready. - try: - win.update_idletasks() - try: - win.transient(parent) - except Exception: - pass - try: - win.deiconify() - except Exception: - pass - win.lift() - try: - win.focus_force() - except Exception: - pass - try: - win.attributes("-alpha", 1.0) - except Exception: - pass - except Exception: - pass + last_error = kernel32.GetLastError() + _APP_MUTEX = mutex + ERROR_ALREADY_EXISTS = 183 + return last_error != ERROR_ALREADY_EXISTS - win.bind("", lambda e: win.destroy()) - win.protocol("WM_DELETE_WINDOW", win.destroy) - return win + +def _build_bypass_session() -> UserSession: + """Create the development session used when authentication is bypassed.""" + + return create_user_session( + operator_id=int(BYPASS_LOGIN_USER["operator_id"]), + login=str(BYPASS_LOGIN_USER["login"]), + nominativo=str(BYPASS_LOGIN_USER["nominativo"]), + privilegio=int(BYPASS_LOGIN_USER["privilegio"]), + codice_unita=str(BYPASS_LOGIN_USER["codice_unita"]), + ) class Launcher(ctk.CTk): """Main launcher window that exposes the available warehouse tools.""" - def __init__(self): + def __init__(self, session: UserSession): """Create the launcher toolbar and wire every button to a feature window.""" super().__init__() - self.title("Warehouse 1.0.0") - self.geometry("1200x70+0+0") + self.session: UserSession = session + self.title(f"Warehouse 1.0.0 - {self.session.display_name}") + self.geometry("1280x96+0+0") wrap = ctk.CTkFrame(self) wrap.pack(pady=10, fill="x") + info = ctk.CTkLabel( + wrap, + text=f"Operatore: {self.session.display_name} ({self.session.login})", + anchor="w", + font=("", 12, "bold"), + ) + info.grid(row=0, column=0, columnspan=5, padx=6, pady=(4, 2), sticky="ew") + ctk.CTkButton( wrap, text="Gestione Corsie", - command=lambda: open_reset_corsie_window(self, db_app), - ).grid(row=0, column=0, padx=6, pady=6, sticky="ew") + state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled", + command=lambda: open_reset_corsie_window(self, db_app, session=self.session), + ).grid(row=1, column=0, padx=6, pady=6, sticky="ew") ctk.CTkButton( wrap, text="Gestione Layout", - command=lambda: open_layout_window(self, db_app), - ).grid(row=0, column=1, padx=6, pady=6, sticky="ew") + state="normal" if self.session.can("launcher.open_layout") else "disabled", + command=lambda: open_layout_window(self, db_app, session=self.session), + ).grid(row=1, column=1, padx=6, pady=6, sticky="ew") ctk.CTkButton( wrap, text="UDC Fantasma", - command=lambda: open_celle_multiple_window(self, db_app), - ).grid(row=0, column=2, padx=6, pady=6, sticky="ew") + state="normal" if self.session.can("launcher.open_multi_udc") else "disabled", + command=lambda: open_celle_multiple_window(self, db_app, session=self.session), + ).grid(row=1, column=2, padx=6, pady=6, sticky="ew") ctk.CTkButton( wrap, text="Ricerca UDC", - command=lambda: open_search_window(self, db_app), - ).grid(row=0, column=3, padx=6, pady=6, sticky="ew") + state="normal" if self.session.can("launcher.open_search") else "disabled", + command=lambda: open_search_window(self, db_app, session=self.session), + ).grid(row=1, column=3, padx=6, pady=6, sticky="ew") ctk.CTkButton( wrap, text="Gestione Picking List", - command=lambda: open_pickinglist_window(self, db_app), - ).grid(row=0, column=4, padx=6, pady=6, sticky="ew") + state="normal" if self.session.can("launcher.open_pickinglist") else "disabled", + command=lambda: open_pickinglist_window(self, db_app, session=self.session), + ).grid(row=1, column=4, padx=6, pady=6, sticky="ew") for i in range(5): wrap.grid_columnconfigure(i, weight=1) @@ -163,6 +157,8 @@ class Launcher(ctk.CTk): def _on_close(): """Dispose shared resources before closing the launcher.""" try: + if self.session is not None: + log_session_event(self.session, action="logout", outcome="ok") fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) try: fut.result(timeout=2) @@ -172,9 +168,65 @@ class Launcher(ctk.CTk): self.destroy() self.protocol("WM_DELETE_WINDOW", _on_close) + try: + self.lift() + self.focus_force() + except Exception: + pass if __name__ == "__main__": ctk.set_appearance_mode("light") ctk.set_default_color_theme("green") - Launcher().mainloop() + if not _acquire_single_instance_mutex(): + root = tk.Tk() + root.withdraw() + messagebox.showwarning( + "Warehouse", + "L'applicazione e' gia' in esecuzione.\nChiudi l'istanza aperta prima di avviarne un'altra.", + parent=root, + ) + try: + root.destroy() + except Exception: + pass + raise SystemExit(0) + + if BYPASS_LOGIN: + session = _build_bypass_session() + log_session_event( + session, + action="login.bypass", + outcome="ok", + details={"login": session.login}, + ) + bootstrap = None + else: + bootstrap = tk.Tk() + bootstrap.geometry("1x1+0+0") + bootstrap.overrideredirect(True) + bootstrap.attributes("-alpha", 0.0) + bootstrap.deiconify() + bootstrap.update_idletasks() + session = prompt_login(bootstrap, db_app) + + if session is None: + fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) + try: + fut.result(timeout=2) + except Exception: + pass + try: + if bootstrap is not None: + bootstrap.destroy() + except Exception: + pass + raise SystemExit(0) + + try: + if bootstrap is not None: + bootstrap.destroy() + except Exception: + pass + + Launcher(session).mainloop() diff --git a/prenota_sprenota_sql.py b/prenota_sprenota_sql.py index cf55357..1c109b3 100644 --- a/prenota_sprenota_sql.py +++ b/prenota_sprenota_sql.py @@ -2,9 +2,150 @@ from __future__ import annotations +import json +import logging +import sys from dataclasses import dataclass +from functools import wraps +from pathlib import Path from typing import Any, Dict, List, Optional +try: + from loguru import logger +except Exception: # pragma: no cover - safety fallback if dependency is missing locally + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + + +PACKINGLIST_SP_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = PACKINGLIST_SP_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if PACKINGLIST_SP_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: Optional[str] = None): + """Trace entry, exit and failure of selected procedure helpers.""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if len(args) > 1 else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = await func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + return wrapper + return decorator + + +def _log_sql(query_name: str, sql: str, params: Dict[str, Any]): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: Any): + """Log query results at summary or full-debug level depending on the mode.""" + if isinstance(rows, list): + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if PACKINGLIST_SP_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + else: + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} scalar={_format_payload(rows)}") + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={PACKINGLIST_SP_LOG_MODE.upper()}" + ) + @dataclass class SPResult: @@ -15,14 +156,18 @@ class SPResult: id_result: Optional[int] = None +@_log_call("DEBUG") async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]: """Return the first column of the first row from a query result.""" + _log_sql("_query_one_value", sql, params) if hasattr(db, "query_json"): res = await db.query_json(sql, params) if isinstance(res, list) and res: row0 = res[0] if isinstance(row0, dict): - return next(iter(row0.values()), None) + value = next(iter(row0.values()), None) + _log_dataset("_query_one_value", value) + return value elif isinstance(res, dict): rows = None for key in ("rows", "data", "result", "records"): @@ -32,58 +177,83 @@ async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any if rows: row0 = rows[0] if isinstance(row0, dict): - return next(iter(row0.values()), None) + value = next(iter(row0.values()), None) + _log_dataset("_query_one_value", value) + return value if isinstance(row0, (list, tuple)) and row0: - return row0[0] + value = row0[0] + _log_dataset("_query_one_value", value) + return value + _log_dataset("_query_one_value", None) return None if hasattr(db, "query_value"): - return await db.query_value(sql, params) + value = await db.query_value(sql, params) + _log_dataset("_query_one_value", value) + return value if hasattr(db, "scalar"): - return await db.scalar(sql, params) + value = await db.scalar(sql, params) + _log_dataset("_query_one_value", value) + return value raise RuntimeError("Il client DB non espone query_json/query_value/scalar") +@_log_call("DEBUG") async def _query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]: """Return all rows as dictionaries, normalizing different DB client APIs.""" + _log_sql("_query_all", sql, params) if hasattr(db, "query_json"): res = await db.query_json(sql, params) if res is None: + _log_dataset("_query_all", []) return [] if isinstance(res, list): - return res if res and isinstance(res[0], dict) else [] + rows = res if res and isinstance(res[0], dict) else [] + _log_dataset("_query_all", rows) + return rows if isinstance(res, dict): for key in ("rows", "data", "result", "records"): if key in res and isinstance(res[key], list): rows = res[key] if rows and isinstance(rows[0], dict): + _log_dataset("_query_all", rows) return rows cols = res.get("columns") or res.get("cols") or [] out = [] for row in rows: if isinstance(row, (list, tuple)) and cols: out.append({(cols[i] if i < len(cols) else f"c{i}"): row[i] for i in range(min(len(cols), len(row)))}) + _log_dataset("_query_all", out) return out + _log_dataset("_query_all", []) return [] if hasattr(db, "fetch_all"): - return await db.fetch_all(sql, params) + rows = await db.fetch_all(sql, params) + _log_dataset("_query_all", rows) + return rows raise RuntimeError("Il client DB non espone query_json/fetch_all") +@_log_call("DEBUG") async def _execute(db, sql: str, params: Dict[str, Any]) -> int: """Execute a DML statement using the best method exposed by the DB client.""" + _log_sql("_execute", sql, params) for name in ("execute", "exec", "execute_non_query"): if hasattr(db, name): rc = await getattr(db, name)(sql, params) if isinstance(rc, int): + _log_dataset("_execute", rc) return rc + _log_dataset("_execute", 0) return 0 if hasattr(db, "query_json"): await db.query_json(sql, params) + _log_dataset("_execute", 0) return 0 raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti") +@_log_call() async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -> SPResult: """Toggle the reservation state of all cells belonging to a packing list. @@ -91,6 +261,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) - the shared async DB client already managed by the application. """ try: + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Procedura async packing list avviata documento={Documento} id_operatore={IDOperatore}") nominativo = await _query_one_value( db, "SELECT LOGIN FROM Operatori WHERE id = :IDOperatore", @@ -107,6 +278,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) - {"Documento": Documento}, ) id_celle = [row.get("Cella") for row in celle if "Cella" in row] + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Celle coinvolte per documento={Documento}: {len(id_celle)}") # Each cell is toggled individually because the original procedure also # updates metadata such as operator and timestamp per row. @@ -118,6 +290,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) - "SELECT IDStato FROM Celle WHERE ID = :IDC", {"IDC": id_cella}, ) + _MODULE_LOGGER.debug(f"Toggling cella id={id_cella} stato_corrente={stato}") if stato == 0: await _execute( db, @@ -165,6 +338,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) - ) new_id = await _query_one_value(db, "SELECT SCOPE_IDENTITY() AS ID", {}) + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Procedura completata documento={Documento} id_result={new_id}") return SPResult(rc=0, message="", id_result=int(new_id) if new_id is not None else None) except Exception as exc: + _MODULE_LOGGER.exception(f"Procedura fallita documento={Documento}: {exc}") return SPResult(rc=-1, message=str(exc), id_result=None) diff --git a/pyproject.toml b/pyproject.toml index 1c715f7..ef6f8a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ requires-python = ">=3.12" dependencies = [ "sqlalchemy[asyncio]>=2.0", "aioodbc>=0.3.3", + "loguru>=0.7", + "tksheet>=7.5", # "orjson>=3.9" # opzionale: il tuo codice fa fallback su json puro ] diff --git a/reset_corsie.py b/reset_corsie.py index fbcff5e..896a108 100644 --- a/reset_corsie.py +++ b/reset_corsie.py @@ -10,7 +10,7 @@ from tkinter import messagebox, simpledialog, ttk import customtkinter as ctk -from gestione_aree_frame_async import AsyncRunner, BusyOverlay +from gestione_aree import AsyncRunner, BusyOverlay SQL_CORSIE = """ WITH C AS ( @@ -94,7 +94,7 @@ WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia; class ResetCorsieWindow(ctk.CTkToplevel): """Toplevel used to inspect and clear the pallets assigned to an aisle.""" - def __init__(self, parent, db_client): + def __init__(self, parent, db_client, session=None): """Create the window and immediately load the list of aisles.""" super().__init__(parent) self.title("Reset Corsie - svuotamento celle per corsia") @@ -103,6 +103,7 @@ class ResetCorsieWindow(ctk.CTkToplevel): self.resizable(True, True) self.db = db_client + self.session = session self._busy = BusyOverlay(self) self._async = AsyncRunner(self) @@ -256,9 +257,22 @@ class ResetCorsieWindow(ctk.CTkToplevel): self._async.run(self.db.query_json(SQL_DELETE, {"corsia": corsia}), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...") -def open_reset_corsie_window(parent, db_app): +def open_reset_corsie_window(parent, db_app, session=None): """Create, focus and return the aisle reset window.""" - win = ResetCorsieWindow(parent, db_app) - win.lift() - win.focus_set() + key = "_reset_corsie_window_singleton" + ex = getattr(parent, key, None) + if ex and ex.winfo_exists(): + try: + ex.lift() + ex.focus_force() + return ex + except Exception: + pass + win = ResetCorsieWindow(parent, db_app, session=session) + setattr(parent, key, win) + try: + win.lift() + win.focus_force() + except Exception: + pass return win diff --git a/search_pallets.py b/search_pallets.py index b7f2559..8c25d16 100644 --- a/search_pallets.py +++ b/search_pallets.py @@ -7,7 +7,7 @@ from tkinter import filedialog, messagebox, ttk import customtkinter as ctk -from gestione_aree_frame_async import AsyncRunner, BusyOverlay +from gestione_aree import AsyncRunner, BusyOverlay try: from openpyxl import Workbook @@ -74,7 +74,7 @@ ORDER BY class SearchWindow(ctk.CTkToplevel): """Window that searches pallets by barcode, lot or product code.""" - def __init__(self, parent: tk.Widget, db_app): + def __init__(self, parent: tk.Widget, db_app, session=None): """Initialize widgets and keep a reference to the shared DB client.""" super().__init__(parent) self.title("Warehouse - Ricerca UDC/Lotto/Codice") @@ -83,6 +83,7 @@ class SearchWindow(ctk.CTkToplevel): self.resizable(True, True) self.db = db_app + self.session = session self._busy = BusyOverlay(self) self._async = AsyncRunner(self) self._sort_state: dict[str, bool] = {} @@ -411,7 +412,7 @@ class SearchWindow(ctk.CTkToplevel): self._async.run(self.db.query_json(SQL_SEARCH, params), _ok, _err, busy=self._busy, message="Cerco...") -def open_search_window(parent, db_app): +def open_search_window(parent, db_app, session=None): """Open a singleton-like search window tied to the launcher instance.""" key = "_search_window_singleton" ex = getattr(parent, key, None) @@ -422,6 +423,6 @@ def open_search_window(parent, db_app): return ex except Exception: pass - w = SearchWindow(parent, db_app) + w = SearchWindow(parent, db_app, session=session) setattr(parent, key, w) return w diff --git a/user_session.py b/user_session.py new file mode 100644 index 0000000..a60d201 --- /dev/null +++ b/user_session.py @@ -0,0 +1,88 @@ +"""Application user session and permission scaffolding for the warehouse app.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import FrozenSet + + +ALL_ACTIONS: FrozenSet[str] = frozenset( + { + "launcher.open_reset_corsie", + "launcher.open_layout", + "launcher.open_multi_udc", + "launcher.open_search", + "launcher.open_pickinglist", + "reset_corsie.view", + "search.view", + "multi_udc.view", + "layout.view", + "layout.carico", + "layout.scarico", + "layout.prenota", + "layout.libera_prenotazione", + "layout.disabilita_cella", + "layout.abilita_cella", + "pickinglist.view", + "pickinglist.prenota", + "pickinglist.sprenota", + } +) + + +def build_allowed_actions(_privilegio: int | None) -> FrozenSet[str]: + """Return the currently enabled action set for the given operator profile. + + For this first iteration every authenticated operator can execute every + function, but the explicit action map already exists so that a future admin + UI can refine profiles without refactoring the whole application. + """ + + return ALL_ACTIONS + + +@dataclass(frozen=True) +class UserSession: + """Minimal in-memory representation of one authenticated application user.""" + + operator_id: int + login: str + nominativo: str + privilegio: int | None = None + codice_unita: str = "" + session_started_at: datetime = field(default_factory=datetime.now) + allowed_actions: FrozenSet[str] = field(default_factory=lambda: ALL_ACTIONS) + + def can(self, action: str) -> bool: + """Return whether the current user can execute one named action.""" + + return action in self.allowed_actions + + @property + def display_name(self) -> str: + """Return the best human-readable identity for the current session.""" + + if str(self.nominativo or "").strip(): + return str(self.nominativo).strip() + return str(self.login or "").strip() + + +def create_user_session( + *, + operator_id: int, + login: str, + nominativo: str, + privilegio: int | None, + codice_unita: str, +) -> UserSession: + """Create one user session with the current default action set.""" + + return UserSession( + operator_id=int(operator_id), + login=str(login or "").strip(), + nominativo=str(nominativo or "").strip(), + privilegio=int(privilegio) if privilegio is not None else None, + codice_unita=str(codice_unita or "").strip(), + allowed_actions=build_allowed_actions(privilegio), + ) diff --git a/view_celle_multiple.py b/view_celle_multi_udc.py similarity index 62% rename from view_celle_multiple.py rename to view_celle_multi_udc.py index b14c19c..13a8166 100644 --- a/view_celle_multiple.py +++ b/view_celle_multi_udc.py @@ -1,15 +1,158 @@ """Exploration window for cells containing more than one pallet.""" +from __future__ import annotations + import json +import logging +import sys import tkinter as tk from datetime import datetime +from functools import wraps +from pathlib import Path from tkinter import filedialog, messagebox, ttk +from typing import Any import customtkinter as ctk from openpyxl import Workbook from openpyxl.styles import Alignment, Font -from gestione_aree_frame_async import AsyncRunner +from gestione_aree import AsyncRunner + +try: + from loguru import logger +except Exception: # pragma: no cover - fallback used only when Loguru is not available + class _FallbackLogger: + """Minimal adapter used only when Loguru is not installed yet.""" + + def __init__(self): + self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def bind(self, **_kwargs): + return self + + def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs): + handler: logging.Handler + if hasattr(sink, "write"): + handler = logging.StreamHandler(sink) + else: + handler = logging.FileHandler(str(sink), encoding=encoding) + handler.setLevel(getattr(logging, str(level).upper(), logging.INFO)) + handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s") + ) + self._logger.addHandler(handler) + return 0 + + def log(self, level, message): + getattr(self._logger, str(level).lower(), self._logger.info)(message) + + def debug(self, message): + self._logger.debug(message) + + def info(self, message): + self._logger.info(message) + + def exception(self, message): + self._logger.exception(message) + + logger = _FallbackLogger() + + +MULTI_UDC_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG" +MODULE_LOG_NAME = Path(__file__).stem +MODULE_LOG_PATH = Path(__file__).with_suffix(".log") +_MODULE_LOG_ENABLED = MULTI_UDC_LOG_MODE.upper() != "OFF" +_MODULE_LOG_LEVEL = "DEBUG" if MULTI_UDC_LOG_MODE.upper() == "DEBUG" else "INFO" +_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) +_MODULE_LOGGING_CONFIGURED = False + + +def _configure_module_logger(): + """Configure console and file logging for this module.""" + global _MODULE_LOGGING_CONFIGURED + if _MODULE_LOGGING_CONFIGURED: + return + if not _MODULE_LOG_ENABLED: + _MODULE_LOGGING_CONFIGURED = True + return + + record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME + + logger.add( + sys.stderr, + level=_MODULE_LOG_LEVEL, + colorize=True, + filter=record_filter, + format=( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "" + MODULE_LOG_NAME + " | " + "{message}" + ), + ) + logger.add( + MODULE_LOG_PATH, + level=_MODULE_LOG_LEVEL, + colorize=False, + encoding="utf-8", + filter=record_filter, + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}", + ) + _MODULE_LOGGING_CONFIGURED = True + + +def _format_payload(payload: Any) -> str: + """Serialize payloads for human-readable logging.""" + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except Exception: + return repr(payload) + + +def _log_call(level: str | None = None): + """Trace entry, exit and failure of selected high-level functions.""" + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + effective_level = level or _MODULE_LOG_LEVEL + _MODULE_LOGGER.log( + effective_level, + f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}", + ) + try: + result = func(*args, **kwargs) + except Exception: + _MODULE_LOGGER.exception(f"FAIL {func.__qualname__}") + raise + _MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}") + return result + + return wrapper + + return decorator + + +def _log_sql(query_name: str, sql: str, params: dict[str, Any] | None = None): + """Log one SQL statement and its parameters.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params or {})}") + _MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}") + + +def _log_dataset(query_name: str, rows: list[Any]): + """Log query results at summary or full-debug level depending on the flag.""" + _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows") + if MULTI_UDC_LOG_MODE.upper() == "DEBUG": + _MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}") + + +_configure_module_logger() +if _MODULE_LOG_ENABLED: + _MODULE_LOGGER.info( + f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={MULTI_UDC_LOG_MODE.upper()}" + ) def _json_obj(res): @@ -80,10 +223,53 @@ ORDER BY b.Colonna, b.Fila; """ SQL_PALLET_IN_CELLA = BASE_CTE + """ +, cell_pallets AS ( + SELECT DISTINCT b.BarcodePallet + FROM base b + WHERE b.IDCella = :idcella +), +latest_any AS ( + SELECT + ranked.BarcodePallet, + ranked.IDCella + FROM ( + SELECT + mp.Attributo AS BarcodePallet, + mp.IDCella, + ROW_NUMBER() OVER ( + PARTITION BY mp.Attributo + ORDER BY mp.ID DESC + ) AS rn + FROM dbo.MagazziniPallet mp + JOIN cell_pallets cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + mp.Attributo COLLATE Latin1_General_CI_AS + WHERE mp.Tipo = 'V' + AND mp.PesoUnitario > 0 + ) ranked + WHERE ranked.rn = 1 +), +shipped AS ( + SELECT DISTINCT shipped.BarcodePallet + FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped + JOIN cell_pallets cp + ON cp.BarcodePallet COLLATE Latin1_General_CI_AS = + shipped.BarcodePallet COLLATE Latin1_General_CI_AS +) SELECT b.BarcodePallet AS Pallet, ta.Descrizione, - ta.Lotto + ta.Lotto, + CASE + WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsShippedGhost, + CASE + WHEN la.IDCella IS NOT NULL + AND la.IDCella <> :idcella + THEN CAST(1 AS int) + ELSE CAST(0 AS int) + END AS IsMovedGhost FROM base b OUTER APPLY ( SELECT TOP (1) t.Descrizione, t.Lotto @@ -91,11 +277,28 @@ OUTER APPLY ( WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS ORDER BY t.Lotto ) AS ta +LEFT JOIN latest_any la + ON la.BarcodePallet COLLATE Latin1_General_CI_AS = + b.BarcodePallet COLLATE Latin1_General_CI_AS +LEFT JOIN shipped + ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS = + b.BarcodePallet COLLATE Latin1_General_CI_AS WHERE b.IDCella = :idcella -GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto +GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto, shipped.BarcodePallet, la.IDCella ORDER BY b.BarcodePallet; """ + +def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str: + """Translate anomaly flags into the operator-facing ghost cause.""" + + notes: list[str] = [] + if bool(is_shipped): + notes.append("Mancato scarico: spedita") + if bool(is_moved): + notes.append("Mancato scarico: spostata") + return " | ".join(notes) + SQL_RIEPILOGO_PERCENTUALI = BASE_CTE + """ , tot AS ( SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle @@ -136,10 +339,12 @@ ORDER BY Ord, Corsia; class CelleMultipleWindow(ctk.CTkToplevel): """Tree-based explorer for duplicated pallet allocations.""" - def __init__(self, root, db_client, runner: AsyncRunner | None = None): + @_log_call() + def __init__(self, root, db_client, runner: AsyncRunner | None = None, session=None): """Bind the shared DB client and immediately load the tree summary.""" super().__init__(root) self.title("Celle con piu' pallet") + self.session = session self.geometry("1100x700") self.minsize(900, 550) self.resizable(True, True) @@ -169,10 +374,15 @@ class CelleMultipleWindow(ctk.CTkToplevel): frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6)) frame.grid_rowconfigure(0, weight=1) frame.grid_columnconfigure(0, weight=1) - self.tree = ttk.Treeview(frame, columns=("col2", "col3"), show="tree headings", selectmode="browse") + self.tree = ttk.Treeview(frame, columns=("col2", "col3", "col4"), show="tree headings", selectmode="browse") self.tree.heading("#0", text="Nodo") self.tree.heading("col2", text="Descrizione") self.tree.heading("col3", text="Lotto") + self.tree.heading("col4", text="Causale") + self.tree.column("#0", width=220, anchor="w") + self.tree.column("col2", width=250, anchor="w") + self.tree.column("col3", width=120, anchor="w") + self.tree.column("col4", width=260, anchor="w") y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set) @@ -207,23 +417,32 @@ class CelleMultipleWindow(ctk.CTkToplevel): """Attach lazy-load behavior when nodes are expanded.""" self.tree.bind("<>", self._on_open_node) + @_log_call() def refresh_all(self): """Reload both the duplication tree and the summary percentage table.""" self._load_corsie() self._load_riepilogo() + @_log_call() def _load_corsie(self): """Load root nodes representing aisles with duplicated cells.""" self.tree.delete(*self.tree.get_children()) + _log_sql("multi_udc_corsie", SQL_CORSIE, {}) async def _q(db): return await db.query_json(SQL_CORSIE, as_dict_rows=True) - self.runner.run(_q(self.db), self._fill_corsie, lambda e: messagebox.showerror("Errore", str(e), parent=self)) + def _err(ex): + _MODULE_LOGGER.exception(f"Errore caricamento corsie UDC fantasma: {ex}") + messagebox.showerror("Errore", str(ex), parent=self) + self.runner.run(_q(self.db), self._fill_corsie, _err) + + @_log_call() def _fill_corsie(self, res): """Populate root tree nodes after the aisle query completes.""" rows = _json_obj(res).get("rows", []) + _log_dataset("multi_udc_corsie", rows) for row in rows: corsia = row.get("Corsia") if not corsia: @@ -252,16 +471,25 @@ class CelleMultipleWindow(ctk.CTkToplevel): self.tree.delete(child) self._load_pallet_for_cella(sel, idcella) + @_log_call() def _load_celle_for_corsia(self, parent_iid, corsia): """Query duplicated cells for the selected aisle.""" + _log_sql("multi_udc_celle_per_corsia", SQL_CELLE_DUP_PER_CORSIA, {"corsia": corsia}) + async def _q(db): return await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True) - self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res), lambda e: messagebox.showerror("Errore", str(e), parent=self)) + def _err(ex): + _MODULE_LOGGER.exception(f"Errore caricamento celle duplicate corsia={corsia}: {ex}") + messagebox.showerror("Errore", str(ex), parent=self) + self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res), _err) + + @_log_call() def _fill_celle(self, parent_iid, res): """Populate duplicated-cell nodes under an aisle node.""" rows = _json_obj(res).get("rows", []) + _log_dataset("multi_udc_celle_per_corsia", rows) if not rows: self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "")) return @@ -279,18 +507,27 @@ class CelleMultipleWindow(ctk.CTkToplevel): if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)): self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "")) + @_log_call() def _load_pallet_for_cella(self, parent_iid, idcella: int): """Query pallet details for a duplicated cell.""" + _log_sql("multi_udc_pallet_in_cella", SQL_PALLET_IN_CELLA, {"idcella": idcella}) + async def _q(db): return await db.query_json(SQL_PALLET_IN_CELLA, params={"idcella": idcella}, as_dict_rows=True) - self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res), lambda e: messagebox.showerror("Errore", str(e), parent=self)) + def _err(ex): + _MODULE_LOGGER.exception(f"Errore caricamento pallet cella idcella={idcella}: {ex}") + messagebox.showerror("Errore", str(ex), parent=self) + self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res), _err) + + @_log_call() def _fill_pallet(self, parent_iid, res): """Add pallet leaves under the selected cell node.""" rows = _json_obj(res).get("rows", []) + _log_dataset("multi_udc_pallet_in_cella", rows) if not rows: - self.tree.insert(parent_iid, "end", text="(nessun pallet)", values=("", "")) + self.tree.insert(parent_iid, "end", text="(nessun pallet)", values=("", "", "")) return parent_tags = self.tree.item(parent_iid, "tags") or () corsia_tag = next((tag for tag in parent_tags if tag.startswith("corsia:")), None) @@ -303,29 +540,39 @@ class CelleMultipleWindow(ctk.CTkToplevel): pallet = row.get("Pallet", "") desc = row.get("Descrizione", "") lotto = row.get("Lotto", "") + causale = _build_diagnostic_note(row.get("IsShippedGhost", 0), row.get("IsMovedGhost", 0)) leaf_id = f"pallet:{idcella_num}:{pallet}" if self.tree.exists(leaf_id): - self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto)) + self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto, causale)) continue self.tree.insert( parent_iid, "end", iid=leaf_id, text=str(pallet), - values=(desc, lotto), + values=(desc, lotto, causale), tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"), ) + @_log_call() def _load_riepilogo(self): """Load the percentage summary by aisle.""" + _log_sql("multi_udc_riepilogo", SQL_RIEPILOGO_PERCENTUALI, {}) + async def _q(db): return await db.query_json(SQL_RIEPILOGO_PERCENTUALI, as_dict_rows=True) - self.runner.run(_q(self.db), self._fill_riepilogo, lambda e: messagebox.showerror("Errore", str(e), parent=self)) + def _err(ex): + _MODULE_LOGGER.exception(f"Errore caricamento riepilogo UDC fantasma: {ex}") + messagebox.showerror("Errore", str(ex), parent=self) + self.runner.run(_q(self.db), self._fill_riepilogo, _err) + + @_log_call() def _fill_riepilogo(self, res): """Refresh the bottom summary table.""" rows = _json_obj(res).get("rows", []) + _log_dataset("multi_udc_riepilogo", rows) for item in self.sum_tbl.get_children(): self.sum_tbl.delete(item) for row in rows: @@ -349,6 +596,7 @@ class CelleMultipleWindow(ctk.CTkToplevel): for iid in self.tree.get_children(""): self.tree.item(iid, open=False) + @_log_call() def export_to_xlsx(self): """Export both the detailed tree and the summary table to Excel.""" ts = datetime.now().strftime("%d_%m_%Y_%H-%M") @@ -367,7 +615,7 @@ class CelleMultipleWindow(ctk.CTkToplevel): ws_det = wb.active ws_det.title = "Dettaglio" ws_sum = wb.create_sheet("Riepilogo") - det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto"] + det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto", "Causale"] sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"] def _hdr(ws, headers): @@ -391,8 +639,8 @@ class CelleMultipleWindow(ctk.CTkToplevel): ubi = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "") idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "") pallet = self.tree.item(pallet_node, "text") - desc, lotto = self.tree.item(pallet_node, "values") - for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto], start=1): + desc, lotto, causale = self.tree.item(pallet_node, "values") + for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1): ws_det.cell(row=row_idx, column=j, value=value) row_idx += 1 @@ -420,12 +668,31 @@ class CelleMultipleWindow(ctk.CTkToplevel): wb.save(fname) messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self) except Exception as ex: + _MODULE_LOGGER.exception(f"Errore esportazione UDC fantasma: {ex}") messagebox.showerror("Errore esportazione", str(ex), parent=self) -def open_celle_multiple_window(root: tk.Tk, db_client, runner: AsyncRunner | None = None): +def open_celle_multiple_window( + root: tk.Tk, + db_client, + runner: AsyncRunner | None = None, + session=None, +): """Create, focus and return the duplicated-cells explorer.""" - win = CelleMultipleWindow(root, db_client, runner=runner) - win.lift() - win.focus_set() + key = "_celle_multiple_window_singleton" + ex = getattr(root, key, None) + if ex and ex.winfo_exists(): + try: + ex.lift() + ex.focus_force() + return ex + except Exception: + pass + win = CelleMultipleWindow(root, db_client, runner=runner, session=session) + setattr(root, key, win) + try: + win.lift() + win.focus_force() + except Exception: + pass return win