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