6 Commits

Author SHA1 Message Date
742f6a9fe9 Release storico UDC e picking list 2026-06-03 11:41:25 +02:00
4dabba8ce7 Milestone alpha3 2026-05-22 16:05:57 +02:00
d2a1f6a068 Milestone alpha2 2026-05-22 15:15:43 +02:00
a5e704c214 Milestone ultima alpha 2026-05-22 14:25:09 +02:00
8489cd7459 Checkpoint before more window sizing work 2026-05-10 16:29:49 +02:00
6ab42a2303 Checkpoint before ghost pallet cleanup workflow 2026-05-09 12:18:59 +02:00
66 changed files with 13055 additions and 1611 deletions

61
INSTALLAZIONE.md Normal file
View File

@@ -0,0 +1,61 @@
# Installazione Warehouse Python
## Requisiti
1. Windows 11 o Windows Server 2019.
2. Python 3.13 o versione compatibile installata e presente nel `PATH`.
3. Microsoft ODBC Driver per SQL Server installato sul PC.
- Driver consigliato: `ODBC Driver 17 for SQL Server`.
4. Accesso di rete al server SQL.
## Installazione dipendenze
Aprire PowerShell nella cartella del programma ed eseguire:
```powershell
python -m pip install -r requirements.txt
```
## Avvio backoffice
```powershell
python main.py
```
Al primo avvio, se manca `db_connection.json`, il programma mostra la finestra di configurazione database.
Per avviare senza finestra console:
```powershell
pythonw warehouse.pyw
```
## Avvio client barcode
```powershell
python barcode_client.py
```
Per avviare senza finestra console:
```powershell
pythonw barcode.pyw
```
## Patch DB Picking List
Per usare in parallelo C# legacy e Python, eseguire in SSMS:
```text
apply_python_parallel_pickinglist_patch.sql
```
Questa patch crea solo oggetti dedicati al Python con prefisso `py_` e non modifica gli oggetti legacy usati dal C#.
Per rimuovere gli oggetti Python dopo i test, eseguire:
```text
rollback_python_parallel_pickinglist_patch.sql
```
La vecchia patch `apply_plist_reservation_patch.sql` modifica invece gli oggetti legacy: usarla solo se si vuole sostituire temporaneamente il comportamento del C#.

View File

@@ -0,0 +1,821 @@
# Analisi bug prenotazione Picking List
## Obiettivo
Documentare in modo chiaro:
- il comportamento attuale della prenotazione picking list
- il bug osservato
- la causa tecnica del bug
- la correzione proposta lato stored procedure
Il documento serve come base di riferimento prima di modificare la logica SQL legacy.
---
## Contesto operativo
L'operatore, dal backoffice:
1. seleziona una picking list
2. preme `Prenota`
3. si aspetta che **una sola** picking list risulti prenotata
Il comportamento atteso del sistema è:
- una sola picking list prenotata alla volta
- quella picking list diventa la coda `F1` sul barcode
- le altre picking list restano non prenotate e alimentano la coda `F2`
---
## Comportamento attuale
Il comportamento effettivamente osservato è questo:
1. l'operatore seleziona una sola picking list
2. preme `Prenota`
3. risultano prenotate **due** picking list, non una sola
Questo comportamento è stato osservato:
- nell'applicazione Python
- anche nell'applicazione C#
Quindi il bug **non nasce nella UI Python**.
---
## Verifica di aderenza Python / C#
### Lato Python
La finestra [gestione_pickinglist.py](C:/devel/python/ware_house/gestione_pickinglist.py) chiama:
- [sp_xExePackingListPallet_async](C:/devel/python/ware_house/prenota_sprenota_sql.py)
che esegue direttamente la stored procedure SQL legacy:
- `dbo.sp_xExePackingListPallet`
### Lato C#
La finestra legacy [FRMagViewLayout.cs](C:/devel/seawall_decompiled/decompiled/SWMagViewLayout/FRMagViewLayout.cs) chiama anch'essa:
- `dbo.sp_xExePackingListPallet`
### Conclusione
Python e C# usano la **stessa** logica DB per la prenotazione.
Se il bug compare in entrambi, il problema è nella stored procedure o nel modello dati su cui essa si basa.
---
## Stato attuale della griglia picking list
La query summary della picking list in [gestione_pickinglist.py](C:/devel/python/ware_house/gestione_pickinglist.py) aggrega così:
- `GROUP BY Documento, CodNazione, NAZIONE, Stato`
- `MAX(IDStato) AS IDStato`
Questo significa:
- se anche **una sola riga** del documento ha `IDStato = 1`
- l'intera picking list appare come prenotata
Questa scelta è coerente con l'idea UI di “documento prenotato”, ma diventa problematica se la stored non lavora in modo esclusivo per documento.
---
## Stored procedure attuale
Stored coinvolta:
- [sp_xExePackingListPallet](C:/devel/python/ware_house/script.sql)
### Logica attuale
La stored:
1. legge da `XMag_ViewPackingList`
2. estrae le `Cella` associate al `Documento`
3. per ogni cella:
- se `IDStato = 0`, la mette a `1`
- altrimenti la rimette a `0`
Quindi la stored non prenota davvero una picking list come entità esclusiva.
Prenota invece:
- **le celle** toccate dal documento selezionato
### Conseguenza tecnica
Se due documenti condividono almeno una stessa cella:
- la prenotazione della prima list mette `IDStato = 1` sulla cella condivisa
- anche la seconda list, leggendo quella stessa cella, eredita `IDStato = 1`
- la griglia alta, usando `MAX(IDStato)`, la mostra come prenotata
---
## Evidenze raccolte
### Query 1 - celle del sottoinsieme di documenti osservati
Scopo:
- verificare quali celle sono coinvolte dai documenti sospetti
- controllare rapidamente se esistono sovrapposizioni evidenti
Query:
```sql
SELECT
Documento,
Cella,
COUNT(*) AS Righe
FROM dbo.XMag_ViewPackingList
WHERE Documento IN (<doc1>, <doc2>)
GROUP BY Documento, Cella
ORDER BY Cella, Documento;
```
Uso nel caso analizzato:
- ha permesso di vedere, per esempio, che documenti diversi insistono sulla stessa cella `8057`
### Query di controllo celle condivise
Risultato:
```text
1000 2
8057 2
```
Interpretazione:
- la cella `1000` è condivisa da 2 documenti
- la cella `8057` è condivisa da 2 documenti
### Query di dettaglio documenti / celle condivise
Risultato:
```text
135 1000 16 16
137 1000 2 1
133 8057 1 1
135 8057 1 1
```
Interpretazione:
- documento `135` e documento `137` condividono la cella `1000`
- documento `133` e documento `135` condividono la cella `8057`
Questa è una prova concreta del fatto che la prenotazione a livello cella può propagarsi a più documenti.
### Query 2 - ricerca globale delle celle condivise
Scopo:
- trovare quali celle sono condivise da più documenti
- identificare i punti strutturalmente più critici del dataset
Query:
```sql
SELECT
Cella,
COUNT(DISTINCT Documento) AS NumDocumenti
FROM dbo.XMag_ViewPackingList
GROUP BY Cella
HAVING COUNT(DISTINCT Documento) > 1
ORDER BY NumDocumenti DESC, Cella;
```
Risultato osservato:
```text
1000 2
8057 2
```
Interpretazione:
- la cella `1000` è condivisa da 2 documenti
- la cella `8057` è condivisa da 2 documenti
### Query 3 - dettaglio completo delle collisioni sulle celle condivise
Scopo:
- capire esattamente quali documenti condividono le celle critiche
- vedere quante righe e quanti pallet distinti insistono su quelle celle
Query:
```sql
SELECT
Documento,
Cella,
COUNT(*) AS Righe,
COUNT(DISTINCT Pallet) AS Pallet
FROM dbo.XMag_ViewPackingList
WHERE Cella IN (1000, 8057)
GROUP BY Documento, Cella
ORDER BY Cella, Documento;
```
Risultato osservato:
```text
135 1000 16 16
137 1000 2 1
133 8057 1 1
135 8057 1 1
```
Interpretazione:
- documento `135` e documento `137` condividono la cella `1000`
- documento `133` e documento `135` condividono la cella `8057`
Queste tre query, lette insieme, sono quelle che hanno permesso di evidenziare in modo oggettivo il bug.
---
## Ruolo delle celle convenzionali
Dalle verifiche fatte con il magazziniere:
- `1000` = `5E1.1`
- `9999` = `7G.1.1`
La cella `1000` è una locazione convenzionale delle UDC non scaffalate.
Questo rende il problema ancora più frequente, perché:
- più documenti possono convivere sulla stessa locazione convenzionale `1000`
- quindi la prenotazione per cella tende naturalmente a contaminare più picking list
---
## Diagnosi finale del bug
Il bug è dovuto alla combinazione di due scelte:
1. la stored `sp_xExePackingListPallet` lavora a livello **cella**
2. la UI mostra la prenotazione a livello **documento**
Quando esistono celle condivise tra documenti:
- la prenotazione di un documento produce `IDStato = 1` anche su righe lette da altri documenti
- quindi più picking list risultano prenotate
### Forma sintetica del bug
> Il sistema non sta prenotando una singola picking list in modo esclusivo.
> Sta prenotando le celle associate a quel documento, e la UI deduce da lì lo stato della picking list.
---
# Correzione proposta
## Obiettivo della correzione
Allineare il comportamento del sistema alla regola di business desiderata:
> una sola picking list prenotata per volta
Questo implica che il comando `Prenota` deve produrre uno stato esclusivo a livello documento.
---
## Principio della nuova logica
La stored non deve più comportarsi come un semplice toggle delle celle del documento selezionato.
Deve invece:
1. rimuovere la prenotazione da tutte le altre picking list
2. applicare la prenotazione solo al documento selezionato
In altre parole:
- **prima** si resetta lo stato prenotato degli altri documenti
- **poi** si prenota il documento scelto
---
## Comportamento desiderato dopo la correzione
### Caso `Prenota`
Quando l'operatore prenota il documento `D`:
1. se il documento `D` è già prenotato, non succede nulla
2. se il documento `D` non è prenotato:
- tutte le celle prenotate appartenenti ad altri documenti vengono riportate a `IDStato = 0`
- tutte le celle del documento `D` vengono portate a `IDStato = 1`
- il log della picking list resta aggiornato come oggi
Effetto atteso:
- solo il documento `D` risulta prenotato nella griglia
- solo il documento `D` alimenta la coda `F1`
- tutte le altre picking list alimentano `F2`
- premere `Prenota` più volte sulla stessa picking list già prenotata non deve produrre toggle né effetti collaterali
### Caso `S-prenota`
Quando l'operatore s-prenota il documento `D`:
1. se il documento `D` non è prenotato, non succede nulla
2. se il documento `D` è prenotato:
- le celle del documento `D` vengono riportate a `IDStato = 0`
- nessun altro documento viene automaticamente prenotato
Effetto atteso:
- nessuna picking list resta prenotata, salvo una nuova prenotazione esplicita
- premere `S-prenota` più volte sulla stessa picking list già non prenotata non deve produrre toggle né effetti collaterali
---
## Strategia tecnica consigliata
### Approccio minimo e prudente
La correzione più sicura, restando vicini al legacy, è mantenere una stored unica ma con una semantica esplicita guidata da un parametro azione.
Proposta:
- stored unica con parametro `@Azione`
- `P` = Prenota
- `S` = S-prenota
Logica:
1. determinare se il documento selezionato è già prenotato o no
2. se `@Azione = 'P'`
- se il documento è già prenotato: non fare nulla
- altrimenti:
- azzerare `IDStato = 1` sulle celle coinvolte da altri documenti prenotati
- impostare `IDStato = 1` sulle celle del documento selezionato
3. se `@Azione = 'S'`
- se il documento non è prenotato: non fare nulla
- altrimenti:
- riportare a `0` solo le celle del documento selezionato
Questa logica mantiene:
- la semantica esistente a livello cella
- ma aggiunge l'esclusività a livello documento
- e mantiene pulita la semantica distinta dei pulsanti `Prenota` e `S-prenota`
### Vantaggi
- modifica contenuta
- comportamento coerente con l'operatività reale
- nessuna necessità immediata di cambiare UI Python o C#
- comportamento idempotente dei pulsanti
---
## Rischi da considerare
### 1. Celle condivise tra documenti
La presenza di celle condivise resta un'anomalia logica del dominio.
Anche con la correzione proposta:
- se una cella appartiene contemporaneamente a più documenti nella vista
- la semantica “quale documento possiede davvero la prenotazione della cella” resta concettualmente debole
Tuttavia la correzione proposta risolve il bug visibile di doppia picking list prenotata.
### 2. Effetti sul flusso barcode
Il barcode usa `IDStato = 1` per la coda `F1`.
Quindi la nuova esclusività deve essere verificata attentamente su:
- proposta UDC in `F1`
- proposta UDC in `F2`
- avanzamento dopo prelievo
- ri-prenotazione automatica residua via `sp_ControllaPrenotazionePackingListPalletNew`
### 3. Interazione con `LogPackingList`
La stored attuale aggiorna il log del documento.
Questa parte va mantenuta, perché il barcode e la logica di ri-prenotazione residua si appoggiano su quel log.
---
## Criteri di accettazione della correzione
La correzione sarà considerata valida se, dopo `Prenota`:
1. una sola picking list risulta prenotata in griglia
2. `F1` propone solo UDC del documento prenotato
3. `F2` propone UDC delle altre picking list
4. il problema di doppia prenotazione non si ripresenta più nemmeno in presenza di celle condivise come `1000` o `8057`
E dopo `S-prenota`:
1. il documento selezionato torna non prenotato
2. `F1` non propone più quella picking list
---
## Decisione consigliata
La correzione lato stored procedure è consigliata e necessaria.
Motivo:
- il bug è reale
- il bug è condiviso da Python e C#
- il bug nasce dalla logica DB attuale
- la prenotazione esclusiva è coerente con il modello operativo del magazzino
---
## Stored procedure proposta
Di seguito una proposta documentale di stored unica con parametro `P` / `S`.
```sql
CREATE OR ALTER PROCEDURE [dbo].[sp_xExePackingListPallet]
@IDOperatore int,
@Documento varchar(8),
@Azione char(1), -- 'P' = Prenota, 'S' = S-prenota
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50);
DECLARE @DocumentoPrenotato bit = 0;
DECLARE @Description varchar(255) = '';
DECLARE @Message varchar(255) = '';
DECLARE @IDResult int = 0;
SELECT @Nominativo = [Login]
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Azione NOT IN ('P', 'S')
BEGIN
SET @RC = -10;
RETURN;
END;
IF OBJECT_ID('tempdb..#TargetCelle') IS NOT NULL DROP TABLE #TargetCelle;
CREATE TABLE #TargetCelle (
IDCella int PRIMARY KEY
);
INSERT INTO #TargetCelle (IDCella)
SELECT DISTINCT Cella
FROM dbo.XMag_ViewPackingList
WHERE Documento = @Documento
AND Cella IS NOT NULL;
IF NOT EXISTS (SELECT 1 FROM #TargetCelle)
BEGIN
SET @RC = -20;
RETURN;
END;
IF EXISTS (
SELECT 1
FROM dbo.XMag_ViewPackingList
WHERE Documento = @Documento
AND ISNULL(IDStato, 0) = 1
)
BEGIN
SET @DocumentoPrenotato = 1;
END;
IF @Azione = 'P'
BEGIN
-- Gia' prenotata: no-op
IF @DocumentoPrenotato = 1
RETURN;
-- Azzera prenotazioni di altri documenti
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE c.ID IN (
SELECT DISTINCT Cella
FROM dbo.XMag_ViewPackingList
WHERE ISNULL(IDStato, 0) = 1
AND Documento <> @Documento
AND Cella IS NOT NULL
);
-- Prenota solo il documento target
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN #TargetCelle t ON t.IDCella = c.ID;
SELECT TOP 1 @Description = NAZIONE
FROM dbo.XMag_ViewPackingList
WHERE Documento = @Documento;
EXEC dbo.sp_LogPackingList
@ID = 0,
@Code = @Documento,
@Description = @Description,
@Message = @Message OUTPUT,
@IDResult = @IDResult OUTPUT;
RETURN;
END;
IF @Azione = 'S'
BEGIN
-- Gia' non prenotata: no-op
IF @DocumentoPrenotato = 0
RETURN;
-- S-prenota solo il documento target
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN #TargetCelle t ON t.IDCella = c.ID;
RETURN;
END;
END;
```
### Note sulla stored proposta
- Non esiste più il toggle implicito.
- `Prenota` è idempotente:
- se la list è già prenotata, non cambia nulla.
- `S-prenota` è idempotente:
- se la list è già non prenotata, non cambia nulla.
- L'esclusività viene garantita solo nel ramo `P`.
- La logica continua a usare `IDStato` sulle celle, così il barcode legacy può continuare a usare `F1` / `F2` con la semantica attuale.
---
## Prossimo passo
Dopo approvazione di questo documento:
1. modificare `sp_xExePackingListPallet` o introdurre una nuova versione compatibile
2. testare il risultato con almeno 3 picking list attive
3. verificare il comportamento sul backoffice
4. verificare il comportamento sul barcode (`F1` / `F2`)
---
## Revisione dopo test reale su documenti 133 / 135 / 137
Dopo avere applicato la prima correzione proposta solo sulla stored, il bug **resta presente**:
- prenotando il documento `133`
- la picking list `135` continua a risultare prenotata
Questo comportamento è coerente con le evidenze raccolte:
- `133` e `135` condividono la cella `8057`
- `135` e `137` condividono la cella `1000`
### Conclusione aggiornata
La correzione che agisce **solo** su `Celle.IDStato` non è sufficiente.
Il motivo è strutturale:
1. la stored può anche rendere idempotente `Prenota` / `S-prenota`
2. ma se la UI e il barcode continuano a dedurre lo stato della prenotazione da `Celle.IDStato`
3. una cella condivisa continuerà a far risultare prenotati anche documenti diversi
Quindi il modello corretto deve spostare la **sorgente di verità** della prenotazione:
- **da:** stato della cella
- **a:** documento prenotato attivo
### Nuova architettura proposta
La soluzione corretta è questa:
1. introdurre una piccola tabella di stato, ad esempio `dbo.PickingListReservation`
2. salvare lì il **documento attualmente prenotato**
3. fare in modo che `XMag_ViewPackingList.IDStato` non legga più la prenotazione da `Celle.IDStato`
4. calcolare invece `IDStato` così:
- `1` se `Documento = documento prenotato attivo`
- `0` negli altri casi
In questo modo:
- il backoffice mostra prenotata una sola picking list
- il barcode continua a usare `F1` = `IDStato 1` e `F2` = `IDStato 0`
- le celle condivise `1000` / `8057` non contaminano più altri documenti
### Ruolo residuo di `Celle.IDStato`
`Celle.IDStato` può restare utile come supporto:
- per visualizzazioni legacy
- per evidenziare le celle del documento attivo
- per non rompere altre parti del sistema che si aspettano quel flag
Ma non deve più essere la **fonte primaria** della prenotazione documento.
### Stored definitiva da realizzare
La stored `sp_xExePackingListPallet` deve quindi:
1. mantenere il parametro `@Azione = 'P' | 'S'`
2. aggiornare la tabella `PickingListReservation`
3. opzionalmente riallineare anche `Celle.IDStato`
4. mantenere il log in `LogPackingList`
Semantica definitiva:
- `Prenota`
- se il documento è già quello attivo: nessun effetto
- altrimenti:
- il documento diventa lunico prenotato attivo
- tutte le altre picking list risultano non prenotate
- `S-prenota`
- se il documento non è quello attivo: nessun effetto
- se il documento è quello attivo:
- la prenotazione viene rimossa
- nessun altro documento viene acceso automaticamente
### Nota importante
Questa revisione **supera** la proposta precedente basata soltanto sul reset/set di `Celle.IDStato`.
La patch SQL definitiva deve quindi includere:
1. creazione della tabella `PickingListReservation`
2. `CREATE OR ALTER` della stored `sp_xExePackingListPallet`
3. `CREATE OR ALTER` della vista `XMag_ViewPackingList`
Questo è il primo assetto realmente coerente con il requisito di business:
> una sola picking list prenotata alla volta, anche in presenza di celle condivise.
---
## Evoluzione proposta: usare `ViewPackingListRestante` anche per la lista alta
### Situazione attuale
Nel legacy C# e nel Python attuale la distinzione è questa:
- la **griglia alta** legge la testata aggregata da `XMag_ViewPackingList`
- la **griglia bassa** legge il dettaglio da `ViewPackingListRestante`
La vista `ViewPackingListRestante` è definita come:
```sql
SELECT ...
FROM XMag_ViewPackingList
WHERE Cella <> 9999
```
Quindi:
- `XMag_ViewPackingList` contiene anche le UDC già finite nella locazione convenzionale `9999 = 7G.1.1`
- `ViewPackingListRestante` mostra invece solo le UDC ancora residue
### Conseguenza pratica
Allistante `t = 0` della prenotazione:
- se la picking list non è ancora stata lavorata, `XMag_ViewPackingList` e `ViewPackingListRestante` mostrano di fatto lo stesso insieme di UDC
- le UDC non scaffalate **restano presenti** in entrambe, perché stanno nella cella convenzionale `1000 = 5E1.1`
Dopo che il magazziniere ha iniziato i prelievi:
- le UDC già spedite/prelevate finiscono in `9999 = 7G.1.1`
- quindi spariscono da `ViewPackingListRestante`
- ma restano ancora visibili in `XMag_ViewPackingList`
### Limite del comportamento attuale
Con la summary alta basata su `XMag_ViewPackingList`:
- il comando `Ricarica` può continuare a mostrare picking list già lavorate in parte
- una picking list completamente esaurita può restare visibile nella lista alta
- il contenuto della lista alta non coincide più con il “residuo operativo reale”
In altre parole:
- la griglia bassa mostra il residuo
- la griglia alta mostra ancora lo storico completo
### Modifica proposta
La proposta è:
- usare `ViewPackingListRestante` anche come sorgente della **lista alta aggregata**
quindi sostituire, nella query summary di [gestione_pickinglist.py](C:/devel/python/ware_house/gestione_pickinglist.py):
- `FROM dbo.XMag_ViewPackingList`
con:
- `FROM dbo.ViewPackingListRestante`
mantenendo invariati:
- `GROUP BY Documento, CodNazione, NAZIONE, Stato`
- `COUNT(DISTINCT Pallet)`
- `COUNT(DISTINCT Lotto)`
- `COUNT(DISTINCT Articolo)`
- `SUM(Qta)`
- `MIN(Ordinamento)`
- `MAX(IDStato)`
### Effetti attesi
Con questa modifica:
1. allinizio una picking list nuova continua a comparire normalmente
2. durante il lavoro il pulsante `Ricarica` mostra solo ciò che resta davvero da prelevare
3. le UDC già finite in `7G.1.1` non inquinano più i conteggi della testata
4. una picking list completamente esaurita esce naturalmente dalla lista visibile
5. le UDC non scaffalate continuano a comparire correttamente, perché stanno in `1000 = 5E1.1`, non in `9999`
### Chiusura automatica della prenotazione
Per mantenere coerenti:
- griglia alta
- stato della prenotazione
- comportamento del barcode `F1`
la prenotazione attiva non deve soltanto sparire dalla griglia: deve essere anche **azzerata logicamente** quando il residuo arriva a zero.
La patch SQL definitiva quindi deve fare anche questo:
- dopo ogni movimento, controllare se il documento prenotato ha ancora righe in `ViewPackingListRestante`
- se non ne ha più:
- la prenotazione attiva va portata a `NULL` / `0`
- le eventuali celle residue marcate `IDStato = 1` vanno riportate a `0`
Effetto operativo:
- plist piena allinizio
- plist via via ridotta durante i prelievi
- plist che, allultima UDC, sparisce dalla griglia alta
- e nello stesso momento cessa anche di essere la coda `F1`
### Impatto funzionale
Questa modifica:
- **non è** una replica pedissequa del legacy C#
- ma è un miglioramento operativo coerente con il comportamento atteso dal magazzino
In particolare rende la lista alta:
- più coerente con il significato di “picking list ancora da esaurire”
- allineata al contenuto della griglia bassa
- più utile nei refresh successivi durante il lavoro reale
### Impatto tecnico
Il cambiamento richiesto è piccolo lato Python:
- aggiornare la query summary in [gestione_pickinglist.py](C:/devel/python/ware_house/gestione_pickinglist.py)
Non richiede, in prima battuta:
- modifiche alla stored di prenotazione
- modifiche al barcode
- modifiche a `ViewPackingListRestante`
### Decisione progettuale
Questa modifica va considerata come una scelta esplicita di evoluzione:
- **se vogliamo restare 1:1 col C#**, la lista alta resta su `XMag_ViewPackingList`
- **se vogliamo rendere il modulo più aderente al residuo operativo reale**, conviene passare a `ViewPackingListRestante`

View File

@@ -0,0 +1,182 @@
/*
Patch online - form storiche Python
Contenuto:
- crea/aggiorna le viste Python-only usate dalla form "Storico Picking List"
- non modifica stored procedure legacy C#
- non modifica le viste legacy C#
- non crea oggetti per "Storico movimenti UDC", perche' quella form legge in sola lettura:
dbo.MagazziniPallet, dbo.Celle, dbo.XMag_GiacenzaPalletPlistChiuse
Oggetti creati/aggiornati:
- dbo.py_vPreparaPackingListSAMA1
- dbo.py_vPreparaPackingList
- dbo.py_XMag_ViewPackingListStorico
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.PyPickingListReservation', N'U') IS NULL
BEGIN
CREATE TABLE [dbo].[PyPickingListReservation](
[ID] [tinyint] NOT NULL,
[Documento] [varchar](8) NULL,
[IDOperatore] [int] NULL,
[ModUtente] [varchar](50) NULL,
[ModDataOra] [datetime] NULL,
CONSTRAINT [PK_PyPickingListReservation] PRIMARY KEY CLUSTERED ([ID] ASC),
CONSTRAINT [CK_PyPickingListReservation_Singleton] CHECK ([ID] = 1)
) ON [PRIMARY];
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.PyPickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PyPickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END
GO
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingListSAMA1]
AS
SELECT
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6) AS UDC,
SUM(SAMA1.dbo.LOTSER.QTTGIAI) AS Qta,
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO AS StatoDocumento,
SAMA1.dbo.NAZIONI.CODICE AS Expr1,
SAMA1.dbo.MTRASP.CODICE + ' ' + SAMA1.dbo.NAZIONI.DESCR AS NAZIONE,
SAMA1.dbo.BAMTES.IDMTRASP
FROM SAMA1.dbo.NAZIONI
INNER JOIN SAMA1.dbo.BAMTES
ON SAMA1.dbo.NAZIONI.ID = SAMA1.dbo.BAMTES.IDNAZDEST
RIGHT OUTER JOIN SAMA1.dbo.LOTTIBF
LEFT OUTER JOIN SAMA1.dbo.LOTSER
ON SAMA1.dbo.LOTTIBF.IDLOTSER = SAMA1.dbo.LOTSER.ID
LEFT OUTER JOIN SAMA1.dbo.ARTICO
ON SAMA1.dbo.LOTSER.IDARTICO = SAMA1.dbo.ARTICO.ID
LEFT OUTER JOIN SAMA1.dbo.FATRIG
ON SAMA1.dbo.LOTTIBF.IDFATRIG = SAMA1.dbo.FATRIG.ID
ON SAMA1.dbo.BAMTES.ID = SAMA1.dbo.FATRIG.IDBAM
LEFT OUTER JOIN SAMA1.sam.EXTUC
ON LEFT(SAMA1.dbo.LOTSER.NUMSER, 6) = SAMA1.sam.EXTUC.CODICE
LEFT OUTER JOIN SAMA1.dbo.MTRASP
ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID
WHERE
SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE())
AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D')
AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000'
GROUP BY
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6),
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO,
SAMA1.dbo.NAZIONI.CODICE,
SAMA1.dbo.NAZIONI.DESCR,
SAMA1.dbo.BAMTES.IDMTRASP,
SAMA1.dbo.MTRASP.CODICE;
GO
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingList]
AS
SELECT
NUMLOT,
CODICE,
DESCR,
UDC,
Qta,
NUMDOC,
DATDOC,
ID,
DESCRDEST,
StatoDocumento,
Expr1,
NAZIONE
FROM dbo.py_vPreparaPackingListSAMA1
WHERE NUMLOT <> '';
GO
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingListStorico]
AS
SELECT TOP (100000)
prep.UDC AS Pallet,
prep.NUMLOT AS Lotto,
prep.CODICE AS Articolo,
prep.DESCR AS Descrizione,
prep.Qta,
prep.NUMDOC AS Documento,
prep.DATDOC AS DataDocumento,
prep.StatoDocumento,
prep.Expr1 AS CodNazione,
prep.NAZIONE,
CASE
WHEN prep.Expr1 = 'DE' THEN 10
WHEN prep.Expr1 = 'TH' THEN
CASE WHEN SUBSTRING(prep.DESCRDEST, 1, 2) = 'NA' THEN 11 ELSE 13 END
WHEN prep.Expr1 = 'MEX' THEN 12
ELSE 4
END AS Stato,
ISNULL(g.NumeroPallet, 0) AS PalletCella,
ISNULL(g.IDMagazzino, 1) AS Magazzino,
ISNULL(g.IDArea, 5) AS Area,
ISNULL(g.IDCella, 1000) AS Cella,
ISNULL(c.Ordinamento, 99999) AS Ordinamento,
ISNULL(c.Corsia + ' - ' + c.Colonna + ' - ' + c.Fila, 'Non scaff.') AS Ubicazione,
SUBSTRING(prep.DESCRDEST, 1, 2) AS DEST,
CASE
WHEN MAX(CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(prep.NUMDOC AS varchar(8))
THEN 1 ELSE 0 END) = 1
THEN 1
ELSE 0
END AS IDStato
FROM dbo.Celle c
INNER JOIN dbo.XMag_GiacenzaPallet g
ON c.ID = g.IDCella
RIGHT OUTER JOIN dbo.py_vPreparaPackingList prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
LEFT JOIN dbo.PyPickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
prep.StatoDocumento,
prep.NUMLOT,
prep.CODICE,
prep.DESCR,
prep.Qta,
g.NumeroPallet,
g.IDMagazzino,
g.IDArea,
g.IDCella,
c.Ordinamento,
c.Corsia,
c.Colonna,
c.Fila,
prep.DESCRDEST;
GO
PRINT 'Patch form storiche Python applicata.';
PRINT 'Creato/aggiornato dbo.py_vPreparaPackingListSAMA1';
PRINT 'Creato/aggiornato dbo.py_vPreparaPackingList';
PRINT 'Creato/aggiornato dbo.py_XMag_ViewPackingListStorico';
GO

View File

@@ -0,0 +1,408 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/*
Patch deploy per i test sul campo:
- salva le definizioni correnti degli oggetti legacy coinvolti
- applica la nuova logica di prenotazione plist a livello documento
Oggetti salvati:
- dbo.sp_xExePackingListPallet
- dbo.XMag_ViewPackingList
- dbo.sp_xExePackingListPalletPrenota
- dbo.sp_ControllaPrenotazionePackingListPalletNew
Oggetti creati/usati dalla patch:
- dbo.WarehouseObjectBackup
- dbo.PickingListReservation
Nota:
- la tabella dbo.PickingListReservation può restare anche dopo il rollback
perché il vecchio codice non la usa.
*/
DECLARE @BackupTag varchar(64) = 'plist_reservation_fix_alpha2';
IF OBJECT_ID(N'dbo.WarehouseObjectBackup', N'U') IS NULL
BEGIN
CREATE TABLE dbo.WarehouseObjectBackup (
BackupTag varchar(64) NOT NULL,
ObjectName sysname NOT NULL,
ObjectType varchar(16) NOT NULL,
Definition nvarchar(max) NOT NULL,
SavedAt datetime NOT NULL CONSTRAINT DF_WarehouseObjectBackup_SavedAt DEFAULT(GETDATE()),
CONSTRAINT PK_WarehouseObjectBackup PRIMARY KEY CLUSTERED (BackupTag, ObjectName)
);
END;
GO
DECLARE @BackupTag varchar(64) = 'plist_reservation_fix_alpha2';
;WITH Targets AS (
SELECT N'dbo.sp_xExePackingListPallet' AS ObjectName, 'PROCEDURE' AS ObjectType
UNION ALL SELECT N'dbo.XMag_ViewPackingList', 'VIEW'
UNION ALL SELECT N'dbo.sp_xExePackingListPalletPrenota', 'PROCEDURE'
UNION ALL SELECT N'dbo.sp_ControllaPrenotazionePackingListPalletNew', 'PROCEDURE'
)
INSERT INTO dbo.WarehouseObjectBackup (BackupTag, ObjectName, ObjectType, Definition)
SELECT
@BackupTag,
t.ObjectName,
t.ObjectType,
sm.[definition]
FROM Targets t
INNER JOIN sys.sql_modules sm
ON sm.object_id = OBJECT_ID(t.ObjectName)
WHERE NOT EXISTS (
SELECT 1
FROM dbo.WarehouseObjectBackup b
WHERE b.BackupTag = @BackupTag
AND b.ObjectName = t.ObjectName
);
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/* ============================================================
Patch prenotazione Picking List a livello documento
Obiettivo:
- rendere esclusiva la prenotazione di una sola picking list
- mantenere la stored con lo stesso nome:
dbo.sp_xExePackingListPallet
- introdurre una sorgente di verità per documento prenotato:
dbo.PickingListReservation
- far leggere XMag_ViewPackingList.IDStato da tale stato
e non più soltanto da Celle.IDStato
============================================================ */
IF OBJECT_ID(N'dbo.PickingListReservation', N'U') IS NULL
BEGIN
CREATE TABLE [dbo].[PickingListReservation](
[ID] [tinyint] NOT NULL,
[Documento] [varchar](8) NULL,
[IDOperatore] [int] NULL,
[ModUtente] [varchar](50) NULL,
[ModDataOra] [datetime] NULL,
CONSTRAINT [PK_PickingListReservation] PRIMARY KEY CLUSTERED ([ID] ASC),
CONSTRAINT [CK_PickingListReservation_Singleton] CHECK ([ID] = 1)
) ON [PRIMARY];
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.PickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_xExePackingListPallet]
@IDOperatore int,
@Documento varchar(8),
@Azione char(1) = 'P',
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
DECLARE @DocumentoAttivo varchar(8) = NULL;
DECLARE @Description varchar(255) = '';
DECLARE @Message varchar(255) = '';
DECLARE @IDResult int = 0;
DECLARE @ID int = 0;
SELECT @Nominativo = [Login]
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
IF @Azione NOT IN ('P', 'S')
BEGIN
SET @RC = -10;
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
SET @RC = -20;
RETURN;
END;
IF NOT EXISTS (SELECT 1 FROM dbo.PickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END;
SELECT @DocumentoAttivo = NULLIF(LTRIM(RTRIM(Documento)), '')
FROM dbo.PickingListReservation
WHERE ID = 1;
DECLARE @TargetCelle TABLE (
IDCella int PRIMARY KEY
);
INSERT INTO @TargetCelle (IDCella)
SELECT DISTINCT Cella
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL;
IF @Azione = 'P'
BEGIN
IF @DocumentoAttivo = @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE ISNULL(c.IDStato, 0) <> 0;
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PickingListReservation
SET Documento = @Documento,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
SELECT TOP 1 @Description = NAZIONE
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento;
EXEC dbo.sp_LogPackingList
@ID = @ID,
@Code = @Documento,
@Description = @Description,
@Message = @Message OUTPUT,
@IDResult = @IDResult OUTPUT;
RETURN;
END;
IF @Azione = 'S'
BEGIN
IF ISNULL(@DocumentoAttivo, '') <> @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
END;
GO
CREATE OR ALTER VIEW [dbo].[XMag_ViewPackingList]
AS
WITH Base AS (
SELECT
dbo.vPreparaPackingList.UDC AS Pallet,
dbo.vPreparaPackingList.NUMLOT AS Lotto,
dbo.vPreparaPackingList.CODICE AS Articolo,
dbo.vPreparaPackingList.DESCR AS Descrizione,
dbo.vPreparaPackingList.Qta,
dbo.vPreparaPackingList.NUMDOC AS Documento,
dbo.vPreparaPackingList.Expr1 AS CodNazione,
dbo.vPreparaPackingList.NAZIONE,
CASE
WHEN Expr1 = 'DE' THEN 10
WHEN Expr1 = 'TH' THEN CASE WHEN SUBSTRING(dbo.vPreparaPackingList.DESCRDEST, 1, 2) = 'NA' THEN 11 ELSE 13 END
WHEN Expr1 = 'MEX' THEN 12
ELSE 4
END AS Stato,
ISNULL(dbo.XMag_GiacenzaPallet.NumeroPallet, 0) AS PalletCella,
ISNULL(dbo.XMag_GiacenzaPallet.IDMagazzino, 1) AS Magazzino,
ISNULL(dbo.XMag_GiacenzaPallet.IDArea, 5) AS Area,
ISNULL(dbo.XMag_GiacenzaPallet.IDCella, 1000) AS Cella,
ISNULL(dbo.Celle.Ordinamento, 99999) AS Ordinamento,
ISNULL(dbo.Celle.Corsia + ' - ' + dbo.Celle.Colonna + ' - ' + dbo.Celle.Fila, 'Non scaff.') AS Ubicazione,
SUBSTRING(dbo.vPreparaPackingList.DESCRDEST, 1, 2) AS DEST
FROM dbo.Celle
INNER JOIN dbo.XMag_GiacenzaPallet
ON dbo.Celle.ID = dbo.XMag_GiacenzaPallet.IDCella
RIGHT OUTER JOIN dbo.vPreparaPackingList
ON dbo.XMag_GiacenzaPallet.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = dbo.vPreparaPackingList.UDC
GROUP BY
dbo.vPreparaPackingList.Expr1,
dbo.vPreparaPackingList.NAZIONE,
dbo.vPreparaPackingList.UDC,
dbo.vPreparaPackingList.NUMDOC,
dbo.vPreparaPackingList.NUMLOT,
dbo.vPreparaPackingList.CODICE,
dbo.vPreparaPackingList.DESCR,
dbo.vPreparaPackingList.Qta,
dbo.XMag_GiacenzaPallet.NumeroPallet,
dbo.XMag_GiacenzaPallet.IDMagazzino,
dbo.XMag_GiacenzaPallet.IDArea,
dbo.XMag_GiacenzaPallet.IDCella,
dbo.Celle.Ordinamento,
dbo.Celle.Corsia,
dbo.Celle.Colonna,
dbo.Celle.Fila,
dbo.vPreparaPackingList.DESCRDEST
)
SELECT TOP 10000
Base.Pallet,
Base.Lotto,
Base.Articolo,
Base.Descrizione,
Base.Qta,
Base.Documento,
Base.CodNazione,
Base.NAZIONE,
Base.Stato,
Base.PalletCella,
Base.Magazzino,
Base.Area,
Base.Cella,
Base.Ordinamento,
Base.Ubicazione,
Base.DEST,
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(Base.Documento AS varchar(8))
THEN 1
ELSE 0
END AS IDStato
FROM Base
LEFT JOIN dbo.PickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
ORDER BY
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(Base.Documento AS varchar(8))
THEN 1
ELSE 0
END DESC,
Base.Documento,
Base.Ordinamento;
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_xExePackingListPalletPrenota]
@IDOperatore int,
@Documento varchar(8),
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
SELECT @Nominativo = LOGIN
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE c.ID IN (
SELECT DISTINCT Cella
FROM dbo.ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL
);
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_ControllaPrenotazionePackingListPalletNew]
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Documento varchar(8) = NULL;
DECLARE @IDOperatore int = 0;
DECLARE @Nominativo varchar(50) = 'SYSTEM';
DECLARE @RC int = 0;
SELECT
@Documento = NULLIF(LTRIM(RTRIM(Documento)), ''),
@IDOperatore = ISNULL(IDOperatore, 0),
@Nominativo = ISNULL(NULLIF(LTRIM(RTRIM(ModUtente)), ''), 'SYSTEM')
FROM dbo.PickingListReservation
WHERE ID = 1;
IF ISNULL(@Documento, '') = ''
RETURN;
IF NOT EXISTS (
SELECT 1
FROM dbo.ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
UPDATE dbo.PickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
IF @IDOperatore <= 0
BEGIN
SELECT TOP 1 @IDOperatore = ID
FROM dbo.Operatori
WHERE LOGIN = @Nominativo;
END;
EXEC dbo.sp_xExePackingListPalletPrenota
@IDOperatore = @IDOperatore,
@Documento = @Documento,
@RC = @RC OUTPUT;
END;
GO

View File

@@ -0,0 +1,328 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/*
Patch parallela per usare C# e Python contemporaneamente.
Questa patch NON modifica gli oggetti legacy usati dal C#.
Il C# continua a usare:
- dbo.XMag_ViewPackingList
- dbo.ViewPackingListRestante
- dbo.sp_xExePackingListPallet
- dbo.sp_xExePackingListPalletPrenota
- dbo.sp_ControllaPrenotazionePackingListPalletNew
Il Python usa invece:
- dbo.py_XMag_ViewPackingList
- dbo.py_ViewPackingListRestante
- dbo.py_sp_xExePackingListPallet
- dbo.py_sp_xExePackingListPalletPrenota
- dbo.py_sp_ControllaPrenotazionePackingListPalletNew
- dbo.PyPickingListReservation
*/
IF OBJECT_ID(N'dbo.PyPickingListReservation', N'U') IS NULL
BEGIN
CREATE TABLE [dbo].[PyPickingListReservation](
[ID] [tinyint] NOT NULL,
[Documento] [varchar](8) NULL,
[IDOperatore] [int] NULL,
[ModUtente] [varchar](50) NULL,
[ModDataOra] [datetime] NULL,
CONSTRAINT [PK_PyPickingListReservation] PRIMARY KEY CLUSTERED ([ID] ASC),
CONSTRAINT [CK_PyPickingListReservation_Singleton] CHECK ([ID] = 1)
) ON [PRIMARY];
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.PyPickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PyPickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END
GO
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingList]
AS
SELECT TOP 10000
legacy.Pallet,
legacy.Lotto,
legacy.Articolo,
legacy.Descrizione,
legacy.Qta,
legacy.Documento,
legacy.CodNazione,
legacy.NAZIONE,
legacy.Stato,
legacy.PalletCella,
legacy.Magazzino,
legacy.Area,
legacy.Cella,
legacy.Ordinamento,
legacy.Ubicazione,
legacy.DEST,
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(legacy.Documento AS varchar(8))
THEN 1
ELSE 0
END AS IDStato
FROM dbo.XMag_ViewPackingList legacy
LEFT JOIN dbo.PyPickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
ORDER BY
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(legacy.Documento AS varchar(8))
THEN 1
ELSE 0
END DESC,
legacy.Documento,
legacy.Ordinamento;
GO
CREATE OR ALTER VIEW [dbo].[py_ViewPackingListRestante]
AS
SELECT
Pallet,
Lotto,
Articolo,
Descrizione,
Qta,
Documento,
CodNazione,
NAZIONE,
Stato,
PalletCella,
Magazzino,
Area,
Cella,
Ordinamento,
Ubicazione,
DEST,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Cella <> 9999;
GO
CREATE OR ALTER PROCEDURE [dbo].[py_sp_xExePackingListPallet]
@IDOperatore int,
@Documento varchar(8),
@Azione char(1) = 'P',
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
DECLARE @DocumentoAttivo varchar(8) = NULL;
DECLARE @Description varchar(255) = '';
DECLARE @Message varchar(255) = '';
DECLARE @IDResult int = 0;
DECLARE @ID int = 0;
SELECT @Nominativo = [Login]
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
IF @Azione NOT IN ('P', 'S')
BEGIN
SET @RC = -10;
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
SET @RC = -20;
RETURN;
END;
IF NOT EXISTS (SELECT 1 FROM dbo.PyPickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PyPickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END;
SELECT @DocumentoAttivo = NULLIF(LTRIM(RTRIM(Documento)), '')
FROM dbo.PyPickingListReservation
WHERE ID = 1;
DECLARE @TargetCelle TABLE (
IDCella int PRIMARY KEY
);
INSERT INTO @TargetCelle (IDCella)
SELECT DISTINCT Cella
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL;
IF @Azione = 'P'
BEGIN
IF @DocumentoAttivo = @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE ISNULL(c.IDStato, 0) <> 0;
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PyPickingListReservation
SET Documento = @Documento,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
SELECT TOP 1 @Description = NAZIONE
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento;
EXEC dbo.sp_LogPackingList
@ID = @ID,
@Code = @Documento,
@Description = @Description,
@Message = @Message OUTPUT,
@IDResult = @IDResult OUTPUT;
RETURN;
END;
IF @Azione = 'S'
BEGIN
IF ISNULL(@DocumentoAttivo, '') <> @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PyPickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[py_sp_xExePackingListPalletPrenota]
@IDOperatore int,
@Documento varchar(8),
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
SELECT @Nominativo = LOGIN
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE c.ID IN (
SELECT DISTINCT Cella
FROM dbo.py_ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL
);
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[py_sp_ControllaPrenotazionePackingListPalletNew]
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Documento varchar(8) = NULL;
DECLARE @IDOperatore int = 0;
DECLARE @Nominativo varchar(50) = 'SYSTEM';
DECLARE @RC int = 0;
SELECT
@Documento = NULLIF(LTRIM(RTRIM(Documento)), ''),
@IDOperatore = ISNULL(IDOperatore, 0),
@Nominativo = ISNULL(NULLIF(LTRIM(RTRIM(ModUtente)), ''), 'SYSTEM')
FROM dbo.PyPickingListReservation
WHERE ID = 1;
IF ISNULL(@Documento, '') = ''
RETURN;
IF NOT EXISTS (
SELECT 1
FROM dbo.py_ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
UPDATE dbo.PyPickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
IF @IDOperatore <= 0
BEGIN
SELECT TOP 1 @IDOperatore = ID
FROM dbo.Operatori
WHERE LOGIN = @Nominativo;
END;
EXEC dbo.py_sp_xExePackingListPalletPrenota
@IDOperatore = @IDOperatore,
@Documento = @Documento,
@RC = @RC OUTPUT;
END;
GO

View File

@@ -0,0 +1,158 @@
/*
Patch SQL - viste storiche picking list per ramo Python
Obiettivo:
- creare un ramo di lettura storico completamente separato dalle viste C#
- non modificare dbo.vPreparaPackingListSAMA1
- non modificare dbo.vPreparaPackingList
- non modificare dbo.XMag_ViewPackingList
- non modificare stored procedure C#
La form Python "Storico Picking List" legge da dbo.py_XMag_ViewPackingListStorico.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingListSAMA1]
AS
SELECT
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6) AS UDC,
SUM(SAMA1.dbo.LOTSER.QTTGIAI) AS Qta,
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO AS StatoDocumento,
SAMA1.dbo.NAZIONI.CODICE AS Expr1,
SAMA1.dbo.MTRASP.CODICE + ' ' + SAMA1.dbo.NAZIONI.DESCR AS NAZIONE,
SAMA1.dbo.BAMTES.IDMTRASP
FROM SAMA1.dbo.NAZIONI
INNER JOIN SAMA1.dbo.BAMTES
ON SAMA1.dbo.NAZIONI.ID = SAMA1.dbo.BAMTES.IDNAZDEST
RIGHT OUTER JOIN SAMA1.dbo.LOTTIBF
LEFT OUTER JOIN SAMA1.dbo.LOTSER
ON SAMA1.dbo.LOTTIBF.IDLOTSER = SAMA1.dbo.LOTSER.ID
LEFT OUTER JOIN SAMA1.dbo.ARTICO
ON SAMA1.dbo.LOTSER.IDARTICO = SAMA1.dbo.ARTICO.ID
LEFT OUTER JOIN SAMA1.dbo.FATRIG
ON SAMA1.dbo.LOTTIBF.IDFATRIG = SAMA1.dbo.FATRIG.ID
ON SAMA1.dbo.BAMTES.ID = SAMA1.dbo.FATRIG.IDBAM
LEFT OUTER JOIN SAMA1.sam.EXTUC
ON LEFT(SAMA1.dbo.LOTSER.NUMSER, 6) = SAMA1.sam.EXTUC.CODICE
LEFT OUTER JOIN SAMA1.dbo.MTRASP
ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID
WHERE
SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE())
AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D')
AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000'
GROUP BY
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6),
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO,
SAMA1.dbo.NAZIONI.CODICE,
SAMA1.dbo.NAZIONI.DESCR,
SAMA1.dbo.BAMTES.IDMTRASP,
SAMA1.dbo.MTRASP.CODICE;
GO
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingList]
AS
SELECT
NUMLOT,
CODICE,
DESCR,
UDC,
Qta,
NUMDOC,
DATDOC,
ID,
DESCRDEST,
StatoDocumento,
Expr1,
NAZIONE
FROM dbo.py_vPreparaPackingListSAMA1
WHERE NUMLOT <> '';
GO
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingListStorico]
AS
SELECT TOP (100000)
prep.UDC AS Pallet,
prep.NUMLOT AS Lotto,
prep.CODICE AS Articolo,
prep.DESCR AS Descrizione,
prep.Qta,
prep.NUMDOC AS Documento,
prep.DATDOC AS DataDocumento,
prep.StatoDocumento,
prep.Expr1 AS CodNazione,
prep.NAZIONE,
CASE
WHEN prep.Expr1 = 'DE' THEN 10
WHEN prep.Expr1 = 'TH' THEN
CASE WHEN SUBSTRING(prep.DESCRDEST, 1, 2) = 'NA' THEN 11 ELSE 13 END
WHEN prep.Expr1 = 'MEX' THEN 12
ELSE 4
END AS Stato,
ISNULL(g.NumeroPallet, 0) AS PalletCella,
ISNULL(g.IDMagazzino, 1) AS Magazzino,
ISNULL(g.IDArea, 5) AS Area,
ISNULL(g.IDCella, 1000) AS Cella,
ISNULL(c.Ordinamento, 99999) AS Ordinamento,
ISNULL(c.Corsia + ' - ' + c.Colonna + ' - ' + c.Fila, 'Non scaff.') AS Ubicazione,
SUBSTRING(prep.DESCRDEST, 1, 2) AS DEST,
CASE
WHEN MAX(CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(prep.NUMDOC AS varchar(8))
THEN 1 ELSE 0 END) = 1
THEN 1
ELSE 0
END AS IDStato
FROM dbo.Celle c
INNER JOIN dbo.XMag_GiacenzaPallet g
ON c.ID = g.IDCella
RIGHT OUTER JOIN dbo.py_vPreparaPackingList prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
LEFT JOIN dbo.PyPickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
prep.StatoDocumento,
prep.NUMLOT,
prep.CODICE,
prep.DESCR,
prep.Qta,
g.NumeroPallet,
g.IDMagazzino,
g.IDArea,
g.IDCella,
c.Ordinamento,
c.Corsia,
c.Colonna,
c.Fila,
prep.DESCRDEST;
GO
PRINT 'Viste storiche Python create/aggiornate:';
PRINT ' - dbo.py_vPreparaPackingListSAMA1';
PRINT ' - dbo.py_vPreparaPackingList';
PRINT ' - dbo.py_XMag_ViewPackingListStorico';
GO

View File

@@ -7,6 +7,7 @@ singleton so every module can schedule work on the same async runtime.
import asyncio import asyncio
import threading import threading
import contextlib
class _LoopHolder: class _LoopHolder:
@@ -15,6 +16,7 @@ class _LoopHolder:
def __init__(self): def __init__(self):
self.loop = None self.loop = None
self.thread = None self.thread = None
self.closing = False
_GLOBAL = _LoopHolder() _GLOBAL = _LoopHolder()
@@ -26,7 +28,7 @@ def get_global_loop() -> asyncio.AbstractEventLoop:
The loop is created lazily the first time the function is called and kept The loop is created lazily the first time the function is called and kept
alive for the lifetime of the application. alive for the lifetime of the application.
""" """
if _GLOBAL.loop: if _GLOBAL.loop and not _GLOBAL.closing:
return _GLOBAL.loop return _GLOBAL.loop
ready = threading.Event() ready = threading.Event()
@@ -35,7 +37,23 @@ def get_global_loop() -> asyncio.AbstractEventLoop:
_GLOBAL.loop = asyncio.new_event_loop() _GLOBAL.loop = asyncio.new_event_loop()
asyncio.set_event_loop(_GLOBAL.loop) asyncio.set_event_loop(_GLOBAL.loop)
ready.set() ready.set()
try:
_GLOBAL.loop.run_forever() _GLOBAL.loop.run_forever()
finally:
loop = _GLOBAL.loop
if loop is not None:
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
for task in pending:
task.cancel()
if pending:
with contextlib.suppress(Exception):
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
with contextlib.suppress(Exception):
loop.run_until_complete(loop.shutdown_asyncgens())
with contextlib.suppress(Exception):
loop.run_until_complete(loop.shutdown_default_executor())
with contextlib.suppress(Exception):
loop.close()
_GLOBAL.thread = threading.Thread(target=_run, name="asyncio-bg-loop", daemon=True) _GLOBAL.thread = threading.Thread(target=_run, name="asyncio-bg-loop", daemon=True)
_GLOBAL.thread.start() _GLOBAL.thread.start()
@@ -46,7 +64,9 @@ def get_global_loop() -> asyncio.AbstractEventLoop:
def stop_global_loop(): def stop_global_loop():
"""Stop the shared loop and join the background thread if present.""" """Stop the shared loop and join the background thread if present."""
if _GLOBAL.loop and _GLOBAL.loop.is_running(): if _GLOBAL.loop and _GLOBAL.loop.is_running():
_GLOBAL.closing = True
_GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop) _GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop)
_GLOBAL.thread.join(timeout=2) _GLOBAL.thread.join(timeout=2)
_GLOBAL.loop = None _GLOBAL.loop = None
_GLOBAL.thread = None _GLOBAL.thread = None
_GLOBAL.closing = False

View File

@@ -19,6 +19,16 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
try:
import pyodbc
# The desktop app opens short-lived SQL connections from a background
# asyncio loop. ODBC pooling can keep native handles alive for a while after
# the GUI closes, which is especially visible with pythonw.
pyodbc.pooling = False
except Exception:
pyodbc = None # type: ignore[assignment]
try: try:
import orjson as _json import orjson as _json
@@ -120,6 +130,7 @@ class AsyncMSSQLClient:
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
*, *,
as_dict_rows: bool = False, as_dict_rows: bool = False,
commit: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a query and return a JSON-friendly payload. """Execute a query and return a JSON-friendly payload.
@@ -128,6 +139,9 @@ class AsyncMSSQLClient:
params: Optional named parameters bound to the statement. params: Optional named parameters bound to the statement.
as_dict_rows: When ``True`` returns rows as dictionaries keyed by as_dict_rows: When ``True`` returns rows as dictionaries keyed by
column name; otherwise rows are returned as lists. column name; otherwise rows are returned as lists.
commit: When ``True`` the statement runs in a transaction that is
committed on success. Useful for SQL batches that both mutate
data and return a final result set.
Returns: Returns:
A dictionary containing column names, rows and elapsed execution A dictionary containing column names, rows and elapsed execution
@@ -135,7 +149,7 @@ class AsyncMSSQLClient:
""" """
await self._ensure_engine() await self._ensure_engine()
t0 = time.perf_counter() t0 = time.perf_counter()
async with self._engine.connect() as conn: async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {}) res = await conn.execute(text(sql), params or {})
rows = res.fetchall() rows = res.fetchall()
cols = list(res.keys()) cols = list(res.keys())

102
audit_log.py Normal file
View File

@@ -0,0 +1,102 @@
"""Central textual audit log for user-driven warehouse operations."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from user_session import UserSession
AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log")
_LOGGER = logging.getLogger("warehouse_audit")
_LOGGER_CONFIGURED = False
def _configure_logger() -> None:
"""Configure the append-only textual audit logger once."""
global _LOGGER_CONFIGURED
if _LOGGER_CONFIGURED:
return
_LOGGER.setLevel(logging.INFO)
_LOGGER.propagate = False
handler = logging.FileHandler(AUDIT_LOG_PATH, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s | %(message)s"))
_LOGGER.addHandler(handler)
_LOGGER_CONFIGURED = True
def _user_label(session: UserSession | None) -> str:
"""Render the user identity consistently in the audit trail."""
if session is None:
return "anonymous"
return f"{session.login or 'anonymous'}#{session.operator_id}"
def _payload(
*,
session: UserSession | None,
module: str,
action: str,
outcome: str,
target: str = "",
details: dict[str, Any] | None = None,
) -> str:
"""Serialize one audit event as a compact text line."""
base = {
"user": _user_label(session),
"module": module,
"action": action,
"outcome": outcome,
"target": target,
"details": details or {},
}
return json.dumps(base, ensure_ascii=False, default=str)
def log_user_action(
session: UserSession | None,
*,
module: str,
action: str,
outcome: str,
target: str = "",
details: dict[str, Any] | None = None,
) -> None:
"""Write one user action event to the textual audit file."""
_configure_logger()
_LOGGER.info(
_payload(
session=session,
module=module,
action=action,
outcome=outcome,
target=target,
details=details,
)
)
def log_session_event(
session: UserSession | None,
*,
action: str,
outcome: str,
details: dict[str, Any] | None = None,
) -> None:
"""Write one session lifecycle event to the textual audit file."""
log_user_action(
session,
module="session",
action=action,
outcome=outcome,
details=details,
)

8
barcode.pyw Normal file
View File

@@ -0,0 +1,8 @@
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_barcode")
from barcode_client import main
raise SystemExit(run_with_fatal_log("Barcode WMS", main))

478
barcode_client.py Normal file
View File

@@ -0,0 +1,478 @@
"""Lightweight standalone barcode client for the warehouse WMS."""
from __future__ import annotations
import asyncio
import sys
import tkinter as tk
from concurrent.futures import Future
from tkinter import messagebox, ttk
from typing import Callable
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_barcode")
from async_loop_singleton import get_global_loop, stop_global_loop
from async_msssql_query import AsyncMSSQLClient
from barcode_repository import BarcodeRepository
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
from db_config import build_dsn_from_config, ensure_db_config
from login_window import prompt_login_compact
class BarcodeClientApp:
"""Single-window Tk barcode client modeled after the C# legacy form."""
BARCODE_MAX_WIDTH = 320
BARCODE_MAX_HEIGHT = 400
DESKTOP_THRESHOLD_WIDTH = 1024
DESKTOP_THRESHOLD_HEIGHT = 768
DESKTOP_WINDOW_WIDTH = 465
DESKTOP_WINDOW_HEIGHT = 531
def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop):
self.root = root
self.db_client = db_client
self.session = session
self.loop = loop
self.repository = BarcodeRepository(db_client)
self.service = BarcodeService(self.repository, session.operator_id)
self._pending: Future | None = None
self._auto_advance_id: str | None = None
self._status_colors = {
"red": "#f4cccc",
"green": "#d9ead3",
"yellow": "#e2f0cb",
}
self._is_barcode_desktop = False
self.queue_var = tk.StringVar(value="")
self.info1_var = tk.StringVar(value="")
self.info2_var = tk.StringVar(value="")
self.info3_var = tk.StringVar(value="")
self.info4_var = tk.StringVar(value="")
self.destination_var = tk.StringVar(value="")
self.scanned_var = tk.StringVar(value="")
self._build_ui()
self._apply_state(self.service.state)
self._bind_keys()
self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
def _build_ui(self) -> None:
self.root.title("WMS")
self.root.configure(bg="#f1f1f1")
self._apply_responsive_geometry()
if self._is_barcode_desktop:
field_font = ("Segoe UI", 9)
entry_font = ("Segoe UI", 11)
band_font = ("Segoe UI", 9, "bold")
info_font = ("Segoe UI", 9)
queue_font = ("Segoe UI", 8)
pad_x = 5
pad_y = 4
band_wrap = 190
info_wrap = 194
button_pad_y = 1
else:
field_font = ("Segoe UI", 12)
entry_font = ("Segoe UI", 15)
band_font = ("Segoe UI", 12, "bold")
info_font = ("Segoe UI", 12)
queue_font = ("Segoe UI", 10)
pad_x = 16
pad_y = 12
band_wrap = 400
info_wrap = 408
button_pad_y = 4
wrap = tk.Frame(self.root, bg="#f1f1f1", padx=pad_x, pady=pad_y)
wrap.pack(fill="both", expand=True)
wrap.rowconfigure(3, weight=1)
wrap.columnconfigure(0, weight=1)
header = tk.Frame(wrap, bg="#f1f1f1")
header.grid(row=0, column=0, sticky="ew", pady=(0, 4 if self._is_barcode_desktop else 10))
tk.Label(
header,
textvariable=self.queue_var,
bg="#f1f1f1",
fg="#333333",
font=queue_font,
anchor="e",
).pack(side="right")
form = tk.Frame(wrap, bg="#f1f1f1")
form.grid(row=1, column=0, sticky="ew")
self._add_compact_field(form, 0, "Pallet", self.scanned_var, scanned=True, label_font=field_font, entry_font=entry_font)
self._add_compact_field(form, 1, "Cella", self.destination_var, scanned=False, label_font=field_font, entry_font=entry_font)
self.status_band = tk.Label(
wrap,
textvariable=self.info1_var,
bg=self._status_colors["red"],
fg="#1f2937",
font=band_font,
anchor="w",
justify="left",
padx=6,
pady=3 if self._is_barcode_desktop else 6,
wraplength=band_wrap,
)
self.status_band.grid(row=2, column=0, sticky="ew", pady=(8 if self._is_barcode_desktop else 14, 4))
info_box = tk.Frame(wrap, bg="#f1f1f1")
info_box.grid(row=3, column=0, sticky="nsew")
info_box.columnconfigure(0, weight=1)
for row_index in range(3):
info_box.rowconfigure(row_index, weight=1)
self.info2_label = tk.Label(
info_box,
textvariable=self.info2_var,
bg="#f1f1f1",
fg="#222222",
font=info_font,
anchor="w",
justify="left",
wraplength=info_wrap,
)
self.info2_label.grid(row=0, column=0, sticky="ew", pady=(2, 0))
self.info3_label = tk.Label(
info_box,
textvariable=self.info3_var,
bg="#f1f1f1",
fg="#222222",
font=info_font,
anchor="w",
justify="left",
wraplength=info_wrap,
)
self.info3_label.grid(row=1, column=0, sticky="ew", pady=(2, 0))
self.info4_label = tk.Label(
info_box,
textvariable=self.info4_var,
bg="#f1f1f1",
fg="#222222",
font=info_font,
anchor="w",
justify="left",
wraplength=info_wrap,
)
self.info4_label.grid(row=2, column=0, sticky="ew", pady=(2, 4 if self._is_barcode_desktop else 10))
buttons = tk.Frame(wrap, bg="#f1f1f1")
buttons.grid(row=4, column=0, sticky="ew", pady=(4, 0))
buttons.columnconfigure(0, weight=1)
buttons.columnconfigure(1, weight=1)
self.btn_f1 = ttk.Button(buttons, text="[F1] H Priority", command=lambda: self._start_queue(1))
self.btn_f1.grid(row=0, column=0, padx=(0, 4), pady=(0, button_pad_y), sticky="ew")
self.btn_submit = ttk.Button(buttons, text="[Ent] Salva", command=self._submit)
self.btn_submit.grid(row=0, column=1, padx=(4, 0), pady=(0, button_pad_y), sticky="ew")
self.btn_f2 = ttk.Button(buttons, text="[F2] L Priority", command=lambda: self._start_queue(0))
self.btn_f2.grid(row=1, column=0, padx=(0, 4), sticky="ew")
self.btn_unload = ttk.Button(buttons, text="[F4] Elimina", command=self._begin_manual_unload)
self.btn_unload.grid(row=1, column=1, padx=(4, 0), sticky="ew")
self.busy_cover = tk.Frame(self.root, bg="#d9d9d9")
panel = ttk.Frame(self.busy_cover, padding=16)
panel.place(relx=0.5, rely=0.5, anchor="center")
self.busy_label = ttk.Label(panel, text="Attendere...", font=("Segoe UI", 11, "bold"))
self.busy_label.pack(pady=(0, 8))
self.busy_bar = ttk.Progressbar(panel, mode="indeterminate", length=260)
self.busy_bar.pack()
def _add_compact_field(
self,
parent: tk.Frame,
row: int,
label: str,
variable: tk.StringVar,
*,
scanned: bool,
label_font,
entry_font,
) -> None:
tk.Label(
parent,
text=label,
bg="#f1f1f1",
fg="#1f1f1f",
font=label_font,
anchor="w",
).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=4 if self._is_barcode_desktop else 6)
entry = ttk.Entry(parent, textvariable=variable, font=entry_font, width=8)
entry.grid(row=row, column=1, sticky="ew", pady=4 if self._is_barcode_desktop else 6, ipady=1 if self._is_barcode_desktop else 4)
parent.columnconfigure(1, weight=1)
if scanned:
self.pallet_entry = entry
self.scanned_var.trace_add("write", lambda *_: self._limit_var(self.scanned_var, 8))
else:
self.destination_entry = entry
self.destination_var.trace_add("write", lambda *_: self._limit_var(self.destination_var, 8))
def _limit_var(self, variable: tk.StringVar, max_len: int) -> None:
value = str(variable.get() or "")
if len(value) > max_len:
variable.set(value[:max_len])
def _apply_responsive_geometry(self) -> None:
"""Adapt the window size to barcode-sized or desktop-sized screens."""
self.root.update_idletasks()
screen_width = max(1, int(self.root.winfo_screenwidth() or 0))
screen_height = max(1, int(self.root.winfo_screenheight() or 0))
is_barcode_desktop = (
screen_width <= self.BARCODE_MAX_WIDTH or screen_height <= self.BARCODE_MAX_HEIGHT
)
self._is_barcode_desktop = is_barcode_desktop
if is_barcode_desktop:
width = screen_width
height = screen_height
x = 0
y = 0
self.root.minsize(max(220, width), max(300, height))
else:
width = min(self.DESKTOP_WINDOW_WIDTH, screen_width)
height = min(self.DESKTOP_WINDOW_HEIGHT, screen_height)
x = 0
y = 0
self.root.minsize(420, 500)
if (
screen_width >= self.DESKTOP_THRESHOLD_WIDTH
and screen_height >= self.DESKTOP_THRESHOLD_HEIGHT
and not is_barcode_desktop
):
width = min(self.DESKTOP_WINDOW_WIDTH, screen_width)
height = min(self.DESKTOP_WINDOW_HEIGHT, screen_height)
x = 0
y = 0
self.root.geometry(f"{width}x{height}+{x}+{y}")
def _bind_keys(self) -> None:
self.root.bind("<F1>", lambda _e: self._start_queue(1))
self.root.bind("<F2>", lambda _e: self._start_queue(0))
self.root.bind("<F4>", lambda _e: self._begin_manual_unload())
self.pallet_entry.bind("<Return>", self._on_pallet_enter)
self.destination_entry.bind("<Return>", self._on_destination_enter)
def _set_busy(self, busy: bool, message: str = "") -> None:
state = "disabled" if busy else "normal"
for button in (self.btn_f1, self.btn_f2, self.btn_unload, self.btn_submit):
try:
button.configure(state=state)
except Exception:
pass
try:
self.pallet_entry.configure(state=state)
except Exception:
pass
try:
if str(self.destination_entry.cget("state")) != "readonly" or busy:
self.destination_entry.configure(state=state)
except Exception:
pass
if busy:
self.busy_label.configure(text=message or "Attendere...")
self.busy_cover.place(relx=0, rely=0, relwidth=1, relheight=1)
self.busy_bar.start(10)
else:
self.busy_bar.stop()
self.busy_cover.place_forget()
def _apply_state(self, state: BarcodeViewState) -> None:
if self._auto_advance_id is not None:
try:
self.root.after_cancel(self._auto_advance_id)
except Exception:
pass
self._auto_advance_id = None
self.queue_var.set(state.queue_label)
self.destination_var.set(state.destination_barcode)
self.scanned_var.set(state.scanned_pallet)
self.info1_var.set(state.status_text)
self.info2_var.set(state.document)
self.info3_var.set(state.customer)
self.info4_var.set(state.expected_pallet)
self.status_band.configure(bg=state.status_color or self._status_colors["red"])
destination_readonly = state.mode in ("priority_high", "priority_low", "manual_unload")
try:
self.destination_entry.configure(state="normal")
if destination_readonly:
self.destination_entry.configure(state="readonly")
except Exception:
pass
if state.mode == "confirm" and state.status_text == "Ok Scarico":
next_queue = self._queue_id_from_label(state.queue_label)
if next_queue is not None:
self._auto_advance_id = self.root.after(1200, lambda q=next_queue: self._start_queue(q))
self.root.after(20, self._focus_primary_input)
def _focus_primary_input(self) -> None:
try:
self.pallet_entry.focus_force()
self.pallet_entry.selection_range(0, "end")
except Exception:
pass
def _focus_destination_input(self) -> None:
try:
self.destination_entry.configure(state="normal")
except Exception:
pass
try:
self.destination_entry.focus_force()
self.destination_entry.selection_range(0, "end")
except Exception:
pass
def _on_pallet_enter(self, _event=None) -> str:
pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip()
if not pallet:
return "break"
if destination == "9000000":
self._submit()
return "break"
self.destination_var.set("")
self._focus_destination_input()
return "break"
def _on_destination_enter(self, _event=None) -> str:
pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip()
if pallet and destination:
self._submit()
else:
self._focus_primary_input()
return "break"
def _begin_manual_unload(self) -> None:
self._apply_state(self.service.begin_manual_unload())
def _start_queue(self, id_stato: int) -> None:
self._run_async(
lambda: self.service.start_priority_queue(id_stato),
busy_message="Carico la coda selezionata...",
)
def _submit(self) -> None:
self._run_async(
lambda: self.service.submit(
scanned_pallet=self.scanned_var.get(),
destination_barcode=self.destination_var.get(),
),
busy_message="Eseguo il movimento...",
)
def _run_async(self, coro_factory: Callable[[], object], busy_message: str) -> None:
if self._pending is not None and not self._pending.done():
return
if self._auto_advance_id is not None:
try:
self.root.after_cancel(self._auto_advance_id)
except Exception:
pass
self._auto_advance_id = None
self._set_busy(True, busy_message)
self._pending = asyncio.run_coroutine_threadsafe(coro_factory(), self.loop)
self.root.after(40, self._poll_future)
def _queue_id_from_label(self, queue_label: str) -> int | None:
text = str(queue_label or "")
if text.startswith("Alta priorita'"):
return 1
if text.startswith("Bassa priorita'"):
return 0
return None
def _poll_future(self) -> None:
if self._pending is None:
self._set_busy(False)
return
if not self._pending.done():
self.root.after(40, self._poll_future)
return
future = self._pending
self._pending = None
self._set_busy(False)
try:
result = future.result()
except Exception as exc:
current = self.service.state
current.status_text = f"Errore operativo: {exc}"
current.status_color = "#f4cccc"
self._apply_state(current)
messagebox.showerror("Barcode WMS", f"Operazione fallita:\n{exc}", parent=self.root)
return
if isinstance(result, BarcodeActionResult):
self._apply_state(result.state)
if result.message and not result.ok:
self.root.bell()
elif isinstance(result, BarcodeViewState):
self._apply_state(result)
def _shutdown(self) -> None:
try:
if self._pending is not None and not self._pending.done():
return
except Exception:
pass
try:
fut = asyncio.run_coroutine_threadsafe(self.db_client.dispose(), self.loop)
fut.result(timeout=2)
except Exception:
pass
try:
self.root.destroy()
finally:
try:
stop_global_loop()
except Exception:
pass
def main() -> int:
loop = get_global_loop()
bootstrap = tk.Tk()
bootstrap.withdraw()
config = ensure_db_config(loop, parent=bootstrap)
if not config:
bootstrap.destroy()
stop_global_loop()
return 1
db_client = AsyncMSSQLClient(build_dsn_from_config(config))
session = prompt_login_compact(bootstrap, db_client)
if session is None:
try:
fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop)
fut.result(timeout=2)
except Exception:
pass
bootstrap.destroy()
stop_global_loop()
return 1
app = BarcodeClientApp(bootstrap, db_client, session, loop)
bootstrap.deiconify()
bootstrap.lift()
bootstrap.focus_force()
app._focus_primary_input()
bootstrap.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(run_with_fatal_log("Barcode WMS", main))

155
barcode_repository.py Normal file
View File

@@ -0,0 +1,155 @@
"""Low-level DB access for the barcode WMS client.
The goal of this module is to mirror the legacy C# barcode form as closely as
possible while keeping SQL isolated from the UI.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
SQL_NEXT_PICKING = """
SELECT TOP (1)
Documento,
CodNazione,
NAZIONE,
Stato,
Pallet,
Cella,
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Ordinamento > 0
AND IDStato = :id_stato
ORDER BY Ordinamento;
"""
SQL_PICKING_BY_PALLET = """
SELECT TOP (1)
Documento,
CodNazione,
NAZIONE,
Stato,
Pallet,
Cella,
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Pallet = :pallet
ORDER BY Ordinamento;
"""
SQL_TRACE_BY_PALLET = """
SELECT TOP (1)
Pallet,
Lotto,
Prodotto,
Descrizione
FROM dbo.vXTracciaProdotti
WHERE Pallet = :pallet
ORDER BY Lotto;
"""
SQL_LEGACY_MOVE = """
SET NOCOUNT ON;
DECLARE @RC int = 0;
EXEC dbo.sp_xMagGestioneMagazziniPallet
@IDOperatore = :id_operatore,
@BarcodeCella = :barcode_cella,
@BarcodePallet = :barcode_pallet,
@NumeroCella = :numero_cella,
@RC = @RC OUTPUT;
EXEC dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
SELECT
@RC AS RC,
:barcode_cella AS BarcodeCella,
:barcode_pallet AS BarcodePallet,
:numero_cella AS NumeroCella;
"""
def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Convert ``query_json`` payloads to a list of row dictionaries."""
if not isinstance(res, dict):
return []
rows = res.get("rows") or []
cols = res.get("columns") or []
if rows and isinstance(rows[0], dict):
return [row for row in rows if isinstance(row, dict)]
out: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, (list, tuple)) and cols:
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
return out
@dataclass
class LegacyMoveResult:
"""Result of one movement executed through the legacy stored procedure."""
rc: int
barcode_cella: str
barcode_pallet: str
numero_cella: int
class BarcodeRepository:
"""Thin async repository used by the lightweight barcode client."""
def __init__(self, db_client):
self.db_client = db_client
async def fetch_next_picking(self, id_stato: int) -> dict[str, Any] | None:
"""Return the next pallet proposed by the legacy F1/F2 queue logic."""
res = await self.db_client.query_json(SQL_NEXT_PICKING, {"id_stato": int(id_stato)})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def fetch_picking_by_pallet(self, pallet: str) -> dict[str, Any] | None:
"""Return one picking row for the given pallet, if still present in the queue."""
res = await self.db_client.query_json(SQL_PICKING_BY_PALLET, {"pallet": str(pallet or "").strip()})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def fetch_trace_by_pallet(self, pallet: str) -> dict[str, Any] | None:
"""Return trace information used by the C# fallback confirmation path."""
res = await self.db_client.query_json(SQL_TRACE_BY_PALLET, {"pallet": str(pallet or "").strip()})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def execute_legacy_move(
self,
*,
operator_id: int,
barcode_cella: str,
barcode_pallet: str,
numero_cella: int,
) -> LegacyMoveResult:
"""Execute the same stored procedure used by the C# barcode form."""
params = {
"id_operatore": int(operator_id),
"barcode_cella": str(barcode_cella or "").strip(),
"barcode_pallet": str(barcode_pallet or "").strip(),
"numero_cella": int(numero_cella),
}
res = await self.db_client.query_json(SQL_LEGACY_MOVE, params, commit=True)
rows = _rows_to_dicts(res)
row = rows[0] if rows else {}
return LegacyMoveResult(
rc=int(row.get("RC") or 0),
barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]),
barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]),
numero_cella=int(row.get("NumeroCella") or params["numero_cella"]),
)

247
barcode_service.py Normal file
View File

@@ -0,0 +1,247 @@
"""Service layer for the lightweight barcode WMS client."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from barcode_repository import BarcodeRepository, LegacyMoveResult
ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"]
@dataclass
class BarcodeViewState:
"""State projected from service logic into the barcode UI."""
mode: ModeName = "idle"
queue_label: str = ""
status_text: str = "Pronto."
status_color: str = "#d9d9d9"
source_location: str = ""
document: str = ""
customer: str = ""
expected_pallet: str = ""
destination_barcode: str = ""
scanned_pallet: str = ""
@dataclass
class BarcodeActionResult:
"""Standard result returned by user actions."""
ok: bool
state: BarcodeViewState
message: str = ""
class BarcodeService:
"""Faithful, but cleaner, port of the legacy barcode form logic."""
GRAY = "#d9d9d9"
RED = "#f4cccc"
LIGHT_GREEN = "#d9ead3"
GREEN_YELLOW = "#e2f0cb"
CONVENTIONAL_LOCATION_BY_CELL = {
1000: "5E1.1",
9999: "7G.1.1",
}
def __init__(self, repository: BarcodeRepository, operator_id: int):
self.repository = repository
self.operator_id = int(operator_id)
self._current_priority_state = 0
self._state = BarcodeViewState()
@property
def state(self) -> BarcodeViewState:
"""Return a copy-safe reference to the current UI state."""
return self._state
def reset(self) -> BarcodeViewState:
"""Return the client to its neutral state."""
self._current_priority_state = 0
self._state = BarcodeViewState()
return self._state
def begin_manual_load(self) -> BarcodeViewState:
"""Prepare a real versamento into a physical warehouse cell."""
self._current_priority_state = 0
self._state = BarcodeViewState(
mode="manual_load",
queue_label="Versamento",
status_text="OP Carico",
status_color=self.GRAY,
)
return self._state
def begin_manual_unload(self) -> BarcodeViewState:
"""Prepare a direct unload toward the virtual outbound cell 9000000."""
self._current_priority_state = 0
self._state = BarcodeViewState(
mode="manual_unload",
queue_label="Prelievo diretto",
status_text="OP Scarico",
status_color=self.GRAY,
destination_barcode="9000000",
)
return self._state
async def start_priority_queue(self, id_stato: int) -> BarcodeActionResult:
"""Load the next item of the selected legacy priority queue."""
row = await self.repository.fetch_next_picking(id_stato)
self._current_priority_state = int(id_stato)
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
if not row:
self._state = BarcodeViewState(
mode="priority_high" if int(id_stato) == 1 else "priority_low",
queue_label=queue_label,
status_text="Pronto.",
status_color=self.RED,
destination_barcode="9000000",
)
return BarcodeActionResult(True, self._state)
customer = f"{row.get('CodNazione') or ''} - {row.get('NAZIONE') or ''}".strip(" -")
source_location = self._display_location(
cella=row.get("Cella"),
ubicazione=row.get("Ubicazione"),
)
self._state = BarcodeViewState(
mode="priority_high" if int(id_stato) == 1 else "priority_low",
queue_label=queue_label,
status_text=f"Ok Cella - {source_location}",
status_color=self.LIGHT_GREEN,
source_location=source_location,
document=str(row.get("Documento") or ""),
customer=customer,
expected_pallet=str(row.get("Pallet") or ""),
destination_barcode="9000000",
)
return BarcodeActionResult(True, self._state)
async def submit(self, *, scanned_pallet: str, destination_barcode: str) -> BarcodeActionResult:
"""Execute the movement according to the current mode."""
pallet = str(scanned_pallet or "").strip()
destination = str(destination_barcode or "").strip()
if not pallet:
return BarcodeActionResult(False, self._state, "Inserisci o leggi il pallet.")
if not destination:
return BarcodeActionResult(False, self._state, "Inserisci o leggi la destinazione.")
if not destination.isdigit():
return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.")
if self._state.mode in ("priority_high", "priority_low") and destination == "9000000":
expected = str(self._state.expected_pallet or "").strip()
if expected and expected != pallet:
self._state.scanned_pallet = pallet
self._state.status_text = "Errata lettura: il pallet letto non coincide con quello atteso."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
move = await self.repository.execute_legacy_move(
operator_id=self.operator_id,
barcode_cella=destination,
barcode_pallet=pallet,
numero_cella=int(destination),
)
if move.rc != 0:
self._state.scanned_pallet = pallet
self._state.status_text = f"Operazione non riuscita (RC={move.rc})."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
self._state = await self._build_post_move_state(
barcode_pallet=pallet,
destination_barcode=destination,
last_priority_state=self._current_priority_state,
)
return BarcodeActionResult(True, self._state, self._state.status_text)
async def _build_post_move_state(
self,
*,
barcode_pallet: str,
destination_barcode: str,
last_priority_state: int,
) -> BarcodeViewState:
"""Mirror the legacy confirmation flow after one stored-procedure move."""
picking_row = await self.repository.fetch_picking_by_pallet(barcode_pallet)
if picking_row:
customer = f"{picking_row.get('CodNazione') or ''} - {picking_row.get('NAZIONE') or ''}".strip(" -")
source_location = self._display_location(
cella=picking_row.get("Cella"),
ubicazione=picking_row.get("Ubicazione"),
)
return BarcodeViewState(
mode="confirm",
queue_label="Alta priorita' (F1)" if last_priority_state == 1 else ("Bassa priorita' (F2)" if last_priority_state == 0 else ""),
status_text=f"Ok Cella - {source_location}",
status_color=self.LIGHT_GREEN,
source_location=source_location,
document=str(picking_row.get("Documento") or ""),
customer=customer,
expected_pallet=str(picking_row.get("Pallet") or ""),
destination_barcode=destination_barcode,
)
trace_row = await self.repository.fetch_trace_by_pallet(barcode_pallet)
if trace_row:
lotto = str(trace_row.get("Lotto") or "")
prodotto = str(trace_row.get("Prodotto") or "")
descrizione = str(trace_row.get("Descrizione") or "")
queue_label = "Alta priorita' (F1)" if last_priority_state == 1 else ("Bassa priorita' (F2)" if last_priority_state == 0 else "Conferma movimento")
return BarcodeViewState(
mode="confirm",
queue_label=queue_label,
status_text=(
"Ok Scarico" if destination_barcode == "9000000" else f"Ok Carico - {destination_barcode}"
),
status_color=self.GREEN_YELLOW,
source_location=str(destination_barcode or ""),
document=(
self.CONVENTIONAL_LOCATION_BY_CELL[9999]
if destination_barcode == "9000000" and last_priority_state in (0, 1)
else lotto
),
customer=(
lotto
if destination_barcode == "9000000" and last_priority_state in (0, 1)
else prodotto
),
expected_pallet=(
" - ".join(part for part in (prodotto, descrizione) if part)
if destination_barcode == "9000000" and last_priority_state in (0, 1)
else descrizione
),
destination_barcode=destination_barcode,
scanned_pallet=barcode_pallet,
)
return BarcodeViewState(
mode="confirm",
queue_label="Conferma movimento",
status_text="Movimento eseguito.",
status_color=self.GREEN_YELLOW,
destination_barcode=destination_barcode,
scanned_pallet=barcode_pallet,
)
def _display_location(self, *, cella: object, ubicazione: object) -> str:
"""Return the operator-facing location, honoring legacy conventional cells."""
try:
cella_int = int(cella)
except Exception:
cella_int = None
if cella_int in self.CONVENTIONAL_LOCATION_BY_CELL:
return self.CONVENTIONAL_LOCATION_BY_CELL[cella_int]
return str(ubicazione or cella or "")

86
busy_overlay.py Normal file
View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
import tkinter as tk
import customtkinter as ctk
from ui_theme import theme_color, theme_font, theme_padding, theme_value
class InlineBusyOverlay:
"""Busy overlay rendered inside the same window, avoiding extra toplevels."""
def __init__(self, parent: tk.Misc, theme_cfg: dict[str, Any] | None = None):
self.parent = parent
self.theme_cfg = theme_cfg or {}
self._cover: ctk.CTkFrame | None = None
self._label: ctk.CTkLabel | None = None
self._bar: ctk.CTkProgressBar | None = None
def show(self, message: str = "Attendere..."):
if self._cover and self._cover.winfo_exists():
if self._label:
self._label.configure(text=message)
try:
self._cover.lift()
except Exception:
pass
return
cover = ctk.CTkFrame(
self.parent,
corner_radius=0,
fg_color=theme_color(self.theme_cfg, "overlay_cover_fg_color", ("#d9d9d9", "#4a4a4a")),
)
cover.place(relx=0, rely=0, relwidth=1, relheight=1)
try:
cover.lift()
except Exception:
pass
wrap = ctk.CTkFrame(
cover,
corner_radius=int(theme_value(self.theme_cfg, "overlay_panel_corner_radius", 10)),
fg_color=theme_color(self.theme_cfg, "overlay_panel_fg_color", ("#f2f2f2", "#353535")),
)
wrap.place(relx=0.5, rely=0.5, anchor="center")
label = ctk.CTkLabel(
wrap,
text=message,
font=theme_font(self.theme_cfg, "overlay_label_font", ("Segoe UI", 11, "bold")),
)
label_pad = theme_padding(self.theme_cfg, "overlay_label_padding", (18, 14, 18, 8))
label.pack(padx=(label_pad[0], label_pad[2]), pady=(label_pad[1], label_pad[3]))
bar = ctk.CTkProgressBar(
wrap,
mode="indeterminate",
width=int(theme_value(self.theme_cfg, "overlay_progress_width", 220)),
)
bar_pad = theme_padding(self.theme_cfg, "overlay_progress_padding", (18, 0, 18, 14))
bar.pack(padx=(bar_pad[0], bar_pad[2]), pady=(bar_pad[1], bar_pad[3]))
try:
bar.start()
except Exception:
pass
self._cover = cover
self._label = label
self._bar = bar
def hide(self):
if self._bar:
try:
self._bar.stop()
except Exception:
pass
self._bar = None
self._label = None
if self._cover and self._cover.winfo_exists():
try:
self._cover.destroy()
except Exception:
pass
self._cover = None

View File

@@ -0,0 +1,69 @@
# Checklist Test Campo Picking List
## Deploy patch
1. Apri [apply_plist_reservation_patch.sql](C:/devel/python/ware_house/apply_plist_reservation_patch.sql) in SSMS.
2. Verifica di essere collegato al database corretto.
3. Esegui lo script completo.
4. Controlla che l'esecuzione termini senza errori SQL.
## Verifica oggetti DB
1. Verifica che esista la tabella `dbo.PickingListReservation`.
2. Verifica che siano stati aggiornati questi oggetti:
- `dbo.sp_xExePackingListPallet`
- `dbo.XMag_ViewPackingList`
- `dbo.sp_xExePackingListPalletPrenota`
- `dbo.sp_ControllaPrenotazionePackingListPalletNew`
3. Verifica che in `dbo.WarehouseObjectBackup` esistano le copie con tag `plist_reservation_fix_alpha2`.
## Test backoffice Gestione Picking List
1. Apri `Gestione Picking List`.
2. Verifica che la griglia alta mostri solo le UDC residue.
3. Prenota una picking list.
4. Verifica che risulti prenotata solo quella.
5. Premi di nuovo `Prenota` sulla stessa lista.
- Atteso: non cambia nulla.
6. Premi `S-prenota` sulla lista prenotata.
- Atteso: la lista passa a non prenotata.
7. Premi di nuovo `S-prenota`.
- Atteso: non cambia nulla.
8. Prenota una lista con UDC non scaffalate.
- Atteso: non devono accendersi altre liste per effetto della locazione `1000 / 5E1.1`.
9. Prenota una lista che in passato collideva sulla cella `8057`.
- Atteso: non deve più prenotare anche l'altra.
## Test barcode
1. Con una sola lista prenotata, premi `F1`.
- Atteso: viene proposta la coda alta della lista prenotata.
2. Premi `F2`.
- Atteso: viene proposta la coda bassa non prenotata.
3. Preleva una UDC della picking list.
- Atteso: compare `Ok Scarico`.
- Atteso: compare `7G.1.1`.
4. Se la UDC è non scaffalata:
- Atteso: compare `5E1.1`.
5. Dopo il prelievo, se restano altre UDC della stessa plist:
- Atteso: compare la UDC successiva.
## Test fine lista
1. Lavora una picking list fino all'ultima UDC.
2. Verifica che la plist sparisca dalla griglia alta.
3. Verifica che la prenotazione venga automaticamente disattivata.
4. Verifica che `F1` non continui più a considerarla attiva.
## Test Ricarica
1. Durante il lavoro su una plist, premi `Ricarica`.
2. Verifica che la griglia alta mostri solo il residuo reale.
3. Verifica che la plist esaurita non ricompaia.
## Rollback
1. Apri [rollback_plist_reservation_patch.sql](C:/devel/python/ware_house/rollback_plist_reservation_patch.sql) in SSMS.
2. Verifica di essere sullo stesso database usato per il deploy.
3. Esegui lo script completo.
4. Controlla che il comportamento legacy sia ripristinato.

427
db_config.py Normal file
View File

@@ -0,0 +1,427 @@
"""Database connection bootstrap helpers for first-run configuration."""
from __future__ import annotations
import asyncio
import json
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Any
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_section, theme_value
CONFIG_PATH = Path(__file__).with_name("db_connection.json")
DEFAULT_DB_CONFIG: dict[str, Any] = {
"server": r"mde3\gesterp",
"database": "Mediseawall",
"user": "sa",
"password": "1Password1",
"driver": "ODBC Driver 17 for SQL Server",
"trust_server_certificate": True,
"encrypt": "",
}
def load_db_config(path: Path = CONFIG_PATH) -> dict[str, Any] | None:
"""Return the DB config from disk, or ``None`` when missing/invalid."""
try:
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return None
return {**DEFAULT_DB_CONFIG, **data}
except Exception:
return None
def save_db_config(config: dict[str, Any], path: Path = CONFIG_PATH) -> None:
"""Persist the DB config as UTF-8 JSON."""
payload = {**DEFAULT_DB_CONFIG, **config}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def build_dsn_from_config(config: dict[str, Any]) -> str:
"""Build the SQLAlchemy DSN from the saved configuration."""
return make_mssql_dsn(
server=str(config.get("server") or "").strip(),
database=str(config.get("database") or "").strip(),
user=str(config.get("user") or "").strip() or None,
password=str(config.get("password") or "").strip() or None,
driver=str(config.get("driver") or DEFAULT_DB_CONFIG["driver"]).strip(),
trust_server_certificate=bool(config.get("trust_server_certificate", True)),
encrypt=(str(config.get("encrypt") or "").strip() or None),
)
def _is_complete(config: dict[str, Any] | None) -> bool:
"""Return True when the config contains the required connection fields."""
if not isinstance(config, dict):
return False
required = ("server", "database", "user", "password")
return all(str(config.get(key) or "").strip() for key in required)
def test_db_config_sync(config: dict[str, Any], loop: asyncio.AbstractEventLoop, timeout: float = 6.0) -> None:
"""Raise an exception when the DB configuration cannot open a connection."""
client = AsyncMSSQLClient(build_dsn_from_config(config), log=False)
async def _job() -> None:
try:
await client.query_json("SELECT 1 AS Ok", {}, as_dict_rows=True)
finally:
try:
await client.dispose()
except Exception:
pass
fut = asyncio.run_coroutine_threadsafe(_job(), loop)
fut.result(timeout=timeout)
class DatabaseConfigWindow(tk.Toplevel):
"""Modal first-run form that collects the SQL Server connection settings."""
def __init__(self, parent: tk.Misc, loop: asyncio.AbstractEventLoop, initial: dict[str, Any] | None = None):
super().__init__(parent)
self._loop = loop
self._theme = theme_section("db_config_window", {})
self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog()
self.result_config: dict[str, Any] | None = None
merged = {**DEFAULT_DB_CONFIG, **(initial or {})}
self.title(loc_text("dbconfig.title", catalog=self._locale_catalog, default="Configurazione Database"))
self.geometry(str(theme_value(self._theme, "window_geometry", "520x360")))
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.server_var = tk.StringVar(value=str(merged.get("server") or ""))
self.database_var = tk.StringVar(value=str(merged.get("database") or ""))
self.user_var = tk.StringVar(value=str(merged.get("user") or ""))
self.password_var = tk.StringVar(value=str(merged.get("password") or ""))
self.driver_var = tk.StringVar(value=str(merged.get("driver") or DEFAULT_DB_CONFIG["driver"]))
self.encrypt_var = tk.StringVar(value=str(merged.get("encrypt") or ""))
self.tsc_var = tk.BooleanVar(value=bool(merged.get("trust_server_certificate", True)))
self._status_var = tk.StringVar(value="")
self._busy_cover: tk.Frame | None = None
self._busy_label: ttk.Label | None = None
self._busy_bar: ttk.Progressbar | None = None
self._build_ui()
self.update_idletasks()
req_w = self.winfo_reqwidth()
req_h = self.winfo_reqheight()
try:
current_w, current_h = [int(v) for v in str(theme_value(self._theme, "window_geometry", "520x360")).split("x", 1)]
except Exception:
current_w, current_h = 520, 360
final_w = max(current_w, req_w + 16)
final_h = max(current_h, req_h + 20)
self.geometry(f"{final_w}x{final_h}")
self.minsize(final_w, final_h)
self.grab_set()
self.deiconify()
self.lift()
self.attributes("-topmost", True)
self.after(50, self._show_ready)
def _build_ui(self) -> None:
body = ttk.Frame(self, padding=14)
body.pack(fill="both", expand=True)
body.columnconfigure(1, weight=1)
heading = ttk.Label(
body,
text=loc_text(
"dbconfig.heading",
catalog=self._locale_catalog,
default="Configura la connessione al database del magazzino",
),
font=("Segoe UI", 11, "bold"),
)
heading.grid(row=0, column=0, columnspan=2, sticky="w", pady=(2, 12))
fields = [
("dbconfig.label.server", "Server", self.server_var, "dbconfig.field.server"),
("dbconfig.label.database", "Database", self.database_var, "dbconfig.field.database"),
("dbconfig.label.user", "Utente", self.user_var, "dbconfig.field.user"),
("dbconfig.label.password", "Password", self.password_var, "dbconfig.field.password"),
("dbconfig.label.driver", "Driver ODBC", self.driver_var, "dbconfig.field.driver"),
("dbconfig.label.encrypt", "Encrypt", self.encrypt_var, "dbconfig.field.encrypt"),
]
self._entries: list[ttk.Entry] = []
for row_idx, (key, default, var, tip_key) in enumerate(fields, start=1):
label = ttk.Label(body, text=loc_text(key, catalog=self._locale_catalog, default=default))
label.grid(
row=row_idx, column=0, sticky="w", padx=(0, 10), pady=6
)
entry = ttk.Entry(body, textvariable=var, width=34, show="*" if var is self.password_var else "")
entry.grid(row=row_idx, column=1, sticky="ew", pady=6)
self._entries.append(entry)
self._attach_tooltip(label, tip_key)
self._attach_tooltip(entry, tip_key)
tsc = ttk.Checkbutton(
body,
text=loc_text(
"dbconfig.label.trust_server_certificate",
catalog=self._locale_catalog,
default="Trust server certificate",
),
variable=self.tsc_var,
)
tsc.grid(row=7, column=0, columnspan=2, sticky="w", pady=(8, 4))
self._attach_tooltip(tsc, "dbconfig.field.trust_server_certificate")
info = ttk.Label(
body,
text=loc_text(
"dbconfig.info",
catalog=self._locale_catalog,
default="Il file verra' salvato localmente e non verra' piu' richiesto ai prossimi avvii.",
),
wraplength=420,
justify="left",
)
info.grid(row=8, column=0, columnspan=2, sticky="w", pady=(6, 8))
ttk.Label(body, textvariable=self._status_var, foreground="#555555").grid(
row=9, column=0, columnspan=2, sticky="w", pady=(2, 2)
)
actions = ttk.Frame(body)
actions.grid(row=10, column=0, columnspan=2, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
self._cancel_btn = ttk.Button(
actions,
text=loc_text("dbconfig.button.cancel", catalog=self._locale_catalog, default="Annulla"),
command=self._on_cancel,
)
self._cancel_btn.grid(row=0, column=1, padx=(0, 8))
self._attach_tooltip(self._cancel_btn, "dbconfig.button.cancel")
self._test_btn = ttk.Button(
actions,
text=loc_text("dbconfig.button.test", catalog=self._locale_catalog, default="Test connessione"),
command=self._on_test,
)
self._test_btn.grid(row=0, column=2, padx=(0, 8))
self._attach_tooltip(self._test_btn, "dbconfig.button.test")
self._save_btn = ttk.Button(
actions,
text=loc_text("dbconfig.button.save", catalog=self._locale_catalog, default="Salva"),
command=self._on_save,
)
self._save_btn.grid(row=0, column=3)
self._attach_tooltip(self._save_btn, "dbconfig.button.save")
self._attach_tooltip(heading, "dbconfig.heading")
self._attach_tooltip(info, "dbconfig.info")
def _attach_tooltip(self, widget: tk.Misc, key: str) -> None:
"""Attach a localized tooltip when a text exists for the given key."""
tip = tooltip_text(key, catalog=self._tooltip_catalog)
if tip:
WidgetToolTip(widget, tip)
def _show_ready(self) -> None:
"""Ensure the modal is visible even with a hidden bootstrap root."""
try:
self.attributes("-topmost", True)
self.deiconify()
self.lift()
self.focus_force()
if self._entries:
self._entries[0].focus_force()
finally:
try:
self.after(250, lambda: self.attributes("-topmost", False))
except Exception:
pass
def _show_busy_overlay(self, message: str) -> None:
"""Show a lightweight inline overlay without CustomTkinter callbacks."""
if self._busy_cover and self._busy_cover.winfo_exists():
if self._busy_label is not None:
self._busy_label.configure(text=message)
try:
self._busy_cover.lift()
except Exception:
pass
return
cover = tk.Frame(self, bg="#d9d9d9")
cover.place(relx=0, rely=0, relwidth=1, relheight=1)
panel = ttk.Frame(cover, padding=14)
panel.place(relx=0.5, rely=0.5, anchor="center")
label = ttk.Label(panel, text=message, font=("Segoe UI", 10, "bold"))
label.pack(padx=16, pady=(4, 8))
bar = ttk.Progressbar(panel, mode="indeterminate", length=220)
bar.pack(padx=16, pady=(0, 6))
try:
bar.start(10)
except Exception:
pass
self._busy_cover = cover
self._busy_label = label
self._busy_bar = bar
def _hide_busy_overlay(self) -> None:
"""Hide the lightweight inline overlay."""
if self._busy_bar is not None:
try:
self._busy_bar.stop()
except Exception:
pass
self._busy_bar = None
self._busy_label = None
if self._busy_cover is not None and self._busy_cover.winfo_exists():
try:
self._busy_cover.destroy()
except Exception:
pass
self._busy_cover = None
def _set_busy(self, busy: bool, message: str = "") -> None:
state = "disabled" if busy else "normal"
try:
for entry in self._entries:
entry.configure(state=state)
self._cancel_btn.configure(state=state)
self._test_btn.configure(state=state)
self._save_btn.configure(state=state)
self.configure(cursor="watch" if busy else "")
self._status_var.set(message)
if busy:
self._show_busy_overlay(message or loc_text("dbconfig.busy", catalog=self._locale_catalog, default="Verifico..."))
else:
self._hide_busy_overlay()
self.update_idletasks()
except Exception:
pass
def _collect(self) -> dict[str, Any]:
return {
"server": str(self.server_var.get() or "").strip(),
"database": str(self.database_var.get() or "").strip(),
"user": str(self.user_var.get() or "").strip(),
"password": str(self.password_var.get() or "").strip(),
"driver": str(self.driver_var.get() or "").strip() or str(DEFAULT_DB_CONFIG["driver"]),
"encrypt": str(self.encrypt_var.get() or "").strip(),
"trust_server_certificate": bool(self.tsc_var.get()),
}
def _validate(self, config: dict[str, Any]) -> bool:
required = ("server", "database", "user", "password")
missing = [name for name in required if not str(config.get(name) or "").strip()]
if not missing:
return True
messagebox.showwarning(
loc_text("dbconfig.msg.title", catalog=self._locale_catalog, default="Configurazione Database"),
loc_text(
"dbconfig.msg.missing",
catalog=self._locale_catalog,
default="Compila almeno server, database, utente e password.",
),
parent=self,
)
return False
def _test(self, config: dict[str, Any]) -> None:
self._set_busy(True, loc_text("dbconfig.busy", catalog=self._locale_catalog, default="Verifico connessione..."))
try:
test_db_config_sync(config, self._loop)
finally:
self._set_busy(False, "")
def _on_test(self) -> None:
config = self._collect()
if not self._validate(config):
return
try:
self._test(config)
except Exception as ex:
messagebox.showerror(
loc_text("dbconfig.msg.title", catalog=self._locale_catalog, default="Configurazione Database"),
loc_text("dbconfig.msg.test_error", catalog=self._locale_catalog, default="Connessione fallita:\n{error}").format(error=ex),
parent=self,
)
return
messagebox.showinfo(
loc_text("dbconfig.msg.title", catalog=self._locale_catalog, default="Configurazione Database"),
loc_text("dbconfig.msg.test_ok", catalog=self._locale_catalog, default="Connessione riuscita."),
parent=self,
)
def _on_save(self) -> None:
config = self._collect()
if not self._validate(config):
return
try:
self._test(config)
save_db_config(config)
except Exception as ex:
messagebox.showerror(
loc_text("dbconfig.msg.title", catalog=self._locale_catalog, default="Configurazione Database"),
loc_text("dbconfig.msg.save_error", catalog=self._locale_catalog, default="Salvataggio fallito:\n{error}").format(error=ex),
parent=self,
)
return
self.result_config = config
self.destroy()
def _on_cancel(self) -> None:
self.result_config = None
try:
self.destroy()
except Exception:
pass
def ensure_db_config(loop: asyncio.AbstractEventLoop, parent: tk.Misc | None = None) -> dict[str, Any] | None:
"""Return a valid DB config, prompting the user the first time when needed."""
existing = load_db_config()
if _is_complete(existing):
return existing
owns_root = False
if parent is None:
parent = tk.Tk()
parent.geometry("1x1+0+0")
parent.overrideredirect(True)
parent.attributes("-alpha", 0.0)
parent.deiconify()
parent.update_idletasks()
owns_root = True
dlg = DatabaseConfigWindow(parent, loop=loop, initial=existing or DEFAULT_DB_CONFIG)
try:
parent.wait_window(dlg)
finally:
if owns_root:
try:
parent.destroy()
except Exception:
pass
return dlg.result_config

115
diagramma_scarico_udc.md Normal file
View File

@@ -0,0 +1,115 @@
# Diagramma Operativo - Scarico UDC / Prelievo
## Obiettivo
Prelevare una UDC dal magazzino e scaricarla verso la cella virtuale `9000000`.
## Nota importante
Dal codice C# emergono **due sottocasi diversi**:
- `scarico picking list`
- parte da `F1` o `F2`
- valida il pallet atteso della coda
- `scarico diretto`
- parte dal pulsante `F4 Elimina`
- non richiede una picking list prenotata
Questo diagramma descrive il **primo scarico UDC diretto**, cioe' il prelievo senza navigazione picking list.
## Stato iniziale del barcode
- form aperta
- nessuna operazione pendente
- focus sul campo `Pallet`
- prima label di stato neutra o grigia
- campo `Cella` non significativo finche' non si entra nel comando
## Come si entra nello stato iniziale dello scarico
Dal comportamento C# la strada piu' fedele e':
1. l'operatore preme `F4`
2. la form entra in modalita' scarico diretto
3. la prima label deve indicare:
- `OP Scarico`
4. il campo `Cella` viene preimpostato a:
- `9000000`
5. il focus va sul campo `Pallet`
## Stato operativo durante lo scarico
- `Pallet` = da leggere
- `Cella` = `9000000`
- focus sul campo `Pallet`
- label 1 grigia con:
- `OP Scarico`
## Sequenza operativa
```mermaid
flowchart TD
A["Stato neutro"] --> B["Operatore preme F4"]
B --> C["Form entra in OP Scarico"]
C --> D["Cella preimpostata a 9000000"]
D --> E["Focus sul campo Pallet"]
E --> F["Operatore legge barcode pallet"]
F --> G{"Invio automatico del lettore o Enter manuale"}
G --> H["Esecuzione stored sp_xMagGestioneMagazziniPallet"]
H --> I{"Esito OK?"}
I -- Si --> L["Label 1 verde/giallo: Ok Scarico"]
L --> M["Label 2 = lotto"]
M --> N["Label 3 = codice prodotto"]
N --> O["Label 4 = descrizione articolo"]
O --> P["Focus torna su Pallet per operazione successiva"]
I -- No --> Q["Label 1 rossa con errore"]
Q --> R["Focus torna su Pallet"]
```
## Stato finale se l'operazione va bene
- prima label:
- verde chiaro o giallo-verde
- testo tipo `Ok Scarico`
- seconda label:
- lotto del pallet movimentato
- terza label:
- codice prodotto
- quarta label:
- descrizione articolo
- focus:
- torna sul campo `Pallet`
- campo `Cella`:
- resta `9000000`
## Stato finale se l'operazione fallisce
- prima label rossa
- testo di errore operativo
- focus sul campo `Pallet`
- nessun avanzamento di coda
## Coerenza con il C#
I punti dedotti direttamente dal codice C# sono:
- `F4 Elimina` forza uno scarico verso `9000000`
- lo scarico usa:
- `sp_xMagGestioneMagazziniPallet`
- dopo il movimento il C# richiama:
- `GetDatiPallet(...)`
- e se il pallet non e' piu' nella vista picking passa a:
- `GetDatiPalletLotto(...)`
- da quest'ultima lettura arrivano:
- `Ok Scarico`
- lotto
- codice prodotto
- descrizione articolo
## Punto ancora da verificare sul campo
Da confermare in prova reale:
- se nel client legacy il colore di successo finale dello scarico diretto sia:
- verde chiaro
- oppure giallo-verde
- se il focus torni sempre al campo `Pallet` anche dopo errore
- se il lettore genera davvero `Enter` automatico in ogni scenario di scansione

View File

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

View File

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

View File

@@ -12,12 +12,13 @@ I diagrammi sono scritti in Mermaid, quindi possono essere:
## Indice ## Indice
- [main](./main_flow.md) - [main](./main_flow.md)
- [layout_window](./layout_window_flow.md) - [gestione_layout](./gestione_layout_flow.md)
- [reset_corsie](./reset_corsie_flow.md) - [reset_corsie](./reset_corsie_flow.md)
- [view_celle_multiple](./view_celle_multiple_flow.md) - [view_celle_multi_udc](./view_celle_multi_udc_flow.md)
- [search_pallets](./search_pallets_flow.md) - [search_pallets](./search_pallets_flow.md)
- [gestione_pickinglist](./gestione_pickinglist_flow.md) - [gestione_pickinglist](./gestione_pickinglist_flow.md)
- [infrastruttura async/db](./async_db_flow.md) - [infrastruttura async/db](./async_db_flow.md)
- [warehouse operational flow](./warehouse_operational_flow.md)
## Convenzioni ## Convenzioni

View File

@@ -35,5 +35,5 @@ flowchart TD
## Note ## Note
- E un helper minimale usato da `main.py`. - E un helper minimale usato da `main.py`.
- Il modulo esiste separato da `gestione_aree_frame_async.py`, ma concettualmente - Il modulo esiste separato da `gestione_aree.py`, ma concettualmente
svolge lo stesso ruolo di gestione del loop condiviso. svolge lo stesso ruolo di gestione del loop condiviso.

View File

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

View File

@@ -1,10 +1,11 @@
# `layout_window.py` # `gestione_layout.py`
## Scopo ## Scopo
Questo modulo visualizza il layout delle corsie come matrice di celle, mostra Questo modulo visualizza il layout delle corsie come matrice di celle, mostra
lo stato di occupazione, consente di cercare una UDC e permette l'export della lo stato di occupazione, consente di cercare una UDC e permette l'export della
matrice. matrice. La griglia ad alte prestazioni e' resa con `tksheet`, mantenendo la
stessa semantica visiva delle celle operative.
## Flusso operativo ## Flusso operativo
@@ -57,5 +58,7 @@ flowchart LR
- Il modulo usa un token `_req_counter` per evitare che risposte async vecchie - Il modulo usa un token `_req_counter` per evitare che risposte async vecchie
aggiornino la UI fuori ordine. aggiornino la UI fuori ordine.
- La statistica globale viene ricalcolata da query SQL, mentre quella della - La statistica globale viene ricalcolata da query SQL, mentre quella della
corsia corrente usa la matrice già caricata in memoria. corsia corrente usa la matrice gia' caricata in memoria.
- `destroy()` marca la finestra come non più attiva per evitare callback tardive. - Il click destro su una cella riusa lo stesso menu contestuale della versione
precedente basata su pulsanti CTk.
- `destroy()` marca la finestra come non piu' attiva per evitare callback tardive.

View File

@@ -13,7 +13,7 @@ Questo modulo gestisce la vista master/detail delle picking list e permette di:
```{mermaid} ```{mermaid}
flowchart TD flowchart TD
A["open_pickinglist_window() da main.py"] --> B["create_pickinglist_frame()"] A["open_pickinglist_window() in gestione_pickinglist.py"] --> B["create_frame()"]
B --> C["GestionePickingListFrame.__init__()"] B --> C["GestionePickingListFrame.__init__()"]
C --> D["_build_layout()"] C --> D["_build_layout()"]
D --> E["after_idle(_first_show)"] D --> E["after_idle(_first_show)"]

View File

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

View File

@@ -22,7 +22,7 @@ flowchart TD
I --> K["open_layout_window()"] I --> K["open_layout_window()"]
I --> L["open_celle_multiple_window()"] I --> L["open_celle_multiple_window()"]
I --> M["open_search_window()"] I --> M["open_search_window()"]
I --> N["open_pickinglist_window()"] I --> N["gestione_pickinglist.open_pickinglist_window()"]
``` ```
## Schema di chiamata ## Schema di chiamata
@@ -33,13 +33,13 @@ flowchart LR
Launcher --> Layout["open_layout_window"] Launcher --> Layout["open_layout_window"]
Launcher --> Ghost["open_celle_multiple_window"] Launcher --> Ghost["open_celle_multiple_window"]
Launcher --> Search["open_search_window"] Launcher --> Search["open_search_window"]
Launcher --> Pick["open_pickinglist_window"] Launcher --> Pick["gestione_pickinglist.open_pickinglist_window"]
Pick --> PickFactory["create_pickinglist_frame"] Pick --> PickFactory["gestione_pickinglist.create_frame"]
``` ```
## Note ## Note
- `db_app` viene creato una sola volta e poi passato a tutte le finestre. - `db_app` viene creato una sola volta e poi passato a tutte le finestre.
- Alla chiusura del launcher viene chiamato `db_app.dispose()` sul loop globale. - Alla chiusura del launcher viene chiamato `db_app.dispose()` sul loop globale.
- `open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende - `gestione_pickinglist.open_pickinglist_window()` costruisce la finestra in modo nascosto e la rende
visibile solo a layout pronto, per ridurre lo sfarfallio iniziale. visibile solo a layout pronto, per ridurre lo sfarfallio iniziale.

View File

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

View File

@@ -0,0 +1,167 @@
# Warehouse Operational Flow
Questo diagramma descrive il flusso operativo integrato tra:
- layout di magazzino;
- gestione picking list;
- movimentazione fisica dei pallet;
- viste SQL e stored procedure coinvolte;
- aggiornamento dell'interfaccia dopo ogni operazione.
## Vista d'insieme
```{mermaid}
flowchart TD
UI["Operatore su interfaccia"] --> LAY["Gestione Layout"]
UI --> PL["Gestione Picking List"]
subgraph Layout["Flusso Layout"]
LAY --> LC1["Caricamento corsie e metadati layout"]
LC1 --> LC2["vViewMappaturaDescrizioneCorsia"]
LC1 --> LC3["vViewMappaturaPosizCorsia"]
LC1 --> LC4["MagLayout"]
LC1 --> LC5["Celle / Magazzini"]
LC2 --> GRID["Rendering griglia scaffale"]
LC3 --> GRID
LC4 --> GRID
LC5 --> GRID
GRID --> LX["Click su cella / ricerca UDC / menu contestuale"]
end
subgraph Picking["Flusso Picking List"]
PL --> PC1["Caricamento elenco documenti"]
PC1 --> PV1["XMag_ViewPackingList"]
PV1 --> PGRID["Griglia documenti aggregata"]
PGRID --> PSEL["Selezione documento"]
PSEL --> PV2["vViewPackingListRestante"]
PV2 --> PDET["Griglia dettaglio documento"]
PDET --> PACT["Prenota / S-prenota / consultazione"]
end
subgraph Mov["Movimentazione pallet"]
LX --> MOVE{"Operazione fisica?"}
MOVE -->|Carico o spostamento| SP1["sp_xMagGestioneMagazziniPallet"]
MOVE -->|Scarico pallet| SP1
PACT -->|Prenota o s-prenota| SP2["sp_xExePackingListPallet"]
SP1 --> M1["Aggiorna movimenti MagazziniPallet"]
SP1 --> M2["Aggiorna cella destinazione / origine"]
SP1 --> M3["Controlla prenotazione automatica"]
M3 --> SP3["sp_ControllaPrenotazionePackingListPalletNew"]
SP3 --> SP4["sp_xExePackingListPalletPrenota"]
SP2 --> M4["Aggiorna Celle.IDStato"]
SP2 --> M5["Scrive LogPackingList"]
SP4 --> M4
end
subgraph Views["Ricostruzione contesto"]
M1 --> GV["XMag_GiacenzaPallet"]
M2 --> CV["Celle"]
GV --> XP["XMag_ViewPackingList"]
GV --> TP["vXTracciaProdotti"]
CV --> XP
XP --> RL1["Stato documento / pallet / ubicazione"]
TP --> RL2["Articolo / lotto / descrizione"]
end
RL1 --> LREF["Refresh Layout / Picking List"]
RL2 --> LREF
LREF --> GRID
LREF --> PGRID
LREF --> PDET
```
## Flusso del layout
```{mermaid}
flowchart TD
A["Apertura Gestione Layout"] --> B["Legge mappatura corsie"]
B --> B1["vViewMappaturaDescrizioneCorsia"]
B --> B2["vViewMappaturaPosizCorsia"]
B --> B3["MagLayout"]
B --> B4["Celle / Magazzini"]
B1 --> C["Costruisce geometria scaffale"]
B2 --> C
B3 --> C
B4 --> C
C --> D["Query giacenza pallet per corsia"]
D --> E["Colora celle: vuota / piena / multipla"]
E --> F["Mostra UDC piu recente nella cella"]
F --> G{"Interazione utente"}
G -->|Ricerca UDC| H["Evidenzia cella in blu"]
G -->|Tasto destro su cella rossa| I["Menu contestuale"]
I --> J["Dialog scarico / analisi multi UDC"]
J --> K["Movimentazione fisica pallet"]
K --> L["Refresh layout"]
```
## Flusso della picking list
```{mermaid}
flowchart TD
A["Apertura Gestione Picking List"] --> B["Query aggregata documenti"]
B --> C["XMag_ViewPackingList"]
C --> D["Una riga per documento"]
D --> E["Utente seleziona il documento"]
E --> F["Query dettaglio documento"]
F --> G["vViewPackingListRestante"]
G --> H["Mostra pallet ancora rilevanti per il documento"]
H --> I{"Azione utente"}
I -->|Prenota o s-prenota| J["sp_xExePackingListPallet"]
I -->|Consulta dettaglio| H
J --> K["Aggiorna IDStato delle celle del documento"]
K --> L["LogPackingList"]
K --> M["Refresh lista documenti"]
K --> N["Refresh dettaglio documento"]
```
## Flusso della movimentazione pallet
```{mermaid}
flowchart TD
A["Utente legge barcode pallet e cella"] --> B["sp_xMagGestioneMagazziniPallet"]
B --> C{"Il pallet esiste gia in giacenza?"}
C -->|No| D["Inserisce movimento V sulla nuova cella"]
C -->|Si| E["Inserisce movimento P sulla vecchia cella"]
E --> F["Inserisce movimento V sulla nuova cella"]
D --> G["Aggiorna ricostruzione giacenza"]
F --> G
G --> H["XMag_GiacenzaPallet"]
H --> I["XMag_ViewPackingList"]
H --> J["vXTracciaProdotti"]
I --> K["Ubicazione e stato documento"]
J --> L["Dati articolo e lotto"]
K --> M["Refresh interfaccia"]
L --> M
```
## Significato delle viste e delle stored procedure
- `XMag_ViewPackingList`:
ricostruisce il collegamento tra pallet, documento, ubicazione e stato
logistico. E' la vista principale per la schermata picking list.
- `vViewPackingListRestante`:
mostra il dettaglio operativo del documento, cioe' le righe ancora visibili
e ordinate per ubicazione.
- `vXTracciaProdotti`:
arricchisce il pallet con lotto, codice articolo e descrizione.
- `sp_xMagGestioneMagazziniPallet`:
esegue il movimento fisico del pallet nel magazzino.
- `sp_xExePackingListPallet`:
fa il toggle di prenotazione delle celle coinvolte in una picking list.
- `sp_xExePackingListPalletPrenota`:
forza la prenotazione a `IDStato = 1`.
- `sp_ControllaPrenotazionePackingListPalletNew`:
controlla se, dopo una movimentazione fisica, debba essere riapplicata una
prenotazione automatica sulle celle coinvolte.
## Lettura pratica del sistema
- Il layout risponde alla domanda:
"dove sono i pallet e come sono distribuiti nello scaffale?"
- La picking list risponde alla domanda:
"quali pallet fanno parte di un documento e quali celle sono prenotate?"
- La movimentazione pallet risponde alla domanda:
"cosa succede nel tracciato quando un pallet viene caricato, spostato o
scaricato?"
- Le viste vengono rilette dopo l'operazione per riportare la UI in uno stato
coerente con il database.

View File

@@ -1,4 +1,4 @@
"""One-off maintenance script to sanitize ``border_color`` usage in ``layout_window``. """One-off maintenance script to sanitize ``border_color`` usage in ``gestione_layout``.
The script removes incompatible ``border_color='transparent'`` assignments from The script removes incompatible ``border_color='transparent'`` assignments from
widget configuration calls while preserving explicit highlight colors that are widget configuration calls while preserving explicit highlight colors that are
@@ -9,7 +9,7 @@ import re
from pathlib import Path from pathlib import Path
# Path default (modifica se serve) # Path default (modifica se serve)
p = Path("./layout_window.py") p = Path("./gestione_layout.py")
if not p.exists(): if not p.exists():
raise SystemExit(f"File non trovato: {p}") raise SystemExit(f"File non trovato: {p}")

View File

@@ -1,4 +1,4 @@
"""One-off maintenance script to patch performance issues in ``layout_window``. """One-off maintenance script to patch performance issues in ``gestione_layout``.
The script was used during development to remove an expensive resize-triggered The script was used during development to remove an expensive resize-triggered
refresh and to inject some lifecycle guards into the window implementation. refresh and to inject some lifecycle guards into the window implementation.
@@ -8,7 +8,7 @@ It is kept in the repository as an auditable patch recipe.
from pathlib import Path from pathlib import Path
import re import re
p = Path("./layout_window.py") p = Path("./gestione_layout.py")
src = p.read_text(encoding="utf-8") src = p.read_text(encoding="utf-8")
backup = p.with_suffix(".py.bak_perf") backup = p.with_suffix(".py.bak_perf")

78
flussi operativi.txt Normal file
View File

@@ -0,0 +1,78 @@
Flusso delle procedure del barcode così come le compie il magazziniere con il barcode.
Le procudere analizzate sono 3: carico(versamento), scarico(prelievo), prelievo pickinglist
1 reset iniziale
Il magazziniere entra nello stato iniziale premendo f1
Poichè non c'è nessuna pickinglist prenotata (stato 1) , (questo è il pre-requisito) questo è lo stato iniziale
Input text e label diventano:
L'input text Pallet diventa vuoto e acquisisce il focus
L'input text Cella diventa 9000000
La label 1 , dall'alto , diventa rossa
Le altre 3 sono vuote e grige.
Questo stato iniziale è identico per carico e scarico , la discriminante tra le due operazioni è ciò che l'operatore farà dopo essere entrato in questo stato.
2 prelievo
Il passaggio 2 è sempre la lettura di un codice udc. Che può essere fatta da barcode oppure da tastiera, se è fatta da barcode la lettura implica uno spostamento del focus sull'input text Cella. Se è fatta da tastiera all'input del 6° carattere il focus salta automaticamente all'inputtext Cella.
Se ora l'operatore preme "f4 elimina" il pallet corrente viene associato alla cella 9000000 e di fatto prelevato.
Questo chiude il prelievo, il dato viene inviato al server e la 4 label diventano
Ok scarico - 698345 -> verde
P2506000007
S-174
Center ring
Questo è lo scarico , dopo 2 secondi la form si resetta come in 1.
3 versamento
Se all'atto della lettura del codice udc anzichè lasciare 9000000 nell'input text l'operatore leggesse un codice di cella questo implicherebbe un versamento di quell'udc in quella cella.
La lettura del codice di cella può avvenire solo da barcode e non da tastiera, a meno che l'oepratore non conosca effettivamente quel codice.
Nel momento in cui l'operatore legge il barcode della cella automaticamente parte l'aggiornamento del versameto sul server. Se l'operazine è andata a buona fine le label intermendi diventano
OK carico . --> verde
Lotto
codice
descrizione
dopo 2 secondi dall'ok il form si resetta come in 1.
4 pickinglkist
se esiste una picking list con id stato 1 questa può essere prelevata mediante f1, con f2 si salta alla lista successiva con numdoc più basso. Se non c'è nessuna picking list prenotata f1 resetta il barcode alla condizione 1 . Se premo f2 salto alla picking list successiva per numdoc a quella con il numdoc più basso di tutte. In pratica quella con il numdoc più basso può andare in f1 solo se viene prenotata.
Supponiamo di avere prentato una pickinglist e quindi di premere f1, oppure di andare sulla successiva a quella con il numdoc più basso con f2. Premere f1 o f2 se ci sono plist prenotate o più plist, non perdispone più al prelievo o versamento di cui ai punti 1 e 2 ma allo scorrimento/prelievo di una picking list.
L'operatore preme f1 , gli inputtext e le label diventano
pallet : vuoto con focus
cella: 9000000
Ok Cella indirizzo cella
numdoc della pickinglist
descrizione picking list
num udc
Questa informazioni sono quelle prese dal contneuto dle picking list
, cioè la prima udc da prelevare, la sua locazione e la descrizone del docuemnto corrente selezionato.
L'operatore va quindi a cercare la cella, il focus si è posizionato su inputtext pallet.
L'operatore legge il barcode della UDC, oppure digita il codice e al sesto carattere scatta il tab sull'input successivo.
Qui , in automatico, parte il controllo ce codice udc sia quello giusto, se ciè è vero parte il dato per il database che associa l'udc alla cella 7G etc. non ricordo, che significa che l'udc è stata spedita. Cosa avvenga in questo punto è da verificare sul codice
Se è tutto ok la prima label diviene "ok scarico " -- verde
e occorre vedere sul codice c# cosa compare nelle altre labels.
Dopo 2 secondi al form si posiziona sulla successiva udc e il ciclo ricomincia.

View File

@@ -1,22 +1,26 @@
"""Shared Tk/async helpers used by multiple warehouse windows. """Shared Tk/async helpers used by multiple warehouse windows.
The module bundles three concerns used throughout the GUI: The module bundles two concerns used throughout the GUI:
* lifecycle of the shared background asyncio loop;
* a modal-like busy overlay shown during long-running tasks; * a modal-like busy overlay shown during long-running tasks;
* an ``AsyncRunner`` that schedules coroutines and re-enters Tk safely. * an ``AsyncRunner`` that schedules coroutines on the shared loop and
re-enters Tk safely.
The shared loop itself is defined only in :mod:`async_loop_singleton` and is
reused here instead of being recreated locally.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import threading
import tkinter as tk import tkinter as tk
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
import customtkinter as ctk import customtkinter as ctk
__VERSION__ = "GestioneAreeFrame v3.2.5-singleloop" from async_loop_singleton import get_global_loop
__VERSION__ = "GestioneAreeFrame v3.3.0-singleloop"
try: try:
from async_msssql_query import AsyncMSSQLClient # noqa: F401 from async_msssql_query import AsyncMSSQLClient # noqa: F401
@@ -24,50 +28,6 @@ except Exception:
AsyncMSSQLClient = object # type: ignore AsyncMSSQLClient = object # type: ignore
class _LoopHolder:
"""Keep references to the shared event loop and its worker thread."""
def __init__(self):
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.thread: Optional[threading.Thread] = None
self.ready = threading.Event()
_GLOBAL = _LoopHolder()
def _run_loop():
"""Create and run the shared event loop inside the worker thread."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_GLOBAL.loop = loop
_GLOBAL.ready.set()
loop.run_forever()
def get_global_loop() -> asyncio.AbstractEventLoop:
"""Return the shared background event loop, creating it if needed."""
if _GLOBAL.loop is not None:
return _GLOBAL.loop
_GLOBAL.thread = threading.Thread(target=_run_loop, name="warehouse-asyncio", daemon=True)
_GLOBAL.thread.start()
_GLOBAL.ready.wait(timeout=5.0)
if _GLOBAL.loop is None:
raise RuntimeError("Impossibile avviare l'event loop globale")
return _GLOBAL.loop
def stop_global_loop():
"""Stop the shared event loop and release thread references."""
if _GLOBAL.loop and _GLOBAL.loop.is_running():
_GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop)
if _GLOBAL.thread:
_GLOBAL.thread.join(timeout=2.0)
_GLOBAL.loop = None
_GLOBAL.thread = None
_GLOBAL.ready.clear()
class BusyOverlay: class BusyOverlay:
"""Semi-transparent overlay used to block interaction during async tasks.""" """Semi-transparent overlay used to block interaction during async tasks."""

1442
gestione_layout.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,72 @@ smooth by relying on deferred updates and lightweight progress indicators.
""" """
from __future__ import annotations from __future__ import annotations
import json
import sys
import tkinter as tk import tkinter as tk
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox from tkinter import messagebox
from typing import Optional, Any, Dict, List, Callable from typing import Optional, Any, Dict, List, Callable
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps
from pathlib import Path
import logging
from audit_log import log_user_action
try:
from loguru import logger
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
class _FallbackLogger:
"""Minimal adapter used only when Loguru is not installed yet."""
def __init__(self):
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = False
def bind(self, **_kwargs):
return self
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
handler: logging.Handler
if hasattr(sink, "write"):
handler = logging.StreamHandler(sink)
else:
handler = logging.FileHandler(str(sink), encoding=encoding)
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
)
self._logger.addHandler(handler)
return 0
def log(self, level, message):
getattr(self._logger, str(level).lower(), self._logger.info)(message)
def debug(self, message):
self._logger.debug(message)
def info(self, message):
self._logger.info(message)
def exception(self, message):
self._logger.exception(message)
logger = _FallbackLogger()
try:
from tksheet import Sheet, natural_sort_key
except Exception:
Sheet = None # type: ignore[assignment]
natural_sort_key = None # type: ignore[assignment]
# Usa overlay e runner "collaudati" # Usa overlay e runner "collaudati"
from gestione_aree_frame_async import BusyOverlay, AsyncRunner from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from user_session import UserSession
from window_placement import place_window_fullsize_below_parent_later
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) === # === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
import asyncio import asyncio
@@ -27,6 +85,118 @@ except Exception:
self.rc = rc; self.message = message; self.id_result = id_result self.rc = rc; self.message = message; self.id_result = id_result
PICKINGLIST_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
PICKINGLIST_DETAIL_TEST_MULTIPLIER = 1 # 1 disables artificial row expansion for UI stress tests
MODULE_LOG_NAME = Path(__file__).stem
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
_MODULE_LOG_ENABLED = PICKINGLIST_LOG_MODE.upper() != "OFF"
_MODULE_LOG_LEVEL = "DEBUG" if PICKINGLIST_LOG_MODE.upper() == "DEBUG" else "INFO"
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
_MODULE_LOGGING_CONFIGURED = False
def _configure_module_logger():
"""Configure console and file logging for this module."""
global _MODULE_LOGGING_CONFIGURED
if _MODULE_LOGGING_CONFIGURED:
return
if not _MODULE_LOG_ENABLED:
_MODULE_LOGGING_CONFIGURED = True
return
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
logger.add(
sys.stderr,
level=_MODULE_LOG_LEVEL,
colorize=True,
filter=record_filter,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
logger.add(
MODULE_LOG_PATH,
level=_MODULE_LOG_LEVEL,
colorize=False,
encoding="utf-8",
filter=record_filter,
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}",
)
_MODULE_LOGGING_CONFIGURED = True
def _format_payload(payload: Any) -> str:
"""Serialize payloads for human-readable logging."""
try:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
except Exception:
return repr(payload)
def _log_call(level: Optional[str] = None):
"""Trace entry, exit and failure of selected high-level functions."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
effective_level = level or _MODULE_LOG_LEVEL
_MODULE_LOGGER.log(
effective_level,
f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}",
)
try:
result = func(*args, **kwargs)
except Exception:
_MODULE_LOGGER.exception(f"FAIL {func.__qualname__}")
raise
_MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}")
return result
return wrapper
return decorator
def _log_sql(query_name: str, sql: str, params: Dict[str, Any]):
"""Log one SQL statement and its parameters."""
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}")
_MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}")
def _log_dataset(query_name: str, rows: List[Dict[str, Any]]):
"""Log query results at summary or full-debug level depending on the flag."""
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows")
if PICKINGLIST_LOG_MODE.upper() == "DEBUG":
_MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}")
def _expand_detail_rows_for_test(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Artificially duplicate detail rows to stress-test the UI with larger datasets."""
multiplier = max(1, int(PICKINGLIST_DETAIL_TEST_MULTIPLIER))
if multiplier == 1 or not rows:
return rows
expanded: List[Dict[str, Any]] = []
for copy_idx in range(multiplier):
for row_idx, row in enumerate(rows):
cloned = dict(row)
cloned["__test_copy__"] = copy_idx
cloned["__test_row__"] = row_idx
expanded.append(cloned)
_MODULE_LOGGER.info(
f"Dataset dettaglio espanso artificialmente da {len(rows)} a {len(expanded)} righe per test UI"
)
return expanded
_configure_module_logger()
if _MODULE_LOG_ENABLED:
_MODULE_LOGGER.info(
f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={PICKINGLIST_LOG_MODE.upper()}"
)
# -------------------- SQL -------------------- # -------------------- SQL --------------------
SQL_PL = """ SQL_PL = """
SELECT SELECT
@@ -45,14 +215,14 @@ SELECT
MAX(Cella) AS Cella, MAX(Cella) AS Cella,
MIN(Ordinamento) AS Ordinamento, MIN(Ordinamento) AS Ordinamento,
MAX(IDStato) AS IDStato MAX(IDStato) AS IDStato
FROM dbo.XMag_ViewPackingList FROM dbo.py_ViewPackingListRestante
GROUP BY Documento, CodNazione, NAZIONE, Stato GROUP BY Documento, CodNazione, NAZIONE, Stato
ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato; ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato;
""" """
SQL_PL_DETAILS = """ SQL_PL_DETAILS = """
SELECT * SELECT *
FROM ViewPackingListRestante FROM dbo.py_ViewPackingListRestante
WHERE Documento = :Documento WHERE Documento = :Documento
ORDER BY Ordinamento; ORDER BY Ordinamento;
""" """
@@ -205,10 +375,18 @@ class ScrollTable(ctk.CTkFrame):
PADX_R = 8 PADX_R = 8
PADY = 2 PADY = 2
def __init__(self, master, columns: List[ColSpec]): def __init__(
self,
master,
columns: List[ColSpec],
on_header_click: Optional[Callable[[ColSpec], None]] = None,
):
"""Create a fixed-header scrollable table rendered with Tk/CTk widgets.""" """Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
super().__init__(master) super().__init__(master)
self.columns = columns self.columns = columns
self.on_header_click = on_header_click
self._sort_key: Optional[str] = None
self._sort_reverse = False
self.total_w = sum(c.width for c in self.columns) self.total_w = sum(c.width for c in self.columns)
self.grid_rowconfigure(1, weight=1) self.grid_rowconfigure(1, weight=1)
@@ -243,6 +421,10 @@ class ScrollTable(ctk.CTkFrame):
# bind # bind
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width()) self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
self.b_inner.bind("<Configure>", lambda e: self._on_body_configure()) self.b_inner.bind("<Configure>", lambda e: self._on_body_configure())
self.b_canvas.bind("<Enter>", self._bind_mousewheel)
self.b_canvas.bind("<Leave>", self._unbind_mousewheel)
self.b_inner.bind("<Enter>", self._bind_mousewheel)
self.b_inner.bind("<Leave>", self._unbind_mousewheel)
self._build_header() self._build_header()
@@ -265,12 +447,27 @@ class ScrollTable(ctk.CTkFrame):
holder.pack(side="left", fill="y") holder.pack(side="left", fill="y")
holder.pack_propagate(False) holder.pack_propagate(False)
lbl = ctk.CTkLabel(holder, text=col.title, anchor="w") header_text = col.title
if col.key == self._sort_key:
header_text = f"{col.title} {'' if self._sort_reverse else ''}"
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w")
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY) lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
if self.on_header_click and col.key != "__check__":
for widget in (holder, lbl):
widget.bind("<Button-1>", lambda e, c=col: self.on_header_click(c))
widget.configure(cursor="hand2")
self.h_inner.configure(width=self.total_w, height=ROW_H) self.h_inner.configure(width=self.total_w, height=ROW_H)
self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H)) self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
def set_sort_state(self, key: Optional[str], reverse: bool = False):
"""Update the header labels so the active sort is visible."""
self._sort_key = key
self._sort_reverse = reverse
self._build_header()
def _update_body_width(self): def _update_body_width(self):
"""Keep the scroll region aligned with the current body content width.""" """Keep the scroll region aligned with the current body content width."""
self.b_canvas.itemconfigure(self.body_window, width=self.total_w) self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
@@ -300,6 +497,22 @@ class ScrollTable(ctk.CTkFrame):
self.h_canvas.xview_moveto(first) self.h_canvas.xview_moveto(first)
self.xbar.set(first, last) self.xbar.set(first, last)
def _bind_mousewheel(self, _event=None):
"""Route mouse-wheel scrolling to the body canvas while the cursor is over the table."""
self.b_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
def _unbind_mousewheel(self, _event=None):
"""Stop routing global mouse-wheel events when the pointer leaves the table."""
self.b_canvas.unbind_all("<MouseWheel>")
def _on_mousewheel(self, event):
"""Scroll the body canvas vertically in response to wheel movement."""
delta = getattr(event, "delta", 0)
if delta == 0:
return
step = -1 if delta > 0 else 1
self.b_canvas.yview_scroll(step, "units")
def clear_rows(self): def clear_rows(self):
"""Remove all rendered body rows.""" """Remove all rendered body rows."""
for w in self.b_inner.winfo_children(): for w in self.b_inner.winfo_children():
@@ -369,28 +582,51 @@ class PLRow:
# -------------------- main frame (no-flicker + UX tuning + spinner) -------------------- # -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
class GestionePickingListFrame(ctk.CTkFrame): class GestionePickingListFrame(ctk.CTkFrame):
def __init__(self, master, *, db_client=None, conn_str=None): @_log_call()
def __init__(self, master, *, db_client=None, conn_str=None, session: UserSession | None = None):
"""Create the master/detail picking list frame.""" """Create the master/detail picking list frame."""
super().__init__(master) super().__init__(master)
self._theme = theme_section("pickinglist_window", {})
self._locale_catalog = load_locale_catalog()
if db_client is None: if db_client is None:
raise ValueError("GestionePickingListFrame richiede un db_client condiviso.") raise ValueError("GestionePickingListFrame richiede un db_client condiviso.")
self.db_client = db_client self.db_client = db_client
self.session = session
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale) self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
self.busy = BusyOverlay(self) # overlay collaudato self.busy = InlineBusyOverlay(self, self._theme)
try:
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
except Exception:
pass
self.rows_models: list[PLRow] = [] self.rows_models: list[PLRow] = []
self._detail_cache: Dict[Any, list] = {} self._detail_cache: Dict[Any, list] = {}
self.detail_doc = None self.detail_doc = None
self._detail_sort_key: Optional[str] = None
self._detail_sort_reverse = False
self._detail_sorting = False
self._first_loading: bool = False # flag per cursore d'attesa solo al primo load self._first_loading: bool = False # flag per cursore d'attesa solo al primo load
self._render_job = None # Tracking del job di rendering in corso self._render_job = None # Tracking del job di rendering in corso
self._build_layout() self._build_layout()
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa) self._initial_load_started = False
self.after_idle(self._first_show)
def _can(self, action: str) -> bool:
"""Return whether the current user can execute one picking-list action."""
return self.session.can(action) if self.session else False
def _operator_id(self) -> int:
"""Return the authenticated operator id or ``0`` if no session is present."""
return int(self.session.operator_id) if self.session else 0
def _first_show(self): def _first_show(self):
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor.""" """Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
if self._initial_load_started:
return
self._initial_load_started = True
self._first_loading = True self._first_loading = True
try: try:
self.winfo_toplevel().configure(cursor="watch") self.winfo_toplevel().configure(cursor="watch")
@@ -408,13 +644,22 @@ class GestionePickingListFrame(ctk.CTkFrame):
top = ctk.CTkFrame(self) top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="ew", padx=10, pady=(8,4)) top.grid(row=0, column=0, sticky="ew", padx=10, pady=(8,4))
try:
top.configure(fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")))
except Exception:
pass
for i, (text, cmd) in enumerate([ for i, (text, cmd) in enumerate([
("Ricarica", self.reload_from_db), (loc_text("picking.button.reload", catalog=self._locale_catalog, default="Ricarica"), self.reload_from_db),
("Prenota", self.on_prenota), (loc_text("picking.button.prenota", catalog=self._locale_catalog, default="Prenota"), self.on_prenota),
("S-prenota", self.on_sprenota), (loc_text("picking.button.sprenota", catalog=self._locale_catalog, default="S-prenota"), self.on_sprenota),
("Esporta XLSX", self.on_export) (loc_text("picking.button.export", catalog=self._locale_catalog, default="Esporta XLSX"), self.on_export)
]): ]):
ctk.CTkButton(top, text=text, command=cmd).grid(row=0, column=i, padx=6) ctk.CTkButton(
top,
text=text,
command=cmd,
font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")),
).grid(row=0, column=i, padx=6)
# --- micro spinner a destra della toolbar --- # --- micro spinner a destra della toolbar ---
self.spinner = ToolbarSpinner(top) self.spinner = ToolbarSpinner(top)
@@ -423,20 +668,171 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.pl_table = ScrollTable(self, PL_COLS) self.pl_table = ScrollTable(self, PL_COLS)
self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8)) self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8))
self.det_table = ScrollTable(self, DET_COLS) self.det_host = tk.Frame(self, bd=0, highlightthickness=0)
self.det_table.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10)) self.det_host.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
self.det_host.grid_rowconfigure(0, weight=1)
self.det_host.grid_columnconfigure(0, weight=1)
self._build_detail_sheet()
self._draw_details_hint() self._draw_details_hint()
def _build_detail_sheet(self):
"""Create the high-volume detail table using tksheet."""
if Sheet is None:
raise RuntimeError("tksheet non disponibile: installa la dipendenza per usare la tabella dettagli.")
self.detail_sheet = Sheet(
self.det_host,
data=[],
show_row_index=False,
show_top_left=False,
width=1000,
height=320,
sort_key=natural_sort_key,
)
self.detail_sheet.change_theme("light green")
self.detail_sheet.enable_bindings("all")
self.detail_sheet.headers(self._detail_headers(), redraw=False)
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
def _draw_details_hint(self): def _draw_details_hint(self):
"""Render the placeholder row shown when no document is selected.""" """Render the placeholder row shown when no document is selected."""
self.det_table.clear_rows() self._load_detail_sheet_data(
self.det_table.add_row( [["", "", "", "Seleziona una Picking List per vedere le UDC...", "", ""]]
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
row_index=0,
anchors=["w"]*6
) )
def _detail_headers(self) -> List[str]:
"""Return detail headers with the active sort indicator, if any."""
headers: List[str] = []
for col in DET_COLS:
title = col.title
if col.key == self._detail_sort_key:
title = f"{title} {'[desc]' if self._detail_sort_reverse else '[asc]'}"
headers.append(title)
return headers
def _detail_rows_to_sheet_data(self, rows: List[Dict[str, Any]]) -> List[List[str]]:
"""Convert detail dictionaries to the row format expected by tksheet."""
data: List[List[str]] = []
for d in rows:
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
lotto = _s(_first(d, ["Lotto"]))
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip() == "") else str(ubi_raw).strip()
data.append([pallet, lotto, articolo, descr, qta, loc])
return data
def _load_detail_sheet_data(self, data: List[List[str]]):
"""Push one full dataset into the tksheet detail widget."""
self.detail_sheet.headers(self._detail_headers(), redraw=False)
self.detail_sheet.set_sheet_data(
data,
reset_col_positions=True,
reset_row_positions=True,
redraw=True,
)
self.detail_sheet.set_all_column_widths()
def _detail_sort_value(self, row: Dict[str, Any], key: str):
"""Return a normalized value used to sort detail rows by one logical column."""
if key == "Ubicazione":
value = _first(row, ["Ubicazione", "Cella", "PalletCella"])
value = "Non scaffalata" if value in (None, "") else value
elif key == "Qta":
value = _first(row, ["Qta", "Quantita", "Qty", "QTY"], 0)
try:
return (0, float(value))
except Exception:
return (1, _s(value).lower())
elif key == "Articolo":
value = _first(row, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"])
elif key == "Descrizione":
value = _first(row, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"])
elif key == "Pallet":
value = _first(row, ["Pallet", "UDC", "PalletID"])
else:
value = row.get(key)
return (0, _s(value).lower())
def _sort_detail_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Return detail rows sorted using the current header state."""
if not self._detail_sort_key:
return rows
return sorted(
rows,
key=lambda row: self._detail_sort_value(row, self._detail_sort_key),
reverse=self._detail_sort_reverse,
)
def _finish_detail_sort_feedback(self, root: tk.Misc):
"""Dismiss busy feedback only after Tk has flushed the detail redraw."""
try:
root.update_idletasks()
except Exception:
pass
self._detail_sorting = False
self.spinner.stop()
self.busy.hide()
try:
self.detail_sheet.configure(cursor="")
root.configure(cursor="")
except Exception:
pass
def _on_detail_header_click(self, col: ColSpec):
"""Toggle detail sorting when the user clicks a detail header."""
if not self.detail_doc or self._detail_sorting:
return
if self._detail_sort_key == col.key:
self._detail_sort_reverse = not self._detail_sort_reverse
else:
self._detail_sort_key = col.key
self._detail_sort_reverse = False
self._detail_sorting = True
self.spinner.start(" Ordino dettagli...")
self.busy.show(f"Ordinamento per {col.title}...")
root = self.winfo_toplevel()
try:
root.configure(cursor="watch")
self.detail_sheet.configure(cursor="watch")
root.update_idletasks()
except Exception:
pass
def _apply_sort():
try:
rows = list(self._detail_cache.get(self.detail_doc, []))
rows = self._sort_detail_rows(rows)
self._detail_cache[self.detail_doc] = rows
self._refresh_details()
finally:
# Wait one more UI turn so the redraw becomes visible before removing feedback.
self.after_idle(lambda r=root: self.after(15, lambda: self._finish_detail_sort_feedback(r)))
self.after(25, _apply_sort)
def _on_detail_sheet_left_click(self, event):
"""Sort detail rows when the user clicks a tksheet header cell."""
try:
region = self.detail_sheet.identify_region(event)
column = self.detail_sheet.identify_column(event, exclude_header=False, allow_end=False)
except Exception:
return
if region != "header" or column is None or column < 0 or column >= len(DET_COLS):
return
self._on_detail_header_click(DET_COLS[column])
def _apply_row_colors(self, rows: List[Dict[str, Any]]): def _apply_row_colors(self, rows: List[Dict[str, Any]]):
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe.""" """Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
try: try:
@@ -517,6 +913,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
break break
# ----- eventi ----- # ----- eventi -----
@_log_call()
def on_row_checked(self, model: PLRow, is_checked: bool): def on_row_checked(self, model: PLRow, is_checked: bool):
"""Handle row selection changes and refresh the detail section.""" """Handle row selection changes and refresh the detail section."""
# selezione esclusiva # selezione esclusiva
@@ -526,18 +923,23 @@ class GestionePickingListFrame(ctk.CTkFrame):
m.set_checked(False) m.set_checked(False)
self.detail_doc = model.pl.get("Documento") self.detail_doc = model.pl.get("Documento")
_MODULE_LOGGER.info(f"Documento selezionato per il dettaglio: {self.detail_doc}")
self.spinner.start(" Carico dettagli…") # spinner ON self.spinner.start(" Carico dettagli…") # spinner ON
async def _job(): async def _job():
_log_sql("SQL_PL_DETAILS", SQL_PL_DETAILS, {"Documento": self.detail_doc})
return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc}) return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
def _ok(res): def _ok(res):
# NON fermare lo spinner subito: lo farà _refresh_details_incremental # NON fermare lo spinner subito: lo farà _refresh_details_incremental
self._detail_cache[self.detail_doc] = _rows_to_dicts(res) rows = _expand_detail_rows_for_test(_rows_to_dicts(res))
_log_dataset("SQL_PL_DETAILS", rows)
self._detail_cache[self.detail_doc] = rows
# Avvia il rendering incrementale che mantiene l'overlay attivo # Avvia il rendering incrementale che mantiene l'overlay attivo
self._refresh_details_incremental() self._refresh_details_incremental()
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore durante il caricamento dettagli del documento {self.detail_doc}: {ex}")
self.spinner.stop() self.spinner.stop()
self.busy.hide() # Chiudi l'overlay in caso di errore self.busy.hide() # Chiudi l'overlay in caso di errore
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}") messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
@@ -555,17 +957,23 @@ class GestionePickingListFrame(ctk.CTkFrame):
else: else:
if not any(m.is_checked() for m in self.rows_models): if not any(m.is_checked() for m in self.rows_models):
self.detail_doc = None self.detail_doc = None
_MODULE_LOGGER.info("Nessun documento selezionato: ripristino placeholder del dettaglio.")
self._refresh_details() self._refresh_details()
# ----- load PL ----- # ----- load PL -----
def reload_from_db(self, first: bool = False): @_log_call()
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
"""Load or reload the picking list summary table from the database.""" """Load or reload the picking list summary table from the database."""
self.spinner.start(" Carico…") # spinner ON self.spinner.start(" Carico…") # spinner ON
async def _job(): async def _job():
_log_sql("SQL_PL", SQL_PL, {})
return await self.db_client.query_json(SQL_PL, {}) return await self.db_client.query_json(SQL_PL, {})
def _on_success(res): def _on_success(res):
rows = _rows_to_dicts(res) rows = _rows_to_dicts(res)
_log_dataset("SQL_PL", rows)
self._refresh_mid_rows(rows) self._refresh_mid_rows(rows)
if reselect_documento:
self.after_idle(lambda doc=reselect_documento: self._reselect_documento_after_reload(doc))
self.spinner.stop() # spinner OFF self.spinner.stop() # spinner OFF
# se era il primo load, ripristina il cursore standard # se era il primo load, ripristina il cursore standard
if self._first_loading: if self._first_loading:
@@ -575,6 +983,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
pass pass
self._first_loading = False self._first_loading = False
def _on_error(ex): def _on_error(ex):
_MODULE_LOGGER.exception(f"Errore durante il caricamento della picking list: {ex}")
self.spinner.stop() self.spinner.stop()
if self._first_loading: if self._first_loading:
try: try:
@@ -592,102 +1001,64 @@ class GestionePickingListFrame(ctk.CTkFrame):
message="Caricamento Picking List…" if first else "Aggiornamento…" message="Caricamento Picking List…" if first else "Aggiornamento…"
) )
@_log_call("DEBUG")
def _refresh_details(self): def _refresh_details(self):
"""Render the detail table for the currently selected document.""" """Render the detail table for the currently selected document."""
self.det_table.clear_rows()
if not self.detail_doc: if not self.detail_doc:
self._draw_details_hint() self._draw_details_hint()
return return
rows = self._detail_cache.get(self.detail_doc, []) rows = list(self._detail_cache.get(self.detail_doc, []))
rows = self._sort_detail_rows(rows)
_MODULE_LOGGER.debug(f"Ridisegno tabella dettaglio per documento={self.detail_doc} righe={len(rows)}")
if not rows: if not rows:
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
row_index=0, anchors=["w"]*6)
return return
for r, d in enumerate(rows): self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
lotto = _s(_first(d, ["Lotto"]))
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
self.det_table.add_row(
values=[pallet, lotto, articolo, descr, qta, loc],
row_index=r,
anchors=[c.anchor for c in DET_COLS]
)
@_log_call("DEBUG")
def _refresh_details_incremental(self, batch_size: int = 25): def _refresh_details_incremental(self, batch_size: int = 25):
""" """
Render detail table incrementally in batches to keep UI responsive. Render detail table using tksheet while keeping busy feedback consistent.
Mantiene l'overlay visibile fino al completamento del rendering.
""" """
self.det_table.clear_rows()
if not self.detail_doc: if not self.detail_doc:
self._draw_details_hint() self._draw_details_hint()
self.spinner.stop() self.spinner.stop()
self.busy.hide() self.busy.hide()
return return
rows = self._detail_cache.get(self.detail_doc, []) rows = list(self._detail_cache.get(self.detail_doc, []))
rows = self._sort_detail_rows(rows)
self._detail_cache[self.detail_doc] = rows
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Avvio rendering dettagli documento={self.detail_doc} righe={len(rows)}")
if not rows: if not rows:
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""], self._load_detail_sheet_data([["", "", "", "Nessuna UDC trovata.", "", ""]])
row_index=0, anchors=["w"]*6)
self.spinner.stop() self.spinner.stop()
self.busy.hide() self.busy.hide()
return return
# Inizia il rendering incrementale
total_rows = len(rows)
self.busy.show(f"Rendering {len(rows)} UDC...") self.busy.show(f"Rendering {len(rows)} UDC...")
self._render_batch(rows, batch_size, 0, total_rows) self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Rendering dettagli completato documento={self.detail_doc} righe={len(rows)}")
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.spinner.stop() self.spinner.stop()
self.busy.hide() 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 ----- # ----- azioni -----
@_log_call()
def on_prenota(self): def on_prenota(self):
"""Reserve the selected picking list.""" """Reserve the selected picking list."""
if not self._can("pickinglist.prenota"):
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' prenotare picking list.", parent=self)
return
model = self._get_selected_model() model = self._get_selected_model()
if not model: if not model:
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.") messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
@@ -700,22 +1071,52 @@ class GestionePickingListFrame(ctk.CTkFrame):
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.") messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
return return
id_operatore = 1 # TODO: recupera dal contesto reale id_operatore = self._operator_id()
if id_operatore <= 0:
messagebox.showerror("Prenota", "Sessione operatore non valida.", parent=self)
return
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta prenotazione documento={documento} id_operatore={id_operatore}")
self.spinner.start(" Prenoto…") self.spinner.start(" Prenoto…")
async def _job(): async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "P")
def _ok(res: SPResult): def _ok(res: SPResult):
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
self.spinner.stop() self.spinner.stop()
if res and res.rc == 0: if res and res.rc == 0:
self._recolor_row_by_documento(documento, desired) log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.prenota",
outcome="ok",
target=documento,
)
self._detail_cache.pop(documento, None)
self.reload_from_db(reselect_documento=documento)
else: else:
msg = (res.message if res else "Errore sconosciuto") msg = (res.message if res else "Errore sconosciuto")
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.prenota",
outcome="denied",
target=documento,
details={"message": msg},
)
messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}") messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore prenotazione documento={documento}: {ex}")
self.spinner.stop() self.spinner.stop()
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.prenota",
outcome="error",
target=documento,
details={"error": str(ex)},
)
messagebox.showerror("Prenota", f"Errore:\n{ex}") messagebox.showerror("Prenota", f"Errore:\n{ex}")
self.runner.run( self.runner.run(
@@ -726,8 +1127,12 @@ class GestionePickingListFrame(ctk.CTkFrame):
message=f"Prenoto la Picking List {documento}" message=f"Prenoto la Picking List {documento}"
) )
@_log_call()
def on_sprenota(self): def on_sprenota(self):
"""Unreserve the selected picking list.""" """Unreserve the selected picking list."""
if not self._can("pickinglist.sprenota"):
messagebox.showwarning("Permesso negato", "L'operatore corrente non puo' s-prenotare picking list.", parent=self)
return
model = self._get_selected_model() model = self._get_selected_model()
if not model: if not model:
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.") messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
@@ -740,22 +1145,52 @@ class GestionePickingListFrame(ctk.CTkFrame):
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.") messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
return return
id_operatore = 1 # TODO: recupera dal contesto reale id_operatore = self._operator_id()
if id_operatore <= 0:
messagebox.showerror("S-prenota", "Sessione operatore non valida.", parent=self)
return
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Richiesta s-prenotazione documento={documento} id_operatore={id_operatore}")
self.spinner.start(" S-prenoto…") self.spinner.start(" S-prenoto…")
async def _job(): async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento) return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "S")
def _ok(res: SPResult): def _ok(res: SPResult):
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Esito s-prenotazione documento={documento} rc={getattr(res, 'rc', None)} message={getattr(res, 'message', None)}")
self.spinner.stop() self.spinner.stop()
if res and res.rc == 0: if res and res.rc == 0:
self._recolor_row_by_documento(documento, desired) log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.sprenota",
outcome="ok",
target=documento,
)
self._detail_cache.pop(documento, None)
self.reload_from_db(reselect_documento=documento)
else: else:
msg = (res.message if res else "Errore sconosciuto") msg = (res.message if res else "Errore sconosciuto")
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.sprenota",
outcome="denied",
target=documento,
details={"message": msg},
)
messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}") messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore s-prenotazione documento={documento}: {ex}")
self.spinner.stop() self.spinner.stop()
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="pickinglist.sprenota",
outcome="error",
target=documento,
details={"error": str(ex)},
)
messagebox.showerror("S-prenota", f"Errore:\n{ex}") messagebox.showerror("S-prenota", f"Errore:\n{ex}")
self.runner.run( self.runner.run(
@@ -772,10 +1207,72 @@ class GestionePickingListFrame(ctk.CTkFrame):
# factory per main # factory per main
def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame': @_log_call()
def create_frame(parent, *, db_client=None, conn_str=None, session: UserSession | None = None) -> 'GestionePickingListFrame':
"""Factory used by the launcher to build the picking list frame.""" """Factory used by the launcher to build the picking list frame."""
ctk.set_appearance_mode("light") ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green") ctk.set_default_color_theme("green")
return GestionePickingListFrame(parent, db_client=db_client) return GestionePickingListFrame(parent, db_client=db_client, session=session)
@_log_call()
def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | None = None) -> tk.Misc:
"""Open the picking list window while minimizing the first paint flicker."""
key = "_gestione_pickinglist_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
frame = getattr(ex, "_pickinglist_frame", None)
if frame is not None:
try:
frame.session = session
except Exception:
pass
try:
ex.deiconify()
except Exception:
pass
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
win = ctk.CTkToplevel(parent)
locale_catalog = load_locale_catalog()
win.title(loc_text("picking.title", catalog=locale_catalog, default="Gestione Picking List"))
theme = theme_section("pickinglist_window", {})
win.geometry(str(theme_value(theme, "window_geometry", "1200x700")))
minsize = theme_value(theme, "window_minsize", [1000, 560])
win.minsize(int(minsize[0]), int(minsize[1]))
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()
place_window_fullsize_below_parent_later(parent, win)
win.after(340, lambda: frame._first_show())
win.after(360, lambda: win.lift() if getattr(win, "winfo_exists", lambda: False)() else None)
win.after(380, lambda: win.focus_force() if getattr(win, "winfo_exists", lambda: False)() else None)
except Exception:
pass
win.bind("<Escape>", lambda e: win.destroy())
win.protocol("WM_DELETE_WINDOW", win.destroy)
return win
# =================== /gestione_pickinglist.py =================== # =================== /gestione_pickinglist.py ===================

772
gestione_scarico.py Normal file
View File

@@ -0,0 +1,772 @@
"""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 AsyncRunner
from audit_log import log_user_action
from busy_overlay import InlineBusyOverlay
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
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 = """
SET NOCOUNT ON;
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.py_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, commit=True)
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._theme = theme_section("scarico_dialog", {})
self._locale_catalog = load_locale_catalog()
self._busy = InlineBusyOverlay(self, self._theme)
self._async = AsyncRunner(self)
self.rows_tree: ttk.Treeview | None = None
self.title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=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=loc_text("scarico.label.location", catalog=self._locale_catalog, default="Ubicazione: {ubicazione}").format(ubicazione=self.ubicazione),
font=theme_font(self._theme, "header_font", ("Segoe UI", 13, "bold")),
).grid(
row=0, column=0, sticky="w"
)
ctk.CTkLabel(
top,
text=loc_text("scarico.label.select", catalog=self._locale_catalog, default="Seleziona le UDC da scaricare"),
anchor="w",
font=theme_font(self._theme, "body_font", ("Segoe UI", 10)),
).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=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)))
style.configure("Scarico.Treeview.Heading", font=theme_font(self._theme, "tree_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=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC"))
self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento"))
self.rows_tree.heading("diag", text=loc_text("scarico.col.diagnostic", catalog=self._locale_catalog, default="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=loc_text("scarico.button.submit", catalog=self._locale_catalog, default="Scarica"),
command=self._on_scarica,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).grid(
row=0, column=1, padx=(8, 0), pady=8
)
ctk.CTkButton(
actions,
text=loc_text("scarico.button.close", catalog=self._locale_catalog, default="Chiudi"),
command=self._close,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).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(
loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"),
loc_text("scarico.msg.load_error", catalog=self._locale_catalog, default="Caricamento UDC fallito:\n{error}").format(error=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(
loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"),
loc_text("scarico.msg.select_one", catalog=self._locale_catalog, default="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(
loc_text("scarico.msg.title", catalog=self._locale_catalog, default="Scarica"),
loc_text("scarico.msg.exec_error", catalog=self._locale_catalog, default="Scarico fallito:\n{error}").format(error=ex),
parent=self,
)
self._async.run(
_job(),
_ok,
_err,
busy=self._busy,
message="Scarico UDC...",
)
@_log_call()
def _close(self):
"""Release the modal grab and close the dialog safely."""
try:
self.grab_release()
except Exception:
pass
try:
self.destroy()
except Exception:
pass
@_log_call()
def open_scarico_dialog(
parent: tk.Misc,
*,
db_client,
idcella: int,
ubicazione: str,
on_completed: Callable[[], None] | None = None,
session: UserSession | None = None,
) -> ScaricoDialog:
"""Create and return the modal unload dialog for one multi-UDC cell."""
return ScaricoDialog(
parent,
db_client=db_client,
idcella=idcella,
ubicazione=ubicazione,
on_completed=on_completed,
session=session,
)

View File

@@ -1,698 +0,0 @@
"""Graphical aisle layout viewer for warehouse cells and pallet occupancy."""
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
"""Format occupancy percentages for the progress-bar labels."""
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
"""Create the window and initialize the state used by the matrix view."""
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._alive = True
self._stats_after_id = None # se mai userai un refresh periodico, potremo cancellarlo qui
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
# disabilitato: il refresh ad ogni <Configure> generava molte query/lag
# self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
"""Create the top toolbar with aisle selection and search controls."""
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
"""Create the container that will host the dynamically rebuilt matrix."""
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
"""Apply the visual state associated with a cell occupancy level."""
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0
)
def _clear_highlight(self):
"""Remove the temporary highlight from the previously focused cell."""
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0)
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0)
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
"""Recreate the visible cell matrix from the latest query result."""
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if getattr(self, '_alive', True) and self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
"""Open the context menu for a single matrix cell."""
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
"""Update a cell state in memory and refresh the local statistics."""
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
"""Create progress bars, labels and legend for occupancy statistics."""
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
"""Add a legend entry describing one matrix color."""
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
"""Load the list of aisles available for visualization."""
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
"""Load the selected aisle when the listbox selection changes."""
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
"""Select a given aisle inside the listbox if it is present."""
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
"""Query and render the matrix for the selected aisle."""
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}")
# ---------------- SEARCH ----------------
def _search_udc(self):
"""Find a pallet barcode and navigate to the aisle and cell that contain it."""
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
"""Highlight a cell by its textual row and column labels."""
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
"""Show a toast when a searched cell cannot be highlighted."""
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
"""Reload the matrix of the currently selected aisle."""
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
"""Export both matrix metadata and the rendered grid to Excel."""
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
"""Refresh global and local occupancy statistics shown in the footer."""
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
"""Draw a horizontal occupancy bar on the given canvas."""
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
"""Show a transient status message at the bottom of the window."""
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
"""Copy a string to the clipboard and inform the user."""
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def destroy(self):
"""Mark the window as closed and release dynamic widgets safely."""
# evita nuovi refresh/async dopo destroy
self._alive = False
# cancella eventuali timer
try:
if self._stats_after_id is not None:
self.after_cancel(self._stats_after_id)
except Exception:
pass
# pulizia UI leggera
try:
for w in list(self.host.winfo_children()):
w.destroy()
except Exception:
pass
try:
super().destroy()
except Exception:
pass
def open_layout_window(parent, db_app):
"""Open the layout window as a singleton-like child of ``parent``."""
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w

229
locale.json Normal file
View File

@@ -0,0 +1,229 @@
{
"default_language": "IT",
"IT": {
"launcher.window_title": "Warehouse 1.0.0",
"launcher.operator": "Operatore: {display_name} ({login})",
"launcher.reset_corsie": "Gestione Corsie",
"launcher.layout": "Gestione Layout",
"launcher.multi_udc": "UDC Fantasma",
"launcher.search": "Ricerca UDC",
"launcher.history_udc": "Storico movimenti UDC",
"launcher.pickinglist": "Gestione Picking List",
"launcher.history_pickinglist": "Storico Picking List",
"launcher.arrange": "Ridisponi finestre",
"launcher.exit": "Esci",
"launcher.already_running_title": "Warehouse",
"launcher.already_running_message": "L'applicazione è già in esecuzione.",
"login.title": "Login Warehouse",
"login.heading": "Autenticazione operatore",
"login.label.login": "Login",
"login.label.password": "Password",
"login.info": "Per ora tutti gli operatori autenticati possono usare tutte le funzioni.",
"login.button.cancel": "Annulla",
"login.button.submit": "Accedi",
"login.status.checking": "Verifico credenziali...",
"login.msg.title": "Login",
"login.msg.missing": "Inserisci login e password.",
"login.msg.invalid": "Credenziali non valide.",
"login.msg.error": "Verifica credenziali fallita:\n{error}",
"dbconfig.title": "Configurazione Database",
"dbconfig.heading": "Configura la connessione al database del magazzino",
"dbconfig.label.server": "Server",
"dbconfig.label.database": "Database",
"dbconfig.label.user": "Utente",
"dbconfig.label.password": "Password",
"dbconfig.label.driver": "Driver ODBC",
"dbconfig.label.encrypt": "Encrypt",
"dbconfig.label.trust_server_certificate": "Trust server certificate",
"dbconfig.info": "Il file verra' salvato localmente e non verra' piu' richiesto ai prossimi avvii.",
"dbconfig.button.cancel": "Annulla",
"dbconfig.button.test": "Test connessione",
"dbconfig.button.save": "Salva",
"dbconfig.busy": "Verifico connessione...",
"dbconfig.msg.title": "Configurazione Database",
"dbconfig.msg.missing": "Compila almeno server, database, utente e password.",
"dbconfig.msg.test_error": "Connessione fallita:\n{error}",
"dbconfig.msg.test_ok": "Connessione riuscita.",
"dbconfig.msg.save_error": "Salvataggio fallito:\n{error}",
"search.title": "Ricerca UDC/Lotto/Codice",
"search.label.udc": "UDC:",
"search.label.lot": "Lotto:",
"search.label.code": "Codice prodotto:",
"search.button.search": "Cerca",
"search.button.export": "Esporta XLSX",
"search.msg.confirm_title": "Conferma",
"search.msg.confirm_all": "Nessun filtro impostato. Vuoi cercare su TUTTO il magazzino?",
"search.msg.export_title": "Esporta",
"search.msg.export_empty": "Non ci sono righe da esportare.",
"search.msg.export_dep": "Per l'esportazione serve 'openpyxl' (pip install openpyxl).",
"search.msg.export_error": "Errore durante l'esportazione:{error}",
"search.msg.no_results_title": "Nessun risultato",
"search.msg.no_results": "Nessuna corrispondenza trovata con le chiavi di ricerca inserite.",
"search.msg.error_title": "Errore ricerca",
"search.busy": "Cerco...",
"history.udc.title": "Storico movimenti UDC",
"history.udc.button.search": "Cerca",
"history.udc.busy": "Carico storico UDC...",
"history.udc.msg.title": "Storico UDC",
"history.udc.msg.empty": "Nessun movimento trovato.",
"history.udc.msg.error": "Errore ricerca:\n{error}",
"history.picking.title": "Storico Picking List",
"history.picking.button.reload": "Ricarica",
"history.picking.detail_title": "Dettaglio contenuto",
"history.picking.busy.master": "Carico storico picking list...",
"history.picking.busy.detail": "Carico dettaglio picking list...",
"history.picking.msg.title": "Storico Picking List",
"history.picking.msg.load_error": "Errore caricamento:\n{error}",
"history.picking.msg.detail_error": "Errore dettaglio:\n{error}",
"reset.title": "Gestione Corsie - svuotamento celle per corsia",
"reset.label.aisle": "Corsia:",
"reset.button.refresh": "Carica",
"reset.button.empty": "Svuota corsia...",
"reset.summary": "Riepilogo",
"layout.title": "Layout corsie",
"layout.button.search": "Cerca per barcode UDC",
"layout.button.refresh": "Aggiorna",
"layout.button.export": "Export XLSX",
"layout.fill.global": "Riempimento globale",
"layout.fill.selected": "Riempimento corsia selezionata",
"layout.legend": "Legenda celle:",
"multi.title": "Celle con piu' pallet",
"multi.button.refresh": "Aggiorna",
"multi.button.expand": "Espandi tutto",
"multi.button.collapse": "Comprimi tutto",
"multi.button.preselect": "Preselezione fantasmi corsia",
"multi.button.remove": "Rimuovi fantasmi corsia",
"multi.button.export": "Esporta in XLSX",
"multi.summary": "Riepilogo % celle multiple per corsia",
"picking.title": "Gestione Picking List",
"picking.button.reload": "Ricarica",
"picking.button.prenota": "Prenota",
"picking.button.sprenota": "S-prenota",
"picking.button.export": "Esporta XLSX",
"scarico.title": "Scarica {ubicazione}",
"scarico.label.location": "Ubicazione: {ubicazione}",
"scarico.label.select": "Seleziona le UDC da scaricare",
"scarico.col.udc": "UDC",
"scarico.col.last_insert": "Ultimo inserimento",
"scarico.col.diagnostic": "Diagnostica",
"scarico.button.submit": "Scarica",
"scarico.button.close": "Chiudi",
"scarico.msg.title": "Scarica",
"scarico.msg.select_one": "Seleziona almeno una UDC da scaricare.",
"scarico.msg.load_error": "Caricamento UDC fallito:\n{error}",
"scarico.msg.exec_error": "Scarico fallito:\n{error}"
},
"ENG": {
"launcher.window_title": "Warehouse 1.0.0",
"launcher.operator": "Operator: {display_name} ({login})",
"launcher.reset_corsie": "Aisle Management",
"launcher.layout": "Layout Management",
"launcher.multi_udc": "Ghost UDC",
"launcher.search": "UDC Search",
"launcher.history_udc": "UDC Movement History",
"launcher.pickinglist": "Picking List Management",
"launcher.history_pickinglist": "Picking List History",
"launcher.arrange": "Arrange windows",
"launcher.exit": "Exit",
"launcher.already_running_title": "Warehouse",
"launcher.already_running_message": "The application is already running.",
"login.title": "Warehouse Login",
"login.heading": "Operator authentication",
"login.label.login": "Login",
"login.label.password": "Password",
"login.info": "For now, every authenticated operator can use every function.",
"login.button.cancel": "Cancel",
"login.button.submit": "Sign in",
"login.status.checking": "Checking credentials...",
"login.msg.title": "Login",
"login.msg.missing": "Enter login and password.",
"login.msg.invalid": "Invalid credentials.",
"login.msg.error": "Credential check failed:\n{error}",
"dbconfig.title": "Database Configuration",
"dbconfig.heading": "Configure the warehouse database connection",
"dbconfig.label.server": "Server",
"dbconfig.label.database": "Database",
"dbconfig.label.user": "User",
"dbconfig.label.password": "Password",
"dbconfig.label.driver": "ODBC Driver",
"dbconfig.label.encrypt": "Encrypt",
"dbconfig.label.trust_server_certificate": "Trust server certificate",
"dbconfig.info": "The file will be saved locally and will not be requested again on the next startup.",
"dbconfig.button.cancel": "Cancel",
"dbconfig.button.test": "Test connection",
"dbconfig.button.save": "Save",
"dbconfig.busy": "Checking connection...",
"dbconfig.msg.title": "Database Configuration",
"dbconfig.msg.missing": "Fill in at least server, database, user and password.",
"dbconfig.msg.test_error": "Connection failed:\n{error}",
"dbconfig.msg.test_ok": "Connection successful.",
"dbconfig.msg.save_error": "Save failed:\n{error}",
"search.title": "Search UDC/Lot/Code",
"search.label.udc": "UDC:",
"search.label.lot": "Lot:",
"search.label.code": "Product code:",
"search.button.search": "Search",
"search.button.export": "Export XLSX",
"search.msg.confirm_title": "Confirm",
"search.msg.confirm_all": "No filters set. Search the whole warehouse?",
"search.msg.export_title": "Export",
"search.msg.export_empty": "There are no rows to export.",
"search.msg.export_dep": "Export requires 'openpyxl' (pip install openpyxl).",
"search.msg.export_error": "Export failed:{error}",
"search.msg.no_results_title": "No results",
"search.msg.no_results": "No matches were found for the entered search keys.",
"search.msg.error_title": "Search error",
"search.busy": "Searching...",
"history.udc.title": "UDC Movement History",
"history.udc.button.search": "Search",
"history.udc.busy": "Loading UDC history...",
"history.udc.msg.title": "UDC History",
"history.udc.msg.empty": "No movement found.",
"history.udc.msg.error": "Search failed:\n{error}",
"history.picking.title": "Picking List History",
"history.picking.button.reload": "Reload",
"history.picking.detail_title": "Content detail",
"history.picking.busy.master": "Loading picking-list history...",
"history.picking.busy.detail": "Loading picking-list detail...",
"history.picking.msg.title": "Picking List History",
"history.picking.msg.load_error": "Load failed:\n{error}",
"history.picking.msg.detail_error": "Detail load failed:\n{error}",
"reset.title": "Aisle Management - empty cells by aisle",
"reset.label.aisle": "Aisle:",
"reset.button.refresh": "Load",
"reset.button.empty": "Empty aisle...",
"reset.summary": "Summary",
"layout.title": "Aisle layout",
"layout.button.search": "Search by UDC barcode",
"layout.button.refresh": "Refresh",
"layout.button.export": "Export XLSX",
"layout.fill.global": "Global occupancy",
"layout.fill.selected": "Selected aisle occupancy",
"layout.legend": "Cell legend:",
"multi.title": "Cells with multiple pallets",
"multi.button.refresh": "Refresh",
"multi.button.expand": "Expand all",
"multi.button.collapse": "Collapse all",
"multi.button.preselect": "Preselect aisle ghosts",
"multi.button.remove": "Remove aisle ghosts",
"multi.button.export": "Export to XLSX",
"multi.summary": "Summary % of multi-UDC cells by aisle",
"picking.title": "Picking List Management",
"picking.button.reload": "Reload",
"picking.button.prenota": "Reserve",
"picking.button.sprenota": "Unreserve",
"picking.button.export": "Export XLSX",
"scarico.title": "Unload {ubicazione}",
"scarico.label.location": "Location: {ubicazione}",
"scarico.label.select": "Select the UDCs to unload",
"scarico.col.udc": "UDC",
"scarico.col.last_insert": "Last insert",
"scarico.col.diagnostic": "Diagnostics",
"scarico.button.submit": "Unload",
"scarico.button.close": "Close",
"scarico.msg.title": "Unload",
"scarico.msg.select_one": "Select at least one UDC to unload.",
"scarico.msg.load_error": "UDC load failed:\n{error}",
"scarico.msg.exec_error": "Unload failed:\n{error}"
}
}

41
locale_text.py Normal file
View File

@@ -0,0 +1,41 @@
"""Localized UI text loader for the warehouse desktop application."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
_LOCALE_FILE = Path(__file__).with_name("locale.json")
@lru_cache(maxsize=1)
def load_locale_catalog() -> dict:
"""Load the locale catalog from JSON, returning a safe default on errors."""
try:
return json.loads(_LOCALE_FILE.read_text(encoding="utf-8"))
except Exception:
return {"default_language": "IT", "IT": {}, "ENG": {}}
def reload_locale_catalog() -> dict:
"""Clear the locale cache and reload the catalog from disk."""
load_locale_catalog.cache_clear()
return load_locale_catalog()
def text(key: str, *, language: str | None = None, catalog: dict | None = None, default: str = "") -> str:
"""Return the localized UI text for ``key`` with Italian fallback."""
data = catalog or load_locale_catalog()
lang = str(language or data.get("default_language") or "IT").upper()
texts = data.get(lang, {}) or {}
if key in texts:
return str(texts[key])
fallback = data.get("IT", {}) or {}
if key in fallback:
return str(fallback[key])
return str(default)

308
login_window.py Normal file
View File

@@ -0,0 +1,308 @@
"""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 locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_section, theme_value
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, *, compact: bool = False):
super().__init__(parent)
self.db_client = db_client
self.compact = bool(compact)
self.result_session: UserSession | None = None
self._async = AsyncRunner(self)
self._theme = theme_section("login_window", {})
self._locale_catalog = load_locale_catalog()
self._login_button: ttk.Button | None = None
self._cancel_button: ttk.Button | None = None
self._status_var = tk.StringVar(value="")
self._show_ready_after_id: str | None = None
self._clear_topmost_after_id: str | None = None
self.title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"))
self.geometry("170x145+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x155+0+0")))
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._show_ready_after_id = self.after(50, self._show_ready)
def _build_ui(self) -> None:
"""Build the compact operator login form."""
body = ttk.Frame(self, padding=8 if self.compact else 8)
body.pack(fill="both", expand=True)
body.columnconfigure(1, weight=0)
row_offset = 0
ttk.Label(body, text="User:").grid(row=row_offset, column=0, sticky="w", padx=(0, 4), pady=4)
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9))
self.login_entry.grid(row=row_offset, column=1, sticky="w", pady=4)
ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 4), pady=4)
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9))
self.password_entry.grid(row=row_offset + 1, column=1, sticky="w", pady=4)
if self.compact:
actions = ttk.Frame(body)
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(6, 0))
self._cancel_button = ttk.Button(
actions,
text="Annulla",
command=self._on_cancel,
)
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
self._login_button = ttk.Button(
actions,
text="OK",
command=self._on_login,
)
self._login_button.grid(row=0, column=0, sticky="ew")
else:
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
self.status_label.grid(row=2, column=0, columnspan=2, sticky="w", pady=(2, 2))
actions = ttk.Frame(body)
actions.grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0))
self._cancel_button = ttk.Button(
actions,
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
command=self._on_cancel,
)
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
self._login_button = ttk.Button(
actions,
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"),
command=self._on_login,
)
self._login_button.grid(row=0, column=0, sticky="ew")
self.bind("<Return>", lambda _e: self._on_login())
self.bind("<Escape>", lambda _e: self._on_cancel())
self.login_var.trace_add("write", lambda *_: self._limit_var(self.login_var, 10))
self.password_var.trace_add("write", lambda *_: self._limit_var(self.password_var, 10))
def _limit_var(self, variable: tk.StringVar, max_len: int) -> None:
value = str(variable.get() or "")
if len(value) > max_len:
variable.set(value[:max_len])
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._clear_topmost_after_id = 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(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.missing", catalog=self._locale_catalog, default="Inserisci login e password."),
parent=self,
)
return
params = {"login": login, "password": password}
self._set_busy(True, loc_text("login.status.checking", catalog=self._locale_catalog, default="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(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.invalid", catalog=self._locale_catalog, default="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(
loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"),
loc_text("login.msg.error", catalog=self._locale_catalog, default="Verifica credenziali fallita:\n{error}").format(error=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."""
if self._show_ready_after_id is not None:
try:
self.after_cancel(self._show_ready_after_id)
except Exception:
pass
self._show_ready_after_id = None
if self._clear_topmost_after_id is not None:
try:
self.after_cancel(self._clear_topmost_after_id)
except Exception:
pass
self._clear_topmost_after_id = None
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
def prompt_login_compact(parent: tk.Misc, db_client) -> UserSession | None:
"""Open the barcode-oriented compact login modal."""
dialog = LoginWindow(parent, db_client, compact=True)
dialog.wait_window()
return dialog.result_session

698
main.py
View File

@@ -6,55 +6,54 @@ project.
""" """
import asyncio import asyncio
import ctypes
import sys import sys
import tkinter as tk import tkinter as tk
import time
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_main")
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox
from async_loop_singleton import get_global_loop from async_loop_singleton import get_global_loop, stop_global_loop
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn from async_msssql_query import AsyncMSSQLClient
from layout_window import open_layout_window from audit_log import log_session_event
from db_config import build_dsn_from_config, ensure_db_config
from gestione_layout import open_layout_window
from gestione_pickinglist import open_pickinglist_window
from login_window import prompt_login
from locale_text import load_locale_catalog, text as loc_text
from reset_corsie import open_reset_corsie_window from reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window from search_pallets import open_search_window
from view_celle_multiple import open_celle_multiple_window from storico_pickinglist import open_storico_pickinglist_window
from storico_udc import open_storico_udc_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
from view_celle_multi_udc import open_celle_multiple_window
from window_placement import (
cascade_children_below_parent,
place_window_below_parent_later,
place_window_fullsize_below_parent_later,
)
# Try factory, else frame, else app (senza passare conn_str all'App) # Development shortcut: skip the login dialog and boot directly as MAG1.
try: # Set to False when you want to restore normal authentication.
from gestione_pickinglist import create_frame as create_pickinglist_frame BYPASS_LOGIN = False
except Exception: BYPASS_LOGIN_USER = {
try: "operator_id": 4,
from gestione_pickinglist import GestionePickingListFrame as _PLFrame "login": "MAG1",
"nominativo": "MAG1",
"privilegio": 3,
"codice_unita": "U1",
}
def create_pickinglist_frame(parent, db_client=None, conn_str=None): # Create one global loop for database work. Tk must keep the main thread clean;
"""Build the picking list UI using the frame-based fallback.""" # callers schedule async jobs on this loop explicitly.
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
return _PLFrame(parent, db_client=db_client, conn_str=conn_str)
except Exception:
from gestione_pickinglist import GestionePickingListApp as _PLApp
def create_pickinglist_frame(parent, db_client=None, conn_str=None):
"""Fallback used only by legacy app-style picking list implementations."""
app = _PLApp()
app.mainloop()
return tk.Frame(parent)
# ---- Config ----
SERVER = r"mde3\gesterp"
DBNAME = "Mediseawall"
USER = "sa"
PASSWORD = "1Password1"
if sys.platform.startswith("win"):
try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
except Exception:
pass
# Create one global loop and make it the default everywhere.
_loop = get_global_loop() _loop = get_global_loop()
asyncio.set_event_loop(_loop)
def _noop(*args, **kwargs): def _noop(*args, **kwargs):
@@ -67,114 +66,543 @@ if not hasattr(tk.Toplevel, "block_update_dimensions_event"):
if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"): if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"):
tk.Toplevel.unblock_update_dimensions_event = _noop # type: ignore[attr-defined] tk.Toplevel.unblock_update_dimensions_event = _noop # type: ignore[attr-defined]
dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD) db_app: AsyncMSSQLClient | None = None
db_app = AsyncMSSQLClient(dsn_app) _APP_MUTEX = None
_APP_MUTEX_NAME = "Local\\WarehousePythonAppSingleton"
def open_pickinglist_window(parent: tk.Misc, db_client: AsyncMSSQLClient): def _dispose_db_client() -> None:
"""Open the picking list window while minimizing initial flicker.""" """Best-effort disposal of the shared DB client."""
win = ctk.CTkToplevel(parent)
win.title("Gestione Picking List")
win.geometry("1200x700+0+100")
win.minsize(1000, 560)
# Keep the toplevel hidden while its content is being created. global db_app
try: if db_app is None:
win.withdraw() return
win.attributes("-alpha", 0.0)
except Exception:
pass
frame = create_pickinglist_frame(win, db_client=db_client)
try:
frame.pack(fill="both", expand=True)
except Exception:
pass
# 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
win.bind("<Escape>", lambda e: win.destroy())
win.protocol("WM_DELETE_WINDOW", win.destroy)
return win
class Launcher(ctk.CTk):
"""Main launcher window that exposes the available warehouse tools."""
def __init__(self):
"""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")
wrap = ctk.CTkFrame(self)
wrap.pack(pady=10, fill="x")
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")
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")
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")
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")
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")
for i in range(5):
wrap.grid_columnconfigure(i, weight=1)
def _on_close():
"""Dispose shared resources before closing the launcher."""
try: try:
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop) fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try: try:
fut.result(timeout=2) fut.result(timeout=2)
except Exception: except Exception:
pass pass
finally:
db_app = None
def _destroy_tk_root(root: tk.Misc | None) -> None:
"""Destroy a hidden bootstrap root without leaking a Tk interpreter."""
if root is None:
return
try:
root.quit()
except Exception:
pass
try:
root.destroy()
except Exception:
pass
def _shutdown_runtime(*, bootstrap: tk.Misc | None = None, dispose_db: bool = True) -> None:
"""Release temporary Tk resources, DB client and background loop."""
try:
_destroy_tk_root(bootstrap)
finally:
if dispose_db:
_dispose_db_client()
try:
stop_global_loop()
except Exception:
pass
def _acquire_single_instance_mutex() -> bool:
"""Return ``True`` only for the first running instance of the application."""
global _APP_MUTEX
if not sys.platform.startswith("win"):
return True
kernel32 = ctypes.windll.kernel32
mutex = kernel32.CreateMutexW(None, False, _APP_MUTEX_NAME)
if not mutex:
return True
last_error = kernel32.GetLastError()
_APP_MUTEX = mutex
ERROR_ALREADY_EXISTS = 183
return last_error != ERROR_ALREADY_EXISTS
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."""
_WINDOW_ORDER = [
"reset_corsie",
"layout",
"multi_udc",
"search",
"storico_udc",
"pickinglist",
"storico_pickinglist",
]
def __init__(self, session: UserSession, db_client: AsyncMSSQLClient):
"""Create the launcher toolbar and wire every button to a feature window."""
super().__init__()
self.session: UserSession = session
self.db_client = db_client
self._theme = theme_section("launcher", {})
self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog()
self._child_windows: list[tk.Misc] = []
self._child_windows_by_key: dict[str, tk.Misc] = {}
self._is_cascading = False
self._focus_restore_pending: set[str] = set()
self._restore_suppressed_until = 0.0
self._exit_icon = self._make_exit_icon(
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
)
self.title(
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}"
)
self._apply_dynamic_geometry()
outer_pady = int(theme_value(self._theme, "outer_pady", 10))
outer_padx = int(theme_value(self._theme, "outer_padx", 0))
info_padx = int(theme_value(self._theme, "info_padx", 6))
info_pady = theme_value(self._theme, "info_pady", [4, 2])
button_padx = int(theme_value(self._theme, "button_padx", 6))
button_pady = int(theme_value(self._theme, "button_pady", 6))
max_buttons_per_row = max(1, int(theme_value(self._theme, "max_buttons_per_row", 7)))
wrap = ctk.CTkFrame(self)
wrap.pack(padx=outer_padx, pady=outer_pady, fill="x")
actions = [
(
"reset_corsie",
loc_text("launcher.reset_corsie", catalog=self._locale_catalog, default="Gestione Corsie"),
"launcher.open_reset_corsie",
lambda: self._open_or_focus_child_window(
"reset_corsie",
lambda: open_reset_corsie_window(self, self.db_client, session=self.session),
),
),
(
"layout",
loc_text("launcher.layout", catalog=self._locale_catalog, default="Gestione Layout"),
"launcher.open_layout",
lambda: self._open_or_focus_child_window(
"layout",
lambda: open_layout_window(self, self.db_client, session=self.session),
),
),
(
"multi_udc",
loc_text("launcher.multi_udc", catalog=self._locale_catalog, default="UDC Fantasma"),
"launcher.open_multi_udc",
lambda: self._open_or_focus_child_window(
"multi_udc",
lambda: open_celle_multiple_window(self, self.db_client, session=self.session),
),
),
(
"search",
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),
"launcher.open_search",
lambda: self._open_or_focus_child_window(
"search",
lambda: open_search_window(self, self.db_client, session=self.session),
),
),
(
"storico_udc",
loc_text("launcher.history_udc", catalog=self._locale_catalog, default="Storico movimenti UDC"),
"launcher.open_history_udc",
lambda: self._open_or_focus_child_window(
"storico_udc",
lambda: open_storico_udc_window(self, self.db_client, session=self.session),
),
),
(
"pickinglist",
loc_text("launcher.pickinglist", catalog=self._locale_catalog, default="Gestione Picking List"),
"launcher.open_pickinglist",
lambda: self._open_or_focus_child_window(
"pickinglist",
lambda: open_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
"storico_pickinglist",
loc_text("launcher.history_pickinglist", catalog=self._locale_catalog, default="Storico Picking List"),
"launcher.open_history_pickinglist",
lambda: self._open_or_focus_child_window(
"storico_pickinglist",
lambda: open_storico_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
"arrange",
loc_text("launcher.arrange", catalog=self._locale_catalog, default="Ridisponi finestre"),
"launcher.arrange_windows",
self._cascade_open_windows,
),
(
"exit",
loc_text("launcher.exit", catalog=self._locale_catalog, default="Esci"),
"launcher.exit",
self._shutdown,
),
]
used_columns = max(1, min(len(actions), max_buttons_per_row))
info = ctk.CTkLabel(
wrap,
text=loc_text(
"launcher.operator",
catalog=self._locale_catalog,
default="Operatore: {display_name} ({login})",
).format(display_name=self.session.display_name, login=self.session.login),
anchor="w",
font=theme_font(self._theme, "info_font", default=("Segoe UI", 12, "bold")),
)
info.grid(row=0, column=0, columnspan=used_columns, padx=info_padx, pady=tuple(info_pady), sticky="ew")
for idx, (_key, label, permission, callback) in enumerate(actions):
row = 1 + (idx // max_buttons_per_row)
column = idx % max_buttons_per_row
text = label
button_options = {}
if _key == "exit":
row = 2
column = max_buttons_per_row - 1
text = label
button_options = {
"image": self._exit_icon,
"compound": "left",
}
button = ctk.CTkButton(
wrap,
text=text,
font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")),
state="normal" if self.session.can(permission) else "disabled",
command=callback,
**button_options,
)
button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew")
tip = tooltip_text(permission, catalog=self._tooltip_catalog)
if tip:
WidgetToolTip(button, tip)
for i in range(max_buttons_per_row):
wrap.grid_columnconfigure(i, weight=1)
self.update_idletasks()
self._apply_dynamic_geometry()
self.protocol("WM_DELETE_WINDOW", self._shutdown)
try:
self.lift()
self.focus_force()
except Exception:
pass
def _make_exit_icon(self, *, color: str) -> tk.PhotoImage:
"""Create a small red X icon without adding image assets to the project."""
size = 14
image = tk.PhotoImage(width=size, height=size)
for offset in range(3, 11):
image.put(color, (offset, offset))
image.put(color, (offset + 1, offset))
image.put(color, (offset, size - 1 - offset))
image.put(color, (offset + 1, size - 1 - offset))
return image
def _apply_dynamic_geometry(self) -> None:
"""Size the launcher around its current content and keep it docked at the top."""
top_x = int(theme_value(self._theme, "window_top_x", 0))
top_y = int(theme_value(self._theme, "window_top_y", 0))
min_width = int(theme_value(self._theme, "window_min_width", 960))
target_width = int(theme_value(self._theme, "window_width", 1280))
requested_width = max(min_width, self.winfo_reqwidth())
width = max(min_width, target_width, requested_width)
height = max(80, self.winfo_reqheight())
self.geometry(f"{width}x{height}+{top_x}+{top_y}")
def _open_child_window(self, key: str, window: tk.Misc | None) -> None:
"""Track child windows opened from the launcher."""
if window is None:
return
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
if window not in self._child_windows:
self._child_windows.append(window)
self._child_windows_by_key = {
child_key: child
for child_key, child in self._child_windows_by_key.items()
if getattr(child, "winfo_exists", lambda: False)()
}
self._child_windows_by_key[key] = window
try:
window.bind(
"<Destroy>",
lambda event, win=window, win_key=key: self._forget_child_window(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<Activate>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
try:
window.bind(
"<FocusIn>",
lambda event, win=window, win_key=key: self._on_child_activate(win_key, win, event.widget),
add="+",
)
except Exception:
pass
def _open_or_focus_child_window(self, key: str, factory) -> None:
"""Open one child per launcher key, or focus the existing window."""
self._child_windows_by_key = {
child_key: child
for child_key, child in self._child_windows_by_key.items()
if getattr(child, "winfo_exists", lambda: False)()
}
existing = self._child_windows_by_key.get(key)
if existing is not None and getattr(existing, "winfo_exists", lambda: False)():
try:
if hasattr(existing, "state") and existing.state() == "iconic":
existing.deiconify()
except Exception:
pass
try:
existing.lift()
existing.focus_force()
except Exception:
pass
try:
place_window_below_parent_later(self, existing)
except Exception:
pass
return
self._open_child_window(key, factory())
def _forget_child_window(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Remove stale references to child windows that have been closed."""
if event_widget is not None and event_widget is not window:
return
try:
tracked = self._child_windows_by_key.get(key)
if tracked is window:
self._child_windows_by_key.pop(key, None)
except Exception:
pass
try:
self._child_windows = [w for w in self._child_windows if w is not window and getattr(w, "winfo_exists", lambda: False)()]
except Exception:
pass
def _widget_belongs_to_window(self, window: tk.Misc, widget: tk.Misc | None) -> bool:
"""Return True when an event widget is the toplevel itself or one of its descendants."""
if widget is None:
return True
if widget is window:
return True
try:
current = widget
while current is not None:
if current is window:
return True
parent_name = current.winfo_parent()
if not parent_name:
break
current = current.nametowidget(parent_name)
except Exception:
pass
return False
def _on_child_activate(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Restore an activated child to the primary slot below the launcher."""
if self._is_cascading:
return
if time.monotonic() < self._restore_suppressed_until:
return
if event_widget is not None and event_widget is not window:
return
tracked = self._child_windows_by_key.get(key)
if tracked is not window or not getattr(window, "winfo_exists", lambda: False)():
return
if key in self._focus_restore_pending:
return
self._focus_restore_pending.add(key)
def _restore() -> None:
try:
tracked_now = self._child_windows_by_key.get(key)
if tracked_now is window and getattr(window, "winfo_exists", lambda: False)():
place_window_below_parent_later(self, window)
try:
window.lift()
except Exception:
pass
finally:
try:
self.after(250, lambda: self._focus_restore_pending.discard(key))
except Exception:
self._focus_restore_pending.discard(key)
try:
self.after(0, _restore)
except Exception:
self._focus_restore_pending.discard(key)
def _cascade_open_windows(self) -> None:
"""Cascade all open child windows following launcher button order."""
self._child_windows = [w for w in self._child_windows if getattr(w, "winfo_exists", lambda: False)()]
self._child_windows_by_key = {
key: window
for key, window in self._child_windows_by_key.items()
if getattr(window, "winfo_exists", lambda: False)()
}
ordered_windows = [
self._child_windows_by_key[key]
for key in self._WINDOW_ORDER
if key in self._child_windows_by_key
]
self._is_cascading = True
self._focus_restore_pending.clear()
self._restore_suppressed_until = time.monotonic() + 1.2
cascade_children_below_parent(
self,
ordered_windows,
x_offset_step=int(theme_value(self._theme, "cascade_x_offset", 28)),
y_offset_step=int(theme_value(self._theme, "cascade_y_offset", 28)),
margin_left=int(theme_value(self._theme, "cascade_margin_left", 0)),
margin_top=int(theme_value(self._theme, "cascade_margin_top", 0)),
)
def _finish_cascade() -> None:
self._is_cascading = False
self._restore_suppressed_until = 0.0
try:
self.lift()
self.focus_force()
except Exception:
pass
try:
self.after(250, _finish_cascade)
except Exception:
self._is_cascading = False
def _shutdown(self) -> None:
"""Dispose session and shared DB resources before closing the launcher."""
try:
if self.session is not None:
log_session_event(self.session, action="logout", outcome="ok")
if self.db_client is not None:
fut = asyncio.run_coroutine_threadsafe(self.db_client.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
finally: finally:
self.destroy() self.destroy()
self.protocol("WM_DELETE_WINDOW", _on_close)
def run_app() -> int:
"""Run the backoffice application entry point."""
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
if not _acquire_single_instance_mutex():
root = tk.Tk()
root.withdraw()
messagebox.showwarning(
loc_text("launcher.already_running_title", default="Warehouse"),
loc_text(
"launcher.already_running_message",
default="L'applicazione e' gia' in esecuzione.\nChiudi l'istanza aperta prima di avviarne un'altra.",
),
parent=root,
)
_destroy_tk_root(root)
try:
stop_global_loop()
except Exception:
pass
return 0
db_cfg = ensure_db_config(_loop)
if db_cfg is None:
try:
stop_global_loop()
except Exception:
pass
return 0
db_app = AsyncMSSQLClient(build_dsn_from_config(db_cfg))
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:
_shutdown_runtime(bootstrap=bootstrap, dispose_db=True)
return 0
_destroy_tk_root(bootstrap)
try:
Launcher(session, db_app).mainloop()
finally:
_shutdown_runtime(bootstrap=None, dispose_db=True)
return 0
if __name__ == "__main__": if __name__ == "__main__":
ctk.set_appearance_mode("light") raise SystemExit(run_with_fatal_log("Warehouse Backoffice", run_app))
ctk.set_default_color_theme("green")
Launcher().mainloop()

View File

@@ -0,0 +1,343 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/* ============================================================
Patch prenotazione Picking List a livello documento
Obiettivo:
- rendere esclusiva la prenotazione di una sola picking list
- mantenere la stored con lo stesso nome:
dbo.sp_xExePackingListPallet
- introdurre una sorgente di verità per documento prenotato:
dbo.PickingListReservation
- far leggere XMag_ViewPackingList.IDStato da tale stato
e non più soltanto da Celle.IDStato
============================================================ */
IF OBJECT_ID(N'dbo.PickingListReservation', N'U') IS NULL
BEGIN
CREATE TABLE [dbo].[PickingListReservation](
[ID] [tinyint] NOT NULL,
[Documento] [varchar](8) NULL,
[IDOperatore] [int] NULL,
[ModUtente] [varchar](50) NULL,
[ModDataOra] [datetime] NULL,
CONSTRAINT [PK_PickingListReservation] PRIMARY KEY CLUSTERED ([ID] ASC),
CONSTRAINT [CK_PickingListReservation_Singleton] CHECK ([ID] = 1)
) ON [PRIMARY];
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.PickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_xExePackingListPallet]
@IDOperatore int,
@Documento varchar(8),
@Azione char(1) = 'P', -- 'P' = Prenota, 'S' = S-prenota
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
DECLARE @DocumentoAttivo varchar(8) = NULL;
DECLARE @Description varchar(255) = '';
DECLARE @Message varchar(255) = '';
DECLARE @IDResult int = 0;
DECLARE @ID int = 0;
SELECT @Nominativo = [Login]
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
IF @Azione NOT IN ('P', 'S')
BEGIN
SET @RC = -10;
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
SET @RC = -20;
RETURN;
END;
IF NOT EXISTS (SELECT 1 FROM dbo.PickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END;
SELECT @DocumentoAttivo = NULLIF(LTRIM(RTRIM(Documento)), '')
FROM dbo.PickingListReservation
WHERE ID = 1;
DECLARE @TargetCelle TABLE (
IDCella int PRIMARY KEY
);
INSERT INTO @TargetCelle (IDCella)
SELECT DISTINCT Cella
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL;
IF @Azione = 'P'
BEGIN
IF @DocumentoAttivo = @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE ISNULL(c.IDStato, 0) <> 0;
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PickingListReservation
SET Documento = @Documento,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
SELECT TOP 1 @Description = NAZIONE
FROM dbo.XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento;
EXEC dbo.sp_LogPackingList
@ID = @ID,
@Code = @Documento,
@Description = @Description,
@Message = @Message OUTPUT,
@IDResult = @IDResult OUTPUT;
RETURN;
END;
IF @Azione = 'S'
BEGIN
IF ISNULL(@DocumentoAttivo, '') <> @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
END;
GO
CREATE OR ALTER VIEW [dbo].[XMag_ViewPackingList]
AS
WITH Base AS (
SELECT
dbo.vPreparaPackingList.UDC AS Pallet,
dbo.vPreparaPackingList.NUMLOT AS Lotto,
dbo.vPreparaPackingList.CODICE AS Articolo,
dbo.vPreparaPackingList.DESCR AS Descrizione,
dbo.vPreparaPackingList.Qta,
dbo.vPreparaPackingList.NUMDOC AS Documento,
dbo.vPreparaPackingList.Expr1 AS CodNazione,
dbo.vPreparaPackingList.NAZIONE,
CASE
WHEN Expr1 = 'DE' THEN 10
WHEN Expr1 = 'TH' THEN CASE WHEN SUBSTRING(dbo.vPreparaPackingList.DESCRDEST, 1, 2) = 'NA' THEN 11 ELSE 13 END
WHEN Expr1 = 'MEX' THEN 12
ELSE 4
END AS Stato,
ISNULL(dbo.XMag_GiacenzaPallet.NumeroPallet, 0) AS PalletCella,
ISNULL(dbo.XMag_GiacenzaPallet.IDMagazzino, 1) AS Magazzino,
ISNULL(dbo.XMag_GiacenzaPallet.IDArea, 5) AS Area,
ISNULL(dbo.XMag_GiacenzaPallet.IDCella, 1000) AS Cella,
ISNULL(dbo.Celle.Ordinamento, 99999) AS Ordinamento,
ISNULL(dbo.Celle.Corsia + ' - ' + dbo.Celle.Colonna + ' - ' + dbo.Celle.Fila, 'Non scaff.') AS Ubicazione,
SUBSTRING(dbo.vPreparaPackingList.DESCRDEST, 1, 2) AS DEST
FROM dbo.Celle
INNER JOIN dbo.XMag_GiacenzaPallet
ON dbo.Celle.ID = dbo.XMag_GiacenzaPallet.IDCella
RIGHT OUTER JOIN dbo.vPreparaPackingList
ON dbo.XMag_GiacenzaPallet.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = dbo.vPreparaPackingList.UDC
GROUP BY
dbo.vPreparaPackingList.Expr1,
dbo.vPreparaPackingList.NAZIONE,
dbo.vPreparaPackingList.UDC,
dbo.vPreparaPackingList.NUMDOC,
dbo.vPreparaPackingList.NUMLOT,
dbo.vPreparaPackingList.CODICE,
dbo.vPreparaPackingList.DESCR,
dbo.vPreparaPackingList.Qta,
dbo.XMag_GiacenzaPallet.NumeroPallet,
dbo.XMag_GiacenzaPallet.IDMagazzino,
dbo.XMag_GiacenzaPallet.IDArea,
dbo.XMag_GiacenzaPallet.IDCella,
dbo.Celle.Ordinamento,
dbo.Celle.Corsia,
dbo.Celle.Colonna,
dbo.Celle.Fila,
dbo.vPreparaPackingList.DESCRDEST
)
SELECT TOP 10000
Base.Pallet,
Base.Lotto,
Base.Articolo,
Base.Descrizione,
Base.Qta,
Base.Documento,
Base.CodNazione,
Base.NAZIONE,
Base.Stato,
Base.PalletCella,
Base.Magazzino,
Base.Area,
Base.Cella,
Base.Ordinamento,
Base.Ubicazione,
Base.DEST,
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(Base.Documento AS varchar(8))
THEN 1
ELSE 0
END AS IDStato
FROM Base
LEFT JOIN dbo.PickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
ORDER BY
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(Base.Documento AS varchar(8))
THEN 1
ELSE 0
END DESC,
Base.Documento,
Base.Ordinamento;
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_xExePackingListPalletPrenota]
@IDOperatore int,
@Documento varchar(8),
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
SELECT @Nominativo = LOGIN
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE c.ID IN (
SELECT DISTINCT Cella
FROM dbo.ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL
);
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[sp_ControllaPrenotazionePackingListPalletNew]
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Documento varchar(8) = NULL;
DECLARE @IDOperatore int = 0;
DECLARE @Nominativo varchar(50) = 'SYSTEM';
DECLARE @RC int = 0;
SELECT
@Documento = NULLIF(LTRIM(RTRIM(Documento)), ''),
@IDOperatore = ISNULL(IDOperatore, 0),
@Nominativo = ISNULL(NULLIF(LTRIM(RTRIM(ModUtente)), ''), 'SYSTEM')
FROM dbo.PickingListReservation
WHERE ID = 1;
IF ISNULL(@Documento, '') = ''
RETURN;
IF NOT EXISTS (
SELECT 1
FROM dbo.ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
UPDATE dbo.PickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
IF @IDOperatore <= 0
BEGIN
SELECT TOP 1 @IDOperatore = ID
FROM dbo.Operatori
WHERE LOGIN = @Nominativo;
END;
EXEC dbo.sp_xExePackingListPalletPrenota
@IDOperatore = @IDOperatore,
@Documento = @Documento,
@RC = @RC OUTPUT;
END;
GO

View File

@@ -2,9 +2,150 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
try:
from loguru import logger
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
class _FallbackLogger:
"""Minimal adapter used only when Loguru is not installed yet."""
def __init__(self):
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = False
def bind(self, **_kwargs):
return self
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
handler: logging.Handler
if hasattr(sink, "write"):
handler = logging.StreamHandler(sink)
else:
handler = logging.FileHandler(str(sink), encoding=encoding)
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
)
self._logger.addHandler(handler)
return 0
def log(self, level, message):
getattr(self._logger, str(level).lower(), self._logger.info)(message)
def debug(self, message):
self._logger.debug(message)
def info(self, message):
self._logger.info(message)
def exception(self, message):
self._logger.exception(message)
logger = _FallbackLogger()
PACKINGLIST_SP_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
MODULE_LOG_NAME = Path(__file__).stem
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
_MODULE_LOG_ENABLED = PACKINGLIST_SP_LOG_MODE.upper() != "OFF"
_MODULE_LOG_LEVEL = "DEBUG" if PACKINGLIST_SP_LOG_MODE.upper() == "DEBUG" else "INFO"
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
_MODULE_LOGGING_CONFIGURED = False
def _configure_module_logger():
"""Configure console and file logging for this module."""
global _MODULE_LOGGING_CONFIGURED
if _MODULE_LOGGING_CONFIGURED:
return
if not _MODULE_LOG_ENABLED:
_MODULE_LOGGING_CONFIGURED = True
return
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
logger.add(
sys.stderr,
level=_MODULE_LOG_LEVEL,
colorize=True,
filter=record_filter,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
logger.add(
MODULE_LOG_PATH,
level=_MODULE_LOG_LEVEL,
colorize=False,
encoding="utf-8",
filter=record_filter,
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}",
)
_MODULE_LOGGING_CONFIGURED = True
def _format_payload(payload: Any) -> str:
"""Serialize payloads for human-readable logging."""
try:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
except Exception:
return repr(payload)
def _log_call(level: Optional[str] = None):
"""Trace entry, exit and failure of selected procedure helpers."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
effective_level = level or _MODULE_LOG_LEVEL
_MODULE_LOGGER.log(
effective_level,
f"CALL {func.__qualname__} args={_format_payload(args[1:] if len(args) > 1 else ())} kwargs={_format_payload(kwargs)}",
)
try:
result = await func(*args, **kwargs)
except Exception:
_MODULE_LOGGER.exception(f"FAIL {func.__qualname__}")
raise
_MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}")
return result
return wrapper
return decorator
def _log_sql(query_name: str, sql: str, params: Dict[str, Any]):
"""Log one SQL statement and its parameters."""
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params)}")
_MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}")
def _log_dataset(query_name: str, rows: Any):
"""Log query results at summary or full-debug level depending on the mode."""
if isinstance(rows, list):
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows")
if PACKINGLIST_SP_LOG_MODE.upper() == "DEBUG":
_MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}")
else:
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} scalar={_format_payload(rows)}")
_configure_module_logger()
if _MODULE_LOG_ENABLED:
_MODULE_LOGGER.info(
f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={PACKINGLIST_SP_LOG_MODE.upper()}"
)
@dataclass @dataclass
class SPResult: class SPResult:
@@ -15,14 +156,18 @@ class SPResult:
id_result: Optional[int] = None id_result: Optional[int] = None
@_log_call("DEBUG")
async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]: async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]:
"""Return the first column of the first row from a query result.""" """Return the first column of the first row from a query result."""
_log_sql("_query_one_value", sql, params)
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
res = await db.query_json(sql, params) res = await db.query_json(sql, params)
if isinstance(res, list) and res: if isinstance(res, list) and res:
row0 = res[0] row0 = res[0]
if isinstance(row0, dict): if isinstance(row0, dict):
return next(iter(row0.values()), None) value = next(iter(row0.values()), None)
_log_dataset("_query_one_value", value)
return value
elif isinstance(res, dict): elif isinstance(res, dict):
rows = None rows = None
for key in ("rows", "data", "result", "records"): for key in ("rows", "data", "result", "records"):
@@ -32,139 +177,126 @@ async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any
if rows: if rows:
row0 = rows[0] row0 = rows[0]
if isinstance(row0, dict): if isinstance(row0, dict):
return next(iter(row0.values()), None) value = next(iter(row0.values()), None)
_log_dataset("_query_one_value", value)
return value
if isinstance(row0, (list, tuple)) and row0: if isinstance(row0, (list, tuple)) and row0:
return row0[0] value = row0[0]
_log_dataset("_query_one_value", value)
return value
_log_dataset("_query_one_value", None)
return None return None
if hasattr(db, "query_value"): if hasattr(db, "query_value"):
return await db.query_value(sql, params) value = await db.query_value(sql, params)
_log_dataset("_query_one_value", value)
return value
if hasattr(db, "scalar"): if hasattr(db, "scalar"):
return await db.scalar(sql, params) value = await db.scalar(sql, params)
_log_dataset("_query_one_value", value)
return value
raise RuntimeError("Il client DB non espone query_json/query_value/scalar") raise RuntimeError("Il client DB non espone query_json/query_value/scalar")
@_log_call("DEBUG")
async def _query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]: async def _query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return all rows as dictionaries, normalizing different DB client APIs.""" """Return all rows as dictionaries, normalizing different DB client APIs."""
_log_sql("_query_all", sql, params)
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
res = await db.query_json(sql, params) res = await db.query_json(sql, params)
if res is None: if res is None:
_log_dataset("_query_all", [])
return [] return []
if isinstance(res, list): if isinstance(res, list):
return res if res and isinstance(res[0], dict) else [] rows = res if res and isinstance(res[0], dict) else []
_log_dataset("_query_all", rows)
return rows
if isinstance(res, dict): if isinstance(res, dict):
for key in ("rows", "data", "result", "records"): for key in ("rows", "data", "result", "records"):
if key in res and isinstance(res[key], list): if key in res and isinstance(res[key], list):
rows = res[key] rows = res[key]
if rows and isinstance(rows[0], dict): if rows and isinstance(rows[0], dict):
_log_dataset("_query_all", rows)
return rows return rows
cols = res.get("columns") or res.get("cols") or [] cols = res.get("columns") or res.get("cols") or []
out = [] out = []
for row in rows: for row in rows:
if isinstance(row, (list, tuple)) and cols: if isinstance(row, (list, tuple)) and cols:
out.append({(cols[i] if i < len(cols) else f"c{i}"): row[i] for i in range(min(len(cols), len(row)))}) out.append({(cols[i] if i < len(cols) else f"c{i}"): row[i] for i in range(min(len(cols), len(row)))})
_log_dataset("_query_all", out)
return out return out
_log_dataset("_query_all", [])
return [] return []
if hasattr(db, "fetch_all"): if hasattr(db, "fetch_all"):
return await db.fetch_all(sql, params) rows = await db.fetch_all(sql, params)
_log_dataset("_query_all", rows)
return rows
raise RuntimeError("Il client DB non espone query_json/fetch_all") raise RuntimeError("Il client DB non espone query_json/fetch_all")
@_log_call("DEBUG")
async def _execute(db, sql: str, params: Dict[str, Any]) -> int: async def _execute(db, sql: str, params: Dict[str, Any]) -> int:
"""Execute a DML statement using the best method exposed by the DB client.""" """Execute a DML statement using the best method exposed by the DB client."""
_log_sql("_execute", sql, params)
for name in ("execute", "exec", "execute_non_query"): for name in ("execute", "exec", "execute_non_query"):
if hasattr(db, name): if hasattr(db, name):
rc = await getattr(db, name)(sql, params) rc = await getattr(db, name)(sql, params)
if isinstance(rc, int): if isinstance(rc, int):
_log_dataset("_execute", rc)
return rc return rc
_log_dataset("_execute", 0)
return 0 return 0
if hasattr(db, "query_json"): if hasattr(db, "query_json"):
await db.query_json(sql, params) await db.query_json(sql, params)
_log_dataset("_execute", 0)
return 0 return 0
raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti") raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti")
async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -> SPResult: @_log_call()
"""Toggle the reservation state of all cells belonging to a packing list. async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, Azione: str = "P") -> SPResult:
"""Execute the Python-specific picking-list reservation stored procedure."""
The implementation mirrors the original SQL stored procedure while using
the shared async DB client already managed by the application.
"""
try: try:
nominativo = await _query_one_value( azione = str(Azione or "P").strip().upper()
db, if azione not in ("P", "S"):
"SELECT LOGIN FROM Operatori WHERE id = :IDOperatore", return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None)
{"IDOperatore": IDOperatore}, _MODULE_LOGGER.log(
) or "" _MODULE_LOG_LEVEL,
f"Procedura packing list via stored procedure documento={Documento} azione={azione} id_operatore={IDOperatore}",
)
sql = """
SET NOCOUNT ON;
DECLARE @RC int = 0;
celle = await _query_all( EXEC dbo.py_sp_xExePackingListPallet
db, @IDOperatore = :IDOperatore,
""" @Documento = :Documento,
SELECT DISTINCT Cella @Azione = :Azione,
FROM dbo.XMag_ViewPackingList @RC = @RC OUTPUT;
WHERE Documento = :Documento
""",
{"Documento": Documento},
)
id_celle = [row.get("Cella") for row in celle if "Cella" in row]
# Each cell is toggled individually because the original procedure also SELECT CAST(@RC AS int) AS RC;
# updates metadata such as operator and timestamp per row.
for id_cella in id_celle:
if id_cella is None:
continue
stato = await _query_one_value(
db,
"SELECT IDStato FROM Celle WHERE ID = :IDC",
{"IDC": id_cella},
)
if stato == 0:
await _execute(
db,
""" """
UPDATE Celle _log_sql("py_sp_xExePackingListPallet", sql, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione})
SET IDStato = 1, if not hasattr(db, "query_json"):
ModUtente = :N, raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.")
ModDataOra = GETDATE() res = await db.query_json(
WHERE ID = :IDC sql,
""", {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione},
{"N": nominativo, "IDC": id_cella}, as_dict_rows=True,
commit=True,
) )
else: rows = []
await _execute( if isinstance(res, dict):
db, rows = res.get("rows", []) or []
""" _log_dataset("py_sp_xExePackingListPallet", rows)
UPDATE Celle rc = 0
SET IDStato = 0, if rows and isinstance(rows[0], dict):
ModUtente = :N, try:
ModDataOra = GETDATE() rc = int(rows[0].get("RC") or 0)
WHERE ID = :IDC except Exception:
""", rc = 0
{"N": nominativo, "IDC": id_cella}, _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Stored procedure completata documento={Documento} azione={azione} rc={rc}")
) return SPResult(rc=rc, message="", id_result=None)
description = await _query_one_value(
db,
"""
SELECT TOP 1 NAZIONE
FROM dbo.XMag_ViewPackingList
WHERE Documento = :Documento
GROUP BY Documento, NAZIONE
ORDER BY NAZIONE
""",
{"Documento": Documento},
)
await _execute(
db,
"""
INSERT INTO dbo.LogPackingList (Code, Description, IDInsUser, InsDateTime)
VALUES (:Code, :Descr, :IDInsUser, GETDATE());
""",
{"Code": Documento, "Descr": description, "IDInsUser": IDOperatore},
)
new_id = await _query_one_value(db, "SELECT SCOPE_IDENTITY() AS ID", {})
return SPResult(rc=0, message="", id_result=int(new_id) if new_id is not None else None)
except Exception as exc: except Exception as exc:
_MODULE_LOGGER.exception(f"Procedura fallita documento={Documento} azione={Azione}: {exc}")
return SPResult(rc=-1, message=str(exc), id_result=None) return SPResult(rc=-1, message=str(exc), id_result=None)

View File

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

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
aioodbc==0.5.0
customtkinter==5.2.2
loguru
openpyxl==3.1.5
orjson
pyodbc==5.3.0
SQLAlchemy==2.0.49
tksheet==7.6.0

View File

@@ -1,16 +1,167 @@
"""Window used to inspect and empty an entire warehouse aisle. """Window used to inspect and logically empty an entire warehouse aisle.
The module exposes a destructive maintenance tool: it summarizes the occupancy The tool summarizes the current occupancy of one aisle and, after explicit
state of a selected aisle and, after explicit confirmation, deletes matching confirmation, unloads every active UDC through the same logical movement
rows from ``MagazziniPallet``. semantics used by the rest of the WMS.
""" """
from __future__ import annotations
import json
import logging
import sys
import tkinter as tk import tkinter as tk
from functools import wraps
from pathlib import Path
from tkinter import messagebox, simpledialog, ttk from tkinter import messagebox, simpledialog, ttk
from typing import Any
import customtkinter as ctk import customtkinter as ctk
from gestione_aree_frame_async import AsyncRunner, BusyOverlay from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_color, theme_font, theme_padding, theme_section, theme_value
from window_placement import place_window_fullsize_below_parent_later
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()
RESET_CORSIE_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
MODULE_LOG_NAME = Path(__file__).stem
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
_MODULE_LOG_ENABLED = RESET_CORSIE_LOG_MODE.upper() != "OFF"
_MODULE_LOG_LEVEL = "DEBUG" if RESET_CORSIE_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 RESET_CORSIE_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={RESET_CORSIE_LOG_MODE.upper()}"
)
SQL_CORSIE = """ SQL_CORSIE = """
WITH C AS ( WITH C AS (
@@ -76,59 +227,192 @@ WHERE COALESCE(s.n,0) > 0
ORDER BY TRY_CONVERT(int,c.Colonna), c.Colonna, TRY_CONVERT(int,c.Fila), c.Fila; ORDER BY TRY_CONVERT(int,c.Colonna), c.Colonna, TRY_CONVERT(int,c.Fila), c.Fila;
""" """
SQL_COUNT_DELETE = """ SQL_COUNT_RESET = """
SELECT COUNT(*) AS RowsToDelete SELECT
FROM dbo.MagazziniPallet mp COUNT(DISTINCT g.BarcodePallet) AS TotUDC,
JOIN dbo.Celle c ON c.ID = mp.IDCella COUNT(DISTINCT g.IDCella) AS TotCelle
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia; FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE c.ID <> 9999
AND LTRIM(RTRIM(c.Corsia)) = :corsia;
""" """
SQL_DELETE = """ SQL_UDC_RESET = """
DELETE mp WITH U AS (
FROM dbo.MagazziniPallet mp SELECT DISTINCT
JOIN dbo.Celle c ON c.ID = mp.IDCella g.BarcodePallet AS BarcodePallet,
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia; g.IDCella AS IDCella,
CONCAT(LTRIM(RTRIM(c.Corsia)), '.', LTRIM(RTRIM(c.Colonna)), '.', LTRIM(RTRIM(c.Fila))) AS Ubicazione,
TRY_CONVERT(int, c.Colonna) AS SortColNum,
LTRIM(RTRIM(c.Colonna)) AS SortColTxt,
TRY_CONVERT(int, c.Fila) AS SortFilaNum,
LTRIM(RTRIM(c.Fila)) AS SortFilaTxt
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE c.ID <> 9999
AND LTRIM(RTRIM(c.Corsia)) = :corsia
)
SELECT
BarcodePallet,
IDCella,
Ubicazione
FROM U
ORDER BY
SortColNum,
SortColTxt,
SortFilaNum,
SortFilaTxt,
BarcodePallet;
""" """
class ResetCorsieWindow(ctk.CTkToplevel): class ResetCorsieWindow(ctk.CTkToplevel):
"""Toplevel used to inspect and clear the pallets assigned to an aisle.""" """Toplevel used to inspect and clear the pallets assigned to an aisle."""
def __init__(self, parent, db_client): @_log_call()
def __init__(self, parent, db_client, session=None):
"""Create the window and immediately load the list of aisles.""" """Create the window and immediately load the list of aisles."""
super().__init__(parent) super().__init__(parent)
self.title("Reset Corsie - svuotamento celle per corsia") self._theme = theme_section("reset_corsie", {})
self.geometry("1000x680") self._locale_catalog = load_locale_catalog()
self.minsize(880, 560) self.title(loc_text("reset.title", catalog=self._locale_catalog, default="Gestione Corsie - svuotamento celle per corsia"))
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680")))
minsize = theme_value(self._theme, "window_minsize", [880, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
self.resizable(True, True) self.resizable(True, True)
try:
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
except Exception:
pass
self.db = db_client self.db = db_client
self._busy = BusyOverlay(self) self.session = session
self._busy = InlineBusyOverlay(self, self._theme)
self._async = AsyncRunner(self) self._async = AsyncRunner(self)
self._refresh_token = 0
self._tooltip_catalog = load_tooltip_catalog()
self._build_ui() self._build_ui()
self._load_corsie() self._load_corsie()
def _setup_tree_style(self):
"""Apply a denser, spreadsheet-like style to the main result grid."""
style = ttk.Style(self)
style.configure(
"ResetCorsie.Treeview.Heading",
font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
background=theme_value(self._theme, "tree_heading_bg", "#b8c7db"),
foreground=theme_value(self._theme, "tree_heading_fg", "#10243e"),
relief="flat",
padding=theme_padding(self._theme, "tree_heading_padding", (8, 6)),
)
style.map(
"ResetCorsie.Treeview.Heading",
background=[("active", theme_value(self._theme, "tree_heading_bg_active", "#aebfd6"))],
relief=[("pressed", "groove"), ("!pressed", "flat")],
)
style.configure(
"ResetCorsie.Treeview",
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
rowheight=int(theme_value(self._theme, "tree_row_height", 30)),
background=theme_value(self._theme, "tree_body_bg", "#ffffff"),
fieldbackground=theme_value(self._theme, "tree_body_bg", "#ffffff"),
foreground=theme_value(self._theme, "tree_body_fg", "#1f1f1f"),
borderwidth=0,
)
style.map(
"ResetCorsie.Treeview",
background=[("selected", theme_value(self._theme, "tree_selected_bg", "#cfe4ff"))],
foreground=[("selected", theme_value(self._theme, "tree_selected_fg", "#10243e"))],
)
@_log_call()
def _build_ui(self): def _build_ui(self):
"""Create selectors, summary widgets and the occupied-cell grid.""" """Create selectors, summary widgets and the occupied-cell grid."""
self._setup_tree_style()
top = ctk.CTkFrame(self) top = ctk.CTkFrame(self)
top.pack(fill="x", padx=8, pady=8) top.pack(
ctk.CTkLabel(top, text="Corsia:").pack(side="left") fill="x",
self.cmb = ctk.CTkComboBox(top, width=140, values=[]) padx=int(theme_value(self._theme, "frame_padx", 8)),
pady=int(theme_value(self._theme, "frame_pady", 8)),
)
try:
top.configure(fg_color=theme_color(self._theme, "top_frame_fg_color", ("#d7d7d7", "#3b3b3b")))
except Exception:
pass
ctk.CTkLabel(
top,
text=loc_text("reset.label.aisle", catalog=self._locale_catalog, default="Corsia:"),
font=theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10)),
).pack(side="left")
self.cmb = ctk.CTkComboBox(
top,
width=int(theme_value(self._theme, "combobox_width", 140)),
height=int(theme_value(self._theme, "combobox_height", 28)),
values=[],
font=theme_font(self._theme, "combobox_font", ("Segoe UI", 10)),
dropdown_font=theme_font(self._theme, "combobox_font", ("Segoe UI", 10)),
)
self.cmb.pack(side="left", padx=(6, 10)) self.cmb.pack(side="left", padx=(6, 10))
ctk.CTkButton(top, text="Carica", command=self.refresh).pack(side="left") btn_refresh = ctk.CTkButton(
ctk.CTkButton(top, text="Svuota corsia...", command=self._ask_reset).pack(side="right") top,
text=loc_text("reset.button.refresh", catalog=self._locale_catalog, default="Carica"),
command=self.refresh,
width=int(theme_value(self._theme, "toolbar_button_width", 140)),
height=int(theme_value(self._theme, "toolbar_button_height", 28)),
corner_radius=int(theme_value(self._theme, "toolbar_button_corner_radius", 6)),
font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")),
)
btn_refresh.pack(side="left")
btn_reset = ctk.CTkButton(
top,
text=loc_text("reset.button.empty", catalog=self._locale_catalog, default="Svuota corsia..."),
command=self._ask_reset,
width=int(theme_value(self._theme, "toolbar_button_width", 140)),
height=int(theme_value(self._theme, "toolbar_button_height", 28)),
corner_radius=int(theme_value(self._theme, "toolbar_button_corner_radius", 6)),
font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")),
)
btn_reset.pack(side="right")
WidgetToolTip(btn_refresh, tooltip_text("reset_corsie.refresh", catalog=self._tooltip_catalog))
WidgetToolTip(btn_reset, tooltip_text("reset_corsie.empty_aisle", catalog=self._tooltip_catalog))
mid = ctk.CTkFrame(self) mid = ctk.CTkFrame(self)
mid.pack(fill="both", expand=True, padx=8, pady=(0, 8)) mid.pack(
fill="both",
expand=True,
padx=int(theme_value(self._theme, "frame_padx", 8)),
pady=(0, int(theme_value(self._theme, "frame_pady", 8))),
)
try:
mid.configure(fg_color=theme_color(self._theme, "mid_frame_fg_color", ("#e5e5e5", "#383838")))
except Exception:
pass
mid.grid_columnconfigure(0, weight=1) mid.grid_columnconfigure(0, weight=1)
mid.grid_rowconfigure(0, weight=1) mid.grid_rowconfigure(0, weight=1)
self.tree = ttk.Treeview(mid, columns=("Ubicazione", "NumUDC"), show="headings", selectmode="browse") self.tree = ttk.Treeview(
mid,
columns=("Ubicazione", "NumUDC"),
show="headings",
selectmode="browse",
style="ResetCorsie.Treeview",
)
self.tree.heading("Ubicazione", text="Ubicazione") self.tree.heading("Ubicazione", text="Ubicazione")
self.tree.heading("NumUDC", text="UDC in cella") self.tree.heading("NumUDC", text="UDC in cella")
self.tree.column("Ubicazione", width=240, anchor="w") self.tree.column(
self.tree.column("NumUDC", width=120, anchor="e") "Ubicazione",
width=int(theme_value(self._theme, "tree_col_ubicazione_width", 340)),
anchor=str(theme_value(self._theme, "tree_col_ubicazione_anchor", "center")),
)
self.tree.column(
"NumUDC",
width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)),
anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")),
)
self.tree.tag_configure("odd", background=theme_value(self._theme, "tree_row_odd_bg", "#ffffff"))
self.tree.tag_configure("even", background=theme_value(self._theme, "tree_row_even_bg", "#f3f6fb"))
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview) sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview) sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
@@ -138,11 +422,27 @@ class ResetCorsieWindow(ctk.CTkToplevel):
sx.grid(row=1, column=0, sticky="ew") sx.grid(row=1, column=0, sticky="ew")
bottom = ctk.CTkFrame(self) bottom = ctk.CTkFrame(self)
bottom.pack(fill="x", padx=8, pady=(0, 8)) bottom.pack(
ctk.CTkLabel(bottom, text="Riepilogo", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0)) fill="x",
padx=int(theme_value(self._theme, "frame_padx", 8)),
pady=(0, int(theme_value(self._theme, "frame_pady", 8))),
)
try:
bottom.configure(fg_color=theme_color(self._theme, "bottom_frame_fg_color", ("#dcdcdc", "#363636")))
except Exception:
pass
ctk.CTkLabel(
bottom,
text=loc_text("reset.summary", catalog=self._locale_catalog, default="Riepilogo"),
font=theme_font(self._theme, "summary_title_font", ("Segoe UI", 12, "bold")),
).pack(anchor="w", padx=8, pady=(8, 0))
g = ctk.CTkFrame(bottom) g = ctk.CTkFrame(bottom)
g.pack(fill="x", padx=8, pady=8) g.pack(fill="x", padx=8, pady=8)
try:
g.configure(fg_color=theme_color(self._theme, "inner_summary_frame_fg_color", ("#d4d4d4", "#404040")))
except Exception:
pass
self.var_tot_celle = tk.StringVar(value="0") self.var_tot_celle = tk.StringVar(value="0")
self.var_occ = tk.StringVar(value="0") self.var_occ = tk.StringVar(value="0")
self.var_dbl = tk.StringVar(value="0") self.var_dbl = tk.StringVar(value="0")
@@ -150,8 +450,16 @@ class ResetCorsieWindow(ctk.CTkToplevel):
def _kv(parent_widget, label, var, col): def _kv(parent_widget, label, var, col):
"""Build a compact summary label/value pair.""" """Build a compact summary label/value pair."""
ctk.CTkLabel(parent_widget, text=label, font=("Segoe UI", 9, "bold")).grid(row=0, column=col * 2, sticky="w", padx=(0, 6)) ctk.CTkLabel(
ctk.CTkLabel(parent_widget, textvariable=var).grid(row=0, column=col * 2 + 1, sticky="w", padx=(0, 18)) parent_widget,
text=label,
font=theme_font(self._theme, "summary_label_font", ("Segoe UI", 9, "bold")),
).grid(row=0, column=col * 2, sticky="w", padx=(0, 6))
ctk.CTkLabel(
parent_widget,
textvariable=var,
font=theme_font(self._theme, "summary_value_font", ("Segoe UI", 9)),
).grid(row=0, column=col * 2 + 1, sticky="w", padx=(0, 18))
g.grid_columnconfigure(7, weight=1) g.grid_columnconfigure(7, weight=1)
_kv(g, "Tot. celle:", self.var_tot_celle, 0) _kv(g, "Tot. celle:", self.var_tot_celle, 0)
@@ -159,10 +467,14 @@ class ResetCorsieWindow(ctk.CTkToplevel):
_kv(g, "Celle doppie:", self.var_dbl, 2) _kv(g, "Celle doppie:", self.var_dbl, 2)
_kv(g, "Tot. pallet:", self.var_pallet, 3) _kv(g, "Tot. pallet:", self.var_pallet, 3)
@_log_call()
def _load_corsie(self): def _load_corsie(self):
"""Load available aisles and preselect ``1A`` when present.""" """Load available aisles and preselect ``1A`` when present."""
_log_sql("reset_corsie_corsie", SQL_CORSIE, {})
def _ok(res): def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else [] rows = res.get("rows", []) if isinstance(res, dict) else []
_log_dataset("reset_corsie_corsie", rows)
items = [r[0] for r in rows] items = [r[0] for r in rows]
self.cmb.configure(values=items) self.cmb.configure(values=items)
if items: if items:
@@ -173,20 +485,37 @@ class ResetCorsieWindow(ctk.CTkToplevel):
messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self) messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self)
def _err(ex): def _err(ex):
_MODULE_LOGGER.exception(f"Errore caricamento corsie reset corsie: {ex}")
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}", parent=self) messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_CORSIE, {}), _ok, _err, busy=self._busy, message="Carico corsie...") self._async.run(self.db.query_json(SQL_CORSIE, {}), _ok, _err, busy=self._busy, message="Carico corsie...")
@_log_call()
def refresh(self): def refresh(self):
"""Refresh both the summary counters and the occupied-cell list.""" """Refresh both the summary counters and the occupied-cell list."""
corsia = self.cmb.get().strip() corsia = self.cmb.get().strip()
if not corsia: if not corsia:
return return
_log_sql("reset_corsie_riepilogo", SQL_RIEPILOGO, {"corsia": corsia})
_log_sql("reset_corsie_dettaglio", SQL_DETTAGLIO, {"corsia": corsia})
def _ok_sum(res): async def _q():
rows = res.get("rows", []) if isinstance(res, dict) else [] riepilogo = await self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia})
if rows: dettaglio = await self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia})
tot, occ, dbl, pallet = rows[0] return {"riepilogo": riepilogo, "dettaglio": dettaglio}
def _ok(payload):
try:
riepilogo = payload.get("riepilogo", {}) if isinstance(payload, dict) else {}
dettaglio = payload.get("dettaglio", {}) if isinstance(payload, dict) else {}
sum_rows = riepilogo.get("rows", []) if isinstance(riepilogo, dict) else []
det_rows = dettaglio.get("rows", []) if isinstance(dettaglio, dict) else []
_log_dataset("reset_corsie_riepilogo", sum_rows)
_log_dataset("reset_corsie_dettaglio", det_rows)
if sum_rows:
tot, occ, dbl, pallet = sum_rows[0]
self.var_tot_celle.set(str(tot or 0)) self.var_tot_celle.set(str(tot or 0))
self.var_occ.set(str(occ or 0)) self.var_occ.set(str(occ or 0))
self.var_dbl.set(str(dbl or 0)) self.var_dbl.set(str(dbl or 0))
@@ -197,68 +526,150 @@ class ResetCorsieWindow(ctk.CTkToplevel):
self.var_dbl.set("0") self.var_dbl.set("0")
self.var_pallet.set("0") self.var_pallet.set("0")
def _err_sum(ex):
messagebox.showerror("Errore", f"Riepilogo fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia}), _ok_sum, _err_sum, busy=self._busy, message=f"Riepilogo {corsia}...")
def _ok_det(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
for _idc, ubi, n in rows: for idx, (_idc, ubi, n) in enumerate(det_rows):
self.tree.insert("", "end", values=(ubi, n)) tag = "even" if idx % 2 else "odd"
self.tree.insert("", "end", values=(ubi, n), tags=(tag,))
except Exception as ex:
_MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}")
messagebox.showerror("Errore", f"Aggiornamento interfaccia fallito:\n{ex}", parent=self)
def _err_det(ex): def _err(ex):
messagebox.showerror("Errore", f"Dettaglio fallito:\n{ex}", parent=self) _MODULE_LOGGER.exception(f"Errore refresh reset corsie corsia={corsia}: {ex}")
messagebox.showerror("Errore", f"Refresh fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia}), _ok_det, _err_det, busy=None, message=None) self._async.run(_q(), _ok, _err, busy=self._busy, message=f"Riepilogo {corsia}...")
@_log_call()
def _ask_reset(self): def _ask_reset(self):
"""Ask for confirmation and start the delete flow for the selected aisle.""" """Ask for confirmation and start the logical unload flow for the selected aisle."""
corsia = self.cmb.get().strip() corsia = self.cmb.get().strip()
if not corsia: if not corsia:
return return
_log_sql("reset_corsie_count_reset", SQL_COUNT_RESET, {"corsia": corsia})
def _ok_count(res): def _ok_count(res):
rows = res.get("rows", []) if isinstance(res, dict) else [] rows = res.get("rows", []) if isinstance(res, dict) else []
n = int(rows[0][0]) if rows else 0 _log_dataset("reset_corsie_count_reset", rows)
if n <= 0: tot_udc = int(rows[0][0] or 0) if rows else 0
messagebox.showinfo("Svuota corsia", f"Nessun pallet da rimuovere per la corsia {corsia}.", parent=self) tot_celle = int(rows[0][1] or 0) if rows else 0
if tot_udc <= 0:
messagebox.showinfo("Svuota corsia", f"Nessuna UDC attiva da scaricare per la corsia {corsia}.", parent=self)
return return
msg = ( msg = (
f"Verranno cancellati {n} record da MagazziniPallet per la corsia {corsia}.", f"Verranno scaricate logicamente {tot_udc} UDC attive distribuite su {tot_celle} celle della corsia {corsia}.",
"Questa operazione e' irreversibile.", "L'operazione verra' eseguita come scarico verso 9000000 / 9999, senza cancellazioni fisiche dirette.",
"Digitare il nome della corsia per confermare:", "Digitare il nome della corsia per confermare:",
) )
confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self) confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self)
if confirm is None: if confirm is None:
_MODULE_LOGGER.info(f"Reset corsia {corsia}: conferma annullata dall'utente")
return return
if confirm.strip().upper() != corsia.upper(): if confirm.strip().upper() != corsia.upper():
_MODULE_LOGGER.info(f"Reset corsia {corsia}: testo conferma non corrispondente ({confirm!r})")
messagebox.showwarning("Annullato", "Testo di conferma non corrispondente.", parent=self) messagebox.showwarning("Annullato", "Testo di conferma non corrispondente.", parent=self)
return return
self._do_reset(corsia) self._do_reset(corsia)
def _err_count(ex): def _err_count(ex):
messagebox.showerror("Errore", f"Conteggio righe da cancellare fallito:\n{ex}", parent=self) _MODULE_LOGGER.exception(f"Errore conteggio reset corsie corsia={corsia}: {ex}")
messagebox.showerror("Errore", f"Conteggio UDC da scaricare fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_COUNT_DELETE, {"corsia": corsia}), _ok_count, _err_count, busy=self._busy, message="Verifico...") self._async.run(self.db.query_json(SQL_COUNT_RESET, {"corsia": corsia}), _ok_count, _err_count, busy=self._busy, message="Verifico...")
@_log_call()
def _do_reset(self, corsia: str): def _do_reset(self, corsia: str):
"""Execute the actual delete and refresh the window afterwards.""" """Execute the logical unload of every active UDC in the selected aisle."""
def _ok_del(_): _log_sql("reset_corsie_udc_reset", SQL_UDC_RESET, {"corsia": corsia})
messagebox.showinfo("Completato", f"Corsia {corsia}: svuotamento completato.", parent=self)
async def _q():
payload = await self.db.query_json(SQL_UDC_RESET, {"corsia": corsia})
rows = payload.get("rows", []) if isinstance(payload, dict) else []
_log_dataset("reset_corsie_udc_reset", rows)
success = 0
failed: list[dict[str, Any]] = []
utente = str(getattr(self.session, "login", "") or "warehouse_ui").strip()
for barcode_pallet, idcella, ubicazione in rows:
try:
await move_pallet_async(
self.db,
barcode_pallet=str(barcode_pallet or "").strip(),
target_idcella=9999,
target_barcode_cella="9000000",
utente=utente,
)
success += 1
except Exception as ex:
failed.append(
{
"barcode_pallet": str(barcode_pallet or ""),
"idcella": int(idcella or 0),
"ubicazione": str(ubicazione or ""),
"error": str(ex),
}
)
return {
"total": len(rows),
"success": success,
"failed": failed,
}
def _ok_del(result):
total = int((result or {}).get("total", 0))
success = int((result or {}).get("success", 0))
failed = list((result or {}).get("failed", []))
_MODULE_LOGGER.info(
f"Reset corsia {corsia}: scarico logico completato success={success} total={total} failed={len(failed)}"
)
if failed:
messagebox.showwarning(
"Completato con errori",
(
f"Corsia {corsia}: scaricate {success} UDC su {total}.\n"
f"Errori su {len(failed)} UDC. Controllare {MODULE_LOG_PATH.name}."
),
parent=self,
)
else:
messagebox.showinfo(
"Completato",
f"Corsia {corsia}: svuotamento logico completato su {success} UDC.",
parent=self,
)
self.refresh() self.refresh()
def _err_del(ex): def _err_del(ex):
_MODULE_LOGGER.exception(f"Errore reset logico corsie corsia={corsia}: {ex}")
messagebox.showerror("Errore", f"Svuotamento fallito:\n{ex}", parent=self) messagebox.showerror("Errore", f"Svuotamento fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_DELETE, {"corsia": corsia}), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...") self._async.run(_q(), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}...")
def open_reset_corsie_window(parent, db_app): def open_reset_corsie_window(parent, db_app, session=None):
"""Create, focus and return the aisle reset window.""" """Create, focus and return the aisle reset window."""
win = ResetCorsieWindow(parent, db_app) key = "_reset_corsie_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.deiconify()
except Exception:
pass
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
win = ResetCorsieWindow(parent, db_app, session=session)
setattr(parent, key, win)
place_window_fullsize_below_parent_later(parent, win)
try:
win.lift() win.lift()
win.focus_set() win.focus_force()
except Exception:
pass
return win return win

View File

@@ -0,0 +1,24 @@
/*
Rollback online - form storiche Python
Rimuove solo le viste Python-only usate dalla form "Storico Picking List".
Non tocca oggetti C# legacy e non tocca dati.
Nota:
- la form "Storico movimenti UDC" non ha oggetti dedicati da rimuovere.
*/
IF OBJECT_ID(N'dbo.py_XMag_ViewPackingListStorico', N'V') IS NOT NULL
DROP VIEW dbo.py_XMag_ViewPackingListStorico;
GO
IF OBJECT_ID(N'dbo.py_vPreparaPackingList', N'V') IS NOT NULL
DROP VIEW dbo.py_vPreparaPackingList;
GO
IF OBJECT_ID(N'dbo.py_vPreparaPackingListSAMA1', N'V') IS NOT NULL
DROP VIEW dbo.py_vPreparaPackingListSAMA1;
GO
PRINT 'Rollback form storiche Python completato.';
GO

View File

@@ -0,0 +1,69 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/*
Rollback della patch prenotazione plist.
Ripristina dal backup salvato da:
- apply_plist_reservation_patch.sql
Oggetti ripristinati:
- dbo.XMag_ViewPackingList
- dbo.sp_xExePackingListPallet
- dbo.sp_xExePackingListPalletPrenota
- dbo.sp_ControllaPrenotazionePackingListPalletNew
La tabella dbo.PickingListReservation NON viene rimossa:
- può restare nel DB
- il codice legacy non la usa
*/
DECLARE @BackupTag varchar(64) = 'plist_reservation_fix_alpha2';
DECLARE @Sql nvarchar(max);
IF OBJECT_ID(N'dbo.WarehouseObjectBackup', N'U') IS NULL
BEGIN
RAISERROR('dbo.WarehouseObjectBackup non esiste. Impossibile fare rollback.', 16, 1);
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM dbo.WarehouseObjectBackup
WHERE BackupTag = @BackupTag
)
BEGIN
RAISERROR('Backup tag %s non trovato. Impossibile fare rollback.', 16, 1, @BackupTag);
RETURN;
END;
SELECT @Sql = Definition
FROM dbo.WarehouseObjectBackup
WHERE BackupTag = @BackupTag
AND ObjectName = N'dbo.XMag_ViewPackingList';
IF @Sql IS NOT NULL EXEC(@Sql);
SELECT @Sql = Definition
FROM dbo.WarehouseObjectBackup
WHERE BackupTag = @BackupTag
AND ObjectName = N'dbo.sp_xExePackingListPallet';
IF @Sql IS NOT NULL EXEC(@Sql);
SELECT @Sql = Definition
FROM dbo.WarehouseObjectBackup
WHERE BackupTag = @BackupTag
AND ObjectName = N'dbo.sp_xExePackingListPalletPrenota';
IF @Sql IS NOT NULL EXEC(@Sql);
SELECT @Sql = Definition
FROM dbo.WarehouseObjectBackup
WHERE BackupTag = @BackupTag
AND ObjectName = N'dbo.sp_ControllaPrenotazionePackingListPalletNew';
IF @Sql IS NOT NULL EXEC(@Sql);

View File

@@ -0,0 +1,35 @@
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
/*
Rollback della patch parallela Python.
Non ripristina oggetti legacy, perche' la patch parallela non li modifica.
Rimuove solo gli oggetti dbo.py_* e la tabella di prenotazione Python.
*/
IF OBJECT_ID(N'dbo.py_sp_ControllaPrenotazionePackingListPalletNew', N'P') IS NOT NULL
DROP PROCEDURE dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
GO
IF OBJECT_ID(N'dbo.py_sp_xExePackingListPalletPrenota', N'P') IS NOT NULL
DROP PROCEDURE dbo.py_sp_xExePackingListPalletPrenota;
GO
IF OBJECT_ID(N'dbo.py_sp_xExePackingListPallet', N'P') IS NOT NULL
DROP PROCEDURE dbo.py_sp_xExePackingListPallet;
GO
IF OBJECT_ID(N'dbo.py_ViewPackingListRestante', N'V') IS NOT NULL
DROP VIEW dbo.py_ViewPackingListRestante;
GO
IF OBJECT_ID(N'dbo.py_XMag_ViewPackingList', N'V') IS NOT NULL
DROP VIEW dbo.py_XMag_ViewPackingList;
GO
IF OBJECT_ID(N'dbo.PyPickingListReservation', N'U') IS NOT NULL
DROP TABLE dbo.PyPickingListReservation;
GO

View File

@@ -0,0 +1,20 @@
/*
Rollback SQL - rimozione viste storiche picking list Python.
Non tocca oggetti C# legacy.
*/
IF OBJECT_ID(N'dbo.py_XMag_ViewPackingListStorico', N'V') IS NOT NULL
DROP VIEW dbo.py_XMag_ViewPackingListStorico;
GO
IF OBJECT_ID(N'dbo.py_vPreparaPackingList', N'V') IS NOT NULL
DROP VIEW dbo.py_vPreparaPackingList;
GO
IF OBJECT_ID(N'dbo.py_vPreparaPackingListSAMA1', N'V') IS NOT NULL
DROP VIEW dbo.py_vPreparaPackingListSAMA1;
GO
PRINT 'Viste storiche Python rimosse.';
GO

68
runtime_support.py Normal file
View File

@@ -0,0 +1,68 @@
"""Runtime helpers for console-less Windows launches."""
from __future__ import annotations
import sys
import traceback
from datetime import datetime
from pathlib import Path
from typing import Callable, TypeVar
BASE_DIR = Path(__file__).resolve().parent
FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
_STDIO_HANDLES = []
T = TypeVar("T")
def ensure_stdio(app_name: str) -> None:
"""Give ``pythonw`` a real stdout/stderr target before loggers are imported."""
stamp = datetime.now().strftime("%Y%m%d")
if sys.stdout is None:
handle = (BASE_DIR / f"{app_name}_stdout_{stamp}.log").open("a", encoding="utf-8", buffering=1)
_STDIO_HANDLES.append(handle)
sys.stdout = handle
if sys.stderr is None:
handle = (BASE_DIR / f"{app_name}_stderr_{stamp}.log").open("a", encoding="utf-8", buffering=1)
_STDIO_HANDLES.append(handle)
sys.stderr = handle
def log_fatal(app_name: str, exc: BaseException) -> None:
"""Write one startup/runtime crash to a persistent log file."""
with FATAL_LOG.open("a", encoding="utf-8") as handle:
handle.write("\n" + "=" * 80 + "\n")
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}\n")
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
def show_fatal_message(app_name: str, exc: BaseException) -> None:
"""Show a best-effort message box for console-less launches."""
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.withdraw()
messagebox.showerror(
app_name,
f"Avvio non riuscito.\n\nDettaglio salvato in:\n{FATAL_LOG}\n\n{exc}",
parent=root,
)
root.destroy()
except Exception:
pass
def run_with_fatal_log(app_name: str, func: Callable[[], T]) -> T:
"""Run an app entry point and persist otherwise invisible ``pythonw`` crashes."""
try:
return func()
except BaseException as exc:
log_fatal(app_name, exc)
show_fatal_message(app_name, exc)
raise

View File

@@ -7,7 +7,12 @@ from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk import customtkinter as ctk
from gestione_aree_frame_async import AsyncRunner, BusyOverlay from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from window_placement import place_window_fullsize_below_parent_later
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
try: try:
from openpyxl import Workbook from openpyxl import Workbook
@@ -74,16 +79,25 @@ ORDER BY
class SearchWindow(ctk.CTkToplevel): class SearchWindow(ctk.CTkToplevel):
"""Window that searches pallets by barcode, lot or product code.""" """Window that searches pallets by barcode, lot or product code."""
def __init__(self, parent: tk.Widget, db_app): def __init__(self, parent: tk.Widget, db_app, session=None):
"""Initialize widgets and keep a reference to the shared DB client.""" """Initialize widgets and keep a reference to the shared DB client."""
super().__init__(parent) super().__init__(parent)
self.title("Warehouse - Ricerca UDC/Lotto/Codice") self._theme = theme_section("search_window", {})
self.geometry("1100x720") self._locale_catalog = load_locale_catalog()
self.minsize(900, 560) self._tooltip_catalog = load_tooltip_catalog()
self.title(loc_text("search.title", catalog=self._locale_catalog, default="Ricerca UDC/Lotto/Codice"))
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
minsize = theme_value(self._theme, "window_minsize", [900, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
self.resizable(True, True) self.resizable(True, True)
self.db = db_app self.db = db_app
self._busy = BusyOverlay(self) self.session = session
try:
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
except Exception:
pass
self._busy = InlineBusyOverlay(self, self._theme)
self._async = AsyncRunner(self) self._async = AsyncRunner(self)
self._sort_state: dict[str, bool] = {} self._sort_state: dict[str, bool] = {}
@@ -95,26 +109,62 @@ class SearchWindow(ctk.CTkToplevel):
self.grid_rowconfigure(1, weight=1) self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(self) top = ctk.CTkFrame(
self,
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=8) top.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
for i in range(8): for i in range(8):
top.grid_columnconfigure(i, weight=0) top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(7, weight=1) top.grid_columnconfigure(7, weight=1)
ctk.CTkLabel(top, text="UDC:").grid(row=0, column=0, sticky="w") label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))
ctk.CTkLabel(
top, text=loc_text("search.label.udc", catalog=self._locale_catalog, default="UDC:"), font=label_font
).grid(row=0, column=0, sticky="w")
self.var_udc = tk.StringVar() self.var_udc = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_udc, width=160).grid(row=0, column=1, sticky="w", padx=(4, 12)) ctk.CTkEntry(top, textvariable=self.var_udc, width=160, font=entry_font).grid(
row=0, column=1, sticky="w", padx=(4, 12)
)
ctk.CTkLabel(top, text="Lotto:").grid(row=0, column=2, sticky="w") ctk.CTkLabel(
top, text=loc_text("search.label.lot", catalog=self._locale_catalog, default="Lotto:"), font=label_font
).grid(row=0, column=2, sticky="w")
self.var_lotto = tk.StringVar() self.var_lotto = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_lotto, width=140).grid(row=0, column=3, sticky="w", padx=(4, 12)) ctk.CTkEntry(top, textvariable=self.var_lotto, width=140, font=entry_font).grid(
row=0, column=3, sticky="w", padx=(4, 12)
)
ctk.CTkLabel(top, text="Codice prodotto:").grid(row=0, column=4, sticky="w") ctk.CTkLabel(
top,
text=loc_text("search.label.code", catalog=self._locale_catalog, default="Codice prodotto:"),
font=label_font,
).grid(row=0, column=4, sticky="w")
self.var_codice = tk.StringVar() self.var_codice = tk.StringVar()
ctk.CTkEntry(top, textvariable=self.var_codice, width=160).grid(row=0, column=5, sticky="w", padx=(4, 12)) ctk.CTkEntry(top, textvariable=self.var_codice, width=160, font=entry_font).grid(
row=0, column=5, sticky="w", padx=(4, 12)
)
ctk.CTkButton(top, text="Cerca", command=self._do_search).grid(row=0, column=6, sticky="w") btn_search = ctk.CTkButton(
ctk.CTkButton(top, text="Esporta XLSX", command=self._export_xlsx).grid(row=0, column=7, sticky="e") top,
text=loc_text("search.button.search", catalog=self._locale_catalog, default="Cerca"),
command=self._do_search,
font=button_font,
)
btn_search.grid(row=0, column=6, sticky="w")
btn_export = ctk.CTkButton(
top,
text=loc_text("search.button.export", catalog=self._locale_catalog, default="Esporta XLSX"),
command=self._export_xlsx,
font=button_font,
)
btn_export.grid(row=0, column=7, sticky="e")
tip = tooltip_text("launcher.open_search", catalog=self._tooltip_catalog)
if tip:
WidgetToolTip(btn_search, tip)
wrap = ctk.CTkFrame(self) wrap = ctk.CTkFrame(self)
wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8)) wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
@@ -166,10 +216,22 @@ class SearchWindow(ctk.CTkToplevel):
"""Export the currently visible search results to an Excel file.""" """Export the currently visible search results to an Excel file."""
rows = [self.tree.item(iid, "values") for iid in self.tree.get_children("")] rows = [self.tree.item(iid, "values") for iid in self.tree.get_children("")]
if not rows: if not rows:
messagebox.showinfo("Esporta", "Non ci sono righe da esportare.", parent=self) messagebox.showinfo(
loc_text("search.msg.export_title", catalog=self._locale_catalog, default="Esporta"),
loc_text("search.msg.export_empty", catalog=self._locale_catalog, default="Non ci sono righe da esportare."),
parent=self,
)
return return
if not _HAS_XLSX: if not _HAS_XLSX:
messagebox.showerror("Esporta", "Per l'esportazione serve 'openpyxl' (pip install openpyxl).", parent=self) messagebox.showerror(
loc_text("search.msg.export_title", catalog=self._locale_catalog, default="Esporta"),
loc_text(
"search.msg.export_dep",
catalog=self._locale_catalog,
default="Per l'esportazione serve 'openpyxl' (pip install openpyxl).",
),
parent=self,
)
return return
from datetime import datetime from datetime import datetime
@@ -209,9 +271,17 @@ class SearchWindow(ctk.CTkToplevel):
for j, width in widths.items(): for j, width in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(width + 2, 10), 60) ws.column_dimensions[get_column_letter(j)].width = min(max(width + 2, 10), 60)
wb.save(fname) wb.save(fname)
messagebox.showinfo("Esporta", f"File creato:\n{fname}", parent=self) messagebox.showinfo(
loc_text("search.msg.export_title", catalog=self._locale_catalog, default="Esporta"),
f"File creato:\n{fname}",
parent=self,
)
except Exception as ex: except Exception as ex:
messagebox.showerror("Esporta", f"Errore durante l'esportazione:{ex}", parent=self) messagebox.showerror(
loc_text("search.msg.export_title", catalog=self._locale_catalog, default="Esporta"),
loc_text("search.msg.export_error", catalog=self._locale_catalog, default="Errore durante l'esportazione:{error}").format(error=ex),
parent=self,
)
def _on_dclick(self, evt): def _on_dclick(self, evt):
"""Copy the selected pallet barcode when a result cell is double-clicked.""" """Copy the selected pallet barcode when a result cell is double-clicked."""
@@ -355,8 +425,12 @@ class SearchWindow(ctk.CTkToplevel):
if not (udc or lotto or codice): if not (udc or lotto or codice):
if not messagebox.askyesno( if not messagebox.askyesno(
"Conferma", loc_text("search.msg.confirm_title", catalog=self._locale_catalog, default="Conferma"),
"Nessun filtro impostato. Vuoi cercare su TUTTO il magazzino?", loc_text(
"search.msg.confirm_all",
catalog=self._locale_catalog,
default="Nessun filtro impostato. Vuoi cercare su TUTTO il magazzino?",
),
parent=self, parent=self,
): ):
return return
@@ -394,8 +468,12 @@ class SearchWindow(ctk.CTkToplevel):
if not rows: if not rows:
messagebox.showinfo( messagebox.showinfo(
"Nessun risultato", loc_text("search.msg.no_results_title", catalog=self._locale_catalog, default="Nessun risultato"),
"Nessuna corrispondenza trovata con le chiavi di ricerca inserite.", loc_text(
"search.msg.no_results",
catalog=self._locale_catalog,
default="Nessuna corrispondenza trovata con le chiavi di ricerca inserite.",
),
parent=self, parent=self,
) )
else: else:
@@ -406,12 +484,22 @@ class SearchWindow(ctk.CTkToplevel):
def _err(ex): def _err(ex):
self._busy.hide() self._busy.hide()
messagebox.showerror("Errore ricerca", str(ex), parent=self) messagebox.showerror(
loc_text("search.msg.error_title", catalog=self._locale_catalog, default="Errore ricerca"),
str(ex),
parent=self,
)
self._async.run(self.db.query_json(SQL_SEARCH, params), _ok, _err, busy=self._busy, message="Cerco...") self._async.run(
self.db.query_json(SQL_SEARCH, params),
_ok,
_err,
busy=self._busy,
message=loc_text("search.busy", catalog=self._locale_catalog, default="Cerco..."),
)
def open_search_window(parent, db_app): def open_search_window(parent, db_app, session=None):
"""Open a singleton-like search window tied to the launcher instance.""" """Open a singleton-like search window tied to the launcher instance."""
key = "_search_window_singleton" key = "_search_window_singleton"
ex = getattr(parent, key, None) ex = getattr(parent, key, None)
@@ -422,6 +510,12 @@ def open_search_window(parent, db_app):
return ex return ex
except Exception: except Exception:
pass pass
w = SearchWindow(parent, db_app) w = SearchWindow(parent, db_app, session=session)
setattr(parent, key, w) setattr(parent, key, w)
place_window_fullsize_below_parent_later(parent, w)
try:
w.lift()
w.focus_force()
except Exception:
pass
return w return w

383
spec_barcode_wms.rtf Normal file
View File

@@ -0,0 +1,383 @@
{\rtf1\adeflang1025\ansi\ansicpg1252\uc1\adeff31507\deff0\stshfdbch31505\stshfloch31506\stshfhich31506\stshfbi31507\deflang1040\deflangfe1040\themelang1040\themelangfe0\themelangcs0{\fonttbl{\f0\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f34\fbidi \froman\fcharset0\fprq2{\*\panose 02040503050406030204}Cambria Math;}
{\f42\fbidi \fswiss\fcharset0\fprq2 Aptos;}{\f45\fbidi \fswiss\fcharset0\fprq2{\*\panose 020b0502040204020203}Segoe UI;}{\f46\fbidi \fmodern\fcharset0\fprq1{\*\panose 020b0609020204030204}Consolas;}
{\flomajor\f31500\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fdbmajor\f31501\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fhimajor\f31502\fbidi \fswiss\fcharset0\fprq2 Aptos Display;}
{\fbimajor\f31503\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\flominor\f31504\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}
{\fdbminor\f31505\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fhiminor\f31506\fbidi \fswiss\fcharset0\fprq2 Aptos;}{\fbiminor\f31507\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}
{\f47\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\f48\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\f50\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\f51\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
{\f52\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\f53\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\f54\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
{\f55\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\f387\fbidi \froman\fcharset238\fprq2 Cambria Math CE;}{\f388\fbidi \froman\fcharset204\fprq2 Cambria Math Cyr;}{\f390\fbidi \froman\fcharset161\fprq2 Cambria Math Greek;}
{\f391\fbidi \froman\fcharset162\fprq2 Cambria Math Tur;}{\f394\fbidi \froman\fcharset186\fprq2 Cambria Math Baltic;}{\f395\fbidi \froman\fcharset163\fprq2 Cambria Math (Vietnamese);}{\f467\fbidi \fswiss\fcharset238\fprq2 Aptos CE;}
{\f468\fbidi \fswiss\fcharset204\fprq2 Aptos Cyr;}{\f470\fbidi \fswiss\fcharset161\fprq2 Aptos Greek;}{\f471\fbidi \fswiss\fcharset162\fprq2 Aptos Tur;}{\f474\fbidi \fswiss\fcharset186\fprq2 Aptos Baltic;}
{\f475\fbidi \fswiss\fcharset163\fprq2 Aptos (Vietnamese);}{\f497\fbidi \fswiss\fcharset238\fprq2 Segoe UI CE;}{\f498\fbidi \fswiss\fcharset204\fprq2 Segoe UI Cyr;}{\f500\fbidi \fswiss\fcharset161\fprq2 Segoe UI Greek;}
{\f501\fbidi \fswiss\fcharset162\fprq2 Segoe UI Tur;}{\f502\fbidi \fswiss\fcharset177\fprq2 Segoe UI (Hebrew);}{\f503\fbidi \fswiss\fcharset178\fprq2 Segoe UI (Arabic);}{\f504\fbidi \fswiss\fcharset186\fprq2 Segoe UI Baltic;}
{\f505\fbidi \fswiss\fcharset163\fprq2 Segoe UI (Vietnamese);}{\f507\fbidi \fmodern\fcharset238\fprq1 Consolas CE;}{\f508\fbidi \fmodern\fcharset204\fprq1 Consolas Cyr;}{\f510\fbidi \fmodern\fcharset161\fprq1 Consolas Greek;}
{\f511\fbidi \fmodern\fcharset162\fprq1 Consolas Tur;}{\f514\fbidi \fmodern\fcharset186\fprq1 Consolas Baltic;}{\f515\fbidi \fmodern\fcharset163\fprq1 Consolas (Vietnamese);}{\flomajor\f31508\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}
{\flomajor\f31509\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flomajor\f31511\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\flomajor\f31512\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
{\flomajor\f31513\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flomajor\f31514\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\flomajor\f31515\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
{\flomajor\f31516\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbmajor\f31518\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fdbmajor\f31519\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}
{\fdbmajor\f31521\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fdbmajor\f31522\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fdbmajor\f31523\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}
{\fdbmajor\f31524\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fdbmajor\f31525\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fdbmajor\f31526\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}
{\fhimajor\f31528\fbidi \fswiss\fcharset238\fprq2 Aptos Display CE;}{\fhimajor\f31529\fbidi \fswiss\fcharset204\fprq2 Aptos Display Cyr;}{\fhimajor\f31531\fbidi \fswiss\fcharset161\fprq2 Aptos Display Greek;}
{\fhimajor\f31532\fbidi \fswiss\fcharset162\fprq2 Aptos Display Tur;}{\fhimajor\f31535\fbidi \fswiss\fcharset186\fprq2 Aptos Display Baltic;}{\fhimajor\f31536\fbidi \fswiss\fcharset163\fprq2 Aptos Display (Vietnamese);}
{\fbimajor\f31538\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fbimajor\f31539\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fbimajor\f31541\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}
{\fbimajor\f31542\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fbimajor\f31543\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fbimajor\f31544\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}
{\fbimajor\f31545\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fbimajor\f31546\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\flominor\f31548\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}
{\flominor\f31549\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flominor\f31551\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\flominor\f31552\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
{\flominor\f31553\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flominor\f31554\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\flominor\f31555\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
{\flominor\f31556\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbminor\f31558\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fdbminor\f31559\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}
{\fdbminor\f31561\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fdbminor\f31562\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fdbminor\f31563\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}
{\fdbminor\f31564\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fdbminor\f31565\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fdbminor\f31566\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}
{\fhiminor\f31568\fbidi \fswiss\fcharset238\fprq2 Aptos CE;}{\fhiminor\f31569\fbidi \fswiss\fcharset204\fprq2 Aptos Cyr;}{\fhiminor\f31571\fbidi \fswiss\fcharset161\fprq2 Aptos Greek;}{\fhiminor\f31572\fbidi \fswiss\fcharset162\fprq2 Aptos Tur;}
{\fhiminor\f31575\fbidi \fswiss\fcharset186\fprq2 Aptos Baltic;}{\fhiminor\f31576\fbidi \fswiss\fcharset163\fprq2 Aptos (Vietnamese);}{\fbiminor\f31578\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}
{\fbiminor\f31579\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fbiminor\f31581\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fbiminor\f31582\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
{\fbiminor\f31583\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fbiminor\f31584\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fbiminor\f31585\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
{\fbiminor\f31586\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}}{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;
\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;\red0\green0\blue0;\red0\green0\blue0;
\red31\green78\blue121;}{\*\defchp \fs24\kerning2\loch\af31506\hich\af31506\dbch\af31505 }{\*\defpap \ql \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 }\noqfpromote {\stylesheet{
\ql \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0
\fs24\lang1040\langfe1040\kerning2\loch\f31506\hich\af31506\dbch\af31505\cgrid\langnp1040\langfenp1040 \snext0 \sqformat \spriority0 Normal;}{\*\cs10 \additive \ssemihidden \sunhideused \spriority1 Default Paragraph Font;}{\*
\ts11\tsrowd\trftsWidthB3\trpaddl108\trpaddr108\trpaddfl3\trpaddft3\trpaddfb3\trpaddfr3\trcbpat1\trcfpat1\tblind0\tblindtype3\tsvertalt\tsbrdrt\tsbrdrl\tsbrdrb\tsbrdrr\tsbrdrdgl\tsbrdrdgr\tsbrdrh\tsbrdrv \ql \li0\ri0\sa160\sl278\slmult1
\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0 \fs24\lang1040\langfe1040\kerning2\loch\f31506\hich\af31506\dbch\af31505\cgrid\langnp1040\langfenp1040
\snext11 \ssemihidden \sunhideused Normal Table;}}{\*\rsidtbl \rsid2231436\rsid8003465\rsid8465363}{\mmathPr\mmathFont34\mbrkBin0\mbrkBinSub0\msmallFrac0\mdispDef1\mlMargin0\mrMargin0\mdefJc1\mwrapIndent1440\mintLim0\mnaryLim1}{\info
{\operator Alessandro Bonvicini}{\creatim\yr2026\mo5\dy12\hr19\min5}{\revtim\yr2026\mo5\dy12\hr19\min18}{\version2}{\edmins13}{\nofpages4}{\nofwords969}{\nofchars5529}{\nofcharsws6486}{\vern125}}{\*\xmlnstbl {\xmlns1 http://schemas.microsoft.com/office/wo
rd/2003/wordml}}\paperw11906\paperh16838\margl1134\margr1134\margt1134\margb1134\gutter0\ltrsect
\widowctrl\ftnbj\aenddoc\hyphhotz283\trackmoves0\trackformatting1\donotembedsysfont0\relyonvml0\donotembedlingdata1\grfdocevents0\validatexml0\showplaceholdtext0\ignoremixedcontent0\saveinvalidxml0\showxmlerrors0\horzdoc\dghspace120\dgvspace120
\dghorigin1701\dgvorigin1984\dghshow0\dgvshow3\jcompress\viewkind1\viewscale100\rsidroot8003465 \fet0{\*\wgrffmtfilter 2450}\ilfomacatclnup0\ltrpar \sectd \ltrsect\linex0\sectdefaultcl\sftnbj {\*\pnseclvl1\pnucrm\pnstart1\pnindent720\pnhang {\pntxta .}}
{\*\pnseclvl2\pnucltr\pnstart1\pnindent720\pnhang {\pntxta .}}{\*\pnseclvl3\pndec\pnstart1\pnindent720\pnhang {\pntxta .}}{\*\pnseclvl4\pnlcltr\pnstart1\pnindent720\pnhang {\pntxta )}}{\*\pnseclvl5\pndec\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}
{\*\pnseclvl6\pnlcltr\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl7\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl8\pnlcltr\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl9
\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}\pard\plain \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0
\fs24\lang1040\langfe1040\kerning2\loch\af31506\hich\af31506\dbch\af31505\cgrid\langnp1040\langfenp1040 {\rtlch\fcs1 \ab\af45\afs32 \ltrch\fcs0 \b\f45\fs32\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Specifica Form Barcode WMS
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
Documento di lavoro per replica Python del client barcode C# con miglioramenti di usabilita'.
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 1. Obiettivo operativo}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0
\f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
La form barcode guida il magazziniere nello scarico dei pallet secondo due code operative:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
- F1 = coda ad alta priorita', cioe' picking list prenotata (IDStato = 1)
\par \hich\af45\dbch\af31505\loch\f45 - F2 = coda a bassa priorita', cioe' picking list non prenotata (IDStato = 0)
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
Il magazziniere puo' passare da una coda all'altra e il sistema riprende dal punto corretto interrogando il database a ogni richiesta.}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid8003465 \hich\af45\dbch\af31505\loch\f45
Occorre che la seconda coda che compare , \hich\af45\dbch\af31505\loch\f45 e che non pu\loch\af45\dbch\af31505\hich\f45 \'f2\loch\f45 esser\hich\af45\dbch\af31505\loch\f45 e pr\hich\af45\dbch\af31505\loch\f45 en\hich\af45\dbch\af31505\loch\f45 otata
\hich\af45\dbch\af31505\loch\f45 sul\hich\af45\dbch\af31505\loch\f45 l\hich\af45\dbch\af31505\loch\f45 \hich\f45 a desktop app se ne \'e8\hich\af45\dbch\af31505\loch\f45 \hich\af45\dbch\af31505\loch\f45 \hich\f45 gi\'e0\loch\f45 stata prenotata una
\hich\af45\dbch\af31505\loch\f45 , \hich\af45\dbch\af31505\loch\f45 sia quella \hich\af45\dbch\af31505\loch\f45 \hich\f45 pi\'f9\loch\f45 vecchi\hich\af45\dbch\af31505\loch\f45 a tra quelle esistenti \hich\af45\dbch\af31505\loch\f45 nell
\loch\af45\dbch\af31505\hich\f45 \rquote \hich\af45\dbch\af31505\loch\f45 ele\hich\af45\dbch\af31505\loch\f45 nco, c\hich\af45\dbch\af31505\loch\f45 \hich\f45 io\'e8\loch\f45 \hich\f45 quella con id del documento pi\'f9\loch\f45 basso.}{\rtlch\fcs1
\af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 2. File C# analizzati}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0
\f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - C:_decompiled.cs
\par }{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\lang1033\langfe1040\kerning0\langnp1033\insrsid2231436\charrsid8003465 \hich\af45\dbch\af31505\loch\f45 - C:_decompiled.cs
\par \hich\af45\dbch\af31505\loch\f45 - C:_house.sql
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 3. Comandi principali della form C#}{\rtlch\fcs1 \af45\afs22
\ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
- F1 / H Priority: imposta iStatoPkPallet = 1 e carica la prossima riga da XMag_ViewPackingList con IDStato = 1
\par \hich\af45\dbch\af31505\loch\f45 - F2 / L Priority: imposta iStatoPkPallet = 0 e carica la prossima riga da XMag_ViewPackingList con IDStato = 0
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0\pararsid8003465 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
- Enter / Salva: esegue il movimento quando i campi sono completi}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid8003465 \hich\af45\dbch\af31505\loch\f45 , \hich\af45\dbch\af31505\loch\f45 qu\hich\af45\dbch\af31505\loch\f45
indi questa operazione effettua un carico, ma prima l\loch\af45\dbch\af31505\hich\f45 \rquote \loch\f45 o\hich\af45\dbch\af31505\loch\f45 peratore deve premer un ta\hich\af45\dbch\af31505\loch\f45 s\hich\af45\dbch\af31505\loch\f45 to che resetta di
\hich\af45\dbch\af31505\loch\f45 \hich\f45 due campi , cio\'e8\loch\f45 li azzera e\hich\af45\dbch\af31505\loch\f45 deve comparire una b\hich\af45\dbch\af31505\loch\f45 arra rossa sul\hich\af45\dbch\af31505\loch\f45 l\hich\af45\dbch\af31505\loch\f45
a form \hich\af45\dbch\af31505\loch\f45 del barcode\hich\af45\dbch\af31505\loch\f45 \hich\af45\dbch\af31505\loch\f45 (ver\hich\af45\dbch\af31505\loch\f45 i\hich\af45\dbch\af31505\loch\f45 ficare il c#)\hich\af45\dbch\af31505\loch\f45 , da quel momento
\hich\af45\dbch\af31505\loch\f45 se l\loch\af45\dbch\af31505\hich\f45 \rquote \hich\af45\dbch\af31505\loch\f45 o\hich\af45\dbch\af31505\loch\f45 peratore legge i barcode della udc e il bacrode della locazi\hich\af45\dbch\af31505\loch\f45 one e preme
\loch\af45\dbch\af31505\hich\f45 \'93\hich\af45\dbch\af31505\loch\f45 carica\loch\af45\dbch\af31505\hich\f45 \'94\hich\af45\dbch\af31505\loch\f45 non \loch\af45\dbch\af31505\hich\f45 \'93\hich\af45\dbch\af31505\loch\f45 salva
\loch\af45\dbch\af31505\hich\f45 \'94\hich\af45\dbch\af31505\loch\f45 \hich\af45\dbch\af31505\loch\f45 avviene il ver\hich\af45\dbch\af31505\loch\f45 sam\hich\af45\dbch\af31505\loch\f45 ento.}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0
\f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 4\hich\af45\dbch\af31505\loch\f45
. Significato reale dei campi nella form legacy}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
La UI C# e' poco intuitiva perche' i nomi dei campi non spiegano bene il flusso.
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - txtDocRif
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 etichetta visibile: Pallet
\par \hich\af45\dbch\af31505\loch\f45 significato reale: barcode del pallet letto o da confermare
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - txtBarcode
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 etichetta visibile: Cella
\par \hich\af45\dbch\af31505\loch\f45 significato reale: ubicazione operativa di destinazione o uscita; nel picking viene impostata a 9000000
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - lblTesto1
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 mostra ubicazione sorgente o stato operazione
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - lblTesto2
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 mostra Documento
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - lblTesto3
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 mostra cliente o nazione
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - lblTesto4
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 mostra il pallet atteso}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0
\f45\fs22\cf1\kerning0\insrsid8003465 \hich\af45\dbch\af31505\loch\f45 , come passo successivo nella co\hich\af45\dbch\af31505\loch\f45 da selezionata. }{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 5. Flusso F1 e F2 nel C#}{\rtlch\fcs1 \af45\afs22
\ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Metodo chiave: }{\rtlch\fcs1 \af46\afs22 \ltrch\fcs0
\f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 GetDatiPallet("", "", idStato)}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 in }{\rtlch\fcs1 \af46\afs22 \ltrch\fcs0
\f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 FSkMovimenti.cs}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 .
\par }{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\lang1033\langfe1040\kerning0\langnp1033\insrsid2231436\charrsid8003465 \hich\af45\dbch\af31505\loch\f45 Query utilizzata:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af46\afs22 \ltrch\fcs0 \f46\fs22\cf1\lang1033\langfe1040\kerning0\langnp1033\insrsid2231436\charrsid8003465 \hich\af46\dbch\af31505\loch\f46
SELECT TOP 1 * FROM XMag_ViewPackingList WHERE Ordinamento > 0 AND IDStato = idStato ORDER BY Ordinamento
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Effetto operativo:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - legge il prossimo pallet della coda scelta
\par \hich\af45\dbch\af31505\loch\f45 - mostra ubicazione, documento, cliente e pallet atteso
\par \hich\af45\dbch\af31505\loch\f45 - se la ricerca parte senza barcode specifico, alla fine la funzione restituisce 9000000
\par \hich\af45\dbch\af31505\loch\f45 - per questo i pulsanti F1/F2 finiscono per scrivere 9000000 nel campo Cella
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Flusso reale per l'operatore:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 1. preme F1 oppure F2
\par \hich\af45\dbch\af31505\loch\f45 2. il sistema mostra il prossimo pallet atteso
\par \hich\af45\dbch\af31505\loch\f45 3. il campo Cella viene impostato a 9000000
\par \hich\af45\dbch\af31505\loch\f45 4. l'operatore scansiona il pallet nel campo Pallet
\par \hich\af45\dbch\af31505\loch\f45 5. se il pallet coincide con quello atteso, viene eseguito lo scarico
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 6. Validazione dello scan}{\rtlch\fcs1 \af45\afs22
\ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Metodo chiave: }{\rtlch\fcs1 \af46\afs22 \ltrch\fcs0
\f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 SalvaOk()}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 .
\par \hich\af45\dbch\af31505\loch\f45 Regola importante:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
- se txtBarcode = 9000000, il pallet letto in txtDocRif deve coincidere con lblTesto4
\par \hich\af45\dbch\af31505\loch\f45 - se non coincide, il sistema scrive Errata Lettura e blocca l'operazione
\par \hich\af45\dbch\af31505\loch\f45 - se coincide, chiama Ricevi(...)
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
Questa e' la protezione che impedisce di scaricare il pallet sbagliato durante il picking.
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 7. Esecuzione del movimento}{\rtlch\fcs1 \af45\afs22
\ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Metodi chiave nel C#:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - Ricevi(...)
\par \hich\af45\dbch\af31505\loch\f45 - spt_SaveStoredProced\hich\af45\dbch\af31505\loch\f45 ure(...)
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Stored procedure usata:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af46\afs22 \ltrch\fcs0 \f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 sp_xMagGestioneMagazziniPallet
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Parametri principali:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - IDOperatore
\par \hich\af45\dbch\af31505\loch\f45 - BarcodeCella
\par \hich\af45\dbch\af31505\loch\f45 - BarcodePallet
\par \hich\af45\dbch\af31505\loch\f45 - NumeroCella
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Dopo il movimento il C# richiama ancora }{\rtlch\fcs1 \af46\afs22
\ltrch\fcs0 \f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 GetDatiPallet(sBarcodePallet, sBarcodeCella, iStatoPkPallet)}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
per riallinearsi alla coda corrente.
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 8. Legame con la prenotazione della picking list}{
\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 La prenotazione dal backoffice chiama la stored:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af46\afs22 \ltrch\fcs0 \f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 sp_xExePackingListPallet
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Effetto DB:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - mette Celle.IDStato = 1 sulle celle del documento prenotato
\par \hich\af45\dbch\af31505\loch\f45 - se richiamata di nuovo, toglie la prenotazione riportando IDStato = 0
\par \hich\af45\dbch\af31505\loch\f45 - registra il documento in LogPackingList
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Dopo ogni scarico, }{\rtlch\fcs1 \af46\afs22 \ltrch\fcs0
\f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 sp_xMagGestioneMagazziniPallet}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 richiama:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af46\afs22 \ltrch\fcs0 \f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 sp_ControllaPrenotazionePackingListPalletNew
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Questa procedura:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - legge l'ultima picking list attiva dal log
\par \hich\af45\dbch\af31505\loch\f45 - riapplica IDStato = 1 alle celle residue del documento tramite }{\rtlch\fcs1 \af46\afs22 \ltrch\fcs0 \f46\fs22\cf1\kerning0\insrsid2231436 \hich\af46\dbch\af31505\loch\f46 sp_xExePackingListPalletPrenota}{\rtlch\fcs1
\af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Quindi il comportamento risultante e':
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - F1 continua a scorrere la pick\hich\af45\dbch\af31505\loch\f45
ing list prenotata residua
\par \hich\af45\dbch\af31505\loch\f45 - F2 continua a scorrere la coda non prenotata
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 9. Allineamento con il backoffice}{\rtlch\fcs1 \af45\afs22
\ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Nel backoffice C# la griglia picking list:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - carica la testata aggregata da XMag_ViewPackingList
\par \hich\af45\dbch\af31505\loch\f45 - il dettaglio basso e' agganciato al solo Documento
\par \hich\af45\dbch\af31505\loch\f45 - Prenota/Sprenota richiama la stessa stored e poi ricarica davvero la griglia con InitGrid()
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Questo modello e' coerente con il comportamento della form barcode.
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 10. Criticita' di usabilita' della form legacy}{\rtlch\fcs1
\af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - etichette fuorvianti: Cella e Pallet non sp
\hich\af45\dbch\af31505\loch\f45 iegano cosa va letto in quel momento
\par \hich\af45\dbch\af31505\loch\f45 - il valore 9000000 compare senza contesto esplicito
\par \hich\af45\dbch\af31505\loch\f45 - non e' chiaro visivamente se si sta lavorando in F1 o in F2
\par \hich\af45\dbch\af31505\loch\f45 - i label non parlano il linguaggio dell'operatore
\par \hich\af45\dbch\af31505\loch\f45 - la regola di validazione contro il pallet atteso non e' evidente a schermo
\par \hich\af45\dbch\af31505\loch\f45 - la UI richiede addestramento e memoria del flusso, non si lascia capire da sola
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 11. Specifica proposta per il nuovo client Python barcode}{
\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Da mantenere identico al C#:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - una sola picking list prenotata per volta
\par \hich\af45\dbch\af31505\loch\f45 - F1 legge IDStato = 1
\par \hich\af45\dbch\af31505\loch\f45 - F2 legge IDStato = 0
\par \hich\af45\dbch\af31505\loch\f45 - ordine di proposta basato su Ordinamento
\par \hich\af45\dbch\af31505\loch\f45 - scarico tramite la stessa semantica DB del C#
\par \hich\af45\dbch\af31505\loch\f45 - verifica del barcode atteso prima dello scarico verso 9000000
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 Da migliorare nella n\hich\af45\dbch\af31505\loch\f45 uova UI:
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
- mostrare chiaramente la coda attiva: Alta priorita' (F1) o Bassa priorita' (F2)
\par \hich\af45\dbch\af31505\loch\f45 - rinominare i campi in modo esplicito
\par }\pard \ltrpar\ql \li720\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin720\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - Pallet atteso
\par \hich\af45\dbch\af31505\loch\f45 - Pallet letto
\par \hich\af45\dbch\af31505\loch\f45 - Ubicazione sorgente
\par \hich\af45\dbch\af31505\loch\f45 - Destinazione operativa
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - mostrare un messaggio guida esplicito: Scansiona il pallet indicato
\par \hich\af45\dbch\af31505\loch\f45 - evidenziare in grande ubicazione da raggiungere e pallet atteso
\par \hich\af45\dbch\af31505\loch\f45 - separare visivamente missione, area scan ed esito operazione
\par \hich\af45\dbch\af31505\loch\f45 - mantenere il focus sempre sul campo scan
\par \hich\af45\dbch\af31505\loch\f45 - usare hotkey semplici: F1, F2, Enter
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 12. Architettura consigliata del client Python}{\rtlch\fcs1
\af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li360\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin360\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - UI minimale in tkinter puro
\par \hich\af45\dbch\af31505\loch\f45 - service layer con logica operativa F1/F2
\par }{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\lang1033\langfe1040\kerning0\langnp1033\insrsid2231436\charrsid8003465 \hich\af45\dbch\af31505\loch\f45 - repository layer per SQL e stored procedure
\par }{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 - timeout configurabili
\par \hich\af45\dbch\af31505\loch\f45 - reconnect DB automatico
\par \hich\af45\dbch\af31505\loch\f45 - log rotante
\par \hich\af45\dbch\af31505\loch\f45 - zero finestre multiple
\par \hich\af45\dbch\af31505\loch\f45 - stato minimo in RAM
\par }\pard \ltrpar\ql \li0\ri0\sa180\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \ab\af45\afs22 \ltrch\fcs0 \b\f45\fs22\cf19\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45 13. Conclusione}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0
\f45\fs22\cf1\kerning0\insrsid2231436
\par }\pard \ltrpar\ql \li0\ri0\sa120\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436 \hich\af45\dbch\af31505\loch\f45
Il comportamento C# della form barcode e' oggi abbastanza chiaro: e' piu' coerente di quanto sembri, ma e' esposto con una UI poco autoesplicativa. Il nuovo client Python dovrebbe quindi essere una replica funzionale fedele, ma con una UI molto piu' guida
\hich\af45\dbch\af31505\loch\f45 ta e leggibile.}{\rtlch\fcs1 \af45\afs22 \ltrch\fcs0 \f45\fs22\cf1\kerning0\insrsid2231436
\par }{\*\themedata 504b030414000600080000002100e9de0fbfff0000001c020000130000005b436f6e74656e745f54797065735d2e786d6cac91cb4ec3301045f748fc83e52d4a
9cb2400825e982c78ec7a27cc0c8992416c9d8b2a755fbf74cd25442a820166c2cd933f79e3be372bd1f07b5c3989ca74aaff2422b24eb1b475da5df374fd9ad
5689811a183c61a50f98f4babebc2837878049899a52a57be670674cb23d8e90721f90a4d2fa3802cb35762680fd800ecd7551dc18eb899138e3c943d7e503b6
b01d583deee5f99824e290b4ba3f364eac4a430883b3c092d4eca8f946c916422ecab927f52ea42b89a1cd59c254f919b0e85e6535d135a8de20f20b8c12c3b0
0c895fcf6720192de6bf3b9e89ecdbd6596cbcdd8eb28e7c365ecc4ec1ff1460f53fe813d3cc7f5b7f020000ffff0300504b030414000600080000002100a5d6
a7e7c0000000360100000b0000005f72656c732f2e72656c73848fcf6ac3300c87ef85bd83d17d51d2c31825762fa590432fa37d00e1287f68221bdb1bebdb4f
c7060abb0884a4eff7a93dfeae8bf9e194e720169aaa06c3e2433fcb68e1763dbf7f82c985a4a725085b787086a37bdbb55fbc50d1a33ccd311ba548b6309512
0f88d94fbc52ae4264d1c910d24a45db3462247fa791715fd71f989e19e0364cd3f51652d73760ae8fa8c9ffb3c330cc9e4fc17faf2ce545046e37944c69e462
a1a82fe353bd90a865aad41ed0b5b8f9d6fd010000ffff0300504b0304140006000800000021006b799616830000008a0000001c0000007468656d652f746865
6d652f7468656d654d616e616765722e786d6c0ccc4d0ac3201040e17da17790d93763bb284562b2cbaebbf600439c1a41c7a0d29fdbd7e5e38337cedf14d59b
4b0d592c9c070d8a65cd2e88b7f07c2ca71ba8da481cc52c6ce1c715e6e97818c9b48d13df49c873517d23d59085adb5dd20d6b52bd521ef2cdd5eb9246a3d8b
4757e8d3f729e245eb2b260a0238fd010000ffff0300504b030414000600080000002100b126ca5f050800000f220000160000007468656d652f7468656d652f
7468656d65312e786d6cec5a4f6f1bb915bf17e87720e6ae78662cc9921165a1bff1267662444a8a3dd212a561cc190a43cab6b058a0c89e7a5960816dd14317
e8ad87a2e8025da08b5efa61022468b71fa28fe468444a54fc07411114b62f33d4ef3dfef8dee37b6f38f3f0b3ab94a10b920bcab356103d080344b2319fd06c
d60a5e8e0695468084c4d904339e9156b02422f8ecd12f7ff1101fca84a404817c260e712b48a49c1feeed89310c63f180cf4906bf4d799e6209b7f96c6f92e3
4bd09bb2bd380ceb7b29a65980329c82da11c8a00945cfa7533a26c1a395fa3e83393229d4c098e543a59c14321676721e2984588a2ecbd10566ad00669af0cb
11b99201625848f8a11584fa2fd87bf4700f1f16424cee90b5e406faaf902b0426e7b19e339f9d959386fdb8518d4afd1ac0e436aedf50ffa53e0dc0e331acd4
70b17546b57ad8880bac0532971eddcd8368dfc55bfaf7b73847cd7a27ae3afa35c8e8af6ee1c341b3dfab39780d32f8da16be1dc69de6be83d72083af6fe1ab
fdf641dc77f01a94309a9d6fa3eb078d46bd4097902967475e78b35e0f0f7a057c8d826828a34b4d31e599dc156b297ecdf30100149061493324977332c56388
e3f65c72817a54cc195e06688e332e60388ca30842af1ac6e5bfb6383e24d89256bc8089d81a527c9018e7742e5bc113d01a5890773ffdf4f6cd8f6fdffcfded
d75fbf7df357744c678934aa1cb9239ccd6cb99ffff4ed7fbeff35faf7dffef8f377bff5e3858d7fff97dfbcffc73f3fa41eb6dada14ef7ef7c3fb1f7f78f7fb
6ffef5e7ef3cdadb393eb3e1239a12819e914bf482a7b0406d0a973f39cb6f27314a30b525dad94ce00cab593cfafb3271d0cf9698610fae435c3bbeca21d5f8
808f17af1dc2c3245f48ead1f834491de009e7acc373af159eaab92c338f16d9cc3f79beb0712f30bef0cdddc599e3e5fe620e3996fa547613e2d03c65389378
46322291fa8d9f13e259dd17943a763da1e39c0b3e95e80b8a3a987a4d32a2674e34ad858e680a7e59fa0882bf1ddb9cbc421dce7cabee910b17097b03330ff9
11618e191fe385c4a94fe508a7cc36f83196898fe470998f6d5c5f48f0f48c308efa1322844fe6790eebb59cfe144376f3bafd842d5317994b7aeed3798c39b7
913d7ede4d703af76187344b6cece7e21c4214a3532e7df013eeee10750f7ec0d94e77bfa2c471f7f5d9e02564399bd23a40d42f8bdce3cbc7843bf13b5cb229
26be54d3ce5327c5b673ea8d8ece62e684f631210c5fe20921e8e5e71e061d3e776cbe26fd2481ac72447c81f504bbb1aaee332208d2cdcd769e3ca6c209d921
99f11d7c4e961b896789b314e7bb343f03afdb36ef9fe5b0193deb7ccec6e736f019852e10e2c56b94e7027458c1bd53eb69829d02a6ee853f5e97b9e3bf9bec
31d897af1d1a37d89720436e2d0389dd96f9a06d46983913ac036684293af6a55b1071dcbf1651c5558b2dbc725377d3aedd00dd91d3f4a434bba603fadf753e
d05fbcfbc3f79e10fc38dd8e5fb193aa6ed9e7ec4a25471bddcd2edc664fd3e5f9847efa2d4d0f2fb2530255643b5fdd7734f71d4df07fdfd1ecdacff77dccae
6ee3be8f09a0bfb8ef638aa3958fd3c7ac5b17e86ad4f18239e6d1873ee9ce339f29656c28978c1c0b7dec23e0696632804125a74f3c497906384fe052953998
c0c1cd72ac6550cee5afa84c86099ec3d9501428253351a89e0934e7028e8cf4b057b7c2b3457ac227e6a8539f2d85a6b20a2cd7e3610d0e9dcc381c534983ae
1f14838a9f3e4f05be9aed4c1fb3ae0828d9db90b0267349ec7b481cac06af21a14ecd3e0e8ba6874543a95fb96acb1440adf40a3c6e2378486f05b5aa2204a7
e4620cadf944f9c9b87ae55dedcc8fe9e95dc67422008e15cd4ae058bef4745371ddb93cb53a136a37f0b443423bc584954b425b463778228187e0223ad5e84d
68dcd6d7cdb54b1d7aca147a3e88ef358d83c68758dcd5d720b7991b5866670a96a14bd8e3316cba008df1bc154ce1cc182ed339048f508f5c98cde0d5cb58e6
66c7df25b5cc73217b5824c6e23aeb18ffa454921c319ab602b5fed20f2cd349c4906bc2d6fd54c9c56ac37d6ae4c0ebae97c9744ac6d2f6bb35a22c6d6e21c5
9b64e1fd558bdf1dac24f902dc3d4c2697e88c2df2171842ac761029ef4ea88057079171f584c2bbb03293ade36fa33215d9df7e19a563c88c63364f705152ec
6c6ee0baa09474f45d6903ebae583318d432495109cf66aac2da4675ca6959bb0c879d65f77a2165392b6bae8ba6935654d9f4a7316786551dd8b0e5ddaabcc5
6a6562486a768937b97b33e73657c96ea35128cb0418bcb4dfdd6abf456d3d99434d31decec32a6917a36ef1582df01a6a37a91256daafafd46ed8ad2c12dee9
60f04ea51fe436a31686a6abc6525b5abf36b7df6bf3b3d7903c7ad0e62e9879d3cd32b8535129e6a7b9f6ed199f2c8b4b264ca2313e574da942b2ec0599223a
b96a05b1af73346f5ba3a21bd06825a68a5729e8edf65cc102af44cd862d854d8097416536a52b5c4ae899a1f72e85f589a28fb6bc5a5156bd3ae0b509855935
98b6b0145c6d5b115efde7187adba1eeec4cee05da57b2c82f708516396d055f86b576b51bd7ba95b051eb57aafbd5b0d2a8b5f72bed5a6d3fead7a2b0d789bf
027a3249a39af9ee61002f81d8b2f8fa418f6f7d0191aede733d18f3748feb2f1bf6b4f7f5171051ec7c0161be664023f58143008e045a713faac6edb85be9f6
a27aa51af7ea95c6c17ebbd28debbdb80d45bb3e687f15a00b0d8e3abdde60508b2bf52ee0aa61bb566977f6bb957aa3df890751bfda0b015c949f2b788a019b
ad6c01979ad7a3ff020000ffff0300504b0304140006000800000021000dd1909fb60000001b010000270000007468656d652f7468656d652f5f72656c732f74
68656d654d616e616765722e786d6c2e72656c73848f4d0ac2301484f78277086f6fd3ba109126dd88d0add40384e4350d363f2451eced0dae2c082e8761be99
69bb979dc9136332de3168aa1a083ae995719ac16db8ec8e4052164e89d93b64b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae
38a38f56e422a3a641c8bbd048f7757da0f19b017cc524bd62107bd5001996509affb3fd381a89672f1f165dfe514173d9850528a2c6cce0239baa4c04ca5bba
bac4df000000ffff0300504b01022d0014000600080000002100e9de0fbfff0000001c0200001300000000000000000000000000000000005b436f6e74656e74
5f54797065735d2e786d6c504b01022d0014000600080000002100a5d6a7e7c0000000360100000b00000000000000000000000000300100005f72656c732f2e
72656c73504b01022d00140006000800000021006b799616830000008a0000001c00000000000000000000000000190200007468656d652f7468656d652f7468
656d654d616e616765722e786d6c504b01022d0014000600080000002100b126ca5f050800000f2200001600000000000000000000000000d60200007468656d
652f7468656d652f7468656d65312e786d6c504b01022d00140006000800000021000dd1909fb60000001b01000027000000000000000000000000000f0b00007468656d652f7468656d652f5f72656c732f7468656d654d616e616765722e786d6c2e72656c73504b050600000000050005005d0100000a0c00000000}
{\*\colorschememapping 3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d3822207374616e64616c6f6e653d22796573223f3e0d0a3c613a636c724d
617020786d6c6e733a613d22687474703a2f2f736368656d61732e6f70656e786d6c666f726d6174732e6f72672f64726177696e676d6c2f323030362f6d6169
6e22206267313d226c743122207478313d22646b3122206267323d226c743222207478323d22646b322220616363656e74313d22616363656e74312220616363
656e74323d22616363656e74322220616363656e74333d22616363656e74332220616363656e74343d22616363656e74342220616363656e74353d22616363656e74352220616363656e74363d22616363656e74362220686c696e6b3d22686c696e6b2220666f6c486c696e6b3d22666f6c486c696e6b222f3e}
{\*\latentstyles\lsdstimax376\lsdlockeddef0\lsdsemihiddendef0\lsdunhideuseddef0\lsdqformatdef0\lsdprioritydef99{\lsdlockedexcept \lsdqformat1 \lsdpriority0 \lsdlocked0 Normal;\lsdqformat1 \lsdpriority9 \lsdlocked0 heading 1;
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 2;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 3;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 4;
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 5;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 6;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 7;
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 8;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 1;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 5;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 9;
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 1;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 2;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 3;
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 4;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 5;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 6;
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 7;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 8;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal Indent;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 header;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footer;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index heading;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority35 \lsdlocked0 caption;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of figures;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope return;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation reference;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 line number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 page number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote text;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of authorities;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 macro;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 toa heading;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 3;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 3;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 3;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 5;\lsdqformat1 \lsdpriority10 \lsdlocked0 Title;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Closing;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Signature;\lsdsemihidden1 \lsdunhideused1 \lsdpriority1 \lsdlocked0 Default Paragraph Font;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 4;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Message Header;\lsdqformat1 \lsdpriority11 \lsdlocked0 Subtitle;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Salutation;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Date;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Note Heading;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 3;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Block Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 FollowedHyperlink;\lsdqformat1 \lsdpriority22 \lsdlocked0 Strong;
\lsdqformat1 \lsdpriority20 \lsdlocked0 Emphasis;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Document Map;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Plain Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 E-mail Signature;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Top of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Bottom of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal (Web);\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Acronym;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Cite;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Code;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Definition;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Keyboard;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Preformatted;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Sample;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Typewriter;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Variable;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal Table;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation subject;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 No List;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 1;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 3;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 6;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 6;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Contemporary;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Elegant;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Professional;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Subtle 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Subtle 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 2;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Balloon Text;\lsdpriority39 \lsdlocked0 Table Grid;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Theme;\lsdsemihidden1 \lsdlocked0 Placeholder Text;
\lsdqformat1 \lsdpriority1 \lsdlocked0 No Spacing;\lsdpriority60 \lsdlocked0 Light Shading;\lsdpriority61 \lsdlocked0 Light List;\lsdpriority62 \lsdlocked0 Light Grid;\lsdpriority63 \lsdlocked0 Medium Shading 1;\lsdpriority64 \lsdlocked0 Medium Shading 2;
\lsdpriority65 \lsdlocked0 Medium List 1;\lsdpriority66 \lsdlocked0 Medium List 2;\lsdpriority67 \lsdlocked0 Medium Grid 1;\lsdpriority68 \lsdlocked0 Medium Grid 2;\lsdpriority69 \lsdlocked0 Medium Grid 3;\lsdpriority70 \lsdlocked0 Dark List;
\lsdpriority71 \lsdlocked0 Colorful Shading;\lsdpriority72 \lsdlocked0 Colorful List;\lsdpriority73 \lsdlocked0 Colorful Grid;\lsdpriority60 \lsdlocked0 Light Shading Accent 1;\lsdpriority61 \lsdlocked0 Light List Accent 1;
\lsdpriority62 \lsdlocked0 Light Grid Accent 1;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 1;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 1;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 1;\lsdsemihidden1 \lsdlocked0 Revision;
\lsdqformat1 \lsdpriority34 \lsdlocked0 List Paragraph;\lsdqformat1 \lsdpriority29 \lsdlocked0 Quote;\lsdqformat1 \lsdpriority30 \lsdlocked0 Intense Quote;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 1;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 1;
\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 1;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 1;\lsdpriority70 \lsdlocked0 Dark List Accent 1;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 1;\lsdpriority72 \lsdlocked0 Colorful List Accent 1;
\lsdpriority73 \lsdlocked0 Colorful Grid Accent 1;\lsdpriority60 \lsdlocked0 Light Shading Accent 2;\lsdpriority61 \lsdlocked0 Light List Accent 2;\lsdpriority62 \lsdlocked0 Light Grid Accent 2;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 2;
\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 2;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 2;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 2;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 2;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 2;
\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 2;\lsdpriority70 \lsdlocked0 Dark List Accent 2;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 2;\lsdpriority72 \lsdlocked0 Colorful List Accent 2;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 2;
\lsdpriority60 \lsdlocked0 Light Shading Accent 3;\lsdpriority61 \lsdlocked0 Light List Accent 3;\lsdpriority62 \lsdlocked0 Light Grid Accent 3;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 3;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 3;
\lsdpriority65 \lsdlocked0 Medium List 1 Accent 3;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 3;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 3;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 3;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 3;
\lsdpriority70 \lsdlocked0 Dark List Accent 3;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 3;\lsdpriority72 \lsdlocked0 Colorful List Accent 3;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 3;\lsdpriority60 \lsdlocked0 Light Shading Accent 4;
\lsdpriority61 \lsdlocked0 Light List Accent 4;\lsdpriority62 \lsdlocked0 Light Grid Accent 4;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 4;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 4;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 4;
\lsdpriority66 \lsdlocked0 Medium List 2 Accent 4;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 4;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 4;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 4;\lsdpriority70 \lsdlocked0 Dark List Accent 4;
\lsdpriority71 \lsdlocked0 Colorful Shading Accent 4;\lsdpriority72 \lsdlocked0 Colorful List Accent 4;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 4;\lsdpriority60 \lsdlocked0 Light Shading Accent 5;\lsdpriority61 \lsdlocked0 Light List Accent 5;
\lsdpriority62 \lsdlocked0 Light Grid Accent 5;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 5;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 5;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 5;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 5;
\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 5;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 5;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 5;\lsdpriority70 \lsdlocked0 Dark List Accent 5;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 5;
\lsdpriority72 \lsdlocked0 Colorful List Accent 5;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 5;\lsdpriority60 \lsdlocked0 Light Shading Accent 6;\lsdpriority61 \lsdlocked0 Light List Accent 6;\lsdpriority62 \lsdlocked0 Light Grid Accent 6;
\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 6;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 6;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 6;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 6;
\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 6;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 6;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 6;\lsdpriority70 \lsdlocked0 Dark List Accent 6;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 6;
\lsdpriority72 \lsdlocked0 Colorful List Accent 6;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 6;\lsdqformat1 \lsdpriority19 \lsdlocked0 Subtle Emphasis;\lsdqformat1 \lsdpriority21 \lsdlocked0 Intense Emphasis;
\lsdqformat1 \lsdpriority31 \lsdlocked0 Subtle Reference;\lsdqformat1 \lsdpriority32 \lsdlocked0 Intense Reference;\lsdqformat1 \lsdpriority33 \lsdlocked0 Book Title;\lsdsemihidden1 \lsdunhideused1 \lsdpriority37 \lsdlocked0 Bibliography;
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority39 \lsdlocked0 TOC Heading;\lsdpriority41 \lsdlocked0 Plain Table 1;\lsdpriority42 \lsdlocked0 Plain Table 2;\lsdpriority43 \lsdlocked0 Plain Table 3;\lsdpriority44 \lsdlocked0 Plain Table 4;
\lsdpriority45 \lsdlocked0 Plain Table 5;\lsdpriority40 \lsdlocked0 Grid Table Light;\lsdpriority46 \lsdlocked0 Grid Table 1 Light;\lsdpriority47 \lsdlocked0 Grid Table 2;\lsdpriority48 \lsdlocked0 Grid Table 3;\lsdpriority49 \lsdlocked0 Grid Table 4;
\lsdpriority50 \lsdlocked0 Grid Table 5 Dark;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 1;
\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 1;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 1;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 1;
\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 1;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 2;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 2;
\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 2;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 2;
\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 3;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 3;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 3;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 3;
\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 3;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 4;
\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 4;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 4;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 4;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 4;
\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 4;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 5;
\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 5;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 5;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 5;
\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 5;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 6;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 6;
\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 6;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 6;
\lsdpriority46 \lsdlocked0 List Table 1 Light;\lsdpriority47 \lsdlocked0 List Table 2;\lsdpriority48 \lsdlocked0 List Table 3;\lsdpriority49 \lsdlocked0 List Table 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark;
\lsdpriority51 \lsdlocked0 List Table 6 Colorful;\lsdpriority52 \lsdlocked0 List Table 7 Colorful;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 List Table 2 Accent 1;\lsdpriority48 \lsdlocked0 List Table 3 Accent 1;
\lsdpriority49 \lsdlocked0 List Table 4 Accent 1;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 1;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 1;
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 List Table 2 Accent 2;\lsdpriority48 \lsdlocked0 List Table 3 Accent 2;\lsdpriority49 \lsdlocked0 List Table 4 Accent 2;
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 2;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 3;
\lsdpriority47 \lsdlocked0 List Table 2 Accent 3;\lsdpriority48 \lsdlocked0 List Table 3 Accent 3;\lsdpriority49 \lsdlocked0 List Table 4 Accent 3;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 3;
\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 4;\lsdpriority47 \lsdlocked0 List Table 2 Accent 4;
\lsdpriority48 \lsdlocked0 List Table 3 Accent 4;\lsdpriority49 \lsdlocked0 List Table 4 Accent 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 4;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 4;
\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 List Table 2 Accent 5;\lsdpriority48 \lsdlocked0 List Table 3 Accent 5;
\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5;
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6;
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention;
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}{\*\datastore 01050000
02000000180000004d73786d6c322e534158584d4c5265616465722e362e3000000000000000000000060000
d0cf11e0a1b11ae1000000000000000000000000000000003e000300feff090006000000000000000000000001000000010000000000000000100000feffffff00000000feffffff0000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
fffffffffffffffffdfffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffff52006f006f007400200045006e00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000500ffffffffffffffffffffffff0c6ad98892f1d411a65f0040963251e5000000000000000000000000404d
a74a33e2dc01feffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000105000000000000}}

View File

@@ -0,0 +1,170 @@
{\rtf1\ansi\deff0
{\fonttbl
{\f0 Segoe UI;}
{\f1 Consolas;}
}
\fs24
\pard\b\f0\fs32 Specifica Form Barcode WMS\par
\pard\b0\fs22 Documento di lavoro aggiornato per replica Python del client barcode C# con miglioramenti di usabilita'.\par
\par
\pard\b 1. Obiettivo operativo\par
\pard\b0 La form barcode guida il magazziniere in movimenti WMS di due tipi:\par
\pard\li360 - prelievo verso la cella virtuale 9000000\par
\pard\li360 - versamento verso una cella reale di magazzino\par
\pard\li0 Entrambe le operazioni usano lo stesso motore di movimento DB; cambia la destinazione operativa.\par
\par
\pard\b 2. Code F1 / F2\par
\pard\b0 Il comportamento reale ricostruito e confermato e' il seguente:\par
\pard\li360 - F1 = coda ad alta priorita', cioe' picking list prenotata (IDStato = 1)\par
\pard\li360 - F2 = coda a bassa priorita', cioe' coda non prenotata (IDStato = 0)\par
\pard\li0 Dall'interfaccia backoffice si puo' prenotare una sola picking list per volta. La lista prenotata entra in F1; la seconda coda operativa viene proposta con F2.\par
\par
\pard\b 3. File C# analizzati\par
\pard\b0\li360 - FSkMovimenti.cs\par
\pard\li360 - FSkAccettazione.cs\par
\pard\li360 - FRMagViewLayout.cs\par
\pard\li360 - GridViewColumnButtonMenu.cs\par
\pard\li360 - script.sql\par
\par
\pard\b 4. Comandi principali della form C#\par
\pard\b0\li360 - F1 / H Priority: imposta iStatoPkPallet = 1 e carica la prossima riga da XMag_ViewPackingList con IDStato = 1\par
\pard\li360 - F2 / L Priority: imposta iStatoPkPallet = 0 e carica la prossima riga da XMag_ViewPackingList con IDStato = 0\par
\pard\li360 - Enter / Salva: esegue il movimento quando i campi sono completi\par
\pard\li360 - F4 / Elimina: forza uno scarico verso 9000000\par
\par
\pard\b 5. Significato reale dei campi nella form legacy\par
\pard\b0 La UI C# e' poco intuitiva perche' i nomi dei campi non spiegano il flusso.\par
\pard\li360 - txtDocRif\par
\pard\li720 etichetta visibile: Pallet\par
\pard\li720 significato reale: barcode del pallet letto o da confermare\par
\pard\li360 - txtBarcode\par
\pard\li720 etichetta visibile: Cella\par
\pard\li720 significato reale: destinazione operativa; nel picking viene impostata a 9000000\par
\pard\li360 - lblTesto1\par
\pard\li720 e' il vero indicatore di stato; viene usato anche come barra colorata\par
\pard\li360 - lblTesto2\par
\pard\li720 Documento nella fase di proposta picking; riga informativa variabile nella fase di conferma\par
\pard\li360 - lblTesto3\par
\pard\li720 cliente / nazione nella fase di proposta picking; riga informativa variabile nella fase di conferma\par
\pard\li360 - lblTesto4\par
\pard\li720 pallet atteso nella fase di proposta picking; riga informativa variabile nella fase di conferma\par
\par
\pard\b 6. Query principale F1 / F2\par
\pard\b0 Metodo chiave nel C#: \f1 GetDatiPallet("", "", idStato)\f0\par
\pard\li360 Query:\par
\pard\li720\f1 SELECT TOP 1 * FROM XMag_ViewPackingList WHERE Ordinamento > 0 AND IDStato = {idStato} ORDER BY Ordinamento\f0\par
\pard\li0 Effetto reale:\par
\pard\li360 1. viene scelta la prossima UDC della coda selezionata\par
\pard\li360 2. vengono riempiti i label con ubicazione, documento, cliente e pallet atteso\par
\pard\li360 3. il campo Cella viene impostato a 9000000\par
\pard\li360 4. l'operatore legge il pallet nel campo Pallet\par
\pard\li360 5. se il pallet coincide con quello atteso, viene eseguito il prelievo\par
\par
\pard\b 7. Reset dei campi e barra rossa / verde\par
\pard\b0 Ricostruzione aggiornata dal C#:\par
\pard\li360 - non emerge un pulsante esplicito che entri formalmente in una "modalita' versamento"\par
\pard\li360 - i reset vengono fatti in modo automatico dentro \f1 GetDatiPallet(...)\f0\ e \f1 GetDatiPalletLotto(...)\f0\par
\pard\li360 - all'inizio di questi metodi il C# pulisce i label, azzera \f1 txtDocRif\f0\ e imposta \f1 lblTesto1.BackColor = Red\f0\par
\pard\li360 - quando il dato viene trovato correttamente, \f1 lblTesto1\f0\ passa a \f1 LightGreen\f0\ o \f1 GreenYellow\f0\par
\pard\li0 Quindi la "barra rossa / verde" ricordata dall'operatore coincide con \f1 lblTesto1\f0, non con un controllo separato.\par
\par
\pard\b 8. Validazione dello scan\par
\pard\b0 Metodo chiave: \f1 SalvaOk()\f0\par
\pard\li360 - se \f1 txtBarcode = 9000000\f0, il pallet letto in \f1 txtDocRif\f0\ deve coincidere con \f1 lblTesto4\f0\par
\pard\li360 - se non coincide: errore \f1 Errata Lettura\f0\ e blocco operazione\par
\pard\li360 - se coincide: chiamata a \f1 Ricevi(...)\f0\par
\pard\li0 Questa e' la protezione che impedisce di scaricare il pallet sbagliato durante il picking.\par
\par
\pard\b 9. Esecuzione del movimento\par
\pard\b0 Metodi chiave nel C#:\par
\pard\li360 - \f1 Ricevi(...)\f0\par
\pard\li360 - \f1 spt_SaveStoredProcedure(...)\f0\par
\pard\li0 Stored procedure usata:\par
\pard\li360\f1 sp_xMagGestioneMagazziniPallet\f0\par
\pard\li0 Parametri principali:\par
\pard\li360 - IDOperatore\par
\pard\li360 - BarcodeCella\par
\pard\li360 - BarcodePallet\par
\pard\li360 - NumeroCella\par
\pard\li0 Dopo il movimento il C# richiama ancora \f1 GetDatiPallet(sBarcodePallet, sBarcodeCella, iStatoPkPallet)\f0.\par
\pard\li0 I messaggi di conferma vengono quindi scritti a valle della transazione DB, non prima.\par
\par
\pard\b 10. Convenzioni logistiche emerse dalla verifica sul campo\par
\pard\b0 La verifica con il magazziniere ha chiarito il significato delle celle convenzionali usate dai fallback del sistema.\par
\pard\li360 - 9000000 = destinazione logica di input usata dalla form per il prelievo\par
\pard\li360 - 9999 = locazione convenzionale 7G.1.1\par
\pard\li360 - 1000 = locazione convenzionale 5E1.1\par
\pard\li0 Questo significa che:\par
\pard\li360 - una UDC prelevata dalla picking list finisce logicamente verso 9000000, ma viene poi rappresentata operativamente come 7G.1.1\par
\pard\li360 - una UDC non scaffalata nella picking list viene proposta con la locazione convenzionale 5E1.1\par
\pard\li0 I fallback che sembravano "sporchi" nel codice sono quindi parte della semantica reale del magazzino.\par
\par
\pard\b 11. Comportamento osservato nel prelievo picking list\par
\pard\b0 Flusso operativo confermato:\par
\pard\li360 1. il magazziniere preme F1 o F2\par
\pard\li360 2. il barcode mostra la prossima UDC della coda scelta con la sua locazione\par
\pard\li360 3. l'operatore legge il pallet atteso\par
\pard\li360 4. il movimento parte verso la destinazione logica 9000000\par
\pard\li360 5. alla conferma compare \f1 Ok Scarico\f0\ nella prima label\par
\pard\li360 6. compare \f1 7G.1.1\f0\ come locazione convenzionale di spedito\par
\pard\li360 7. dopo il tempo della logica lato server viene proposta la UDC successiva\par
\pard\li0 Questo comportamento percepito dall'operatore e' compatibile con il fatto che le label vengano aggiornate dopo il ritorno della stored.\par
\par
\pard\b 12. Caso UDC non scaffalata\par
\pard\b0 Nella vista SQL la UDC non scaffalata usa un fallback tecnico, ma per l'operatore la locazione visibile e significativa e':\par
\pard\li360 - 5E1.1\par
\pard\li0 Quindi il client Python non deve mostrare soltanto la stringa tecnica `Non scaff.`, ma deve privilegiare la convenzione operativa 5E1.1 dove il legacy la rende significativa.\par
\par
\pard\b 13. Legame con la prenotazione della picking list\par
\pard\b0 La prenotazione backoffice chiama:\par
\pard\li360\f1 sp_xExePackingListPallet\f0\par
\pard\li0 Effetto DB:\par
\pard\li360 - mette \f1 Celle.IDStato = 1\f0\ sulle celle del documento prenotato\par
\pard\li360 - se richiamata di nuovo, riporta \f1 IDStato = 0\f0\par
\pard\li360 - scrive il documento in \f1 LogPackingList\f0\par
\pard\li0 Dopo ogni prelievo, \f1 sp_xMagGestioneMagazziniPallet\f0\ richiama:\par
\pard\li360\f1 sp_ControllaPrenotazionePackingListPalletNew\f0\par
\pard\li0 Questa procedura ri-prenota automaticamente le celle residue della picking list attiva.\par
\pard\li0 Quindi:\par
\pard\li360 - F1 continua a scorrere la picking list prenotata residua\par
\pard\li360 - F2 continua a scorrere la coda non prenotata\par
\par
\pard\b 14. Tempi percepiti dall'operatore\par
\pard\b0 Nel C# non emerge un vero timer di reset a 2 secondi. Il ritardo percepito dal magazziniere e' piu' coerente con:\par
\pard\li360 - tempo di transazione DB\par
\pard\li360 - tempo di riesecuzione della logica server\par
\pard\li360 - tempo di riaggancio alla UDC successiva\par
\pard\li0 Quindi il "reset" percepito e' in realta' la finestra temporale durante cui il terminale attende l'esito e poi aggiorna le label.\par
\par
\pard\b 15. Allineamento richiesto nel client Python\par
\pard\b0 Da mantenere uguale al C# e alla prassi operativa:\par
\pard\li360 - una sola picking list prenotata per volta\par
\pard\li360 - F1 legge IDStato = 1\par
\pard\li360 - F2 legge IDStato = 0\par
\pard\li360 - proposta UDC ordinata da DB\par
\pard\li360 - movimento tramite la stessa stored legacy \f1 sp_xMagGestioneMagazziniPallet\f0\par
\pard\li360 - validazione del pallet atteso prima del prelievo verso 9000000\par
\pard\li360 - visualizzazione di 7G.1.1 come locazione convenzionale di spedito\par
\pard\li360 - visualizzazione di 5E1.1 per le UDC non scaffalate\par
\par
\pard\b 16. Criticita' di usabilita' della form legacy\par
\pard\b0\li360 - etichette fuorvianti: Cella e Pallet non spiegano cosa va letto in quel momento\par
\pard\li360 - il valore 9000000 compare senza contesto esplicito\par
\pard\li360 - non e' chiaro visivamente se si sta lavorando in F1 o in F2\par
\pard\li360 - la validazione contro il pallet atteso non e' evidente a schermo\par
\pard\li360 - il flusso dipende da reset e colori impliciti, quindi richiede addestramento\par
\par
\pard\b 17. Specifica proposta per il nuovo client Python barcode\par
\pard\b0 Replica funzionale fedele, ma con UI piu' guidata.\par
\pard\li360 - una sola finestra tkinter pura\par
\pard\li360 - service layer separato dalla UI\par
\pard\li360 - repository layer separato dall'accesso SQL\par
\pard\li360 - focus sempre sul campo scansione corretto\par
\pard\li360 - stato visivo chiaro: F1, F2, versamento, conferma\par
\pard\li360 - messaggi espliciti per l'operatore: cosa leggere adesso\par
\pard\li360 - barra stato grande con colori coerenti al legacy\par
\pard\li360 - gestione esplicita delle locazioni convenzionali 5E1.1 e 7G.1.1\par
\par
\pard\b 18. Conclusione\par
\pard\b0 Il comportamento C# della form barcode e' abbastanza chiaro: e' piu' coerente di quanto sembri, ma e' esposto con una UI poco autoesplicativa. Il client Python deve quindi mantenere la semantica legacy lato DB e coda operativa, ma renderla finalmente leggibile e stabile per l'operatore.\par
}

View File

@@ -0,0 +1,68 @@
# Specifica - Storico Picking List
## Obiettivo
La finestra "Storico Picking List" deve permettere di consultare le picking list visibili nel modello dati Python parallelo, comprese quelle gia' esaurite, mostrando lo stato sintetico della lista e il dettaglio delle UDC.
## Accesso
- La finestra si apre dal launcher principale tramite il pulsante "Storico Picking List".
- In una fase successiva potra' essere collegata alla form "Gestione Picking List" per aprire direttamente il dettaglio della lista selezionata.
- La finestra e' disponibile agli operatori autenticati secondo il permesso `launcher.open_history_pickinglist`.
## Comportamento UI
- La finestra usa lo stesso posizionamento delle altre form del backoffice.
- Tutte le interrogazioni al database sono asincrone.
- Durante il caricamento della lista e del dettaglio deve comparire l'overlay standard dell'applicazione.
- La parte alta mostra l'elenco delle picking list.
- La parte bassa mostra il dettaglio della picking list selezionata.
- Il filtro principale e' il numero documento, con ricerca parziale.
## Logica dati
La form usa un ramo di lettura storico Python separato dalle viste operative C#.
Oggetti SQL dedicati:
- `dbo.py_vPreparaPackingListSAMA1`
- `dbo.py_vPreparaPackingList`
- `dbo.py_XMag_ViewPackingListStorico`
Questi oggetti sono creati dallo script `apply_python_pickinglist_history_views.sql` e possono essere rimossi con `rollback_python_pickinglist_history_views.sql`.
La vista storica non usa `dbo.vPreparaPackingListSAMA1`, `dbo.vPreparaPackingList` o `dbo.XMag_ViewPackingList`, cosi' il programma C# continua a lavorare sul proprio ramo legacy.
La vista `dbo.py_vPreparaPackingListSAMA1` include:
- documenti dell'anno corrente: `BAMTES.ANNDOC >= YEAR(GETDATE())`
- documenti aperti e chiusi: `BAMTES.STATO IN ('P', 'D')`
- nessun filtro sugli ultimi 10 giorni
La lista alta aggrega per documento:
- data documento
- stato documento gestionale
- totale UDC
- righe residue
- righe spedite
- stato operativo
- stato prenotazione Python
Lo stato operativo e' calcolato cosi':
- `Chiusa`: il documento gestionale ha `BAMTES.STATO = 'D'`.
- `Da lavorare`: nessuna riga risulta spedita.
- `In corso`: almeno una riga risulta spedita e almeno una riga resta da lavorare.
- `Esaurita`: non restano righe operative.
Il criterio primario per riconoscere una picking list lavorata nello storico e' lo stato gestionale `BAMTES.STATO`.
La cella `9999 = 7G.1.1` resta un indizio logistico importante, ma non e' sufficiente come unico criterio storico perche' una UDC non piu' in giacenza puo' ricadere nei fallback della vista.
## Convenzioni operative
Una riga con `Cella = 9999` viene considerata spedita/prelevata, coerentemente con la convenzione `7G.1.1` osservata sul barcode.
## Limiti noti
Questa finestra e' una prima vista storica di consultazione. Non sostituisce ancora una vera storicizzazione annuale progettata a database. Quando il database verra' riprogettato per FlyWMS, sara' preferibile costruire una vista storica dedicata, con date di chiusura lista, utente, tempi e movimento UDC collegato.

53
specifica_storico_udc.md Normal file
View File

@@ -0,0 +1,53 @@
# Specifica - Storico movimenti UDC
## Obiettivo
La finestra "Storico movimenti UDC" deve permettere di ricostruire, in sola lettura, la sequenza dei movimenti registrati per una UDC. La funzione nasce come strumento di diagnosi per capire dove e da chi una unita' di carico e' stata movimentata fino alla spedizione.
## Accesso
- La finestra si apre dal launcher principale tramite il pulsante "Storico movimenti UDC".
- In una fase successiva potra' essere richiamata anche dalle form esistenti, ad esempio dalla ricerca UDC o dal dettaglio picking list, passando direttamente il codice UDC selezionato.
- La finestra e' disponibile agli operatori autenticati secondo il permesso `launcher.open_history_udc`.
## Comportamento UI
- La finestra usa lo stesso posizionamento delle altre form del backoffice.
- Tutte le interrogazioni al database sono asincrone.
- Durante il caricamento deve comparire l'overlay standard dell'applicazione.
- Il filtro principale e' il codice UDC, con ricerca parziale.
- La griglia mostra al massimo 500 righe per evitare carichi eccessivi durante i test.
- Le righe di tipo `V` sono evidenziate come versamenti/carichi.
- Le righe di tipo `P` sono evidenziate come prelievi/scarichi.
## Dati visualizzati
La prima implementazione legge da `dbo.MagazziniPallet`, collegando quando possibile `dbo.Celle` per mostrare l'ubicazione in forma leggibile.
La ricerca non si limita al match diretto su `MagazziniPallet.Attributo`: include anche i movimenti collegati tramite la catena `ID` / `IDRiferimento`. Questo e' importante nel vecchio modello dati, perche' un prelievo o un trasferimento puo' essere legato alla riga originaria di versamento.
La griglia integra anche la vista `dbo.XMag_GiacenzaPalletPlistChiuse` come fallback gestionale:
- prima vengono mostrati i movimenti fisici reali `V` e `P` da `dbo.MagazziniPallet`
- se per la UDC esiste almeno un movimento fisico `P`, anche collegato tramite la catena `ID` / `IDRiferimento`, non viene aggiunta nessuna riga diagnostica
- se non esistono movimenti `P` ma la UDC compare in una picking list gestionale chiusa, viene mostrata una riga diagnostica `SPED`
La riga `SPED` non e' un movimento fisico: serve solo a spiegare perche' le form operative considerano la UDC gia' spedita anche quando manca la tracciatura fisica `P`. Deve quindi essere considerata l'ultima opzione: se esistono movimenti reali `V` o `P`, questi restano la fonte primaria per data, utente e ubicazione.
Campi principali:
- ID movimento
- Tipo movimento
- Riferimento
- UDC
- IDCella
- Ubicazione
- DataMagazzino
- Utente di inserimento
- Data inserimento
- Utente di modifica
- Data modifica
## Limiti noti
La funzione e' diagnostica e non modifica dati. Se in futuro servira' uno storico piu' ricco, sara' opportuno introdurre una vista o tabella storica dedicata con nomi parlanti nel nuovo schema FlyWMS.

364
storico_pickinglist.py Normal file
View File

@@ -0,0 +1,364 @@
"""Read-only picking-list history window."""
from __future__ import annotations
from datetime import date, datetime, timedelta
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any
import customtkinter as ctk
from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from window_placement import place_window_fullsize_below_parent_later
SQL_STORICO_PL = """
WITH base AS (
SELECT *
FROM dbo.py_XMag_ViewPackingListStorico
),
agg AS (
SELECT
Documento,
MAX(DataDocumento) AS DataDocumento,
MAX(StatoDocumento) AS StatoDocumento,
MAX(NAZIONE) AS NAZIONE,
MAX(CodNazione) AS CodNazione,
COUNT(DISTINCT Pallet) AS TotUDC,
SUM(CASE WHEN Cella = 9999 THEN 1 ELSE 0 END) AS RigheSpedite,
SUM(CASE WHEN Cella <> 9999 OR Cella IS NULL THEN 1 ELSE 0 END) AS RigheResidue,
COUNT(*) AS RigheTotali,
MIN(Ordinamento) AS PrimoOrdine,
MAX(IDStato) AS IDStato
FROM base
GROUP BY Documento
)
SELECT
Documento,
DataDocumento,
StatoDocumento,
NAZIONE,
CodNazione,
TotUDC,
RigheResidue,
RigheSpedite,
RigheTotali,
CASE
WHEN StatoDocumento = 'D' THEN 'Chiusa'
WHEN RigheResidue = 0 THEN 'Esaurita'
WHEN RigheSpedite > 0 THEN 'In corso'
ELSE 'Da lavorare'
END AS StatoOperativo,
IDStato,
PrimoOrdine
FROM agg
WHERE (:documento IS NULL OR CAST(Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
ORDER BY Documento DESC;
"""
SQL_STORICO_PL_DETAILS = """
SELECT
Documento,
Pallet,
Lotto,
Articolo,
Descrizione,
Qta,
DataDocumento,
StatoDocumento,
Cella,
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingListStorico
WHERE Documento = :documento
ORDER BY Ordinamento, Pallet;
"""
def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(res, dict):
return []
rows = res.get("rows") or []
cols = res.get("columns") or []
if rows and isinstance(rows[0], dict):
return [row for row in rows if isinstance(row, dict)]
out: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, (list, tuple)) and cols:
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
return out
def _format_date(value: Any) -> str:
"""Format SQL Server/SAMA date values into dd/mm/yyyy for operators."""
if value in (None, ""):
return ""
if isinstance(value, datetime):
return value.strftime("%d/%m/%Y")
if isinstance(value, date):
return value.strftime("%d/%m/%Y")
if isinstance(value, (int, float)):
try:
# SQL Server numeric datetime: CAST(datetime AS int), day 0 = 1900-01-01.
return (datetime(1900, 1, 1) + timedelta(days=int(value))).strftime("%d/%m/%Y")
except Exception:
return str(value)
text = str(value).strip()
if not text:
return ""
if text.isdigit() and len(text) == 8:
try:
return datetime.strptime(text, "%Y%m%d").strftime("%d/%m/%Y")
except ValueError:
pass
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).strftime("%d/%m/%Y")
except Exception:
return text
class StoricoPickingListWindow(ctk.CTkToplevel):
"""Window that shows historical picking-list status and details."""
def __init__(self, parent: tk.Widget, db_client, session=None):
super().__init__(parent)
self.db_client = db_client
self.session = session
self._theme = theme_section("history_picking_window", theme_section("pickinglist_window", {}))
self._locale_catalog = load_locale_catalog()
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
self.var_documento = tk.StringVar()
self.title(loc_text("history.picking.title", catalog=self._locale_catalog, default="Storico Picking List"))
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x720")))
minsize = theme_value(self._theme, "window_minsize", [980, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
try:
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
except Exception:
pass
self._build_ui()
self.after(300, self._load_master)
def _build_ui(self) -> None:
self.grid_rowconfigure(1, weight=1)
self.grid_rowconfigure(3, weight=1)
self.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(
self,
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
)
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
top.grid_columnconfigure(3, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))
ctk.CTkLabel(top, text="Documento:", font=label_font).grid(row=0, column=0, sticky="w")
ctk.CTkEntry(top, textvariable=self.var_documento, width=140, font=entry_font).grid(
row=0, column=1, sticky="w", padx=(4, 12)
)
ctk.CTkButton(
top,
text=loc_text("history.picking.button.reload", catalog=self._locale_catalog, default="Ricarica"),
command=self._load_master,
font=button_font,
).grid(
row=0, column=2, sticky="w"
)
self.master_tree = self._make_tree(
row=1,
columns=("Documento", "Data", "StatoDoc", "NAZIONE", "TotUDC", "Residue", "Spedite", "Stato", "IDStato"),
widths={
"Documento": 100,
"Data": 110,
"StatoDoc": 75,
"NAZIONE": 260,
"TotUDC": 90,
"Residue": 90,
"Spedite": 90,
"Stato": 120,
"IDStato": 80,
},
)
self.master_tree.bind("<<TreeviewSelect>>", self._on_master_select)
ctk.CTkLabel(
self,
text=loc_text("history.picking.detail_title", catalog=self._locale_catalog, default="Dettaglio contenuto"),
anchor="w",
font=button_font,
).grid(
row=2, column=0, sticky="ew", padx=8, pady=(4, 2)
)
self.detail_tree = self._make_tree(
row=3,
columns=("Pallet", "Lotto", "Articolo", "Descrizione", "Qta", "Data", "StatoDoc", "Cella", "Ubicazione", "Ordine"),
widths={
"Pallet": 120,
"Lotto": 120,
"Articolo": 130,
"Descrizione": 320,
"Qta": 80,
"Data": 110,
"StatoDoc": 75,
"Cella": 80,
"Ubicazione": 180,
"Ordine": 80,
},
)
def _make_tree(self, *, row: int, columns: tuple[str, ...], widths: dict[str, int]) -> ttk.Treeview:
wrap = ctk.CTkFrame(self)
wrap.grid(row=row, column=0, sticky="nsew", padx=8, pady=(0, 8))
wrap.grid_rowconfigure(0, weight=1)
wrap.grid_columnconfigure(0, weight=1)
tree = ttk.Treeview(wrap, columns=columns, show="headings")
for col in columns:
tree.heading(col, text=col)
tree.column(col, width=widths.get(col, 100), anchor="w")
tree.tag_configure("done", background="#ECECEC")
tree.tag_configure("active", background="#EAF7EA")
sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
tree.grid(row=0, column=0, sticky="nsew")
sy.grid(row=0, column=1, sticky="ns")
sx.grid(row=1, column=0, sticky="ew")
return tree
def _load_master(self) -> None:
params = {"documento": str(self.var_documento.get() or "").strip() or None}
async def _job():
return await self.db_client.query_json(SQL_STORICO_PL, params)
def _ok(res):
self._fill_master(_rows_to_dicts(res))
def _err(ex):
messagebox.showerror(
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
loc_text(
"history.picking.msg.load_error",
catalog=self._locale_catalog,
default="Errore caricamento:\n{error}",
).format(error=ex),
parent=self,
)
self._async.run(
_job(),
_ok,
_err,
busy=self._busy,
message=loc_text(
"history.picking.busy.master",
catalog=self._locale_catalog,
default="Carico storico picking list...",
),
)
def _fill_master(self, rows: list[dict[str, Any]]) -> None:
self.master_tree.delete(*self.master_tree.get_children(""))
self.detail_tree.delete(*self.detail_tree.get_children(""))
for index, row in enumerate(rows):
stato = str(row.get("StatoOperativo") or "")
tag = "done" if stato in {"Chiusa", "Esaurita"} else "active" if int(row.get("IDStato") or 0) == 1 else ""
self.master_tree.insert(
"",
"end",
iid=f"doc_{row.get('Documento')}_{index}",
values=(
row.get("Documento", ""),
_format_date(row.get("DataDocumento", "")),
row.get("StatoDocumento", ""),
row.get("NAZIONE", ""),
row.get("TotUDC", ""),
row.get("RigheResidue", ""),
row.get("RigheSpedite", ""),
stato,
row.get("IDStato", ""),
),
tags=(tag,) if tag else (),
)
def _on_master_select(self, _event=None) -> None:
selected = self.master_tree.selection()
if not selected:
return
values = self.master_tree.item(selected[0], "values")
if not values:
return
documento = values[0]
async def _job():
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
def _ok(res):
self._fill_detail(_rows_to_dicts(res))
def _err(ex):
messagebox.showerror(
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
loc_text(
"history.picking.msg.detail_error",
catalog=self._locale_catalog,
default="Errore dettaglio:\n{error}",
).format(error=ex),
parent=self,
)
self._async.run(
_job(),
_ok,
_err,
busy=self._busy,
message=loc_text(
"history.picking.busy.detail",
catalog=self._locale_catalog,
default="Carico dettaglio picking list...",
),
)
def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
self.detail_tree.delete(*self.detail_tree.get_children(""))
for row in rows:
done = str(row.get("StatoDocumento") or "") == "D" or int(row.get("Cella") or 0) == 9999
self.detail_tree.insert(
"",
"end",
values=(
row.get("Pallet", ""),
row.get("Lotto", ""),
row.get("Articolo", ""),
row.get("Descrizione", ""),
row.get("Qta", ""),
_format_date(row.get("DataDocumento", "")),
row.get("StatoDocumento", ""),
row.get("Cella", ""),
row.get("Ubicazione", ""),
row.get("Ordinamento", ""),
),
tags=("done",) if done else (),
)
def open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
"""Open the picking-list history window."""
win = StoricoPickingListWindow(parent, db_client, session=session)
place_window_fullsize_below_parent_later(parent, win)
win.bind("<Escape>", lambda _e: win.destroy())
return win

312
storico_udc.py Normal file
View File

@@ -0,0 +1,312 @@
"""Read-only UDC movement history window."""
from __future__ import annotations
import tkinter as tk
from tkinter import messagebox, ttk
from typing import Any
import customtkinter as ctk
from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from window_placement import place_window_fullsize_below_parent_later
SQL_STORICO_UDC = """
WITH direct AS (
SELECT
ID,
IDRiferimento
FROM dbo.MagazziniPallet
WHERE (:udc IS NULL OR Attributo COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :udc, '%'))
),
shipped AS (
SELECT
shipped.BarcodePallet,
shipped.NumeroPallet,
shipped.IDMagazzino,
shipped.IDArea,
shipped.IDCella
FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped
WHERE (:udc IS NULL OR shipped.BarcodePallet COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :udc, '%'))
),
has_physical_p AS (
SELECT DISTINCT
s.BarcodePallet
FROM shipped s
JOIN dbo.MagazziniPallet seed
ON seed.Attributo COLLATE Latin1_General_CI_AS =
s.BarcodePallet COLLATE Latin1_General_CI_AS
JOIN dbo.MagazziniPallet mp
ON mp.Tipo = 'P'
AND (
mp.Attributo COLLATE Latin1_General_CI_AS =
s.BarcodePallet COLLATE Latin1_General_CI_AS
OR mp.ID = seed.ID
OR mp.IDRiferimento = seed.ID
OR (seed.IDRiferimento IS NOT NULL AND seed.IDRiferimento > 0 AND mp.ID = seed.IDRiferimento)
OR (seed.IDRiferimento IS NOT NULL AND seed.IDRiferimento > 0 AND mp.IDRiferimento = seed.IDRiferimento)
)
),
roots AS (
SELECT ID AS RootID
FROM direct
UNION
SELECT IDRiferimento AS RootID
FROM direct
WHERE IDRiferimento IS NOT NULL AND IDRiferimento > 0
UNION
SELECT mp.ID AS RootID
FROM dbo.MagazziniPallet mp
JOIN shipped s
ON s.BarcodePallet COLLATE Latin1_General_CI_AS =
mp.Attributo COLLATE Latin1_General_CI_AS
)
SELECT TOP (500) *
FROM (
SELECT
CAST(mp.ID AS int) AS ID,
CAST(mp.Tipo AS varchar(8)) AS Tipo,
CAST(mp.IDRiferimento AS int) AS IDRiferimento,
CAST(mp.NumeroPallet AS int) AS NumeroPallet,
CAST(mp.Attributo AS varchar(16)) AS UDC,
CAST(mp.IDMagazzino AS int) AS IDMagazzino,
CAST(mp.IDArea AS int) AS IDArea,
CAST(mp.IDCella AS int) AS IDCella,
UPPER(
CONCAT(
COALESCE(LTRIM(RTRIM(c.Corsia)), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(c.Colonna AS varchar(32)))), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(c.Fila AS varchar(32)))), 'NA')
)
) AS Ubicazione,
CAST(mp.DataMagazzino AS datetime) AS DataMagazzino,
CAST(mp.InsUtente AS varchar(50)) AS InsUtente,
CAST(mp.InsDataOra AS datetime) AS InsDataOra,
CAST(mp.ModUtente AS varchar(50)) AS ModUtente,
CAST(mp.ModDataOra AS datetime) AS ModDataOra
FROM dbo.MagazziniPallet mp
LEFT JOIN dbo.Celle c ON c.ID = mp.IDCella
WHERE
:udc IS NULL
OR mp.Attributo COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :udc, '%')
OR mp.ID IN (SELECT RootID FROM roots)
OR mp.IDRiferimento IN (SELECT RootID FROM roots)
UNION ALL
SELECT
CAST(NULL AS int) AS ID,
CAST('SPED' AS varchar(8)) AS Tipo,
CAST(NULL AS int) AS IDRiferimento,
CAST(s.NumeroPallet AS int) AS NumeroPallet,
CAST(s.BarcodePallet AS varchar(16)) AS UDC,
CAST(s.IDMagazzino AS int) AS IDMagazzino,
CAST(s.IDArea AS int) AS IDArea,
CAST(s.IDCella AS int) AS IDCella,
UPPER(
CONCAT(
COALESCE(LTRIM(RTRIM(c.Corsia)), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(c.Colonna AS varchar(32)))), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(c.Fila AS varchar(32)))), 'NA')
)
) AS Ubicazione,
CAST(NULL AS datetime) AS DataMagazzino,
CAST('Picking list chiusa' AS varchar(50)) AS InsUtente,
CAST(NULL AS datetime) AS InsDataOra,
CAST(NULL AS varchar(50)) AS ModUtente,
CAST(NULL AS datetime) AS ModDataOra
FROM shipped s
LEFT JOIN dbo.Celle c ON c.ID = s.IDCella
LEFT JOIN has_physical_p hp
ON hp.BarcodePallet COLLATE Latin1_General_CI_AS =
s.BarcodePallet COLLATE Latin1_General_CI_AS
WHERE hp.BarcodePallet IS NULL
) rows
ORDER BY UDC, CASE WHEN ID IS NULL THEN 0 ELSE 1 END DESC, ID DESC;
"""
def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(res, dict):
return []
rows = res.get("rows") or []
cols = res.get("columns") or []
if rows and isinstance(rows[0], dict):
return [row for row in rows if isinstance(row, dict)]
out: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, (list, tuple)) and cols:
out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
return out
class StoricoUDCWindow(ctk.CTkToplevel):
"""Window that shows the P/V movement timeline of one or more UDCs."""
def __init__(self, parent: tk.Widget, db_client, session=None, initial_udc: str | None = None):
super().__init__(parent)
self.db_client = db_client
self.session = session
self._theme = theme_section("history_udc_window", theme_section("search_window", {}))
self._locale_catalog = load_locale_catalog()
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
self.var_udc = tk.StringVar(value=str(initial_udc or ""))
self.title(loc_text("history.udc.title", catalog=self._locale_catalog, default="Storico movimenti UDC"))
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
minsize = theme_value(self._theme, "window_minsize", [900, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
try:
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
except Exception:
pass
self._build_ui()
if initial_udc:
self.after(250, self._do_search)
def _build_ui(self) -> None:
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(
self,
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
)
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
top.grid_columnconfigure(3, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))
ctk.CTkLabel(top, text="UDC:", font=label_font).grid(row=0, column=0, sticky="w")
ctk.CTkEntry(top, textvariable=self.var_udc, width=160, font=entry_font).grid(
row=0, column=1, sticky="w", padx=(4, 12)
)
ctk.CTkButton(
top,
text=loc_text("history.udc.button.search", catalog=self._locale_catalog, default="Cerca"),
command=self._do_search,
font=button_font,
).grid(
row=0, column=2, sticky="w"
)
wrap = ctk.CTkFrame(self)
wrap.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
wrap.grid_rowconfigure(0, weight=1)
wrap.grid_columnconfigure(0, weight=1)
cols = (
"ID",
"Tipo",
"Rif",
"UDC",
"Cella",
"Ubicazione",
"Data",
"InsUtente",
"InsDataOra",
"ModUtente",
"ModDataOra",
)
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
for col in cols:
self.tree.heading(col, text=col)
self.tree.column(col, width=90, anchor="w")
self.tree.column("ID", width=70, anchor="e")
self.tree.column("Tipo", width=55, anchor="center")
self.tree.column("Rif", width=70, anchor="e")
self.tree.column("UDC", width=110)
self.tree.column("Cella", width=70, anchor="e")
self.tree.column("Ubicazione", width=130)
self.tree.column("Data", width=150)
self.tree.column("InsDataOra", width=150)
self.tree.column("ModDataOra", width=150)
self.tree.tag_configure("versamento", background="#EAF7EA")
self.tree.tag_configure("prelievo", background="#FFF0E6")
self.tree.tag_configure("spedita", background="#FFECEC", foreground="#B00020")
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
self.tree.grid(row=0, column=0, sticky="nsew")
sy.grid(row=0, column=1, sticky="ns")
sx.grid(row=1, column=0, sticky="ew")
def _do_search(self) -> None:
udc = str(self.var_udc.get() or "").strip()
params = {"udc": udc or None}
async def _job():
return await self.db_client.query_json(SQL_STORICO_UDC, params)
def _ok(res):
rows = _rows_to_dicts(res)
self._fill(rows)
if not rows:
messagebox.showinfo(
loc_text("history.udc.msg.title", catalog=self._locale_catalog, default="Storico UDC"),
loc_text("history.udc.msg.empty", catalog=self._locale_catalog, default="Nessun movimento trovato."),
parent=self,
)
def _err(ex):
messagebox.showerror(
loc_text("history.udc.msg.title", catalog=self._locale_catalog, default="Storico UDC"),
loc_text(
"history.udc.msg.error",
catalog=self._locale_catalog,
default="Errore ricerca:\n{error}",
).format(error=ex),
parent=self,
)
self._async.run(
_job(),
_ok,
_err,
busy=self._busy,
message=loc_text("history.udc.busy", catalog=self._locale_catalog, default="Carico storico UDC..."),
)
def _fill(self, rows: list[dict[str, Any]]) -> None:
self.tree.delete(*self.tree.get_children(""))
def _value(row: dict[str, Any], name: str) -> Any:
value = row.get(name, "")
return "" if value is None else value
for row in rows:
tipo = str(row.get("Tipo") or "")
tag = "versamento" if tipo == "V" else "prelievo" if tipo == "P" else "spedita" if tipo == "SPED" else ""
self.tree.insert(
"",
"end",
values=(
_value(row, "ID"),
tipo,
_value(row, "IDRiferimento"),
_value(row, "UDC"),
_value(row, "IDCella"),
_value(row, "Ubicazione"),
_value(row, "DataMagazzino"),
_value(row, "InsUtente"),
_value(row, "InsDataOra"),
_value(row, "ModUtente"),
_value(row, "ModDataOra"),
),
tags=(tag,) if tag else (),
)
def open_storico_udc_window(parent: tk.Misc, db_client, session=None, initial_udc: str | None = None) -> tk.Misc:
"""Open the UDC history window."""
win = StoricoUDCWindow(parent, db_client, session=session, initial_udc=initial_udc)
place_window_fullsize_below_parent_later(parent, win)
win.bind("<Escape>", lambda _e: win.destroy())
return win

71
tooltip.json Normal file
View File

@@ -0,0 +1,71 @@
{
"default_language": "IT",
"IT": {
"launcher.open_reset_corsie": "Apre la finestra di gestione corsie per visualizzare il contenuto di una corsia e svuotarla in modo controllato.",
"launcher.open_layout": "Apre la vista layout delle corsie con celle, UDC presenti e menu operativo contestuale.",
"launcher.open_multi_udc": "Apre la vista UDC fantasma per analizzare celle con piu' pallet e bonificare i candidati fantasma.",
"launcher.open_search": "Apre la ricerca UDC per trovare rapidamente una unita' di carico e verificarne la posizione.",
"launcher.open_history_udc": "Apre lo storico movimenti UDC per ricostruire carichi, scarichi, celle e utenti coinvolti.",
"launcher.open_pickinglist": "Apre la gestione delle picking list per prenotare, controllare e aggiornare le liste di prelievo.",
"launcher.open_history_pickinglist": "Apre lo storico picking list per consultare liste, stato operativo e dettaglio UDC.",
"launcher.arrange_windows": "Dispone in cascata le finestre aperte seguendo l'ordine dei pulsanti del launcher.",
"launcher.exit": "Chiude l'applicazione in modo pulito terminando la sessione utente e rilasciando la connessione condivisa al database.",
"dbconfig.heading": "Spiega che qui si inseriscono i dati minimi per permettere al programma di collegarsi al database del magazzino al primo avvio.",
"dbconfig.field.server": "Nome server o indirizzo IP dell'istanza SQL Server che ospita il database del magazzino.",
"dbconfig.field.database": "Nome del database applicativo da usare per il WMS.",
"dbconfig.field.user": "Utente SQL Server da usare per l'accesso al database.",
"dbconfig.field.password": "Password dell'utente SQL Server configurato per il collegamento.",
"dbconfig.field.driver": "Driver ODBC installato sul PC che verra' usato da Python per collegarsi a SQL Server.",
"dbconfig.field.encrypt": "Valore opzionale del parametro Encrypt. Lascialo vuoto se non serve una configurazione specifica.",
"dbconfig.field.trust_server_certificate": "Attiva la fiducia sul certificato del server SQL quando l'ambiente usa certificati locali o autofirmati.",
"dbconfig.info": "Il file di configurazione viene salvato una sola volta sul PC e riutilizzato ai successivi avvii del programma.",
"dbconfig.button.cancel": "Chiude la configurazione iniziale senza salvare. In questo caso il programma non prosegue.",
"dbconfig.button.test": "Prova subito la connessione con i dati inseriti senza ancora salvarli definitivamente.",
"dbconfig.button.save": "Verifica i dati, salva il file di configurazione e permette al programma di continuare l'avvio.",
"reset_corsie.refresh": "Ricarica il riepilogo e l'elenco delle celle occupate per la corsia selezionata.",
"reset_corsie.empty_aisle": "Scarica logicamente tutte le UDC attive della corsia selezionata verso l'ubicazione di uscita.",
"layout.search_udc": "Cerca una UDC per barcode, cambia automaticamente corsia e porta in evidenza la cella trovata.",
"layout.refresh": "Ricarica la corsia selezionata e aggiorna matrice, colori e statistiche.",
"layout.export_xlsx": "Esporta la vista corrente del layout corsia in un file Excel.",
"multi_udc.refresh": "Ricarica l'albero delle celle con UDC multiple e il riepilogo percentuale per corsia.",
"multi_udc.expand_all": "Espande tutti i nodi gia' caricati nell'albero.",
"multi_udc.collapse_all": "Comprime tutti i nodi dell'albero.",
"multi_udc.preselect": "Espande la corsia selezionata e preseleziona automaticamente le UDC con causale fantasma.",
"multi_udc.remove_ghosts": "Scarica logicamente le UDC selezionate della corsia attiva verso l'ubicazione di uscita.",
"multi_udc.export_xlsx": "Esporta in Excel il contenuto corrente della vista UDC fantasma."
},
"ENG": {
"launcher.open_reset_corsie": "Open the aisle management window to inspect an aisle and empty it in a controlled way.",
"launcher.open_layout": "Open the aisle layout view with cells, present UDCs and the operational context menu.",
"launcher.open_multi_udc": "Open the ghost UDC view to inspect cells with multiple pallets and clean ghost candidates.",
"launcher.open_search": "Open the UDC search window to quickly locate a load unit and verify its position.",
"launcher.open_history_udc": "Open UDC movement history to review loads, unloads, cells and users involved.",
"launcher.open_pickinglist": "Open picking list management to reserve, inspect and update picking lists.",
"launcher.open_history_pickinglist": "Open picking-list history to inspect lists, operational status and UDC detail.",
"launcher.arrange_windows": "Arrange open windows in cascade order following the launcher buttons.",
"launcher.exit": "Close the application cleanly by ending the user session and releasing the shared database connection.",
"dbconfig.heading": "Explain that this form collects the minimum data needed to connect the program to the warehouse database on first startup.",
"dbconfig.field.server": "SQL Server instance name or IP address hosting the warehouse database.",
"dbconfig.field.database": "Application database name used by the WMS.",
"dbconfig.field.user": "SQL Server user used to access the database.",
"dbconfig.field.password": "Password for the configured SQL Server user.",
"dbconfig.field.driver": "ODBC driver installed on the PC that Python will use to connect to SQL Server.",
"dbconfig.field.encrypt": "Optional Encrypt parameter value. Leave it blank if no specific encryption setting is required.",
"dbconfig.field.trust_server_certificate": "Enable trust for the SQL Server certificate when the environment uses local or self-signed certificates.",
"dbconfig.info": "The configuration file is saved once on the PC and reused on the next application startups.",
"dbconfig.button.cancel": "Close the first-run configuration without saving. In this case the program will not continue.",
"dbconfig.button.test": "Try the connection immediately with the entered values without saving them yet.",
"dbconfig.button.save": "Validate the connection, save the configuration file and let the program continue startup.",
"reset_corsie.refresh": "Reload the summary and the list of occupied cells for the selected aisle.",
"reset_corsie.empty_aisle": "Logically unload all active UDCs in the selected aisle to the outbound location.",
"layout.search_udc": "Search a UDC by barcode, switch aisle automatically and highlight the matching cell.",
"layout.refresh": "Reload the selected aisle and refresh matrix, colors and statistics.",
"layout.export_xlsx": "Export the current aisle layout view to an Excel file.",
"multi_udc.refresh": "Reload the tree of cells with multiple UDCs and the percentage summary by aisle.",
"multi_udc.expand_all": "Expand all nodes currently loaded in the tree.",
"multi_udc.collapse_all": "Collapse all tree nodes.",
"multi_udc.preselect": "Expand the selected aisle and automatically preselect UDCs classified as ghost candidates.",
"multi_udc.remove_ghosts": "Logically unload the selected UDCs of the active aisle to the outbound location.",
"multi_udc.export_xlsx": "Export the current ghost UDC view to Excel."
}
}

101
tooltips.py Normal file
View File

@@ -0,0 +1,101 @@
"""Tooltip catalog and widget helper utilities."""
from __future__ import annotations
import json
from pathlib import Path
import tkinter as tk
_TOOLTIP_FILE = Path(__file__).with_name("tooltip.json")
def load_tooltip_catalog() -> dict:
"""Load the tooltip catalog from JSON, returning a safe default on errors."""
try:
return json.loads(_TOOLTIP_FILE.read_text(encoding="utf-8"))
except Exception:
return {"default_language": "IT", "IT": {}, "ENG": {}}
def tooltip_text(key: str, *, language: str | None = None, catalog: dict | None = None) -> str:
"""Return the localized tooltip text for ``key``."""
data = catalog or load_tooltip_catalog()
lang = str(language or data.get("default_language") or "IT").upper()
texts = data.get(lang, {}) or {}
if key in texts:
return str(texts[key])
fallback = data.get("IT", {}) or {}
return str(fallback.get(key, ""))
class WidgetToolTip:
"""Simple delayed tooltip for Tk/customtkinter widgets."""
def __init__(self, widget: tk.Misc, text: str, *, delay_ms: int = 400, wraplength: int = 320):
self.widget = widget
self.text = text.strip()
self.delay_ms = int(delay_ms)
self.wraplength = int(wraplength)
self._after_id: str | None = None
self._tip: tk.Toplevel | None = None
if self.text:
self.widget.bind("<Enter>", self._schedule_show, add="+")
self.widget.bind("<Leave>", self._hide, add="+")
self.widget.bind("<ButtonPress>", self._hide, add="+")
def _schedule_show(self, _event=None):
self._cancel_schedule()
self._after_id = self.widget.after(self.delay_ms, self._show)
def _cancel_schedule(self):
if self._after_id is not None:
try:
self.widget.after_cancel(self._after_id)
except Exception:
pass
self._after_id = None
def _show(self):
self._after_id = None
if self._tip is not None or not self.text:
return
try:
x = self.widget.winfo_rootx() + 18
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8
tip = tk.Toplevel(self.widget)
tip.withdraw()
tip.overrideredirect(True)
tip.attributes("-topmost", True)
frame = tk.Frame(tip, bg="#fff7c7", bd=1, relief="solid")
frame.pack(fill="both", expand=True)
label = tk.Label(
frame,
text=self.text,
justify="left",
anchor="w",
wraplength=self.wraplength,
bg="#fff7c7",
fg="#1f1f1f",
font=("Segoe UI", 9),
padx=8,
pady=6,
)
label.pack(fill="both", expand=True)
tip.geometry(f"+{x}+{y}")
tip.deiconify()
self._tip = tip
except Exception:
self._tip = None
def _hide(self, _event=None):
self._cancel_schedule()
tip = self._tip
self._tip = None
if tip is not None:
try:
tip.destroy()
except Exception:
pass

336
ui_theme.json Normal file
View File

@@ -0,0 +1,336 @@
{
"global": {
"window_bg": ["#f1f1f1", "#2b2b2b"],
"panel_bg": ["#d9d9d9", "#3a3a3a"],
"panel_alt_bg": ["#cfcfcf", "#454545"],
"text_primary": "#1f1f1f",
"text_secondary": "#4b4b4b",
"accent": "#2ebf74",
"accent_hover": "#28a766",
"danger": "#ca3d3d",
"danger_hover": "#aa2f2f",
"border": "#bdbdbd"
},
"launcher": {
"window_width": 1280,
"window_min_width": 960,
"window_top_x": 0,
"window_top_y": 0,
"outer_pady": 10,
"outer_padx": 0,
"max_buttons_per_row": 7,
"button_padx": 6,
"button_pady": 6,
"info_padx": 6,
"info_pady": [4, 2],
"exit_icon_color": "#ca3d3d",
"info_font": {
"family": "Segoe UI",
"size": 12,
"weight": "bold"
},
"cascade_x_offset": 42,
"cascade_y_offset": 34,
"cascade_margin_left": 0,
"cascade_margin_top": 0,
"button_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
}
},
"reset_corsie": {
"window_geometry": "1000x680",
"window_minsize": [880, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"top_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"mid_frame_fg_color": ["#e5e5e5", "#383838"],
"bottom_frame_fg_color": ["#dcdcdc", "#363636"],
"inner_summary_frame_fg_color": ["#d4d4d4", "#404040"],
"frame_padx": 8,
"frame_pady": 8,
"toolbar_button_width": 140,
"toolbar_button_height": 28,
"toolbar_button_corner_radius": 6,
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"combobox_width": 140,
"combobox_height": 28,
"combobox_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"summary_title_font": {
"family": "Segoe UI",
"size": 12,
"weight": "bold"
},
"summary_label_font": {
"family": "Segoe UI",
"size": 9,
"weight": "bold"
},
"summary_value_font": {
"family": "Segoe UI",
"size": 9,
"weight": "normal"
},
"tree_heading_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"tree_body_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"tree_row_height": 30,
"tree_heading_bg": "#9fb2cb",
"tree_heading_bg_active": "#90a5c0",
"tree_heading_fg": "#10243e",
"tree_heading_padding": [8, 6],
"tree_body_bg": "#ffffff",
"tree_body_fg": "#1f1f1f",
"tree_row_odd_bg": "#ffffff",
"tree_row_even_bg": "#edf3fb",
"tree_selected_bg": "#cfe4ff",
"tree_selected_fg": "#10243e",
"tree_col_ubicazione_width": 360,
"tree_col_ubicazione_anchor": "center",
"tree_col_num_udc_width": 180,
"tree_col_num_udc_anchor": "center",
"tree_show_grid_hint": true,
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"layout_window": {
"window_geometry": "1200x740",
"window_minsize": [980, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"top_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"panel_frame_fg_color": ["#dcdcdc", "#363636"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 12,
"weight": "bold"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
}
},
"multi_udc": {
"window_geometry": "1100x700",
"window_minsize": [900, 550],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"content_frame_fg_color": ["#e5e5e5", "#383838"],
"summary_frame_fg_color": ["#dcdcdc", "#363636"],
"inner_summary_frame_fg_color": ["#d4d4d4", "#404040"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"summary_title_font": {
"family": "Segoe UI",
"size": 12,
"weight": "bold"
}
},
"login_window": {
"window_geometry": "165x155+0+0",
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"search_window": {
"window_geometry": "1100x720",
"window_minsize": [900, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"history_udc_window": {
"window_geometry": "1100x720",
"window_minsize": [900, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"scarico_dialog": {
"header_font": {
"family": "Segoe UI",
"size": 13,
"weight": "bold"
},
"body_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"tree_heading_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"tree_body_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"pickinglist_window": {
"window_geometry": "1200x700",
"window_minsize": [1000, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"history_picking_window": {
"window_geometry": "1200x720",
"window_minsize": [980, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
}
}

97
ui_theme.py Normal file
View File

@@ -0,0 +1,97 @@
"""External UI theme loader for the warehouse desktop application.
The module reads ``ui_theme.json`` from the workspace root and exposes a few
helpers to resolve fonts, colors, paddings and section-specific configuration
without hardcoding presentation details inside each window module.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
THEME_PATH = Path(__file__).with_name("ui_theme.json")
@lru_cache(maxsize=1)
def load_theme() -> dict[str, Any]:
"""Load the external JSON theme, returning an empty dict on failure."""
try:
return json.loads(THEME_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
def reload_theme() -> dict[str, Any]:
"""Clear cache and reload the theme from disk."""
load_theme.cache_clear()
return load_theme()
def theme_section(name: str, default: dict[str, Any] | None = None) -> dict[str, Any]:
"""Return one top-level theme section as a dictionary."""
theme = load_theme()
value = theme.get(name)
if isinstance(value, dict):
return value
return dict(default or {})
def theme_value(section: dict[str, Any], key: str, default: Any = None) -> Any:
"""Return a scalar theme value from a section."""
return section.get(key, default)
def theme_color(section: dict[str, Any], key: str, default: Any = None) -> Any:
"""Return a CTk-compatible color value (string or light/dark tuple)."""
value = section.get(key, default)
if isinstance(value, list) and len(value) == 2:
return tuple(value)
return value
def theme_padding(section: dict[str, Any], key: str, default: tuple[int, ...]) -> tuple[int, ...]:
"""Return tuple-like padding values from a JSON list."""
value = section.get(key)
if isinstance(value, list):
try:
return tuple(int(v) for v in value)
except Exception:
return default
return default
def theme_font(section: dict[str, Any], key: str, default: tuple[Any, ...]) -> tuple[Any, ...]:
"""Resolve a Tk font tuple from JSON."""
spec = section.get(key)
if not isinstance(spec, dict):
return default
family = spec.get("family", default[0] if default else "Segoe UI")
size = int(spec.get("size", default[1] if len(default) > 1 else 10))
parts: list[Any] = [family, size]
weight = str(spec.get("weight", "")).strip().lower()
if weight in {"bold", "normal"} and weight != "normal":
parts.append(weight)
slant = str(spec.get("slant", "")).strip().lower()
if slant == "italic":
parts.append(slant)
if bool(spec.get("underline", False)):
parts.append("underline")
if bool(spec.get("overstrike", False)):
parts.append("overstrike")
return tuple(parts)

94
user_session.py Normal file
View File

@@ -0,0 +1,94 @@
"""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_history_udc",
"launcher.open_pickinglist",
"launcher.open_history_pickinglist",
"launcher.arrange_windows",
"launcher.exit",
"reset_corsie.view",
"search.view",
"history_udc.view",
"history_pickinglist.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),
)

1112
view_celle_multi_udc.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,431 +0,0 @@
"""Exploration window for cells containing more than one pallet."""
import json
import tkinter as tk
from datetime import datetime
from tkinter import filedialog, messagebox, ttk
import customtkinter as ctk
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
from gestione_aree_frame_async import AsyncRunner
def _json_obj(res):
"""Normalize raw DB responses into a dictionary with a ``rows`` key."""
if isinstance(res, str):
try:
res = json.loads(res)
except Exception as ex:
raise RuntimeError(f"Risposta non JSON: {ex}\nRaw: {res!r}")
if isinstance(res, dict) and "error" in res:
err = res.get("error") or "Errore sconosciuto"
detail = res.get("sql") or ""
raise RuntimeError(f"{err}\n{detail}")
return res if isinstance(res, dict) else {"rows": res}
UBI_B = (
"UPPER("
" CONCAT("
" RTRIM(b.Corsia), '.', RTRIM(CAST(b.Colonna AS varchar(32))), '.', RTRIM(CAST(b.Fila AS varchar(32)))"
" )"
")"
)
BASE_CTE = """
WITH base AS (
SELECT
g.IDCella,
g.BarcodePallet,
RTRIM(c.Corsia) AS Corsia,
c.Colonna,
c.Fila
FROM dbo.XMag_GiacenzaPallet AS g
JOIN dbo.Celle AS c ON c.ID = g.IDCella
WHERE g.IDCella <> 9999 AND RTRIM(c.Corsia) <> '7G'
)
"""
SQL_CORSIE = BASE_CTE + """
, dup_celle AS (
SELECT IDCCella = b.IDCella
FROM base b
GROUP BY b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
)
SELECT DISTINCT b.Corsia
FROM base b
WHERE EXISTS (SELECT 1 FROM dup_celle d WHERE d.IDCCella = b.IDCella)
ORDER BY b.Corsia;
"""
SQL_CELLE_DUP_PER_CORSIA = BASE_CTE + f"""
, dup_celle AS (
SELECT b.IDCella, COUNT(DISTINCT b.BarcodePallet) AS NumUDC
FROM base b
GROUP BY b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
)
SELECT dc.IDCella,
{UBI_B} AS Ubicazione,
b.Colonna, b.Fila, b.Corsia,
dc.NumUDC
FROM dup_celle dc
JOIN base b ON b.IDCella = dc.IDCella
WHERE b.Corsia = RTRIM(:corsia)
GROUP BY dc.IDCella, {UBI_B}, b.Colonna, b.Fila, b.Corsia, dc.NumUDC
ORDER BY b.Colonna, b.Fila;
"""
SQL_PALLET_IN_CELLA = BASE_CTE + """
SELECT
b.BarcodePallet AS Pallet,
ta.Descrizione,
ta.Lotto
FROM base b
OUTER APPLY (
SELECT TOP (1) t.Descrizione, t.Lotto
FROM dbo.vXTracciaProdotti AS t
WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS
ORDER BY t.Lotto
) AS ta
WHERE b.IDCella = :idcella
GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto
ORDER BY b.BarcodePallet;
"""
SQL_RIEPILOGO_PERCENTUALI = BASE_CTE + """
, tot AS (
SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle
FROM base b GROUP BY b.Corsia
),
dup_celle AS (
SELECT b.Corsia, b.IDCella
FROM base b
GROUP BY b.Corsia, b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
),
per_corsia AS (
SELECT t.Corsia, t.TotCelle, COALESCE(d.CelleMultiple, 0) AS CelleMultiple
FROM tot t
LEFT JOIN (
SELECT Corsia, COUNT(IDCella) AS CelleMultiple
FROM dup_celle GROUP BY Corsia
) d ON d.Corsia = t.Corsia
),
unione AS (
SELECT Corsia, TotCelle, CelleMultiple,
CAST(100.0 * CelleMultiple / NULLIF(TotCelle, 0) AS decimal(5,2)) AS Percentuale,
CAST(0 AS int) AS Ord
FROM per_corsia
UNION ALL
SELECT 'TOTALE' AS Corsia,
SUM(TotCelle), SUM(CelleMultiple),
CAST(100.0 * SUM(CelleMultiple) / NULLIF(SUM(TotCelle), 0) AS decimal(5,2)),
CAST(1 AS int) AS Ord
FROM per_corsia
)
SELECT Corsia, TotCelle, CelleMultiple, Percentuale
FROM unione
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):
"""Bind the shared DB client and immediately load the tree summary."""
super().__init__(root)
self.title("Celle con piu' pallet")
self.geometry("1100x700")
self.minsize(900, 550)
self.resizable(True, True)
self.db = db_client
self.runner = runner or AsyncRunner(self)
self._build_layout()
self._bind_events()
self.refresh_all()
def _build_layout(self):
"""Create the toolbar, lazy-loaded tree and percentage summary table."""
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=70)
self.grid_rowconfigure(2, weight=25, minsize=160)
self.grid_columnconfigure(0, weight=1)
toolbar = ctk.CTkFrame(self)
toolbar.grid(row=0, column=0, sticky="nsew")
ctk.CTkButton(toolbar, text="Aggiorna", command=self.refresh_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Espandi tutto", command=self.expand_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Comprimi tutto", command=self.collapse_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Esporta in XLSX", command=self.export_to_xlsx).pack(side="left", padx=6, pady=4)
frame = ctk.CTkFrame(self)
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.heading("#0", text="Nodo")
self.tree.heading("col2", text="Descrizione")
self.tree.heading("col3", text="Lotto")
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)
self.tree.grid(row=0, column=0, sticky="nsew")
y.grid(row=0, column=1, sticky="ns")
x.grid(row=1, column=0, sticky="ew")
sumf = ctk.CTkFrame(self)
sumf.grid(row=2, column=0, sticky="nsew", padx=6, pady=(0, 6))
ctk.CTkLabel(sumf, text="Riepilogo % celle multiple per corsia", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0))
inner = ctk.CTkFrame(sumf)
inner.pack(fill="both", expand=True, padx=6, pady=6)
inner.grid_rowconfigure(0, weight=1)
inner.grid_columnconfigure(0, weight=1)
self.sum_tbl = ttk.Treeview(inner, columns=("Corsia", "TotCelle", "CelleMultiple", "Percentuale"), show="headings")
for key, title, width, anchor in (
("Corsia", "Corsia", 100, "center"),
("TotCelle", "Totale celle", 120, "e"),
("CelleMultiple", ">1 UDC", 120, "e"),
("Percentuale", "%", 80, "e"),
):
self.sum_tbl.heading(key, text=title)
self.sum_tbl.column(key, width=width, anchor=anchor)
y2 = ttk.Scrollbar(inner, orient="vertical", command=self.sum_tbl.yview)
x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview)
self.sum_tbl.configure(yscrollcommand=y2.set, xscrollcommand=x2.set)
self.sum_tbl.grid(row=0, column=0, sticky="nsew")
y2.grid(row=0, column=1, sticky="ns")
x2.grid(row=1, column=0, sticky="ew")
def _bind_events(self):
"""Attach lazy-load behavior when nodes are expanded."""
self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
def refresh_all(self):
"""Reload both the duplication tree and the summary percentage table."""
self._load_corsie()
self._load_riepilogo()
def _load_corsie(self):
"""Load root nodes representing aisles with duplicated cells."""
self.tree.delete(*self.tree.get_children())
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 _fill_corsie(self, res):
"""Populate root tree nodes after the aisle query completes."""
rows = _json_obj(res).get("rows", [])
for row in rows:
corsia = row.get("Corsia")
if not corsia:
continue
node_id = f"corsia:{corsia}"
self.tree.insert("", "end", iid=node_id, text=f"Corsia {corsia}", values=("", ""), open=False, tags=("corsia",))
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", ""))
def _on_open_node(self, _evt):
"""Lazy-load children when a tree node is expanded."""
sel = self.tree.focus()
if not sel:
return
if sel.startswith("corsia:"):
lazy_id = f"{sel}::lazy"
if lazy_id in self.tree.get_children(sel):
self.tree.delete(lazy_id)
corsia = sel.split(":", 1)[1]
self._load_celle_for_corsia(sel, corsia)
elif sel.startswith("cella:"):
lazy_id = f"{sel}::lazy"
if lazy_id in self.tree.get_children(sel):
self.tree.delete(lazy_id)
idcella = int(sel.split(":", 1)[1])
for child in self.tree.get_children(sel):
self.tree.delete(child)
self._load_pallet_for_cella(sel, idcella)
def _load_celle_for_corsia(self, parent_iid, corsia):
"""Query duplicated cells for the selected aisle."""
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 _fill_celle(self, parent_iid, res):
"""Populate duplicated-cell nodes under an aisle node."""
rows = _json_obj(res).get("rows", [])
if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", ""))
return
for row in rows:
idc = row["IDCella"]
ubi = row["Ubicazione"]
corsia = row.get("Corsia")
num = row.get("NumUDC", 0)
node_id = f"cella:{idc}"
label = f"{ubi} [x{num}]"
if self.tree.exists(node_id):
self.tree.item(node_id, text=label, values=(f"IDCella {idc}", ""))
else:
self.tree.insert(parent_iid, "end", iid=node_id, text=label, values=(f"IDCella {idc}", ""), open=False, tags=("cella", f"corsia:{corsia}"))
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=("", ""))
def _load_pallet_for_cella(self, parent_iid, idcella: int):
"""Query pallet details for a duplicated cell."""
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 _fill_pallet(self, parent_iid, res):
"""Add pallet leaves under the selected cell node."""
rows = _json_obj(res).get("rows", [])
if not rows:
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)
corsia_val = corsia_tag.split(":", 1)[1] if corsia_tag else ""
cella_ubi = self.tree.item(parent_iid, "text")
idcella_txt = self.tree.item(parent_iid, "values")[0]
idcella_num = int(idcella_txt.split()[-1]) if idcella_txt else None
for row in rows:
pallet = row.get("Pallet", "")
desc = row.get("Descrizione", "")
lotto = row.get("Lotto", "")
leaf_id = f"pallet:{idcella_num}:{pallet}"
if self.tree.exists(leaf_id):
self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto))
continue
self.tree.insert(
parent_iid,
"end",
iid=leaf_id,
text=str(pallet),
values=(desc, lotto),
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
)
def _load_riepilogo(self):
"""Load the percentage summary by aisle."""
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 _fill_riepilogo(self, res):
"""Refresh the bottom summary table."""
rows = _json_obj(res).get("rows", [])
for item in self.sum_tbl.get_children():
self.sum_tbl.delete(item)
for row in rows:
self.sum_tbl.insert(
"",
"end",
values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"),
)
def expand_all(self):
"""Expand all aisle roots and trigger lazy loading where needed."""
for iid in self.tree.get_children(""):
self.tree.item(iid, open=True)
if f"{iid}::lazy" in self.tree.get_children(iid):
self.tree.delete(f"{iid}::lazy")
corsia = iid.split(":", 1)[1]
self._load_celle_for_corsia(iid, corsia)
def collapse_all(self):
"""Collapse all root nodes in the duplication tree."""
for iid in self.tree.get_children(""):
self.tree.item(iid, open=False)
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")
default_name = f"esportazione_celle_udc_multiple_{ts}.xlsx"
fname = filedialog.asksaveasfilename(
parent=self,
title="Esporta in Excel",
defaultextension=".xlsx",
filetypes=[("Excel Workbook", "*.xlsx")],
initialfile=default_name,
)
if not fname:
return
try:
wb = Workbook()
ws_det = wb.active
ws_det.title = "Dettaglio"
ws_sum = wb.create_sheet("Riepilogo")
det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto"]
sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"]
def _hdr(ws, headers):
"""Write formatted headers into the given worksheet."""
for j, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=j, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
_hdr(ws_det, det_headers)
_hdr(ws_sum, sum_headers)
row_idx = 2
for corsia_node in self.tree.get_children(""):
for cella_node in self.tree.get_children(corsia_node):
for pallet_node in self.tree.get_children(cella_node):
tags = self.tree.item(pallet_node, "tags") or ()
if "pallet" not in tags:
continue
corsia = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "")
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):
ws_det.cell(row=row_idx, column=j, value=value)
row_idx += 1
row_idx = 2
for iid in self.sum_tbl.get_children(""):
vals = self.sum_tbl.item(iid, "values")
for j, value in enumerate(vals, start=1):
ws_sum.cell(row=row_idx, column=j, value=value)
row_idx += 1
def _autosize(ws):
"""Resize worksheet columns based on their longest value."""
widths = {}
for row in ws.iter_rows(values_only=True):
for j, value in enumerate(row, start=1):
value_s = "" if value is None else str(value)
widths[j] = max(widths.get(j, 0), len(value_s))
from openpyxl.utils import get_column_letter
for j, width in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(width + 2, 10), 60)
_autosize(ws_det)
_autosize(ws_sum)
wb.save(fname)
messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self)
except Exception as ex:
messagebox.showerror("Errore esportazione", str(ex), parent=self)
def open_celle_multiple_window(root: tk.Tk, db_client, runner: AsyncRunner | None = None):
"""Create, focus and return the duplicated-cells explorer."""
win = CelleMultipleWindow(root, db_client, runner=runner)
win.lift()
win.focus_set()
return win

8
warehouse.pyw Normal file
View File

@@ -0,0 +1,8 @@
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_main")
from main import run_app
raise SystemExit(run_with_fatal_log("Warehouse Backoffice", run_app))

592
window_placement.py Normal file
View File

@@ -0,0 +1,592 @@
"""Helpers to place child windows consistently relative to the launcher."""
from __future__ import annotations
import ctypes
import logging
import math
import tkinter as tk
from pathlib import Path
MODULE_LOG_NAME = "window_placement"
MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log")
_MODULE_LOGGER = logging.getLogger(MODULE_LOG_NAME)
if not _MODULE_LOGGER.handlers:
_MODULE_LOGGER.setLevel(logging.DEBUG)
_MODULE_LOGGER.propagate = False
_handler = logging.FileHandler(MODULE_LOG_PATH, encoding="utf-8")
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
_MODULE_LOGGER.addHandler(_handler)
def _safe_xy(window: tk.Misc) -> tuple[int | None, int | None]:
"""Return current window coordinates without raising."""
try:
return int(window.winfo_x()), int(window.winfo_y())
except Exception:
return None, None
def _safe_wh(window: tk.Misc) -> tuple[int | None, int | None]:
"""Return current window size without raising."""
try:
return int(window.winfo_width()), int(window.winfo_height())
except Exception:
return None, None
def _window_label(window: tk.Misc) -> str:
"""Return a readable label for log messages."""
try:
title = str(window.title()).strip()
if title:
return title
except Exception:
pass
try:
return str(window)
except Exception:
return "<window>"
def _work_area_bounds(window: tk.Misc) -> tuple[int, int, int, int]:
"""Return the desktop work area excluding the taskbar when available."""
try:
if hasattr(ctypes, "windll"):
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
rect = RECT()
SPI_GETWORKAREA = 0x0030
if ctypes.windll.user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0):
return int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)
except Exception:
pass
return 0, 0, int(window.winfo_screenwidth()), int(window.winfo_screenheight())
def _taskbar_thickness(window: tk.Misc) -> int:
"""Return the Windows taskbar thickness when it can be determined."""
try:
screen_h = int(window.winfo_screenheight())
_left, _top, _right, work_bottom = _work_area_bounds(window)
inferred = max(0, screen_h - int(work_bottom))
if inferred > 0:
return inferred
except Exception:
pass
try:
if hasattr(ctypes, "windll"):
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
class APPBARDATA(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("hWnd", ctypes.c_void_p),
("uCallbackMessage", ctypes.c_uint),
("uEdge", ctypes.c_uint),
("rc", RECT),
("lParam", ctypes.c_long),
]
ABM_GETTASKBARPOS = 0x00000005
abd = APPBARDATA()
abd.cbSize = ctypes.sizeof(APPBARDATA)
if ctypes.windll.shell32.SHAppBarMessage(ABM_GETTASKBARPOS, ctypes.byref(abd)):
rect = abd.rc
width = max(0, int(rect.right) - int(rect.left))
height = max(0, int(rect.bottom) - int(rect.top))
return max(width, height)
except Exception:
pass
return 0
def _window_nonclient_extra(window: tk.Misc) -> tuple[int, int]:
"""Return extra outer frame size added by Windows around the client area."""
try:
if not hasattr(ctypes, "windll"):
return 0, 0
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
class POINT(ctypes.Structure):
_fields_ = [
("x", ctypes.c_long),
("y", ctypes.c_long),
]
user32 = ctypes.windll.user32
hwnd = int(window.winfo_id())
outer = RECT()
client = RECT()
origin = POINT()
if not user32.GetWindowRect(hwnd, ctypes.byref(outer)):
return 0, 0
if not user32.GetClientRect(hwnd, ctypes.byref(client)):
return 0, 0
if not user32.ClientToScreen(hwnd, ctypes.byref(origin)):
return 0, 0
outer_w = max(0, int(outer.right) - int(outer.left))
outer_h = max(0, int(outer.bottom) - int(outer.top))
client_w = max(0, int(client.right) - int(client.left))
client_h = max(0, int(client.bottom) - int(client.top))
extra_w = max(0, outer_w - client_w)
extra_h = max(0, outer_h - client_h)
return extra_w, extra_h
except Exception:
return 0, 0
def _window_outer_bounds(window: tk.Misc) -> tuple[int, int, int, int] | None:
"""Return the actual outer window rect in screen coordinates."""
try:
if not hasattr(ctypes, "windll"):
return None
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
rect = RECT()
hwnd = int(window.winfo_id())
if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)):
return None
return int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)
except Exception:
return None
def _set_window_alpha(window: tk.Misc, alpha: float) -> None:
"""Best-effort helper to change a window opacity."""
try:
window.attributes("-alpha", float(alpha))
except Exception:
pass
def _set_window_bounds(child: tk.Misc, x: int, y: int, width: int | None = None, height: int | None = None) -> None:
"""Move a toplevel to the requested bounds, resizing it when dimensions are provided."""
before_x, before_y = _safe_xy(child)
before_w, before_h = _safe_wh(child)
_MODULE_LOGGER.debug(
"set_bounds.start window=%s from=(%s,%s,%s,%s) target=(%s,%s,%s,%s)",
_window_label(child),
before_x,
before_y,
before_w,
before_h,
x,
y,
width,
height,
)
try:
child.state("normal")
except Exception:
pass
try:
child.deiconify()
except Exception:
pass
try:
if hasattr(ctypes, "windll"):
hwnd = int(child.winfo_id())
SWP_NOZORDER = 0x0004
SWP_NOACTIVATE = 0x0010
flags = SWP_NOZORDER | SWP_NOACTIVATE
move_w = 0 if width is None else int(width)
move_h = 0 if height is None else int(height)
if width is None or height is None:
flags |= 0x0001 # SWP_NOSIZE
ctypes.windll.user32.SetWindowPos(hwnd, 0, int(x), int(y), move_w, move_h, flags)
if width is None or height is None:
child.geometry(f"+{x}+{y}")
else:
child.geometry(f"{int(width)}x{int(height)}+{x}+{y}")
except Exception:
fallback_w = max(child.winfo_width(), child.winfo_reqwidth()) if width is None else int(width)
fallback_h = max(child.winfo_height(), child.winfo_reqheight()) if height is None else int(height)
child.geometry(f"{fallback_w}x{fallback_h}+{x}+{y}")
try:
child.update_idletasks()
except Exception:
pass
after_x, after_y = _safe_xy(child)
after_w, after_h = _safe_wh(child)
_MODULE_LOGGER.debug(
"set_bounds.end window=%s final=(%s,%s,%s,%s) target=(%s,%s,%s,%s)",
_window_label(child),
after_x,
after_y,
after_w,
after_h,
x,
y,
width,
height,
)
def _set_window_position(child: tk.Misc, x: int, y: int) -> None:
"""Move a toplevel to the requested screen coordinates without resizing it."""
_set_window_bounds(child, x, y, None, None)
def _ensure_window_position(child: tk.Misc, x: int, y: int, *, tolerance: int = 2) -> None:
"""Only correct the window position when it really drifted from the target."""
try:
current_x, current_y = _safe_xy(child)
if current_x is None or current_y is None:
_set_window_position(child, x, y)
return
if abs(current_x - int(x)) <= tolerance and abs(current_y - int(y)) <= tolerance:
return
except Exception:
pass
_set_window_position(child, x, y)
def _batch_set_window_positions(windows: list[tk.Misc], positions: list[tuple[int, int]]) -> bool:
"""Try to reposition multiple windows in one Win32 batch to reduce flicker."""
if not hasattr(ctypes, "windll"):
return False
if len(windows) != len(positions) or not windows:
return False
try:
user32 = ctypes.windll.user32
WM_SETREDRAW = 0x000B
RDW_INVALIDATE = 0x0001
RDW_ALLCHILDREN = 0x0080
RDW_FRAME = 0x0400
redraw_hwnds: list[int] = []
hdwp = user32.BeginDeferWindowPos(len(windows))
if not hdwp:
return False
SWP_NOSIZE = 0x0001
SWP_NOZORDER = 0x0004
SWP_NOACTIVATE = 0x0010
SWP_NOREDRAW = 0x0008
SWP_DEFERERASE = 0x2000
flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW | SWP_DEFERERASE
for child, (x, y) in zip(windows, positions):
try:
child.state("normal")
except Exception:
pass
try:
child.deiconify()
except Exception:
pass
hwnd = int(child.winfo_id())
redraw_hwnds.append(hwnd)
try:
user32.SendMessageW(hwnd, WM_SETREDRAW, 0, 0)
except Exception:
pass
hdwp = user32.DeferWindowPos(hdwp, hwnd, 0, int(x), int(y), 0, 0, flags)
if not hdwp:
for redraw_hwnd in redraw_hwnds:
try:
user32.SendMessageW(redraw_hwnd, WM_SETREDRAW, 1, 0)
user32.RedrawWindow(redraw_hwnd, None, None, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME)
except Exception:
pass
return False
ok = bool(user32.EndDeferWindowPos(hdwp))
for redraw_hwnd in redraw_hwnds:
try:
user32.SendMessageW(redraw_hwnd, WM_SETREDRAW, 1, 0)
user32.RedrawWindow(redraw_hwnd, None, None, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_FRAME)
except Exception:
pass
return ok
except Exception:
return False
def _restack_windows(windows: list[tk.Misc]) -> None:
"""Lift windows from back to front without mixing movement and z-order updates."""
for child in windows:
try:
child.lift()
except Exception:
pass
def place_window_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0):
"""Place ``child`` so its outer top edge sits just below ``parent``.
The placement uses root-window coordinates and preserves the child's
computed width/height. Call it after the child has a geometry.
"""
try:
parent.update_idletasks()
child.update_idletasks()
# On Windows/Tk, ``winfo_rootx`` starts at the inner client area,
# while ``winfo_x`` tracks the outer window frame. Using ``winfo_x``
# keeps child windows flush with the launcher's external left border.
x = parent.winfo_x() + int(x_offset)
y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap)
_set_window_position(child, x, y)
except Exception:
pass
def place_window_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0):
"""Schedule child placement on the Tk queue after geometry settles."""
try:
child.after(0, lambda: place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap))
except Exception:
place_window_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)
def place_window_fullsize_below_parent(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0):
"""Place a child below the launcher and size it to the full remaining work area."""
try:
parent.update_idletasks()
child.update_idletasks()
work_left, _work_top, work_right, work_bottom = _work_area_bounds(parent)
screen_h = int(parent.winfo_screenheight())
x = parent.winfo_x() + int(x_offset)
y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap)
taskbar_h = _taskbar_thickness(parent)
usable_bottom = int(work_bottom)
if taskbar_h > 0:
usable_bottom = min(usable_bottom, screen_h - int(taskbar_h))
extra_w, extra_h = _window_nonclient_extra(child)
width = max(320, int(work_right) - int(x) - int(extra_w))
height = max(240, int(usable_bottom) - int(y) - int(extra_h))
_MODULE_LOGGER.debug(
"fullsize.calc window=%s x=%s y=%s work_right=%s usable_bottom=%s taskbar=%s extra=(%s,%s) final=(%s,%s)",
_window_label(child),
x,
y,
work_right,
usable_bottom,
taskbar_h,
extra_w,
extra_h,
width,
height,
)
_set_window_bounds(child, x, y, width, height)
except Exception:
pass
def _fit_window_to_work_area(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0) -> None:
"""Trim a rendered child window so its outer frame stays above the taskbar."""
try:
if not getattr(child, "winfo_exists", lambda: False)():
return
parent.update_idletasks()
child.update_idletasks()
_work_left, _work_top, _work_right, work_bottom = _work_area_bounds(parent)
screen_h = int(parent.winfo_screenheight())
taskbar_h = _taskbar_thickness(parent)
usable_bottom = int(work_bottom)
if taskbar_h > 0:
usable_bottom = min(usable_bottom, screen_h - int(taskbar_h))
outer = _window_outer_bounds(child)
if outer is None:
return
left, top, right, bottom = outer
overflow = int(bottom) - int(usable_bottom)
_MODULE_LOGGER.debug(
"fit_window.check window=%s outer=(%s,%s,%s,%s) usable_bottom=%s overflow=%s",
_window_label(child),
left,
top,
right,
bottom,
usable_bottom,
overflow,
)
if overflow <= 0:
return
current_w, current_h = _safe_wh(child)
if current_w is None or current_h is None:
return
new_h = max(240, int(current_h) - int(overflow) - 2)
x = parent.winfo_x() + int(x_offset)
y = parent.winfo_rooty() + parent.winfo_height() + int(y_gap)
_MODULE_LOGGER.debug(
"fit_window.apply window=%s current=(%s,%s) new_h=%s",
_window_label(child),
current_w,
current_h,
new_h,
)
_set_window_bounds(child, x, y, int(current_w), int(new_h))
except Exception:
_MODULE_LOGGER.exception("fit_window.error")
def place_window_fullsize_below_parent_later(parent: tk.Misc, child: tk.Misc, *, x_offset: int = 0, y_gap: int = 0):
"""Schedule full-size placement below the launcher after geometry settles."""
try:
try:
_set_window_alpha(child, 0.0)
except Exception:
pass
child.after(0, lambda: place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap))
child.after(120, lambda: _fit_window_to_work_area(parent, child, x_offset=x_offset, y_gap=y_gap))
child.after(260, lambda: _fit_window_to_work_area(parent, child, x_offset=x_offset, y_gap=y_gap))
child.after(300, lambda: _set_window_alpha(child, 1.0) if getattr(child, "winfo_exists", lambda: False)() else None)
except Exception:
place_window_fullsize_below_parent(parent, child, x_offset=x_offset, y_gap=y_gap)
def tile_children_below_parent(parent: tk.Misc, children: list[tk.Misc], *, gap: int = 8, margin: int = 8):
"""Arrange open children in a compact grid below the launcher."""
windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()]
if not windows:
return
try:
parent.update_idletasks()
start_x = parent.winfo_rootx() + int(margin)
start_y = parent.winfo_rooty() + parent.winfo_height() + int(margin)
screen_w = parent.winfo_screenwidth()
screen_h = parent.winfo_screenheight()
avail_w = max(320, screen_w - start_x - int(margin))
avail_h = max(240, screen_h - start_y - int(margin))
count = len(windows)
cols = max(1, math.ceil(math.sqrt(count)))
rows = max(1, math.ceil(count / cols))
cell_w = max(320, (avail_w - (cols - 1) * int(gap)) // cols)
cell_h = max(240, (avail_h - (rows - 1) * int(gap)) // rows)
for idx, child in enumerate(windows):
row = idx // cols
col = idx % cols
x = start_x + col * (cell_w + int(gap))
y = start_y + row * (cell_h + int(gap))
child.geometry(f"{cell_w}x{cell_h}+{x}+{y}")
try:
child.lift()
except Exception:
pass
except Exception:
pass
def cascade_children_below_parent(
parent: tk.Misc,
children: list[tk.Misc],
*,
x_offset_step: int = 20,
y_offset_step: int = 20,
margin_left: int = 0,
margin_top: int = 0,
):
"""Arrange open children in cascade order below the launcher."""
windows = [w for w in children if w is not None and getattr(w, "winfo_exists", lambda: False)()]
if not windows:
return
try:
parent.update_idletasks()
base_x = parent.winfo_x() + int(margin_left)
base_y = parent.winfo_rooty() + parent.winfo_height() + int(margin_top)
positions: list[tuple[int, int]] = []
_MODULE_LOGGER.info(
"cascade.start parent=%s base=(%s,%s) count=%s x_step=%s y_step=%s",
_window_label(parent),
base_x,
base_y,
len(windows),
x_offset_step,
y_offset_step,
)
for idx, child in enumerate(windows):
child.update_idletasks()
x = base_x + idx * int(x_offset_step)
y = base_y + idx * int(y_offset_step)
positions.append((x, y))
_MODULE_LOGGER.info(
"cascade.window index=%s window=%s target=(%s,%s)",
idx,
_window_label(child),
x,
y,
)
batched = _batch_set_window_positions(windows, positions)
_MODULE_LOGGER.info("cascade.batch_applied=%s", batched)
for child, (x, y) in zip(windows, positions):
try:
if not batched:
_set_window_position(child, x, y)
except Exception:
pass
try:
parent.after(10, lambda wins=list(windows): _restack_windows([w for w in wins if getattr(w, "winfo_exists", lambda: False)()]))
except Exception:
_restack_windows(windows)
for child, (x, y) in zip(windows, positions):
try:
child.after(
110,
lambda w=child, px=x, py=y: _ensure_window_position(w, px, py)
if getattr(w, "winfo_exists", lambda: False)()
else None,
)
except Exception:
pass
except Exception:
_MODULE_LOGGER.exception("cascade.error")