Checkpoint before ghost pallet cleanup workflow
This commit is contained in:
102
audit_log.py
Normal file
102
audit_log.py
Normal 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,
|
||||
)
|
||||
@@ -20,18 +20,18 @@ async_msssql_query.py
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
gestione_aree_frame_async.py
|
||||
gestione_aree.py
|
||||
----------------------------
|
||||
|
||||
.. automodule:: gestione_aree_frame_async
|
||||
.. automodule:: gestione_aree
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
layout_window.py
|
||||
----------------
|
||||
gestione_layout.py
|
||||
------------------
|
||||
|
||||
.. automodule:: layout_window
|
||||
.. automodule:: gestione_layout
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -44,10 +44,10 @@ reset_corsie.py
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
view_celle_multiple.py
|
||||
----------------------
|
||||
view_celle_multi_udc.py
|
||||
-----------------------
|
||||
|
||||
.. automodule:: view_celle_multiple
|
||||
.. automodule:: view_celle_multi_udc
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
@@ -12,12 +12,12 @@ flowchart TD
|
||||
Main --> DB["AsyncMSSQLClient"]
|
||||
|
||||
Launcher --> Reset["reset_corsie.py"]
|
||||
Launcher --> Layout["layout_window.py"]
|
||||
Launcher --> Ghost["view_celle_multiple.py"]
|
||||
Launcher --> Layout["gestione_layout.py"]
|
||||
Launcher --> Ghost["view_celle_multi_udc.py"]
|
||||
Launcher --> Search["search_pallets.py"]
|
||||
Launcher --> Picking["gestione_pickinglist.py"]
|
||||
|
||||
Reset --> Runner["gestione_aree_frame_async.AsyncRunner"]
|
||||
Reset --> Runner["gestione_aree.AsyncRunner"]
|
||||
Layout --> Runner
|
||||
Ghost --> Runner
|
||||
Search --> Runner
|
||||
|
||||
@@ -12,12 +12,13 @@ I diagrammi sono scritti in Mermaid, quindi possono essere:
|
||||
## Indice
|
||||
|
||||
- [main](./main_flow.md)
|
||||
- [layout_window](./layout_window_flow.md)
|
||||
- [gestione_layout](./gestione_layout_flow.md)
|
||||
- [reset_corsie](./reset_corsie_flow.md)
|
||||
- [view_celle_multiple](./view_celle_multiple_flow.md)
|
||||
- [view_celle_multi_udc](./view_celle_multi_udc_flow.md)
|
||||
- [search_pallets](./search_pallets_flow.md)
|
||||
- [gestione_pickinglist](./gestione_pickinglist_flow.md)
|
||||
- [infrastruttura async/db](./async_db_flow.md)
|
||||
- [warehouse operational flow](./warehouse_operational_flow.md)
|
||||
|
||||
## Convenzioni
|
||||
|
||||
|
||||
@@ -35,5 +35,5 @@ flowchart TD
|
||||
## Note
|
||||
|
||||
- E un helper minimale usato da `main.py`.
|
||||
- Il modulo esiste separato da `gestione_aree_frame_async.py`, ma concettualmente
|
||||
- Il modulo esiste separato da `gestione_aree.py`, ma concettualmente
|
||||
svolge lo stesso ruolo di gestione del loop condiviso.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# `gestione_aree_frame_async.py`
|
||||
# `gestione_aree.py`
|
||||
|
||||
## Scopo
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# `layout_window.py`
|
||||
# `gestione_layout.py`
|
||||
|
||||
## Scopo
|
||||
|
||||
Questo modulo visualizza il layout delle corsie come matrice di celle, mostra
|
||||
lo stato di occupazione, consente di cercare una UDC e permette l'export della
|
||||
matrice.
|
||||
matrice. La griglia ad alte prestazioni e' resa con `tksheet`, mantenendo la
|
||||
stessa semantica visiva delle celle operative.
|
||||
|
||||
## Flusso operativo
|
||||
|
||||
@@ -57,5 +58,7 @@ flowchart LR
|
||||
- Il modulo usa un token `_req_counter` per evitare che risposte async vecchie
|
||||
aggiornino la UI fuori ordine.
|
||||
- La statistica globale viene ricalcolata da query SQL, mentre quella della
|
||||
corsia corrente usa la matrice già caricata in memoria.
|
||||
- `destroy()` marca la finestra come non più attiva per evitare callback tardive.
|
||||
corsia corrente usa la matrice gia' caricata in memoria.
|
||||
- Il click destro su una cella riusa lo stesso menu contestuale della versione
|
||||
precedente basata su pulsanti CTk.
|
||||
- `destroy()` marca la finestra come non piu' attiva per evitare callback tardive.
|
||||
@@ -13,7 +13,7 @@ Questo modulo gestisce la vista master/detail delle picking list e permette di:
|
||||
|
||||
```{mermaid}
|
||||
flowchart TD
|
||||
A["open_pickinglist_window() da main.py"] --> B["create_pickinglist_frame()"]
|
||||
A["open_pickinglist_window() in gestione_pickinglist.py"] --> B["create_frame()"]
|
||||
B --> C["GestionePickingListFrame.__init__()"]
|
||||
C --> D["_build_layout()"]
|
||||
D --> E["after_idle(_first_show)"]
|
||||
|
||||
@@ -9,12 +9,13 @@ infrastrutturali.
|
||||
|
||||
README.md
|
||||
main_flow.md
|
||||
layout_window_flow.md
|
||||
gestione_layout_flow.md
|
||||
reset_corsie_flow.md
|
||||
view_celle_multiple_flow.md
|
||||
view_celle_multi_udc_flow.md
|
||||
search_pallets_flow.md
|
||||
gestione_pickinglist_flow.md
|
||||
warehouse_operational_flow.md
|
||||
async_db_flow.md
|
||||
async_msssql_query_flow.md
|
||||
gestione_aree_frame_async_flow.md
|
||||
gestione_aree_flow.md
|
||||
async_loop_singleton_flow.md
|
||||
|
||||
@@ -22,7 +22,7 @@ flowchart TD
|
||||
I --> K["open_layout_window()"]
|
||||
I --> L["open_celle_multiple_window()"]
|
||||
I --> M["open_search_window()"]
|
||||
I --> N["open_pickinglist_window()"]
|
||||
I --> N["gestione_pickinglist.open_pickinglist_window()"]
|
||||
```
|
||||
|
||||
## Schema di chiamata
|
||||
@@ -33,13 +33,13 @@ flowchart LR
|
||||
Launcher --> Layout["open_layout_window"]
|
||||
Launcher --> Ghost["open_celle_multiple_window"]
|
||||
Launcher --> Search["open_search_window"]
|
||||
Launcher --> Pick["open_pickinglist_window"]
|
||||
Pick --> PickFactory["create_pickinglist_frame"]
|
||||
Launcher --> Pick["gestione_pickinglist.open_pickinglist_window"]
|
||||
Pick --> PickFactory["gestione_pickinglist.create_frame"]
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
- `db_app` viene creato una sola volta e poi passato a tutte le finestre.
|
||||
- Alla chiusura del launcher viene chiamato `db_app.dispose()` sul loop globale.
|
||||
- `open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende
|
||||
- `gestione_pickinglist.open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende
|
||||
visibile solo a layout pronto, per ridurre lo sfarfallio iniziale.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# `view_celle_multiple.py`
|
||||
# `view_celle_multi_udc.py`
|
||||
|
||||
## Scopo
|
||||
|
||||
167
docs/flows/warehouse_operational_flow.md
Normal file
167
docs/flows/warehouse_operational_flow.md
Normal 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.
|
||||
@@ -1,4 +1,4 @@
|
||||
"""One-off maintenance script to sanitize ``border_color`` usage in ``layout_window``.
|
||||
"""One-off maintenance script to sanitize ``border_color`` usage in ``gestione_layout``.
|
||||
|
||||
The script removes incompatible ``border_color='transparent'`` assignments from
|
||||
widget configuration calls while preserving explicit highlight colors that are
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
# Path default (modifica se serve)
|
||||
p = Path("./layout_window.py")
|
||||
p = Path("./gestione_layout.py")
|
||||
if not p.exists():
|
||||
raise SystemExit(f"File non trovato: {p}")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""One-off maintenance script to patch performance issues in ``layout_window``.
|
||||
"""One-off maintenance script to patch performance issues in ``gestione_layout``.
|
||||
|
||||
The script was used during development to remove an expensive resize-triggered
|
||||
refresh and to inject some lifecycle guards into the window implementation.
|
||||
@@ -8,7 +8,7 @@ It is kept in the repository as an auditable patch recipe.
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
p = Path("./layout_window.py")
|
||||
p = Path("./gestione_layout.py")
|
||||
src = p.read_text(encoding="utf-8")
|
||||
|
||||
backup = p.with_suffix(".py.bak_perf")
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
"""Shared Tk/async helpers used by multiple warehouse windows.
|
||||
|
||||
The module bundles three concerns used throughout the GUI:
|
||||
The module bundles two concerns used throughout the GUI:
|
||||
|
||||
* lifecycle of the shared background asyncio loop;
|
||||
* a modal-like busy overlay shown during long-running tasks;
|
||||
* an ``AsyncRunner`` that schedules coroutines and re-enters Tk safely.
|
||||
* an ``AsyncRunner`` that schedules coroutines on the shared loop and
|
||||
re-enters Tk safely.
|
||||
|
||||
The shared loop itself is defined only in :mod:`async_loop_singleton` and is
|
||||
reused here instead of being recreated locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
__VERSION__ = "GestioneAreeFrame v3.2.5-singleloop"
|
||||
from async_loop_singleton import get_global_loop
|
||||
|
||||
__VERSION__ = "GestioneAreeFrame v3.3.0-singleloop"
|
||||
|
||||
try:
|
||||
from async_msssql_query import AsyncMSSQLClient # noqa: F401
|
||||
@@ -24,50 +28,6 @@ except Exception:
|
||||
AsyncMSSQLClient = object # type: ignore
|
||||
|
||||
|
||||
class _LoopHolder:
|
||||
"""Keep references to the shared event loop and its worker thread."""
|
||||
|
||||
def __init__(self):
|
||||
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.ready = threading.Event()
|
||||
|
||||
|
||||
_GLOBAL = _LoopHolder()
|
||||
|
||||
|
||||
def _run_loop():
|
||||
"""Create and run the shared event loop inside the worker thread."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_GLOBAL.loop = loop
|
||||
_GLOBAL.ready.set()
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
def get_global_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return the shared background event loop, creating it if needed."""
|
||||
if _GLOBAL.loop is not None:
|
||||
return _GLOBAL.loop
|
||||
_GLOBAL.thread = threading.Thread(target=_run_loop, name="warehouse-asyncio", daemon=True)
|
||||
_GLOBAL.thread.start()
|
||||
_GLOBAL.ready.wait(timeout=5.0)
|
||||
if _GLOBAL.loop is None:
|
||||
raise RuntimeError("Impossibile avviare l'event loop globale")
|
||||
return _GLOBAL.loop
|
||||
|
||||
|
||||
def stop_global_loop():
|
||||
"""Stop the shared event loop and release thread references."""
|
||||
if _GLOBAL.loop and _GLOBAL.loop.is_running():
|
||||
_GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop)
|
||||
if _GLOBAL.thread:
|
||||
_GLOBAL.thread.join(timeout=2.0)
|
||||
_GLOBAL.loop = None
|
||||
_GLOBAL.thread = None
|
||||
_GLOBAL.ready.clear()
|
||||
|
||||
|
||||
class BusyOverlay:
|
||||
"""Semi-transparent overlay used to block interaction during async tasks."""
|
||||
|
||||
1372
gestione_layout.py
Normal file
1372
gestione_layout.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,68 @@ smooth by relying on deferred updates and lightweight progress indicators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from typing import Optional, Any, Dict, List, Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from audit_log import log_user_action
|
||||
try:
|
||||
from loguru import logger
|
||||
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
|
||||
class _FallbackLogger:
|
||||
"""Minimal adapter used only when Loguru is not installed yet."""
|
||||
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
|
||||
self._logger.setLevel(logging.DEBUG)
|
||||
self._logger.propagate = False
|
||||
|
||||
def bind(self, **_kwargs):
|
||||
return self
|
||||
|
||||
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
|
||||
handler: logging.Handler
|
||||
if hasattr(sink, "write"):
|
||||
handler = logging.StreamHandler(sink)
|
||||
else:
|
||||
handler = logging.FileHandler(str(sink), encoding=encoding)
|
||||
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
||||
)
|
||||
self._logger.addHandler(handler)
|
||||
return 0
|
||||
|
||||
def log(self, level, message):
|
||||
getattr(self._logger, str(level).lower(), self._logger.info)(message)
|
||||
|
||||
def debug(self, message):
|
||||
self._logger.debug(message)
|
||||
|
||||
def info(self, message):
|
||||
self._logger.info(message)
|
||||
|
||||
def exception(self, message):
|
||||
self._logger.exception(message)
|
||||
|
||||
logger = _FallbackLogger()
|
||||
|
||||
try:
|
||||
from tksheet import Sheet, natural_sort_key
|
||||
except Exception:
|
||||
Sheet = None # type: ignore[assignment]
|
||||
natural_sort_key = None # type: ignore[assignment]
|
||||
|
||||
# Usa overlay e runner "collaudati"
|
||||
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
|
||||
from gestione_aree import BusyOverlay, AsyncRunner
|
||||
from user_session import UserSession
|
||||
|
||||
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
||||
import asyncio
|
||||
@@ -27,6 +81,118 @@ except Exception:
|
||||
self.rc = rc; self.message = message; self.id_result = id_result
|
||||
|
||||
|
||||
PICKINGLIST_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
|
||||
PICKINGLIST_DETAIL_TEST_MULTIPLIER = 1 # 1 disables artificial row expansion for UI stress tests
|
||||
MODULE_LOG_NAME = Path(__file__).stem
|
||||
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
|
||||
_MODULE_LOG_ENABLED = PICKINGLIST_LOG_MODE.upper() != "OFF"
|
||||
_MODULE_LOG_LEVEL = "DEBUG" if PICKINGLIST_LOG_MODE.upper() == "DEBUG" else "INFO"
|
||||
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
|
||||
_MODULE_LOGGING_CONFIGURED = False
|
||||
|
||||
|
||||
def _configure_module_logger():
|
||||
"""Configure console and file logging for this module."""
|
||||
global _MODULE_LOGGING_CONFIGURED
|
||||
if _MODULE_LOGGING_CONFIGURED:
|
||||
return
|
||||
if not _MODULE_LOG_ENABLED:
|
||||
_MODULE_LOGGING_CONFIGURED = True
|
||||
return
|
||||
|
||||
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=_MODULE_LOG_LEVEL,
|
||||
colorize=True,
|
||||
filter=record_filter,
|
||||
format=(
|
||||
"<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_PL = """
|
||||
SELECT
|
||||
@@ -205,10 +371,18 @@ class ScrollTable(ctk.CTkFrame):
|
||||
PADX_R = 8
|
||||
PADY = 2
|
||||
|
||||
def __init__(self, master, columns: List[ColSpec]):
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
columns: List[ColSpec],
|
||||
on_header_click: Optional[Callable[[ColSpec], None]] = None,
|
||||
):
|
||||
"""Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
|
||||
super().__init__(master)
|
||||
self.columns = columns
|
||||
self.on_header_click = on_header_click
|
||||
self._sort_key: Optional[str] = None
|
||||
self._sort_reverse = False
|
||||
self.total_w = sum(c.width for c in self.columns)
|
||||
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
@@ -243,6 +417,10 @@ class ScrollTable(ctk.CTkFrame):
|
||||
# bind
|
||||
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
|
||||
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()
|
||||
|
||||
@@ -265,12 +443,27 @@ class ScrollTable(ctk.CTkFrame):
|
||||
holder.pack(side="left", fill="y")
|
||||
holder.pack_propagate(False)
|
||||
|
||||
lbl = ctk.CTkLabel(holder, text=col.title, anchor="w")
|
||||
header_text = col.title
|
||||
if col.key == self._sort_key:
|
||||
header_text = f"{col.title} {'↓' if self._sort_reverse else '↑'}"
|
||||
|
||||
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w")
|
||||
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
|
||||
|
||||
if self.on_header_click and col.key != "__check__":
|
||||
for widget in (holder, lbl):
|
||||
widget.bind("<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_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
|
||||
|
||||
def set_sort_state(self, key: Optional[str], reverse: bool = False):
|
||||
"""Update the header labels so the active sort is visible."""
|
||||
self._sort_key = key
|
||||
self._sort_reverse = reverse
|
||||
self._build_header()
|
||||
|
||||
def _update_body_width(self):
|
||||
"""Keep the scroll region aligned with the current body content width."""
|
||||
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
|
||||
@@ -300,6 +493,22 @@ class ScrollTable(ctk.CTkFrame):
|
||||
self.h_canvas.xview_moveto(first)
|
||||
self.xbar.set(first, last)
|
||||
|
||||
def _bind_mousewheel(self, _event=None):
|
||||
"""Route mouse-wheel scrolling to the body canvas while the cursor is over the table."""
|
||||
self.b_canvas.bind_all("<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):
|
||||
"""Remove all rendered body rows."""
|
||||
for w in self.b_inner.winfo_children():
|
||||
@@ -369,18 +578,23 @@ class PLRow:
|
||||
|
||||
# -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
|
||||
class GestionePickingListFrame(ctk.CTkFrame):
|
||||
def __init__(self, master, *, db_client=None, conn_str=None):
|
||||
@_log_call()
|
||||
def __init__(self, master, *, db_client=None, conn_str=None, session: UserSession | None = None):
|
||||
"""Create the master/detail picking list frame."""
|
||||
super().__init__(master)
|
||||
if db_client is None:
|
||||
raise ValueError("GestionePickingListFrame richiede un db_client condiviso.")
|
||||
self.db_client = db_client
|
||||
self.session = session
|
||||
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
|
||||
self.busy = BusyOverlay(self) # overlay collaudato
|
||||
|
||||
self.rows_models: list[PLRow] = []
|
||||
self._detail_cache: Dict[Any, list] = {}
|
||||
self.detail_doc = None
|
||||
self._detail_sort_key: Optional[str] = None
|
||||
self._detail_sort_reverse = False
|
||||
self._detail_sorting = False
|
||||
|
||||
self._first_loading: bool = False # flag per cursore d'attesa solo al primo load
|
||||
self._render_job = None # Tracking del job di rendering in corso
|
||||
@@ -389,6 +603,16 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
|
||||
self.after_idle(self._first_show)
|
||||
|
||||
def _can(self, action: str) -> bool:
|
||||
"""Return whether the current user can execute one picking-list action."""
|
||||
|
||||
return self.session.can(action) if self.session else False
|
||||
|
||||
def _operator_id(self) -> int:
|
||||
"""Return the authenticated operator id or ``0`` if no session is present."""
|
||||
|
||||
return int(self.session.operator_id) if self.session else 0
|
||||
|
||||
def _first_show(self):
|
||||
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
|
||||
self._first_loading = True
|
||||
@@ -423,20 +647,171 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
self.pl_table = ScrollTable(self, PL_COLS)
|
||||
self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8))
|
||||
|
||||
self.det_table = ScrollTable(self, DET_COLS)
|
||||
self.det_table.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
|
||||
self.det_host = tk.Frame(self, bd=0, highlightthickness=0)
|
||||
self.det_host.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
|
||||
self.det_host.grid_rowconfigure(0, weight=1)
|
||||
self.det_host.grid_columnconfigure(0, weight=1)
|
||||
self._build_detail_sheet()
|
||||
|
||||
self._draw_details_hint()
|
||||
|
||||
def _build_detail_sheet(self):
|
||||
"""Create the high-volume detail table using tksheet."""
|
||||
if Sheet is None:
|
||||
raise RuntimeError("tksheet non disponibile: installa la dipendenza per usare la tabella dettagli.")
|
||||
|
||||
self.detail_sheet = Sheet(
|
||||
self.det_host,
|
||||
data=[],
|
||||
show_row_index=False,
|
||||
show_top_left=False,
|
||||
width=1000,
|
||||
height=320,
|
||||
sort_key=natural_sort_key,
|
||||
)
|
||||
self.detail_sheet.change_theme("light green")
|
||||
self.detail_sheet.enable_bindings("all")
|
||||
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
||||
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
|
||||
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
def _draw_details_hint(self):
|
||||
"""Render the placeholder row shown when no document is selected."""
|
||||
self.det_table.clear_rows()
|
||||
self.det_table.add_row(
|
||||
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
|
||||
row_index=0,
|
||||
anchors=["w"]*6
|
||||
self._load_detail_sheet_data(
|
||||
[["", "", "", "Seleziona una Picking List per vedere le UDC...", "", ""]]
|
||||
)
|
||||
|
||||
def _detail_headers(self) -> List[str]:
|
||||
"""Return detail headers with the active sort indicator, if any."""
|
||||
headers: List[str] = []
|
||||
for col in DET_COLS:
|
||||
title = col.title
|
||||
if col.key == self._detail_sort_key:
|
||||
title = f"{title} {'[desc]' if self._detail_sort_reverse else '[asc]'}"
|
||||
headers.append(title)
|
||||
return headers
|
||||
|
||||
def _detail_rows_to_sheet_data(self, rows: List[Dict[str, Any]]) -> List[List[str]]:
|
||||
"""Convert detail dictionaries to the row format expected by tksheet."""
|
||||
data: List[List[str]] = []
|
||||
for d in rows:
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip() == "") else str(ubi_raw).strip()
|
||||
data.append([pallet, lotto, articolo, descr, qta, loc])
|
||||
return data
|
||||
|
||||
def _load_detail_sheet_data(self, data: List[List[str]]):
|
||||
"""Push one full dataset into the tksheet detail widget."""
|
||||
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
||||
self.detail_sheet.set_sheet_data(
|
||||
data,
|
||||
reset_col_positions=True,
|
||||
reset_row_positions=True,
|
||||
redraw=True,
|
||||
)
|
||||
self.detail_sheet.set_all_column_widths()
|
||||
|
||||
def _detail_sort_value(self, row: Dict[str, Any], key: str):
|
||||
"""Return a normalized value used to sort detail rows by one logical column."""
|
||||
if key == "Ubicazione":
|
||||
value = _first(row, ["Ubicazione", "Cella", "PalletCella"])
|
||||
value = "Non scaffalata" if value in (None, "") else value
|
||||
elif key == "Qta":
|
||||
value = _first(row, ["Qta", "Quantita", "Qty", "QTY"], 0)
|
||||
try:
|
||||
return (0, float(value))
|
||||
except Exception:
|
||||
return (1, _s(value).lower())
|
||||
elif key == "Articolo":
|
||||
value = _first(row, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"])
|
||||
elif key == "Descrizione":
|
||||
value = _first(row, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"])
|
||||
elif key == "Pallet":
|
||||
value = _first(row, ["Pallet", "UDC", "PalletID"])
|
||||
else:
|
||||
value = row.get(key)
|
||||
|
||||
return (0, _s(value).lower())
|
||||
|
||||
def _sort_detail_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return detail rows sorted using the current header state."""
|
||||
if not self._detail_sort_key:
|
||||
return rows
|
||||
|
||||
return sorted(
|
||||
rows,
|
||||
key=lambda row: self._detail_sort_value(row, self._detail_sort_key),
|
||||
reverse=self._detail_sort_reverse,
|
||||
)
|
||||
|
||||
def _finish_detail_sort_feedback(self, root: tk.Misc):
|
||||
"""Dismiss busy feedback only after Tk has flushed the detail redraw."""
|
||||
try:
|
||||
root.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._detail_sorting = False
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
try:
|
||||
self.detail_sheet.configure(cursor="")
|
||||
root.configure(cursor="")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_detail_header_click(self, col: ColSpec):
|
||||
"""Toggle detail sorting when the user clicks a detail header."""
|
||||
if not self.detail_doc or self._detail_sorting:
|
||||
return
|
||||
|
||||
if self._detail_sort_key == col.key:
|
||||
self._detail_sort_reverse = not self._detail_sort_reverse
|
||||
else:
|
||||
self._detail_sort_key = col.key
|
||||
self._detail_sort_reverse = False
|
||||
|
||||
self._detail_sorting = True
|
||||
self.spinner.start(" Ordino dettagli...")
|
||||
self.busy.show(f"Ordinamento per {col.title}...")
|
||||
root = self.winfo_toplevel()
|
||||
try:
|
||||
root.configure(cursor="watch")
|
||||
self.detail_sheet.configure(cursor="watch")
|
||||
root.update_idletasks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_sort():
|
||||
try:
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
self._refresh_details()
|
||||
finally:
|
||||
# Wait one more UI turn so the redraw becomes visible before removing feedback.
|
||||
self.after_idle(lambda r=root: self.after(15, lambda: self._finish_detail_sort_feedback(r)))
|
||||
|
||||
self.after(25, _apply_sort)
|
||||
|
||||
def _on_detail_sheet_left_click(self, event):
|
||||
"""Sort detail rows when the user clicks a tksheet header cell."""
|
||||
try:
|
||||
region = self.detail_sheet.identify_region(event)
|
||||
column = self.detail_sheet.identify_column(event, exclude_header=False, allow_end=False)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if region != "header" or column is None or column < 0 or column >= len(DET_COLS):
|
||||
return
|
||||
|
||||
self._on_detail_header_click(DET_COLS[column])
|
||||
|
||||
def _apply_row_colors(self, rows: List[Dict[str, Any]]):
|
||||
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
|
||||
try:
|
||||
@@ -517,6 +892,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
break
|
||||
|
||||
# ----- eventi -----
|
||||
@_log_call()
|
||||
def on_row_checked(self, model: PLRow, is_checked: bool):
|
||||
"""Handle row selection changes and refresh the detail section."""
|
||||
# selezione esclusiva
|
||||
@@ -526,18 +902,23 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
m.set_checked(False)
|
||||
|
||||
self.detail_doc = model.pl.get("Documento")
|
||||
_MODULE_LOGGER.info(f"Documento selezionato per il dettaglio: {self.detail_doc}")
|
||||
self.spinner.start(" Carico dettagli…") # spinner ON
|
||||
|
||||
async def _job():
|
||||
_log_sql("SQL_PL_DETAILS", SQL_PL_DETAILS, {"Documento": self.detail_doc})
|
||||
return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
|
||||
|
||||
def _ok(res):
|
||||
# NON fermare lo spinner subito: lo farà _refresh_details_incremental
|
||||
self._detail_cache[self.detail_doc] = _rows_to_dicts(res)
|
||||
rows = _expand_detail_rows_for_test(_rows_to_dicts(res))
|
||||
_log_dataset("SQL_PL_DETAILS", rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
# Avvia il rendering incrementale che mantiene l'overlay attivo
|
||||
self._refresh_details_incremental()
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore durante il caricamento dettagli del documento {self.detail_doc}: {ex}")
|
||||
self.spinner.stop()
|
||||
self.busy.hide() # Chiudi l'overlay in caso di errore
|
||||
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
|
||||
@@ -555,16 +936,20 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
else:
|
||||
if not any(m.is_checked() for m in self.rows_models):
|
||||
self.detail_doc = None
|
||||
_MODULE_LOGGER.info("Nessun documento selezionato: ripristino placeholder del dettaglio.")
|
||||
self._refresh_details()
|
||||
|
||||
# ----- load PL -----
|
||||
@_log_call()
|
||||
def reload_from_db(self, first: bool = False):
|
||||
"""Load or reload the picking list summary table from the database."""
|
||||
self.spinner.start(" Carico…") # spinner ON
|
||||
async def _job():
|
||||
_log_sql("SQL_PL", SQL_PL, {})
|
||||
return await self.db_client.query_json(SQL_PL, {})
|
||||
def _on_success(res):
|
||||
rows = _rows_to_dicts(res)
|
||||
_log_dataset("SQL_PL", rows)
|
||||
self._refresh_mid_rows(rows)
|
||||
self.spinner.stop() # spinner OFF
|
||||
# se era il primo load, ripristina il cursore standard
|
||||
@@ -575,6 +960,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
pass
|
||||
self._first_loading = False
|
||||
def _on_error(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore durante il caricamento della picking list: {ex}")
|
||||
self.spinner.stop()
|
||||
if self._first_loading:
|
||||
try:
|
||||
@@ -592,102 +978,64 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
message="Caricamento Picking List…" if first else "Aggiornamento…"
|
||||
)
|
||||
|
||||
@_log_call("DEBUG")
|
||||
def _refresh_details(self):
|
||||
"""Render the detail table for the currently selected document."""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
return
|
||||
|
||||
rows = self._detail_cache.get(self.detail_doc, [])
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
_MODULE_LOGGER.debug(f"Ridisegno tabella dettaglio per documento={self.detail_doc} righe={len(rows)}")
|
||||
if not rows:
|
||||
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
||||
row_index=0, anchors=["w"]*6)
|
||||
self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
|
||||
return
|
||||
|
||||
for r, d in enumerate(rows):
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
|
||||
|
||||
self.det_table.add_row(
|
||||
values=[pallet, lotto, articolo, descr, qta, loc],
|
||||
row_index=r,
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
|
||||
|
||||
@_log_call("DEBUG")
|
||||
def _refresh_details_incremental(self, batch_size: int = 25):
|
||||
"""
|
||||
Render detail table incrementally in batches to keep UI responsive.
|
||||
Mantiene l'overlay visibile fino al completamento del rendering.
|
||||
Render detail table using tksheet while keeping busy feedback consistent.
|
||||
"""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
rows = self._detail_cache.get(self.detail_doc, [])
|
||||
rows = list(self._detail_cache.get(self.detail_doc, []))
|
||||
rows = self._sort_detail_rows(rows)
|
||||
self._detail_cache[self.detail_doc] = rows
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Avvio rendering dettagli documento={self.detail_doc} righe={len(rows)}")
|
||||
if not rows:
|
||||
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
||||
row_index=0, anchors=["w"]*6)
|
||||
self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
# Inizia il rendering incrementale
|
||||
total_rows = len(rows)
|
||||
self.busy.show(f"Rendering {len(rows)} UDC...")
|
||||
self._render_batch(rows, batch_size, 0, total_rows)
|
||||
|
||||
def _render_batch(self, rows: List[Dict[str, Any]], batch_size: int, start_idx: int, total_rows: int):
|
||||
"""
|
||||
Render a batch of rows and schedule the next batch.
|
||||
Mantiene lo spinner attivo fino all'ultimo batch.
|
||||
"""
|
||||
end_idx = min(start_idx + batch_size, total_rows)
|
||||
|
||||
# Aggiorna lo spinner con il progresso
|
||||
progress_pct = int((end_idx / total_rows) * 100)
|
||||
self.spinner.lbl.configure(text=f"◐ Rendering {progress_pct}%")
|
||||
|
||||
# Aggiorna anche il messaggio dell'overlay
|
||||
self.busy.show(f"Rendering {progress_pct}% ({end_idx}/{total_rows} UDC)...")
|
||||
|
||||
# Renderizza il batch corrente
|
||||
for r in range(start_idx, end_idx):
|
||||
d = rows[r]
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
|
||||
|
||||
self.det_table.add_row(
|
||||
values=[pallet, lotto, articolo, descr, qta, loc],
|
||||
row_index=r,
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
|
||||
# Se ci sono ancora righe da renderizzare, schedula il prossimo batch
|
||||
if end_idx < total_rows:
|
||||
# Lascia respirare Tk tra i batch (10ms)
|
||||
self.after(10, lambda: self._render_batch(rows, batch_size, end_idx, total_rows))
|
||||
else:
|
||||
# Ultimo batch completato: ferma lo spinner e chiudi l'overlay
|
||||
self._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):
|
||||
"""
|
||||
Legacy helper kept for compatibility after the move to tksheet.
|
||||
"""
|
||||
del batch_size, start_idx, total_rows
|
||||
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
|
||||
|
||||
# ----- azioni -----
|
||||
@_log_call()
|
||||
def on_prenota(self):
|
||||
"""Reserve the selected picking list."""
|
||||
if not self._can("pickinglist.prenota"):
|
||||
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' prenotare picking list.", parent=self)
|
||||
return
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
|
||||
@@ -700,22 +1048,51 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
|
||||
return
|
||||
|
||||
id_operatore = 1 # TODO: recupera dal contesto reale
|
||||
id_operatore = self._operator_id()
|
||||
if id_operatore <= 0:
|
||||
messagebox.showerror("Prenota", "Sessione operatore non valida.", parent=self)
|
||||
return
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta prenotazione documento={documento} id_operatore={id_operatore}")
|
||||
self.spinner.start(" Prenoto…")
|
||||
|
||||
async def _job():
|
||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
||||
|
||||
def _ok(res: SPResult):
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
|
||||
self.spinner.stop()
|
||||
if res and res.rc == 0:
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="ok",
|
||||
target=documento,
|
||||
)
|
||||
self._recolor_row_by_documento(documento, desired)
|
||||
else:
|
||||
msg = (res.message if res else "Errore sconosciuto")
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="denied",
|
||||
target=documento,
|
||||
details={"message": msg},
|
||||
)
|
||||
messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore prenotazione documento={documento}: {ex}")
|
||||
self.spinner.stop()
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.prenota",
|
||||
outcome="error",
|
||||
target=documento,
|
||||
details={"error": str(ex)},
|
||||
)
|
||||
messagebox.showerror("Prenota", f"Errore:\n{ex}")
|
||||
|
||||
self.runner.run(
|
||||
@@ -726,8 +1103,12 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
message=f"Prenoto la Picking List {documento}…"
|
||||
)
|
||||
|
||||
@_log_call()
|
||||
def on_sprenota(self):
|
||||
"""Unreserve the selected picking list."""
|
||||
if not self._can("pickinglist.sprenota"):
|
||||
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' s-prenotare picking list.", parent=self)
|
||||
return
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
|
||||
@@ -740,22 +1121,51 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
|
||||
return
|
||||
|
||||
id_operatore = 1 # TODO: recupera dal contesto reale
|
||||
id_operatore = self._operator_id()
|
||||
if id_operatore <= 0:
|
||||
messagebox.showerror("S-prenota", "Sessione operatore non valida.", parent=self)
|
||||
return
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta s-prenotazione documento={documento} id_operatore={id_operatore}")
|
||||
self.spinner.start(" S-prenoto…")
|
||||
|
||||
async def _job():
|
||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
|
||||
|
||||
def _ok(res: SPResult):
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito s-prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
|
||||
self.spinner.stop()
|
||||
if res and res.rc == 0:
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="ok",
|
||||
target=documento,
|
||||
)
|
||||
self._recolor_row_by_documento(documento, desired)
|
||||
else:
|
||||
msg = (res.message if res else "Errore sconosciuto")
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="denied",
|
||||
target=documento,
|
||||
details={"message": msg},
|
||||
)
|
||||
messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
|
||||
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore s-prenotazione documento={documento}: {ex}")
|
||||
self.spinner.stop()
|
||||
log_user_action(
|
||||
self.session,
|
||||
module=MODULE_LOG_NAME,
|
||||
action="pickinglist.sprenota",
|
||||
outcome="error",
|
||||
target=documento,
|
||||
details={"error": str(ex)},
|
||||
)
|
||||
messagebox.showerror("S-prenota", f"Errore:\n{ex}")
|
||||
|
||||
self.runner.run(
|
||||
@@ -772,10 +1182,82 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
|
||||
# factory per main
|
||||
def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame':
|
||||
@_log_call()
|
||||
def create_frame(parent, *, db_client=None, conn_str=None, session: UserSession | None = None) -> 'GestionePickingListFrame':
|
||||
"""Factory used by the launcher to build the picking list frame."""
|
||||
ctk.set_appearance_mode("light")
|
||||
ctk.set_default_color_theme("green")
|
||||
return GestionePickingListFrame(parent, db_client=db_client)
|
||||
return GestionePickingListFrame(parent, db_client=db_client, session=session)
|
||||
|
||||
|
||||
@_log_call()
|
||||
def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | None = None) -> tk.Misc:
|
||||
"""Open the picking list window while minimizing the first paint flicker."""
|
||||
key = "_gestione_pickinglist_window_singleton"
|
||||
ex = getattr(parent, key, None)
|
||||
if ex and ex.winfo_exists():
|
||||
frame = getattr(ex, "_pickinglist_frame", None)
|
||||
if frame is not None:
|
||||
try:
|
||||
frame.session = session
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.lift()
|
||||
ex.focus_force()
|
||||
return ex
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
win = ctk.CTkToplevel(parent)
|
||||
win.title("Gestione Picking List")
|
||||
win.geometry("1200x700+0+100")
|
||||
win.minsize(1000, 560)
|
||||
setattr(parent, key, win)
|
||||
|
||||
# Keep the toplevel hidden until the child frame has built its initial layout.
|
||||
try:
|
||||
win.withdraw()
|
||||
win.attributes("-alpha", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frame = create_frame(win, db_client=db_client, session=session)
|
||||
try:
|
||||
frame.pack(fill="both", expand=True)
|
||||
except Exception:
|
||||
pass
|
||||
setattr(win, "_pickinglist_frame", frame)
|
||||
|
||||
# Reveal the fully-laid out window only after pending geometry work completes.
|
||||
try:
|
||||
win.update_idletasks()
|
||||
try:
|
||||
win.transient(parent)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
win.lift()
|
||||
try:
|
||||
win.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.attributes("-alpha", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
win.bind("<Escape>", lambda e: win.destroy())
|
||||
win.protocol("WM_DELETE_WINDOW", win.destroy)
|
||||
return win
|
||||
|
||||
# =================== /gestione_pickinglist.py ===================
|
||||
|
||||
734
gestione_scarico.py
Normal file
734
gestione_scarico.py
Normal 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,
|
||||
)
|
||||
698
layout_window.py
698
layout_window.py
@@ -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
251
login_window.py
Normal 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
210
main.py
@@ -6,38 +6,23 @@ project.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import sys
|
||||
import tkinter as tk
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
|
||||
from async_loop_singleton import get_global_loop
|
||||
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
|
||||
from layout_window import open_layout_window
|
||||
from gestione_layout import open_layout_window
|
||||
from gestione_pickinglist import open_pickinglist_window
|
||||
from login_window import prompt_login
|
||||
from reset_corsie import open_reset_corsie_window
|
||||
from search_pallets import open_search_window
|
||||
from view_celle_multiple import open_celle_multiple_window
|
||||
|
||||
# Try factory, else frame, else app (senza passare conn_str all'App)
|
||||
try:
|
||||
from gestione_pickinglist import create_frame as create_pickinglist_frame
|
||||
except Exception:
|
||||
try:
|
||||
from gestione_pickinglist import GestionePickingListFrame as _PLFrame
|
||||
|
||||
def create_pickinglist_frame(parent, db_client=None, conn_str=None):
|
||||
"""Build the picking list UI using the frame-based fallback."""
|
||||
ctk.set_appearance_mode("light")
|
||||
ctk.set_default_color_theme("green")
|
||||
return _PLFrame(parent, db_client=db_client, conn_str=conn_str)
|
||||
except Exception:
|
||||
from gestione_pickinglist import GestionePickingListApp as _PLApp
|
||||
|
||||
def create_pickinglist_frame(parent, db_client=None, conn_str=None):
|
||||
"""Fallback used only by legacy app-style picking list implementations."""
|
||||
app = _PLApp()
|
||||
app.mainloop()
|
||||
return tk.Frame(parent)
|
||||
from audit_log import log_session_event
|
||||
from view_celle_multi_udc import open_celle_multiple_window
|
||||
from user_session import UserSession, create_user_session
|
||||
|
||||
|
||||
# ---- Config ----
|
||||
@@ -46,6 +31,17 @@ DBNAME = "Mediseawall"
|
||||
USER = "sa"
|
||||
PASSWORD = "1Password1"
|
||||
|
||||
# Development shortcut: skip the login dialog and boot directly as MAG1.
|
||||
# Set to False when you want to restore normal authentication.
|
||||
BYPASS_LOGIN = True
|
||||
BYPASS_LOGIN_USER = {
|
||||
"operator_id": 4,
|
||||
"login": "MAG1",
|
||||
"nominativo": "MAG1",
|
||||
"privilegio": 3,
|
||||
"codice_unita": "U1",
|
||||
}
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
try:
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
@@ -69,93 +65,91 @@ if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"):
|
||||
|
||||
dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD)
|
||||
db_app = AsyncMSSQLClient(dsn_app)
|
||||
_APP_MUTEX = None
|
||||
_APP_MUTEX_NAME = "Local\\WarehousePythonAppSingleton"
|
||||
|
||||
|
||||
def open_pickinglist_window(parent: tk.Misc, db_client: AsyncMSSQLClient):
|
||||
"""Open the picking list window while minimizing initial flicker."""
|
||||
win = ctk.CTkToplevel(parent)
|
||||
win.title("Gestione Picking List")
|
||||
win.geometry("1200x700+0+100")
|
||||
win.minsize(1000, 560)
|
||||
def _acquire_single_instance_mutex() -> bool:
|
||||
"""Return ``True`` only for the first running instance of the application."""
|
||||
|
||||
# Keep the toplevel hidden while its content is being created.
|
||||
try:
|
||||
win.withdraw()
|
||||
win.attributes("-alpha", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
global _APP_MUTEX
|
||||
if not sys.platform.startswith("win"):
|
||||
return True
|
||||
|
||||
frame = create_pickinglist_frame(win, db_client=db_client)
|
||||
try:
|
||||
frame.pack(fill="both", expand=True)
|
||||
except Exception:
|
||||
pass
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
mutex = kernel32.CreateMutexW(None, False, _APP_MUTEX_NAME)
|
||||
if not mutex:
|
||||
return True
|
||||
|
||||
# Show the window only when the layout is ready.
|
||||
try:
|
||||
win.update_idletasks()
|
||||
try:
|
||||
win.transient(parent)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
win.lift()
|
||||
try:
|
||||
win.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
win.attributes("-alpha", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
last_error = kernel32.GetLastError()
|
||||
_APP_MUTEX = mutex
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
return last_error != ERROR_ALREADY_EXISTS
|
||||
|
||||
win.bind("<Escape>", lambda e: win.destroy())
|
||||
win.protocol("WM_DELETE_WINDOW", win.destroy)
|
||||
return win
|
||||
|
||||
def _build_bypass_session() -> UserSession:
|
||||
"""Create the development session used when authentication is bypassed."""
|
||||
|
||||
return create_user_session(
|
||||
operator_id=int(BYPASS_LOGIN_USER["operator_id"]),
|
||||
login=str(BYPASS_LOGIN_USER["login"]),
|
||||
nominativo=str(BYPASS_LOGIN_USER["nominativo"]),
|
||||
privilegio=int(BYPASS_LOGIN_USER["privilegio"]),
|
||||
codice_unita=str(BYPASS_LOGIN_USER["codice_unita"]),
|
||||
)
|
||||
|
||||
|
||||
class Launcher(ctk.CTk):
|
||||
"""Main launcher window that exposes the available warehouse tools."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, session: UserSession):
|
||||
"""Create the launcher toolbar and wire every button to a feature window."""
|
||||
super().__init__()
|
||||
self.title("Warehouse 1.0.0")
|
||||
self.geometry("1200x70+0+0")
|
||||
self.session: UserSession = session
|
||||
self.title(f"Warehouse 1.0.0 - {self.session.display_name}")
|
||||
self.geometry("1280x96+0+0")
|
||||
|
||||
wrap = ctk.CTkFrame(self)
|
||||
wrap.pack(pady=10, fill="x")
|
||||
|
||||
info = ctk.CTkLabel(
|
||||
wrap,
|
||||
text=f"Operatore: {self.session.display_name} ({self.session.login})",
|
||||
anchor="w",
|
||||
font=("", 12, "bold"),
|
||||
)
|
||||
info.grid(row=0, column=0, columnspan=5, padx=6, pady=(4, 2), sticky="ew")
|
||||
|
||||
ctk.CTkButton(
|
||||
wrap,
|
||||
text="Gestione Corsie",
|
||||
command=lambda: open_reset_corsie_window(self, db_app),
|
||||
).grid(row=0, column=0, padx=6, pady=6, sticky="ew")
|
||||
state="normal" if self.session.can("launcher.open_reset_corsie") else "disabled",
|
||||
command=lambda: open_reset_corsie_window(self, db_app, session=self.session),
|
||||
).grid(row=1, column=0, padx=6, pady=6, sticky="ew")
|
||||
ctk.CTkButton(
|
||||
wrap,
|
||||
text="Gestione Layout",
|
||||
command=lambda: open_layout_window(self, db_app),
|
||||
).grid(row=0, column=1, padx=6, pady=6, sticky="ew")
|
||||
state="normal" if self.session.can("launcher.open_layout") else "disabled",
|
||||
command=lambda: open_layout_window(self, db_app, session=self.session),
|
||||
).grid(row=1, column=1, padx=6, pady=6, sticky="ew")
|
||||
ctk.CTkButton(
|
||||
wrap,
|
||||
text="UDC Fantasma",
|
||||
command=lambda: open_celle_multiple_window(self, db_app),
|
||||
).grid(row=0, column=2, padx=6, pady=6, sticky="ew")
|
||||
state="normal" if self.session.can("launcher.open_multi_udc") else "disabled",
|
||||
command=lambda: open_celle_multiple_window(self, db_app, session=self.session),
|
||||
).grid(row=1, column=2, padx=6, pady=6, sticky="ew")
|
||||
ctk.CTkButton(
|
||||
wrap,
|
||||
text="Ricerca UDC",
|
||||
command=lambda: open_search_window(self, db_app),
|
||||
).grid(row=0, column=3, padx=6, pady=6, sticky="ew")
|
||||
state="normal" if self.session.can("launcher.open_search") else "disabled",
|
||||
command=lambda: open_search_window(self, db_app, session=self.session),
|
||||
).grid(row=1, column=3, padx=6, pady=6, sticky="ew")
|
||||
ctk.CTkButton(
|
||||
wrap,
|
||||
text="Gestione Picking List",
|
||||
command=lambda: open_pickinglist_window(self, db_app),
|
||||
).grid(row=0, column=4, padx=6, pady=6, sticky="ew")
|
||||
state="normal" if self.session.can("launcher.open_pickinglist") else "disabled",
|
||||
command=lambda: open_pickinglist_window(self, db_app, session=self.session),
|
||||
).grid(row=1, column=4, padx=6, pady=6, sticky="ew")
|
||||
|
||||
for i in range(5):
|
||||
wrap.grid_columnconfigure(i, weight=1)
|
||||
@@ -163,6 +157,8 @@ class Launcher(ctk.CTk):
|
||||
def _on_close():
|
||||
"""Dispose shared resources before closing the launcher."""
|
||||
try:
|
||||
if self.session is not None:
|
||||
log_session_event(self.session, action="logout", outcome="ok")
|
||||
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
|
||||
try:
|
||||
fut.result(timeout=2)
|
||||
@@ -172,9 +168,65 @@ class Launcher(ctk.CTk):
|
||||
self.destroy()
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", _on_close)
|
||||
try:
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ctk.set_appearance_mode("light")
|
||||
ctk.set_default_color_theme("green")
|
||||
Launcher().mainloop()
|
||||
if not _acquire_single_instance_mutex():
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
messagebox.showwarning(
|
||||
"Warehouse",
|
||||
"L'applicazione e' gia' in esecuzione.\nChiudi l'istanza aperta prima di avviarne un'altra.",
|
||||
parent=root,
|
||||
)
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(0)
|
||||
|
||||
if BYPASS_LOGIN:
|
||||
session = _build_bypass_session()
|
||||
log_session_event(
|
||||
session,
|
||||
action="login.bypass",
|
||||
outcome="ok",
|
||||
details={"login": session.login},
|
||||
)
|
||||
bootstrap = None
|
||||
else:
|
||||
bootstrap = tk.Tk()
|
||||
bootstrap.geometry("1x1+0+0")
|
||||
bootstrap.overrideredirect(True)
|
||||
bootstrap.attributes("-alpha", 0.0)
|
||||
bootstrap.deiconify()
|
||||
bootstrap.update_idletasks()
|
||||
session = prompt_login(bootstrap, db_app)
|
||||
|
||||
if session is None:
|
||||
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
|
||||
try:
|
||||
fut.result(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if bootstrap is not None:
|
||||
bootstrap.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(0)
|
||||
|
||||
try:
|
||||
if bootstrap is not None:
|
||||
bootstrap.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Launcher(session).mainloop()
|
||||
|
||||
@@ -2,9 +2,150 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from loguru import logger
|
||||
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
|
||||
class _FallbackLogger:
|
||||
"""Minimal adapter used only when Loguru is not installed yet."""
|
||||
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
|
||||
self._logger.setLevel(logging.DEBUG)
|
||||
self._logger.propagate = False
|
||||
|
||||
def bind(self, **_kwargs):
|
||||
return self
|
||||
|
||||
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
|
||||
handler: logging.Handler
|
||||
if hasattr(sink, "write"):
|
||||
handler = logging.StreamHandler(sink)
|
||||
else:
|
||||
handler = logging.FileHandler(str(sink), encoding=encoding)
|
||||
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
||||
)
|
||||
self._logger.addHandler(handler)
|
||||
return 0
|
||||
|
||||
def log(self, level, message):
|
||||
getattr(self._logger, str(level).lower(), self._logger.info)(message)
|
||||
|
||||
def debug(self, message):
|
||||
self._logger.debug(message)
|
||||
|
||||
def info(self, message):
|
||||
self._logger.info(message)
|
||||
|
||||
def exception(self, message):
|
||||
self._logger.exception(message)
|
||||
|
||||
logger = _FallbackLogger()
|
||||
|
||||
|
||||
PACKINGLIST_SP_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
|
||||
MODULE_LOG_NAME = Path(__file__).stem
|
||||
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
|
||||
_MODULE_LOG_ENABLED = PACKINGLIST_SP_LOG_MODE.upper() != "OFF"
|
||||
_MODULE_LOG_LEVEL = "DEBUG" if PACKINGLIST_SP_LOG_MODE.upper() == "DEBUG" else "INFO"
|
||||
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
|
||||
_MODULE_LOGGING_CONFIGURED = False
|
||||
|
||||
|
||||
def _configure_module_logger():
|
||||
"""Configure console and file logging for this module."""
|
||||
global _MODULE_LOGGING_CONFIGURED
|
||||
if _MODULE_LOGGING_CONFIGURED:
|
||||
return
|
||||
if not _MODULE_LOG_ENABLED:
|
||||
_MODULE_LOGGING_CONFIGURED = True
|
||||
return
|
||||
|
||||
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=_MODULE_LOG_LEVEL,
|
||||
colorize=True,
|
||||
filter=record_filter,
|
||||
format=(
|
||||
"<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
|
||||
class SPResult:
|
||||
@@ -15,14 +156,18 @@ class SPResult:
|
||||
id_result: Optional[int] = None
|
||||
|
||||
|
||||
@_log_call("DEBUG")
|
||||
async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]:
|
||||
"""Return the first column of the first row from a query result."""
|
||||
_log_sql("_query_one_value", sql, params)
|
||||
if hasattr(db, "query_json"):
|
||||
res = await db.query_json(sql, params)
|
||||
if isinstance(res, list) and res:
|
||||
row0 = res[0]
|
||||
if isinstance(row0, dict):
|
||||
return next(iter(row0.values()), None)
|
||||
value = next(iter(row0.values()), None)
|
||||
_log_dataset("_query_one_value", value)
|
||||
return value
|
||||
elif isinstance(res, dict):
|
||||
rows = None
|
||||
for key in ("rows", "data", "result", "records"):
|
||||
@@ -32,58 +177,83 @@ async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any
|
||||
if rows:
|
||||
row0 = rows[0]
|
||||
if isinstance(row0, dict):
|
||||
return next(iter(row0.values()), None)
|
||||
value = next(iter(row0.values()), None)
|
||||
_log_dataset("_query_one_value", value)
|
||||
return value
|
||||
if isinstance(row0, (list, tuple)) and row0:
|
||||
return row0[0]
|
||||
value = row0[0]
|
||||
_log_dataset("_query_one_value", value)
|
||||
return value
|
||||
_log_dataset("_query_one_value", None)
|
||||
return None
|
||||
|
||||
if hasattr(db, "query_value"):
|
||||
return await db.query_value(sql, params)
|
||||
value = await db.query_value(sql, params)
|
||||
_log_dataset("_query_one_value", value)
|
||||
return value
|
||||
if hasattr(db, "scalar"):
|
||||
return await db.scalar(sql, params)
|
||||
value = await db.scalar(sql, params)
|
||||
_log_dataset("_query_one_value", value)
|
||||
return value
|
||||
raise RuntimeError("Il client DB non espone query_json/query_value/scalar")
|
||||
|
||||
|
||||
@_log_call("DEBUG")
|
||||
async def _query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return all rows as dictionaries, normalizing different DB client APIs."""
|
||||
_log_sql("_query_all", sql, params)
|
||||
if hasattr(db, "query_json"):
|
||||
res = await db.query_json(sql, params)
|
||||
if res is None:
|
||||
_log_dataset("_query_all", [])
|
||||
return []
|
||||
if isinstance(res, list):
|
||||
return res if res and isinstance(res[0], dict) else []
|
||||
rows = res if res and isinstance(res[0], dict) else []
|
||||
_log_dataset("_query_all", rows)
|
||||
return rows
|
||||
if isinstance(res, dict):
|
||||
for key in ("rows", "data", "result", "records"):
|
||||
if key in res and isinstance(res[key], list):
|
||||
rows = res[key]
|
||||
if rows and isinstance(rows[0], dict):
|
||||
_log_dataset("_query_all", rows)
|
||||
return rows
|
||||
cols = res.get("columns") or res.get("cols") or []
|
||||
out = []
|
||||
for row in rows:
|
||||
if isinstance(row, (list, tuple)) and cols:
|
||||
out.append({(cols[i] if i < len(cols) else f"c{i}"): row[i] for i in range(min(len(cols), len(row)))})
|
||||
_log_dataset("_query_all", out)
|
||||
return out
|
||||
_log_dataset("_query_all", [])
|
||||
return []
|
||||
if hasattr(db, "fetch_all"):
|
||||
return await db.fetch_all(sql, params)
|
||||
rows = await db.fetch_all(sql, params)
|
||||
_log_dataset("_query_all", rows)
|
||||
return rows
|
||||
raise RuntimeError("Il client DB non espone query_json/fetch_all")
|
||||
|
||||
|
||||
@_log_call("DEBUG")
|
||||
async def _execute(db, sql: str, params: Dict[str, Any]) -> int:
|
||||
"""Execute a DML statement using the best method exposed by the DB client."""
|
||||
_log_sql("_execute", sql, params)
|
||||
for name in ("execute", "exec", "execute_non_query"):
|
||||
if hasattr(db, name):
|
||||
rc = await getattr(db, name)(sql, params)
|
||||
if isinstance(rc, int):
|
||||
_log_dataset("_execute", rc)
|
||||
return rc
|
||||
_log_dataset("_execute", 0)
|
||||
return 0
|
||||
if hasattr(db, "query_json"):
|
||||
await db.query_json(sql, params)
|
||||
_log_dataset("_execute", 0)
|
||||
return 0
|
||||
raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti")
|
||||
|
||||
|
||||
@_log_call()
|
||||
async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -> SPResult:
|
||||
"""Toggle the reservation state of all cells belonging to a packing list.
|
||||
|
||||
@@ -91,6 +261,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -
|
||||
the shared async DB client already managed by the application.
|
||||
"""
|
||||
try:
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Procedura async packing list avviata documento={Documento} id_operatore={IDOperatore}")
|
||||
nominativo = await _query_one_value(
|
||||
db,
|
||||
"SELECT LOGIN FROM Operatori WHERE id = :IDOperatore",
|
||||
@@ -107,6 +278,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -
|
||||
{"Documento": Documento},
|
||||
)
|
||||
id_celle = [row.get("Cella") for row in celle if "Cella" in row]
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Celle coinvolte per documento={Documento}: {len(id_celle)}")
|
||||
|
||||
# Each cell is toggled individually because the original procedure also
|
||||
# updates metadata such as operator and timestamp per row.
|
||||
@@ -118,6 +290,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -
|
||||
"SELECT IDStato FROM Celle WHERE ID = :IDC",
|
||||
{"IDC": id_cella},
|
||||
)
|
||||
_MODULE_LOGGER.debug(f"Toggling cella id={id_cella} stato_corrente={stato}")
|
||||
if stato == 0:
|
||||
await _execute(
|
||||
db,
|
||||
@@ -165,6 +338,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -
|
||||
)
|
||||
|
||||
new_id = await _query_one_value(db, "SELECT SCOPE_IDENTITY() AS ID", {})
|
||||
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Procedura completata documento={Documento} id_result={new_id}")
|
||||
return SPResult(rc=0, message="", id_result=int(new_id) if new_id is not None else None)
|
||||
except Exception as exc:
|
||||
_MODULE_LOGGER.exception(f"Procedura fallita documento={Documento}: {exc}")
|
||||
return SPResult(rc=-1, message=str(exc), id_result=None)
|
||||
|
||||
@@ -5,6 +5,8 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"aioodbc>=0.3.3",
|
||||
"loguru>=0.7",
|
||||
"tksheet>=7.5",
|
||||
# "orjson>=3.9" # opzionale: il tuo codice fa fallback su json puro
|
||||
]
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from tkinter import messagebox, simpledialog, ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gestione_aree_frame_async import AsyncRunner, BusyOverlay
|
||||
from gestione_aree import AsyncRunner, BusyOverlay
|
||||
|
||||
SQL_CORSIE = """
|
||||
WITH C AS (
|
||||
@@ -94,7 +94,7 @@ WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
|
||||
class ResetCorsieWindow(ctk.CTkToplevel):
|
||||
"""Toplevel used to inspect and clear the pallets assigned to an aisle."""
|
||||
|
||||
def __init__(self, parent, db_client):
|
||||
def __init__(self, parent, db_client, session=None):
|
||||
"""Create the window and immediately load the list of aisles."""
|
||||
super().__init__(parent)
|
||||
self.title("Reset Corsie - svuotamento celle per corsia")
|
||||
@@ -103,6 +103,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
||||
self.resizable(True, True)
|
||||
|
||||
self.db = db_client
|
||||
self.session = session
|
||||
self._busy = BusyOverlay(self)
|
||||
self._async = AsyncRunner(self)
|
||||
|
||||
@@ -256,9 +257,22 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
||||
self._async.run(self.db.query_json(SQL_DELETE, {"corsia": corsia}), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...")
|
||||
|
||||
|
||||
def open_reset_corsie_window(parent, db_app):
|
||||
def open_reset_corsie_window(parent, db_app, session=None):
|
||||
"""Create, focus and return the aisle reset window."""
|
||||
win = ResetCorsieWindow(parent, db_app)
|
||||
key = "_reset_corsie_window_singleton"
|
||||
ex = getattr(parent, key, None)
|
||||
if ex and ex.winfo_exists():
|
||||
try:
|
||||
ex.lift()
|
||||
ex.focus_force()
|
||||
return ex
|
||||
except Exception:
|
||||
pass
|
||||
win = ResetCorsieWindow(parent, db_app, session=session)
|
||||
setattr(parent, key, win)
|
||||
try:
|
||||
win.lift()
|
||||
win.focus_set()
|
||||
win.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
return win
|
||||
|
||||
@@ -7,7 +7,7 @@ from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from gestione_aree_frame_async import AsyncRunner, BusyOverlay
|
||||
from gestione_aree import AsyncRunner, BusyOverlay
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
@@ -74,7 +74,7 @@ ORDER BY
|
||||
class SearchWindow(ctk.CTkToplevel):
|
||||
"""Window that searches pallets by barcode, lot or product code."""
|
||||
|
||||
def __init__(self, parent: tk.Widget, db_app):
|
||||
def __init__(self, parent: tk.Widget, db_app, session=None):
|
||||
"""Initialize widgets and keep a reference to the shared DB client."""
|
||||
super().__init__(parent)
|
||||
self.title("Warehouse - Ricerca UDC/Lotto/Codice")
|
||||
@@ -83,6 +83,7 @@ class SearchWindow(ctk.CTkToplevel):
|
||||
self.resizable(True, True)
|
||||
|
||||
self.db = db_app
|
||||
self.session = session
|
||||
self._busy = BusyOverlay(self)
|
||||
self._async = AsyncRunner(self)
|
||||
self._sort_state: dict[str, bool] = {}
|
||||
@@ -411,7 +412,7 @@ class SearchWindow(ctk.CTkToplevel):
|
||||
self._async.run(self.db.query_json(SQL_SEARCH, params), _ok, _err, busy=self._busy, message="Cerco...")
|
||||
|
||||
|
||||
def open_search_window(parent, db_app):
|
||||
def open_search_window(parent, db_app, session=None):
|
||||
"""Open a singleton-like search window tied to the launcher instance."""
|
||||
key = "_search_window_singleton"
|
||||
ex = getattr(parent, key, None)
|
||||
@@ -422,6 +423,6 @@ def open_search_window(parent, db_app):
|
||||
return ex
|
||||
except Exception:
|
||||
pass
|
||||
w = SearchWindow(parent, db_app)
|
||||
w = SearchWindow(parent, db_app, session=session)
|
||||
setattr(parent, key, w)
|
||||
return w
|
||||
|
||||
88
user_session.py
Normal file
88
user_session.py
Normal 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),
|
||||
)
|
||||
@@ -1,15 +1,158 @@
|
||||
"""Exploration window for cells containing more than one pallet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
import customtkinter as ctk
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font
|
||||
|
||||
from gestione_aree_frame_async import AsyncRunner
|
||||
from gestione_aree import AsyncRunner
|
||||
|
||||
try:
|
||||
from loguru import logger
|
||||
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
||||
class _FallbackLogger:
|
||||
"""Minimal adapter used only when Loguru is not installed yet."""
|
||||
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
|
||||
self._logger.setLevel(logging.DEBUG)
|
||||
self._logger.propagate = False
|
||||
|
||||
def bind(self, **_kwargs):
|
||||
return self
|
||||
|
||||
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
|
||||
handler: logging.Handler
|
||||
if hasattr(sink, "write"):
|
||||
handler = logging.StreamHandler(sink)
|
||||
else:
|
||||
handler = logging.FileHandler(str(sink), encoding=encoding)
|
||||
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
||||
)
|
||||
self._logger.addHandler(handler)
|
||||
return 0
|
||||
|
||||
def log(self, level, message):
|
||||
getattr(self._logger, str(level).lower(), self._logger.info)(message)
|
||||
|
||||
def debug(self, message):
|
||||
self._logger.debug(message)
|
||||
|
||||
def info(self, message):
|
||||
self._logger.info(message)
|
||||
|
||||
def exception(self, message):
|
||||
self._logger.exception(message)
|
||||
|
||||
logger = _FallbackLogger()
|
||||
|
||||
|
||||
MULTI_UDC_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
|
||||
MODULE_LOG_NAME = Path(__file__).stem
|
||||
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
|
||||
_MODULE_LOG_ENABLED = MULTI_UDC_LOG_MODE.upper() != "OFF"
|
||||
_MODULE_LOG_LEVEL = "DEBUG" if MULTI_UDC_LOG_MODE.upper() == "DEBUG" else "INFO"
|
||||
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
|
||||
_MODULE_LOGGING_CONFIGURED = False
|
||||
|
||||
|
||||
def _configure_module_logger():
|
||||
"""Configure console and file logging for this module."""
|
||||
global _MODULE_LOGGING_CONFIGURED
|
||||
if _MODULE_LOGGING_CONFIGURED:
|
||||
return
|
||||
if not _MODULE_LOG_ENABLED:
|
||||
_MODULE_LOGGING_CONFIGURED = True
|
||||
return
|
||||
|
||||
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=_MODULE_LOG_LEVEL,
|
||||
colorize=True,
|
||||
filter=record_filter,
|
||||
format=(
|
||||
"<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):
|
||||
@@ -80,10 +223,53 @@ ORDER BY b.Colonna, b.Fila;
|
||||
"""
|
||||
|
||||
SQL_PALLET_IN_CELLA = BASE_CTE + """
|
||||
, cell_pallets AS (
|
||||
SELECT DISTINCT b.BarcodePallet
|
||||
FROM base b
|
||||
WHERE b.IDCella = :idcella
|
||||
),
|
||||
latest_any AS (
|
||||
SELECT
|
||||
ranked.BarcodePallet,
|
||||
ranked.IDCella
|
||||
FROM (
|
||||
SELECT
|
||||
mp.Attributo AS BarcodePallet,
|
||||
mp.IDCella,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mp.Attributo
|
||||
ORDER BY mp.ID DESC
|
||||
) AS rn
|
||||
FROM dbo.MagazziniPallet mp
|
||||
JOIN cell_pallets cp
|
||||
ON cp.BarcodePallet COLLATE Latin1_General_CI_AS =
|
||||
mp.Attributo COLLATE Latin1_General_CI_AS
|
||||
WHERE mp.Tipo = 'V'
|
||||
AND mp.PesoUnitario > 0
|
||||
) ranked
|
||||
WHERE ranked.rn = 1
|
||||
),
|
||||
shipped AS (
|
||||
SELECT DISTINCT shipped.BarcodePallet
|
||||
FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped
|
||||
JOIN cell_pallets cp
|
||||
ON cp.BarcodePallet COLLATE Latin1_General_CI_AS =
|
||||
shipped.BarcodePallet COLLATE Latin1_General_CI_AS
|
||||
)
|
||||
SELECT
|
||||
b.BarcodePallet AS Pallet,
|
||||
ta.Descrizione,
|
||||
ta.Lotto
|
||||
ta.Lotto,
|
||||
CASE
|
||||
WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int)
|
||||
ELSE CAST(0 AS int)
|
||||
END AS IsShippedGhost,
|
||||
CASE
|
||||
WHEN la.IDCella IS NOT NULL
|
||||
AND la.IDCella <> :idcella
|
||||
THEN CAST(1 AS int)
|
||||
ELSE CAST(0 AS int)
|
||||
END AS IsMovedGhost
|
||||
FROM base b
|
||||
OUTER APPLY (
|
||||
SELECT TOP (1) t.Descrizione, t.Lotto
|
||||
@@ -91,11 +277,28 @@ OUTER APPLY (
|
||||
WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS
|
||||
ORDER BY t.Lotto
|
||||
) AS ta
|
||||
LEFT JOIN latest_any la
|
||||
ON la.BarcodePallet COLLATE Latin1_General_CI_AS =
|
||||
b.BarcodePallet COLLATE Latin1_General_CI_AS
|
||||
LEFT JOIN shipped
|
||||
ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS =
|
||||
b.BarcodePallet COLLATE Latin1_General_CI_AS
|
||||
WHERE b.IDCella = :idcella
|
||||
GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto
|
||||
GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto, shipped.BarcodePallet, la.IDCella
|
||||
ORDER BY b.BarcodePallet;
|
||||
"""
|
||||
|
||||
|
||||
def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str:
|
||||
"""Translate anomaly flags into the operator-facing ghost cause."""
|
||||
|
||||
notes: list[str] = []
|
||||
if bool(is_shipped):
|
||||
notes.append("Mancato scarico: spedita")
|
||||
if bool(is_moved):
|
||||
notes.append("Mancato scarico: spostata")
|
||||
return " | ".join(notes)
|
||||
|
||||
SQL_RIEPILOGO_PERCENTUALI = BASE_CTE + """
|
||||
, tot AS (
|
||||
SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle
|
||||
@@ -136,10 +339,12 @@ ORDER BY Ord, Corsia;
|
||||
class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
"""Tree-based explorer for duplicated pallet allocations."""
|
||||
|
||||
def __init__(self, root, db_client, runner: AsyncRunner | None = None):
|
||||
@_log_call()
|
||||
def __init__(self, root, db_client, runner: AsyncRunner | None = None, session=None):
|
||||
"""Bind the shared DB client and immediately load the tree summary."""
|
||||
super().__init__(root)
|
||||
self.title("Celle con piu' pallet")
|
||||
self.session = session
|
||||
self.geometry("1100x700")
|
||||
self.minsize(900, 550)
|
||||
self.resizable(True, True)
|
||||
@@ -169,10 +374,15 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6))
|
||||
frame.grid_rowconfigure(0, weight=1)
|
||||
frame.grid_columnconfigure(0, weight=1)
|
||||
self.tree = ttk.Treeview(frame, columns=("col2", "col3"), show="tree headings", selectmode="browse")
|
||||
self.tree = ttk.Treeview(frame, columns=("col2", "col3", "col4"), show="tree headings", selectmode="browse")
|
||||
self.tree.heading("#0", text="Nodo")
|
||||
self.tree.heading("col2", text="Descrizione")
|
||||
self.tree.heading("col3", text="Lotto")
|
||||
self.tree.heading("col4", text="Causale")
|
||||
self.tree.column("#0", width=220, anchor="w")
|
||||
self.tree.column("col2", width=250, anchor="w")
|
||||
self.tree.column("col3", width=120, anchor="w")
|
||||
self.tree.column("col4", width=260, anchor="w")
|
||||
y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
|
||||
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
|
||||
self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
|
||||
@@ -207,23 +417,32 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
"""Attach lazy-load behavior when nodes are expanded."""
|
||||
self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
|
||||
|
||||
@_log_call()
|
||||
def refresh_all(self):
|
||||
"""Reload both the duplication tree and the summary percentage table."""
|
||||
self._load_corsie()
|
||||
self._load_riepilogo()
|
||||
|
||||
@_log_call()
|
||||
def _load_corsie(self):
|
||||
"""Load root nodes representing aisles with duplicated cells."""
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
_log_sql("multi_udc_corsie", SQL_CORSIE, {})
|
||||
|
||||
async def _q(db):
|
||||
return await db.query_json(SQL_CORSIE, as_dict_rows=True)
|
||||
|
||||
self.runner.run(_q(self.db), self._fill_corsie, lambda e: messagebox.showerror("Errore", str(e), parent=self))
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore caricamento corsie UDC fantasma: {ex}")
|
||||
messagebox.showerror("Errore", str(ex), parent=self)
|
||||
|
||||
self.runner.run(_q(self.db), self._fill_corsie, _err)
|
||||
|
||||
@_log_call()
|
||||
def _fill_corsie(self, res):
|
||||
"""Populate root tree nodes after the aisle query completes."""
|
||||
rows = _json_obj(res).get("rows", [])
|
||||
_log_dataset("multi_udc_corsie", rows)
|
||||
for row in rows:
|
||||
corsia = row.get("Corsia")
|
||||
if not corsia:
|
||||
@@ -252,16 +471,25 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
self.tree.delete(child)
|
||||
self._load_pallet_for_cella(sel, idcella)
|
||||
|
||||
@_log_call()
|
||||
def _load_celle_for_corsia(self, parent_iid, corsia):
|
||||
"""Query duplicated cells for the selected aisle."""
|
||||
_log_sql("multi_udc_celle_per_corsia", SQL_CELLE_DUP_PER_CORSIA, {"corsia": corsia})
|
||||
|
||||
async def _q(db):
|
||||
return await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True)
|
||||
|
||||
self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res), lambda e: messagebox.showerror("Errore", str(e), parent=self))
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore caricamento celle duplicate corsia={corsia}: {ex}")
|
||||
messagebox.showerror("Errore", str(ex), parent=self)
|
||||
|
||||
self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res), _err)
|
||||
|
||||
@_log_call()
|
||||
def _fill_celle(self, parent_iid, res):
|
||||
"""Populate duplicated-cell nodes under an aisle node."""
|
||||
rows = _json_obj(res).get("rows", [])
|
||||
_log_dataset("multi_udc_celle_per_corsia", rows)
|
||||
if not rows:
|
||||
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", ""))
|
||||
return
|
||||
@@ -279,18 +507,27 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)):
|
||||
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", ""))
|
||||
|
||||
@_log_call()
|
||||
def _load_pallet_for_cella(self, parent_iid, idcella: int):
|
||||
"""Query pallet details for a duplicated cell."""
|
||||
_log_sql("multi_udc_pallet_in_cella", SQL_PALLET_IN_CELLA, {"idcella": idcella})
|
||||
|
||||
async def _q(db):
|
||||
return await db.query_json(SQL_PALLET_IN_CELLA, params={"idcella": idcella}, as_dict_rows=True)
|
||||
|
||||
self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res), lambda e: messagebox.showerror("Errore", str(e), parent=self))
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore caricamento pallet cella idcella={idcella}: {ex}")
|
||||
messagebox.showerror("Errore", str(ex), parent=self)
|
||||
|
||||
self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res), _err)
|
||||
|
||||
@_log_call()
|
||||
def _fill_pallet(self, parent_iid, res):
|
||||
"""Add pallet leaves under the selected cell node."""
|
||||
rows = _json_obj(res).get("rows", [])
|
||||
_log_dataset("multi_udc_pallet_in_cella", rows)
|
||||
if not rows:
|
||||
self.tree.insert(parent_iid, "end", text="(nessun pallet)", values=("", ""))
|
||||
self.tree.insert(parent_iid, "end", text="(nessun pallet)", values=("", "", ""))
|
||||
return
|
||||
parent_tags = self.tree.item(parent_iid, "tags") or ()
|
||||
corsia_tag = next((tag for tag in parent_tags if tag.startswith("corsia:")), None)
|
||||
@@ -303,29 +540,39 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
pallet = row.get("Pallet", "")
|
||||
desc = row.get("Descrizione", "")
|
||||
lotto = row.get("Lotto", "")
|
||||
causale = _build_diagnostic_note(row.get("IsShippedGhost", 0), row.get("IsMovedGhost", 0))
|
||||
leaf_id = f"pallet:{idcella_num}:{pallet}"
|
||||
if self.tree.exists(leaf_id):
|
||||
self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto))
|
||||
self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto, causale))
|
||||
continue
|
||||
self.tree.insert(
|
||||
parent_iid,
|
||||
"end",
|
||||
iid=leaf_id,
|
||||
text=str(pallet),
|
||||
values=(desc, lotto),
|
||||
values=(desc, lotto, causale),
|
||||
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
|
||||
)
|
||||
|
||||
@_log_call()
|
||||
def _load_riepilogo(self):
|
||||
"""Load the percentage summary by aisle."""
|
||||
_log_sql("multi_udc_riepilogo", SQL_RIEPILOGO_PERCENTUALI, {})
|
||||
|
||||
async def _q(db):
|
||||
return await db.query_json(SQL_RIEPILOGO_PERCENTUALI, as_dict_rows=True)
|
||||
|
||||
self.runner.run(_q(self.db), self._fill_riepilogo, lambda e: messagebox.showerror("Errore", str(e), parent=self))
|
||||
def _err(ex):
|
||||
_MODULE_LOGGER.exception(f"Errore caricamento riepilogo UDC fantasma: {ex}")
|
||||
messagebox.showerror("Errore", str(ex), parent=self)
|
||||
|
||||
self.runner.run(_q(self.db), self._fill_riepilogo, _err)
|
||||
|
||||
@_log_call()
|
||||
def _fill_riepilogo(self, res):
|
||||
"""Refresh the bottom summary table."""
|
||||
rows = _json_obj(res).get("rows", [])
|
||||
_log_dataset("multi_udc_riepilogo", rows)
|
||||
for item in self.sum_tbl.get_children():
|
||||
self.sum_tbl.delete(item)
|
||||
for row in rows:
|
||||
@@ -349,6 +596,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
for iid in self.tree.get_children(""):
|
||||
self.tree.item(iid, open=False)
|
||||
|
||||
@_log_call()
|
||||
def export_to_xlsx(self):
|
||||
"""Export both the detailed tree and the summary table to Excel."""
|
||||
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
|
||||
@@ -367,7 +615,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
ws_det = wb.active
|
||||
ws_det.title = "Dettaglio"
|
||||
ws_sum = wb.create_sheet("Riepilogo")
|
||||
det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto"]
|
||||
det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto", "Causale"]
|
||||
sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"]
|
||||
|
||||
def _hdr(ws, headers):
|
||||
@@ -391,8 +639,8 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
ubi = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "")
|
||||
idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "")
|
||||
pallet = self.tree.item(pallet_node, "text")
|
||||
desc, lotto = self.tree.item(pallet_node, "values")
|
||||
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto], start=1):
|
||||
desc, lotto, causale = self.tree.item(pallet_node, "values")
|
||||
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1):
|
||||
ws_det.cell(row=row_idx, column=j, value=value)
|
||||
row_idx += 1
|
||||
|
||||
@@ -420,12 +668,31 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
||||
wb.save(fname)
|
||||
messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self)
|
||||
except Exception as ex:
|
||||
_MODULE_LOGGER.exception(f"Errore esportazione UDC fantasma: {ex}")
|
||||
messagebox.showerror("Errore esportazione", str(ex), parent=self)
|
||||
|
||||
|
||||
def open_celle_multiple_window(root: tk.Tk, db_client, runner: AsyncRunner | None = None):
|
||||
def open_celle_multiple_window(
|
||||
root: tk.Tk,
|
||||
db_client,
|
||||
runner: AsyncRunner | None = None,
|
||||
session=None,
|
||||
):
|
||||
"""Create, focus and return the duplicated-cells explorer."""
|
||||
win = CelleMultipleWindow(root, db_client, runner=runner)
|
||||
key = "_celle_multiple_window_singleton"
|
||||
ex = getattr(root, key, None)
|
||||
if ex and ex.winfo_exists():
|
||||
try:
|
||||
ex.lift()
|
||||
ex.focus_force()
|
||||
return ex
|
||||
except Exception:
|
||||
pass
|
||||
win = CelleMultipleWindow(root, db_client, runner=runner, session=session)
|
||||
setattr(root, key, win)
|
||||
try:
|
||||
win.lift()
|
||||
win.focus_set()
|
||||
win.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
return win
|
||||
Reference in New Issue
Block a user