Checkpoint before ghost pallet cleanup workflow

This commit is contained in:
2026-05-09 12:18:59 +02:00
parent f556b476ff
commit 6ab42a2303
27 changed files with 3947 additions and 973 deletions

102
audit_log.py Normal file
View File

@@ -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,
)

View File

@@ -20,18 +20,18 @@ async_msssql_query.py
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
gestione_aree_frame_async.py gestione_aree.py
---------------------------- ----------------------------
.. automodule:: gestione_aree_frame_async .. automodule:: gestione_aree
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
layout_window.py gestione_layout.py
---------------- ------------------
.. automodule:: layout_window .. automodule:: gestione_layout
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
@@ -44,10 +44,10 @@ reset_corsie.py
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
view_celle_multiple.py view_celle_multi_udc.py
---------------------- -----------------------
.. automodule:: view_celle_multiple .. automodule:: view_celle_multi_udc
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:

View File

@@ -12,12 +12,12 @@ flowchart TD
Main --> DB["AsyncMSSQLClient"] Main --> DB["AsyncMSSQLClient"]
Launcher --> Reset["reset_corsie.py"] Launcher --> Reset["reset_corsie.py"]
Launcher --> Layout["layout_window.py"] Launcher --> Layout["gestione_layout.py"]
Launcher --> Ghost["view_celle_multiple.py"] Launcher --> Ghost["view_celle_multi_udc.py"]
Launcher --> Search["search_pallets.py"] Launcher --> Search["search_pallets.py"]
Launcher --> Picking["gestione_pickinglist.py"] Launcher --> Picking["gestione_pickinglist.py"]
Reset --> Runner["gestione_aree_frame_async.AsyncRunner"] Reset --> Runner["gestione_aree.AsyncRunner"]
Layout --> Runner Layout --> Runner
Ghost --> Runner Ghost --> Runner
Search --> Runner Search --> Runner

View File

@@ -12,12 +12,13 @@ I diagrammi sono scritti in Mermaid, quindi possono essere:
## Indice ## Indice
- [main](./main_flow.md) - [main](./main_flow.md)
- [layout_window](./layout_window_flow.md) - [gestione_layout](./gestione_layout_flow.md)
- [reset_corsie](./reset_corsie_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) - [search_pallets](./search_pallets_flow.md)
- [gestione_pickinglist](./gestione_pickinglist_flow.md) - [gestione_pickinglist](./gestione_pickinglist_flow.md)
- [infrastruttura async/db](./async_db_flow.md) - [infrastruttura async/db](./async_db_flow.md)
- [warehouse operational flow](./warehouse_operational_flow.md)
## Convenzioni ## Convenzioni

View File

@@ -35,5 +35,5 @@ flowchart TD
## Note ## Note
- E un helper minimale usato da `main.py`. - 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. svolge lo stesso ruolo di gestione del loop condiviso.

View File

@@ -1,4 +1,4 @@
# `gestione_aree_frame_async.py` # `gestione_aree.py`
## Scopo ## Scopo

View File

@@ -1,10 +1,11 @@
# `layout_window.py` # `gestione_layout.py`
## Scopo ## Scopo
Questo modulo visualizza il layout delle corsie come matrice di celle, mostra 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 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 ## Flusso operativo
@@ -57,5 +58,7 @@ flowchart LR
- Il modulo usa un token `_req_counter` per evitare che risposte async vecchie - Il modulo usa un token `_req_counter` per evitare che risposte async vecchie
aggiornino la UI fuori ordine. aggiornino la UI fuori ordine.
- La statistica globale viene ricalcolata da query SQL, mentre quella della - La statistica globale viene ricalcolata da query SQL, mentre quella della
corsia corrente usa la matrice già caricata in memoria. corsia corrente usa la matrice gia' caricata in memoria.
- `destroy()` marca la finestra come non più attiva per evitare callback tardive. - 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.

View File

@@ -13,7 +13,7 @@ Questo modulo gestisce la vista master/detail delle picking list e permette di:
```{mermaid} ```{mermaid}
flowchart TD 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__()"] B --> C["GestionePickingListFrame.__init__()"]
C --> D["_build_layout()"] C --> D["_build_layout()"]
D --> E["after_idle(_first_show)"] D --> E["after_idle(_first_show)"]

View File

@@ -9,12 +9,13 @@ infrastrutturali.
README.md README.md
main_flow.md main_flow.md
layout_window_flow.md gestione_layout_flow.md
reset_corsie_flow.md reset_corsie_flow.md
view_celle_multiple_flow.md view_celle_multi_udc_flow.md
search_pallets_flow.md search_pallets_flow.md
gestione_pickinglist_flow.md gestione_pickinglist_flow.md
warehouse_operational_flow.md
async_db_flow.md async_db_flow.md
async_msssql_query_flow.md async_msssql_query_flow.md
gestione_aree_frame_async_flow.md gestione_aree_flow.md
async_loop_singleton_flow.md async_loop_singleton_flow.md

View File

@@ -22,7 +22,7 @@ flowchart TD
I --> K["open_layout_window()"] I --> K["open_layout_window()"]
I --> L["open_celle_multiple_window()"] I --> L["open_celle_multiple_window()"]
I --> M["open_search_window()"] I --> M["open_search_window()"]
I --> N["open_pickinglist_window()"] I --> N["gestione_pickinglist.open_pickinglist_window()"]
``` ```
## Schema di chiamata ## Schema di chiamata
@@ -33,13 +33,13 @@ flowchart LR
Launcher --> Layout["open_layout_window"] Launcher --> Layout["open_layout_window"]
Launcher --> Ghost["open_celle_multiple_window"] Launcher --> Ghost["open_celle_multiple_window"]
Launcher --> Search["open_search_window"] Launcher --> Search["open_search_window"]
Launcher --> Pick["open_pickinglist_window"] Launcher --> Pick["gestione_pickinglist.open_pickinglist_window"]
Pick --> PickFactory["create_pickinglist_frame"] Pick --> PickFactory["gestione_pickinglist.create_frame"]
``` ```
## Note ## Note
- `db_app` viene creato una sola volta e poi passato a tutte le finestre. - `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. - 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. visibile solo a layout pronto, per ridurre lo sfarfallio iniziale.

View File

@@ -1,4 +1,4 @@
# `view_celle_multiple.py` # `view_celle_multi_udc.py`
## Scopo ## Scopo

View File

@@ -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.

View File

@@ -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 The script removes incompatible ``border_color='transparent'`` assignments from
widget configuration calls while preserving explicit highlight colors that are widget configuration calls while preserving explicit highlight colors that are
@@ -9,7 +9,7 @@ import re
from pathlib import Path from pathlib import Path
# Path default (modifica se serve) # Path default (modifica se serve)
p = Path("./layout_window.py") p = Path("./gestione_layout.py")
if not p.exists(): if not p.exists():
raise SystemExit(f"File non trovato: {p}") raise SystemExit(f"File non trovato: {p}")

View File

@@ -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 The script was used during development to remove an expensive resize-triggered
refresh and to inject some lifecycle guards into the window implementation. 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 from pathlib import Path
import re import re
p = Path("./layout_window.py") p = Path("./gestione_layout.py")
src = p.read_text(encoding="utf-8") src = p.read_text(encoding="utf-8")
backup = p.with_suffix(".py.bak_perf") backup = p.with_suffix(".py.bak_perf")

View File

@@ -1,22 +1,26 @@
"""Shared Tk/async helpers used by multiple warehouse windows. """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; * 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 from __future__ import annotations
import asyncio import asyncio
import threading
import tkinter as tk import tkinter as tk
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
import customtkinter as ctk 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: try:
from async_msssql_query import AsyncMSSQLClient # noqa: F401 from async_msssql_query import AsyncMSSQLClient # noqa: F401
@@ -24,50 +28,6 @@ except Exception:
AsyncMSSQLClient = object # type: ignore 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: class BusyOverlay:
"""Semi-transparent overlay used to block interaction during async tasks.""" """Semi-transparent overlay used to block interaction during async tasks."""

1372
gestione_layout.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,68 @@ smooth by relying on deferred updates and lightweight progress indicators.
""" """
from __future__ import annotations from __future__ import annotations
import json
import sys
import tkinter as tk import tkinter as tk
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox from tkinter import messagebox
from typing import Optional, Any, Dict, List, Callable from typing import Optional, Any, Dict, List, Callable
from dataclasses import dataclass 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" # 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 procedura async prenota/s-prenota (no pyodbc qui) ===
import asyncio import asyncio
@@ -27,6 +81,118 @@ except Exception:
self.rc = rc; self.message = message; self.id_result = id_result 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=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
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 --------------------
SQL_PL = """ SQL_PL = """
SELECT SELECT
@@ -205,10 +371,18 @@ class ScrollTable(ctk.CTkFrame):
PADX_R = 8 PADX_R = 8
PADY = 2 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.""" """Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
super().__init__(master) super().__init__(master)
self.columns = columns 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.total_w = sum(c.width for c in self.columns)
self.grid_rowconfigure(1, weight=1) self.grid_rowconfigure(1, weight=1)
@@ -243,6 +417,10 @@ class ScrollTable(ctk.CTkFrame):
# bind # bind
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width()) self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
self.b_inner.bind("<Configure>", lambda e: self._on_body_configure()) self.b_inner.bind("<Configure>", lambda e: self._on_body_configure())
self.b_canvas.bind("<Enter>", self._bind_mousewheel)
self.b_canvas.bind("<Leave>", self._unbind_mousewheel)
self.b_inner.bind("<Enter>", self._bind_mousewheel)
self.b_inner.bind("<Leave>", self._unbind_mousewheel)
self._build_header() self._build_header()
@@ -265,12 +443,27 @@ class ScrollTable(ctk.CTkFrame):
holder.pack(side="left", fill="y") holder.pack(side="left", fill="y")
holder.pack_propagate(False) 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) 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("<Button-1>", 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_inner.configure(width=self.total_w, height=ROW_H)
self.h_canvas.configure(scrollregion=(0,0,self.total_w,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): def _update_body_width(self):
"""Keep the scroll region aligned with the current body content width.""" """Keep the scroll region aligned with the current body content width."""
self.b_canvas.itemconfigure(self.body_window, width=self.total_w) 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.h_canvas.xview_moveto(first)
self.xbar.set(first, last) 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("<MouseWheel>", 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("<MouseWheel>")
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): def clear_rows(self):
"""Remove all rendered body rows.""" """Remove all rendered body rows."""
for w in self.b_inner.winfo_children(): for w in self.b_inner.winfo_children():
@@ -369,18 +578,23 @@ class PLRow:
# -------------------- main frame (no-flicker + UX tuning + spinner) -------------------- # -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
class GestionePickingListFrame(ctk.CTkFrame): 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.""" """Create the master/detail picking list frame."""
super().__init__(master) super().__init__(master)
if db_client is None: if db_client is None:
raise ValueError("GestionePickingListFrame richiede un db_client condiviso.") raise ValueError("GestionePickingListFrame richiede un db_client condiviso.")
self.db_client = db_client self.db_client = db_client
self.session = session
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale) self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
self.busy = BusyOverlay(self) # overlay collaudato self.busy = BusyOverlay(self) # overlay collaudato
self.rows_models: list[PLRow] = [] self.rows_models: list[PLRow] = []
self._detail_cache: Dict[Any, list] = {} self._detail_cache: Dict[Any, list] = {}
self.detail_doc = None 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._first_loading: bool = False # flag per cursore d'attesa solo al primo load
self._render_job = None # Tracking del job di rendering in corso 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) # 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
self.after_idle(self._first_show) 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): def _first_show(self):
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor.""" """Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
self._first_loading = True self._first_loading = True
@@ -423,20 +647,171 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.pl_table = ScrollTable(self, PL_COLS) self.pl_table = ScrollTable(self, PL_COLS)
self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8)) self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8))
self.det_table = ScrollTable(self, DET_COLS) self.det_host = tk.Frame(self, bd=0, highlightthickness=0)
self.det_table.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10)) 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() 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("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
def _draw_details_hint(self): def _draw_details_hint(self):
"""Render the placeholder row shown when no document is selected.""" """Render the placeholder row shown when no document is selected."""
self.det_table.clear_rows() self._load_detail_sheet_data(
self.det_table.add_row( [["", "", "", "Seleziona una Picking List per vedere le UDC...", "", ""]]
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
row_index=0,
anchors=["w"]*6
) )
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]]): def _apply_row_colors(self, rows: List[Dict[str, Any]]):
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe.""" """Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
try: try:
@@ -517,6 +892,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
break break
# ----- eventi ----- # ----- eventi -----
@_log_call()
def on_row_checked(self, model: PLRow, is_checked: bool): def on_row_checked(self, model: PLRow, is_checked: bool):
"""Handle row selection changes and refresh the detail section.""" """Handle row selection changes and refresh the detail section."""
# selezione esclusiva # selezione esclusiva
@@ -526,18 +902,23 @@ class GestionePickingListFrame(ctk.CTkFrame):
m.set_checked(False) m.set_checked(False)
self.detail_doc = model.pl.get("Documento") 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 self.spinner.start(" Carico dettagli…") # spinner ON
async def _job(): 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}) return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
def _ok(res): def _ok(res):
# NON fermare lo spinner subito: lo farà _refresh_details_incremental # 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 # Avvia il rendering incrementale che mantiene l'overlay attivo
self._refresh_details_incremental() self._refresh_details_incremental()
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore durante il caricamento dettagli del documento {self.detail_doc}: {ex}")
self.spinner.stop() self.spinner.stop()
self.busy.hide() # Chiudi l'overlay in caso di errore self.busy.hide() # Chiudi l'overlay in caso di errore
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}") messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
@@ -555,16 +936,20 @@ class GestionePickingListFrame(ctk.CTkFrame):
else: else:
if not any(m.is_checked() for m in self.rows_models): if not any(m.is_checked() for m in self.rows_models):
self.detail_doc = None self.detail_doc = None
_MODULE_LOGGER.info("Nessun documento selezionato: ripristino placeholder del dettaglio.")
self._refresh_details() self._refresh_details()
# ----- load PL ----- # ----- load PL -----
@_log_call()
def reload_from_db(self, first: bool = False): def reload_from_db(self, first: bool = False):
"""Load or reload the picking list summary table from the database.""" """Load or reload the picking list summary table from the database."""
self.spinner.start(" Carico…") # spinner ON self.spinner.start(" Carico…") # spinner ON
async def _job(): async def _job():
_log_sql("SQL_PL", SQL_PL, {})
return await self.db_client.query_json(SQL_PL, {}) return await self.db_client.query_json(SQL_PL, {})
def _on_success(res): def _on_success(res):
rows = _rows_to_dicts(res) rows = _rows_to_dicts(res)
_log_dataset("SQL_PL", rows)
self._refresh_mid_rows(rows) self._refresh_mid_rows(rows)
self.spinner.stop() # spinner OFF self.spinner.stop() # spinner OFF
# se era il primo load, ripristina il cursore standard # se era il primo load, ripristina il cursore standard
@@ -575,6 +960,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
pass pass
self._first_loading = False self._first_loading = False
def _on_error(ex): def _on_error(ex):
_MODULE_LOGGER.exception(f"Errore durante il caricamento della picking list: {ex}")
self.spinner.stop() self.spinner.stop()
if self._first_loading: if self._first_loading:
try: try:
@@ -592,102 +978,64 @@ class GestionePickingListFrame(ctk.CTkFrame):
message="Caricamento Picking List…" if first else "Aggiornamento…" message="Caricamento Picking List…" if first else "Aggiornamento…"
) )
@_log_call("DEBUG")
def _refresh_details(self): def _refresh_details(self):
"""Render the detail table for the currently selected document.""" """Render the detail table for the currently selected document."""
self.det_table.clear_rows()
if not self.detail_doc: if not self.detail_doc:
self._draw_details_hint() self._draw_details_hint()
return 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: if not rows:
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
row_index=0, anchors=["w"]*6)
return return
for r, d in enumerate(rows): self._load_detail_sheet_data(self._detail_rows_to_sheet_data(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]
)
@_log_call("DEBUG")
def _refresh_details_incremental(self, batch_size: int = 25): def _refresh_details_incremental(self, batch_size: int = 25):
""" """
Render detail table incrementally in batches to keep UI responsive. Render detail table using tksheet while keeping busy feedback consistent.
Mantiene l'overlay visibile fino al completamento del rendering.
""" """
self.det_table.clear_rows()
if not self.detail_doc: if not self.detail_doc:
self._draw_details_hint() self._draw_details_hint()
self.spinner.stop() self.spinner.stop()
self.busy.hide() self.busy.hide()
return 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: if not rows:
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
row_index=0, anchors=["w"]*6)
self.spinner.stop() self.spinner.stop()
self.busy.hide() self.busy.hide()
return return
# Inizia il rendering incrementale
total_rows = len(rows)
self.busy.show(f"Rendering {len(rows)} UDC...") 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): 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. Legacy helper kept for compatibility after the move to tksheet.
Mantiene lo spinner attivo fino all'ultimo batch.
""" """
end_idx = min(start_idx + batch_size, total_rows) del batch_size, start_idx, total_rows
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(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()
# ----- azioni ----- # ----- azioni -----
@_log_call()
def on_prenota(self): def on_prenota(self):
"""Reserve the selected picking list.""" """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() model = self._get_selected_model()
if not model: if not model:
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.") 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.") messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
return 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…") self.spinner.start(" Prenoto…")
async def _job(): async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
def _ok(res: SPResult): 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() self.spinner.stop()
if res and res.rc == 0: 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) self._recolor_row_by_documento(documento, desired)
else: else:
msg = (res.message if res else "Errore sconosciuto") 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}") messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore prenotazione documento={documento}: {ex}")
self.spinner.stop() 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}") messagebox.showerror("Prenota", f"Errore:\n{ex}")
self.runner.run( self.runner.run(
@@ -726,8 +1103,12 @@ class GestionePickingListFrame(ctk.CTkFrame):
message=f"Prenoto la Picking List {documento}" message=f"Prenoto la Picking List {documento}"
) )
@_log_call()
def on_sprenota(self): def on_sprenota(self):
"""Unreserve the selected picking list.""" """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() model = self._get_selected_model()
if not model: if not model:
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.") 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.") messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
return 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…") self.spinner.start(" S-prenoto…")
async def _job(): async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
def _ok(res: SPResult): 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() self.spinner.stop()
if res and res.rc == 0: 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) self._recolor_row_by_documento(documento, desired)
else: else:
msg = (res.message if res else "Errore sconosciuto") 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}") messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore s-prenotazione documento={documento}: {ex}")
self.spinner.stop() 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}") messagebox.showerror("S-prenota", f"Errore:\n{ex}")
self.runner.run( self.runner.run(
@@ -772,10 +1182,82 @@ class GestionePickingListFrame(ctk.CTkFrame):
# factory per main # 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.""" """Factory used by the launcher to build the picking list frame."""
ctk.set_appearance_mode("light") ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green") 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("<Escape>", lambda e: win.destroy())
win.protocol("WM_DELETE_WINDOW", win.destroy)
return win
# =================== /gestione_pickinglist.py =================== # =================== /gestione_pickinglist.py ===================

734
gestione_scarico.py Normal file
View File

@@ -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=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
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("<Button-1>", 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,
)

View File

@@ -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 <Configure> generava molte query/lag
# self.bind("<Configure>", 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("<<ListboxSelect>>", 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("<Button-3>", 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

251
login_window.py Normal file
View File

@@ -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("<Return>", lambda _e: self._on_login())
self.bind("<Escape>", 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

210
main.py
View File

@@ -6,38 +6,23 @@ project.
""" """
import asyncio import asyncio
import ctypes
import sys import sys
import tkinter as tk import tkinter as tk
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox
from async_loop_singleton import get_global_loop from async_loop_singleton import get_global_loop
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn 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 reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window from search_pallets import open_search_window
from view_celle_multiple import open_celle_multiple_window from audit_log import log_session_event
from view_celle_multi_udc import open_celle_multiple_window
# Try factory, else frame, else app (senza passare conn_str all'App) from user_session import UserSession, create_user_session
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)
# ---- Config ---- # ---- Config ----
@@ -46,6 +31,17 @@ DBNAME = "Mediseawall"
USER = "sa" USER = "sa"
PASSWORD = "1Password1" 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"): if sys.platform.startswith("win"):
try: try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 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) dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD)
db_app = AsyncMSSQLClient(dsn_app) db_app = AsyncMSSQLClient(dsn_app)
_APP_MUTEX = None
_APP_MUTEX_NAME = "Local\\WarehousePythonAppSingleton"
def open_pickinglist_window(parent: tk.Misc, db_client: AsyncMSSQLClient): def _acquire_single_instance_mutex() -> bool:
"""Open the picking list window while minimizing initial flicker.""" """Return ``True`` only for the first running instance of the application."""
win = ctk.CTkToplevel(parent)
win.title("Gestione Picking List")
win.geometry("1200x700+0+100")
win.minsize(1000, 560)
# Keep the toplevel hidden while its content is being created. global _APP_MUTEX
try: if not sys.platform.startswith("win"):
win.withdraw() return True
win.attributes("-alpha", 0.0)
except Exception:
pass
frame = create_pickinglist_frame(win, db_client=db_client) kernel32 = ctypes.windll.kernel32
try: mutex = kernel32.CreateMutexW(None, False, _APP_MUTEX_NAME)
frame.pack(fill="both", expand=True) if not mutex:
except Exception: return True
pass
# Show the window only when the layout is ready. last_error = kernel32.GetLastError()
try: _APP_MUTEX = mutex
win.update_idletasks() ERROR_ALREADY_EXISTS = 183
try: return last_error != ERROR_ALREADY_EXISTS
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("<Escape>", lambda e: win.destroy())
win.protocol("WM_DELETE_WINDOW", win.destroy) def _build_bypass_session() -> UserSession:
return win """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): class Launcher(ctk.CTk):
"""Main launcher window that exposes the available warehouse tools.""" """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.""" """Create the launcher toolbar and wire every button to a feature window."""
super().__init__() super().__init__()
self.title("Warehouse 1.0.0") self.session: UserSession = session
self.geometry("1200x70+0+0") self.title(f"Warehouse 1.0.0 - {self.session.display_name}")
self.geometry("1280x96+0+0")
wrap = ctk.CTkFrame(self) wrap = ctk.CTkFrame(self)
wrap.pack(pady=10, fill="x") 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( ctk.CTkButton(
wrap, wrap,
text="Gestione Corsie", text="Gestione Corsie",
command=lambda: open_reset_corsie_window(self, db_app), state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled",
).grid(row=0, column=0, padx=6, pady=6, sticky="ew") 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( ctk.CTkButton(
wrap, wrap,
text="Gestione Layout", text="Gestione Layout",
command=lambda: open_layout_window(self, db_app), state="normal" if self.session.can("launcher.open_layout") else "disabled",
).grid(row=0, column=1, padx=6, pady=6, sticky="ew") command=lambda: open_layout_window(self, db_app, session=self.session),
).grid(row=1, column=1, padx=6, pady=6, sticky="ew")
ctk.CTkButton( ctk.CTkButton(
wrap, wrap,
text="UDC Fantasma", text="UDC Fantasma",
command=lambda: open_celle_multiple_window(self, db_app), state="normal" if self.session.can("launcher.open_multi_udc") else "disabled",
).grid(row=0, column=2, padx=6, pady=6, sticky="ew") 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( ctk.CTkButton(
wrap, wrap,
text="Ricerca UDC", text="Ricerca UDC",
command=lambda: open_search_window(self, db_app), state="normal" if self.session.can("launcher.open_search") else "disabled",
).grid(row=0, column=3, padx=6, pady=6, sticky="ew") command=lambda: open_search_window(self, db_app, session=self.session),
).grid(row=1, column=3, padx=6, pady=6, sticky="ew")
ctk.CTkButton( ctk.CTkButton(
wrap, wrap,
text="Gestione Picking List", text="Gestione Picking List",
command=lambda: open_pickinglist_window(self, db_app), state="normal" if self.session.can("launcher.open_pickinglist") else "disabled",
).grid(row=0, column=4, padx=6, pady=6, sticky="ew") 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): for i in range(5):
wrap.grid_columnconfigure(i, weight=1) wrap.grid_columnconfigure(i, weight=1)
@@ -163,6 +157,8 @@ class Launcher(ctk.CTk):
def _on_close(): def _on_close():
"""Dispose shared resources before closing the launcher.""" """Dispose shared resources before closing the launcher."""
try: 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) fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try: try:
fut.result(timeout=2) fut.result(timeout=2)
@@ -172,9 +168,65 @@ class Launcher(ctk.CTk):
self.destroy() self.destroy()
self.protocol("WM_DELETE_WINDOW", _on_close) self.protocol("WM_DELETE_WINDOW", _on_close)
try:
self.lift()
self.focus_force()
except Exception:
pass
if __name__ == "__main__": if __name__ == "__main__":
ctk.set_appearance_mode("light") ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green") 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()

View File

@@ -2,9 +2,150 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional 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=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
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 @dataclass
class SPResult: class SPResult:
@@ -15,14 +156,18 @@ class SPResult:
id_result: Optional[int] = None id_result: Optional[int] = None
@_log_call("DEBUG")
async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]: 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.""" """Return the first column of the first row from a query result."""
_log_sql("_query_one_value", sql, params)
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
res = await db.query_json(sql, params) res = await db.query_json(sql, params)
if isinstance(res, list) and res: if isinstance(res, list) and res:
row0 = res[0] row0 = res[0]
if isinstance(row0, dict): 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): elif isinstance(res, dict):
rows = None rows = None
for key in ("rows", "data", "result", "records"): 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: if rows:
row0 = rows[0] row0 = rows[0]
if isinstance(row0, dict): 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: 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 return None
if hasattr(db, "query_value"): 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"): 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") 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]]: 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.""" """Return all rows as dictionaries, normalizing different DB client APIs."""
_log_sql("_query_all", sql, params)
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
res = await db.query_json(sql, params) res = await db.query_json(sql, params)
if res is None: if res is None:
_log_dataset("_query_all", [])
return [] return []
if isinstance(res, list): 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): if isinstance(res, dict):
for key in ("rows", "data", "result", "records"): for key in ("rows", "data", "result", "records"):
if key in res and isinstance(res[key], list): if key in res and isinstance(res[key], list):
rows = res[key] rows = res[key]
if rows and isinstance(rows[0], dict): if rows and isinstance(rows[0], dict):
_log_dataset("_query_all", rows)
return rows return rows
cols = res.get("columns") or res.get("cols") or [] cols = res.get("columns") or res.get("cols") or []
out = [] out = []
for row in rows: for row in rows:
if isinstance(row, (list, tuple)) and cols: 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)))}) 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 return out
_log_dataset("_query_all", [])
return [] return []
if hasattr(db, "fetch_all"): 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") 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: async def _execute(db, sql: str, params: Dict[str, Any]) -> int:
"""Execute a DML statement using the best method exposed by the DB client.""" """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"): for name in ("execute", "exec", "execute_non_query"):
if hasattr(db, name): if hasattr(db, name):
rc = await getattr(db, name)(sql, params) rc = await getattr(db, name)(sql, params)
if isinstance(rc, int): if isinstance(rc, int):
_log_dataset("_execute", rc)
return rc return rc
_log_dataset("_execute", 0)
return 0 return 0
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
await db.query_json(sql, params) await db.query_json(sql, params)
_log_dataset("_execute", 0)
return 0 return 0
raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti") 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: async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -> SPResult:
"""Toggle the reservation state of all cells belonging to a packing list. """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. the shared async DB client already managed by the application.
""" """
try: try:
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Procedura async packing list avviata documento={Documento} id_operatore={IDOperatore}")
nominativo = await _query_one_value( nominativo = await _query_one_value(
db, db,
"SELECT LOGIN FROM Operatori WHERE id = :IDOperatore", "SELECT LOGIN FROM Operatori WHERE id = :IDOperatore",
@@ -107,6 +278,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -
{"Documento": Documento}, {"Documento": Documento},
) )
id_celle = [row.get("Cella") for row in celle if "Cella" in row] 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 # Each cell is toggled individually because the original procedure also
# updates metadata such as operator and timestamp per row. # 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", "SELECT IDStato FROM Celle WHERE ID = :IDC",
{"IDC": id_cella}, {"IDC": id_cella},
) )
_MODULE_LOGGER.debug(f"Toggling cella id={id_cella} stato_corrente={stato}")
if stato == 0: if stato == 0:
await _execute( await _execute(
db, 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", {}) 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) return SPResult(rc=0, message="", id_result=int(new_id) if new_id is not None else None)
except Exception as exc: except Exception as exc:
_MODULE_LOGGER.exception(f"Procedura fallita documento={Documento}: {exc}")
return SPResult(rc=-1, message=str(exc), id_result=None) return SPResult(rc=-1, message=str(exc), id_result=None)

View File

@@ -5,6 +5,8 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"sqlalchemy[asyncio]>=2.0", "sqlalchemy[asyncio]>=2.0",
"aioodbc>=0.3.3", "aioodbc>=0.3.3",
"loguru>=0.7",
"tksheet>=7.5",
# "orjson>=3.9" # opzionale: il tuo codice fa fallback su json puro # "orjson>=3.9" # opzionale: il tuo codice fa fallback su json puro
] ]

View File

@@ -10,7 +10,7 @@ from tkinter import messagebox, simpledialog, ttk
import customtkinter as ctk import customtkinter as ctk
from gestione_aree_frame_async import AsyncRunner, BusyOverlay from gestione_aree import AsyncRunner, BusyOverlay
SQL_CORSIE = """ SQL_CORSIE = """
WITH C AS ( WITH C AS (
@@ -94,7 +94,7 @@ WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
class ResetCorsieWindow(ctk.CTkToplevel): class ResetCorsieWindow(ctk.CTkToplevel):
"""Toplevel used to inspect and clear the pallets assigned to an aisle.""" """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.""" """Create the window and immediately load the list of aisles."""
super().__init__(parent) super().__init__(parent)
self.title("Reset Corsie - svuotamento celle per corsia") self.title("Reset Corsie - svuotamento celle per corsia")
@@ -103,6 +103,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
self.resizable(True, True) self.resizable(True, True)
self.db = db_client self.db = db_client
self.session = session
self._busy = BusyOverlay(self) self._busy = BusyOverlay(self)
self._async = AsyncRunner(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}...") 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.""" """Create, focus and return the aisle reset window."""
win = ResetCorsieWindow(parent, db_app) key = "_reset_corsie_window_singleton"
win.lift() ex = getattr(parent, key, None)
win.focus_set() 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 return win

View File

@@ -7,7 +7,7 @@ from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk import customtkinter as ctk
from gestione_aree_frame_async import AsyncRunner, BusyOverlay from gestione_aree import AsyncRunner, BusyOverlay
try: try:
from openpyxl import Workbook from openpyxl import Workbook
@@ -74,7 +74,7 @@ ORDER BY
class SearchWindow(ctk.CTkToplevel): class SearchWindow(ctk.CTkToplevel):
"""Window that searches pallets by barcode, lot or product code.""" """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.""" """Initialize widgets and keep a reference to the shared DB client."""
super().__init__(parent) super().__init__(parent)
self.title("Warehouse - Ricerca UDC/Lotto/Codice") self.title("Warehouse - Ricerca UDC/Lotto/Codice")
@@ -83,6 +83,7 @@ class SearchWindow(ctk.CTkToplevel):
self.resizable(True, True) self.resizable(True, True)
self.db = db_app self.db = db_app
self.session = session
self._busy = BusyOverlay(self) self._busy = BusyOverlay(self)
self._async = AsyncRunner(self) self._async = AsyncRunner(self)
self._sort_state: dict[str, bool] = {} 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...") 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.""" """Open a singleton-like search window tied to the launcher instance."""
key = "_search_window_singleton" key = "_search_window_singleton"
ex = getattr(parent, key, None) ex = getattr(parent, key, None)
@@ -422,6 +423,6 @@ def open_search_window(parent, db_app):
return ex return ex
except Exception: except Exception:
pass pass
w = SearchWindow(parent, db_app) w = SearchWindow(parent, db_app, session=session)
setattr(parent, key, w) setattr(parent, key, w)
return w return w

88
user_session.py Normal file
View File

@@ -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),
)

View File

@@ -1,15 +1,158 @@
"""Exploration window for cells containing more than one pallet.""" """Exploration window for cells containing more than one pallet."""
from __future__ import annotations
import json import json
import logging
import sys
import tkinter as tk import tkinter as tk
from datetime import datetime from datetime import datetime
from functools import wraps
from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Any
import customtkinter as ctk import customtkinter as ctk
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Font 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=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
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): def _json_obj(res):
@@ -80,10 +223,53 @@ ORDER BY b.Colonna, b.Fila;
""" """
SQL_PALLET_IN_CELLA = BASE_CTE + """ 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 SELECT
b.BarcodePallet AS Pallet, b.BarcodePallet AS Pallet,
ta.Descrizione, 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 FROM base b
OUTER APPLY ( OUTER APPLY (
SELECT TOP (1) t.Descrizione, t.Lotto SELECT TOP (1) t.Descrizione, t.Lotto
@@ -91,11 +277,28 @@ OUTER APPLY (
WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS
ORDER BY t.Lotto ORDER BY t.Lotto
) AS ta ) 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 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; 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 + """ SQL_RIEPILOGO_PERCENTUALI = BASE_CTE + """
, tot AS ( , tot AS (
SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle
@@ -136,10 +339,12 @@ ORDER BY Ord, Corsia;
class CelleMultipleWindow(ctk.CTkToplevel): class CelleMultipleWindow(ctk.CTkToplevel):
"""Tree-based explorer for duplicated pallet allocations.""" """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.""" """Bind the shared DB client and immediately load the tree summary."""
super().__init__(root) super().__init__(root)
self.title("Celle con piu' pallet") self.title("Celle con piu' pallet")
self.session = session
self.geometry("1100x700") self.geometry("1100x700")
self.minsize(900, 550) self.minsize(900, 550)
self.resizable(True, True) 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(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6))
frame.grid_rowconfigure(0, weight=1) frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(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("#0", text="Nodo")
self.tree.heading("col2", text="Descrizione") self.tree.heading("col2", text="Descrizione")
self.tree.heading("col3", text="Lotto") 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) y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set) 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.""" """Attach lazy-load behavior when nodes are expanded."""
self.tree.bind("<<TreeviewOpen>>", self._on_open_node) self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
@_log_call()
def refresh_all(self): def refresh_all(self):
"""Reload both the duplication tree and the summary percentage table.""" """Reload both the duplication tree and the summary percentage table."""
self._load_corsie() self._load_corsie()
self._load_riepilogo() self._load_riepilogo()
@_log_call()
def _load_corsie(self): def _load_corsie(self):
"""Load root nodes representing aisles with duplicated cells.""" """Load root nodes representing aisles with duplicated cells."""
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
_log_sql("multi_udc_corsie", SQL_CORSIE, {})
async def _q(db): async def _q(db):
return await db.query_json(SQL_CORSIE, as_dict_rows=True) 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): def _fill_corsie(self, res):
"""Populate root tree nodes after the aisle query completes.""" """Populate root tree nodes after the aisle query completes."""
rows = _json_obj(res).get("rows", []) rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_corsie", rows)
for row in rows: for row in rows:
corsia = row.get("Corsia") corsia = row.get("Corsia")
if not corsia: if not corsia:
@@ -252,16 +471,25 @@ class CelleMultipleWindow(ctk.CTkToplevel):
self.tree.delete(child) self.tree.delete(child)
self._load_pallet_for_cella(sel, idcella) self._load_pallet_for_cella(sel, idcella)
@_log_call()
def _load_celle_for_corsia(self, parent_iid, corsia): def _load_celle_for_corsia(self, parent_iid, corsia):
"""Query duplicated cells for the selected aisle.""" """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): async def _q(db):
return await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True) 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): def _fill_celle(self, parent_iid, res):
"""Populate duplicated-cell nodes under an aisle node.""" """Populate duplicated-cell nodes under an aisle node."""
rows = _json_obj(res).get("rows", []) rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_celle_per_corsia", rows)
if not rows: if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "")) self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", ""))
return return
@@ -279,18 +507,27 @@ class CelleMultipleWindow(ctk.CTkToplevel):
if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)): 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=("", "")) 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): def _load_pallet_for_cella(self, parent_iid, idcella: int):
"""Query pallet details for a duplicated cell.""" """Query pallet details for a duplicated cell."""
_log_sql("multi_udc_pallet_in_cella", SQL_PALLET_IN_CELLA, {"idcella": idcella})
async def _q(db): async def _q(db):
return await db.query_json(SQL_PALLET_IN_CELLA, params={"idcella": idcella}, as_dict_rows=True) 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): def _fill_pallet(self, parent_iid, res):
"""Add pallet leaves under the selected cell node.""" """Add pallet leaves under the selected cell node."""
rows = _json_obj(res).get("rows", []) rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_pallet_in_cella", rows)
if not 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 return
parent_tags = self.tree.item(parent_iid, "tags") or () parent_tags = self.tree.item(parent_iid, "tags") or ()
corsia_tag = next((tag for tag in parent_tags if tag.startswith("corsia:")), None) 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", "") pallet = row.get("Pallet", "")
desc = row.get("Descrizione", "") desc = row.get("Descrizione", "")
lotto = row.get("Lotto", "") lotto = row.get("Lotto", "")
causale = _build_diagnostic_note(row.get("IsShippedGhost", 0), row.get("IsMovedGhost", 0))
leaf_id = f"pallet:{idcella_num}:{pallet}" leaf_id = f"pallet:{idcella_num}:{pallet}"
if self.tree.exists(leaf_id): 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 continue
self.tree.insert( self.tree.insert(
parent_iid, parent_iid,
"end", "end",
iid=leaf_id, iid=leaf_id,
text=str(pallet), text=str(pallet),
values=(desc, lotto), values=(desc, lotto, causale),
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"), tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
) )
@_log_call()
def _load_riepilogo(self): def _load_riepilogo(self):
"""Load the percentage summary by aisle.""" """Load the percentage summary by aisle."""
_log_sql("multi_udc_riepilogo", SQL_RIEPILOGO_PERCENTUALI, {})
async def _q(db): async def _q(db):
return await db.query_json(SQL_RIEPILOGO_PERCENTUALI, as_dict_rows=True) 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): def _fill_riepilogo(self, res):
"""Refresh the bottom summary table.""" """Refresh the bottom summary table."""
rows = _json_obj(res).get("rows", []) rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_riepilogo", rows)
for item in self.sum_tbl.get_children(): for item in self.sum_tbl.get_children():
self.sum_tbl.delete(item) self.sum_tbl.delete(item)
for row in rows: for row in rows:
@@ -349,6 +596,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
for iid in self.tree.get_children(""): for iid in self.tree.get_children(""):
self.tree.item(iid, open=False) self.tree.item(iid, open=False)
@_log_call()
def export_to_xlsx(self): def export_to_xlsx(self):
"""Export both the detailed tree and the summary table to Excel.""" """Export both the detailed tree and the summary table to Excel."""
ts = datetime.now().strftime("%d_%m_%Y_%H-%M") ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
@@ -367,7 +615,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
ws_det = wb.active ws_det = wb.active
ws_det.title = "Dettaglio" ws_det.title = "Dettaglio"
ws_sum = wb.create_sheet("Riepilogo") 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"] sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"]
def _hdr(ws, headers): 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:")), "") 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:")), "") idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "")
pallet = self.tree.item(pallet_node, "text") pallet = self.tree.item(pallet_node, "text")
desc, lotto = self.tree.item(pallet_node, "values") desc, lotto, causale = self.tree.item(pallet_node, "values")
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto], start=1): for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1):
ws_det.cell(row=row_idx, column=j, value=value) ws_det.cell(row=row_idx, column=j, value=value)
row_idx += 1 row_idx += 1
@@ -420,12 +668,31 @@ class CelleMultipleWindow(ctk.CTkToplevel):
wb.save(fname) wb.save(fname)
messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self) messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self)
except Exception as ex: except Exception as ex:
_MODULE_LOGGER.exception(f"Errore esportazione UDC fantasma: {ex}")
messagebox.showerror("Errore esportazione", str(ex), parent=self) 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.""" """Create, focus and return the duplicated-cells explorer."""
win = CelleMultipleWindow(root, db_client, runner=runner) key = "_celle_multiple_window_singleton"
win.lift() ex = getattr(root, key, None)
win.focus_set() 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 return win