Compare commits

...

13 Commits

36 changed files with 5514 additions and 176 deletions

View File

@@ -0,0 +1,70 @@
# Warehouse monolista - freeze 2.0
Data: 02/10/2026.
Titolo release: warehouse monolista.
Tag Git: `warehouse-monolista-2.0`.
## Scopo e contenuto
Punto di ripristino prima dello sviluppo del prelievo guidato di due liste.
La versione conserva la prenotazione singola e i cicli F1/F2 separati.
Include i sorgenti e gli script SQL gia' versionati, oltre alla correzione
del documento fisso F1/F2, ai suoi test e alla relativa documentazione.
La denominazione 2.0 identifica questo freeze, non una rinumerazione di tutti
i moduli. Il barcode conserva la versione visibile 1.0.27; repository 1.0.14,
servizio 1.0.25. Il registro delle versioni resta `version_info.py`.
La specifica `specifica_prelievo_due_pickinglist.md` descrive lo sviluppo
successivo, NON una funzionalita' gia' implementata in questa release.
Comprende il vincolo di due liste nella stessa area operativa (5 oppure 6).
## Verifiche del freeze
Gli 11 test di `tests/test_barcode_document_binding.py` sono passati con:
```bat
python -B -m unittest discover -s tests -v
```
Si tratta di test automatici con repository simulato, non di un collaudo
completo della GUI o del terminalino fisico. Non eseguono movimenti sul DB.
## Database e file locali
La correzione F1/F2 non richiede nuovi oggetti SQL. Le patch gia' necessarie
alla versione precedente restano nel repository, inclusa
`apply_online_python_wms_full_patch.sql`. Questo freeze non esegue patch SQL
e non costituisce un backup dei database Mediseawall e SAM.
Gli script in `sql_tests/` servono soltanto alla preparazione locale dei test:
NON lanciarli in produzione. Il ripristino delle sole testate non annulla
eventuali movimenti successivi; leggere `sql_tests/README.md`.
Configurazione locale `db_connection.json`, log, cache, impostazioni IDE,
registrazioni e vecchi ZIP locali non vengono aggiunti a questo commit.
Conservare separatamente configurazione e backup coerenti dei database
prima di modificare schema o dati per il futuro prelievo abbinato.
## Recuperare esattamente il freeze
Chiudere l'applicazione. Nella cartella del repository, con working tree pulito:
```bat
git fetch origin --tags
git switch --detach warehouse-monolista-2.0
git describe --tags --exact-match
python -m pip install -r requirements.txt
```
Se Git segnala modifiche locali, fermarsi e salvarle prima: non forzare la
sovrascrittura della configurazione o dei sorgenti. Per una prima installazione:
```bat
git clone --branch warehouse-monolista-2.0 --single-branch https://gitea.alessandrobonvicini.it/administrator/ware_house.git C:\flywms-monolista-2.0
```
Il checkout di un tag e' intenzionalmente in detached HEAD: identifica una
versione fissa. `git pull origin main` non e' il comando per restare su questo
freeze. Il ritorno al codice precedente non ripristina il database: eventuali
future patch richiederanno il rispettivo rollback compatibile o un backup.

View File

@@ -0,0 +1,247 @@
# Picking list con nazione di destinazione mancante
## Scopo
Questo documento descrive il difetto che rendeva invisibile una picking list
quando il documento SAM utilizzava un indirizzo di destinazione alternativo
senza nazione valorizzata. Descrive inoltre la correzione applicata al solo
ramo dati Python del WMS, le conseguenze funzionali e le verifiche da eseguire.
La correzione non modifica tabelle o viste legacy utilizzate dal programma C#
e non aggiorna dati anagrafici o documentali in SAM.
## Caso che ha evidenziato il difetto
Il problema e' stato rilevato sulla picking list `244/2026`:
| Dato | Valore |
|---|---|
| `BAMTES.ID` | `11896` |
| `BAMTES.NUMDOC` | `244` |
| `BAMTES.ANNDOC` | `2026` |
| Data documento | `24/09/2026` |
| `BAMTES.STATO` | `P` |
| `BAMTES.IDINDDEST` | `274` |
| Destinazione | `c/o UNITRANS Magazzino 4 Mirandola` |
| `BAMTES.IDNAZDEST` | `NULL` |
L'indirizzo alternativo `SAMA1.dbo.INDANA.ID = 274` esiste, ma anche il suo
campo `IDNAZIONI` e' `NULL`. L'indirizzo abituale della stessa controparte ha
invece `IDNAZIONI = 21`, corrispondente all'Italia.
Il documento non aveva altri problemi:
- anno corrente valido;
- stato `P` valido;
- data compresa nell'intervallo operativo;
- 4 righe documento;
- 494 righe lotto valide;
- 26 UDC distinte;
- trasportatore esistente.
## Causa tecnica
La preparazione delle picking list collegava la testata del documento alla
tabella delle nazioni con un collegamento obbligatorio:
```sql
FROM SAMA1.dbo.NAZIONI
INNER JOIN SAMA1.dbo.BAMTES
ON SAMA1.dbo.NAZIONI.ID = SAMA1.dbo.BAMTES.IDNAZDEST
```
Un `INNER JOIN` conserva soltanto le testate per le quali esiste una nazione
compatibile. Quando `BAMTES.IDNAZDEST` e' `NULL`, il confronto non puo' trovare
alcuna riga e l'intero documento scompare prima che vengano elaborate righe,
lotti e UDC.
Nel caso 244 la diagnosi ha prodotto:
```text
Righe valide senza join obbligatorio a NAZIONI: 494
Righe valide con join obbligatorio a NAZIONI: 0
```
Il filtro della GUI a 10 giorni, la prenotazione e la sequenza di prelievo non
erano quindi responsabili del problema.
## Flusso dati interessato
La pagina Gestione Picking List usa questa catena:
```text
SAMA1.dbo.BAMTES / FATRIG / LOTTIBF / LOTSER
|
v
dbo.py_XMag_ViewPackingList
|
v
dbo.py_ViewPackingListRestante
|
v
dbo.py_ViewPackingListPrelievo
|
v
Gestione Picking List e barcode Python
```
Lo storico Python utilizza inoltre:
```text
SAMA1
-> dbo.py_vPreparaPackingListSAMA1
-> dbo.py_vPreparaPackingList
-> dbo.py_XMag_ViewPackingListStorico
```
Per evitare comportamenti diversi fra operativita' e storico, la correzione e'
stata applicata sia alla preparazione operativa sia alla preparazione storica.
## Correzione applicata
Il collegamento e' stato reso opzionale:
```sql
FROM SAMA1.dbo.BAMTES AS bt
LEFT OUTER JOIN SAMA1.dbo.NAZIONI AS n
ON n.ID = bt.IDNAZDEST
```
Con il `LEFT OUTER JOIN` la testata BAMTES viene conservata anche quando la
nazione e' assente. Gli altri collegamenti essenziali verso righe, lotti e
seriali restano obbligatori.
Sono state modificate soltanto queste viste Python:
- `dbo.py_vPreparaPackingListSAMA1`;
- `dbo.py_XMag_ViewPackingList`.
Le viste dipendenti ricevono automaticamente i dati corretti:
- `dbo.py_vPreparaPackingList`;
- `dbo.py_XMag_ViewPackingListStorico`;
- `dbo.py_ViewPackingListRestante`;
- `dbo.py_ViewPackingListPrelievo`.
## Gestione dei valori mancanti
La patch non assegna automaticamente una nazione. Questo evita di trasformare
un'anagrafica incompleta in un dato apparentemente certo.
Quando la nazione manca:
- `CodNazione` resta `NULL`;
- `NAZIONE` resta `NULL`;
- il documento e le sue UDC restano visibili;
- il `CASE` applicativo gia' esistente usa il ramo generico `Stato = 4`;
- prenotazione, dettaglio UDC e sequenza di prelievo continuano a funzionare.
La correzione dell'anagrafica SAM rimane comunque consigliata. La robustezza
della vista impedisce pero' che un campo descrittivo incompleto blocchi un
processo operativo di magazzino.
## Script di installazione
La patch autonoma e' contenuta in:
```text
apply_pickinglist_optional_destination_country_patch.sql
```
Lo stesso cambiamento e' stato integrato anche negli script cumulativi:
- `apply_pickinglist_days_filter_patch.sql`;
- `apply_online_python_wms_full_patch.sql`.
Per aggiornare un database che possiede gia' le viste Python e' sufficiente
eseguire una sola volta in SSMS la patch autonoma. Gli script usano
`CREATE OR ALTER VIEW`, quindi possono essere rieseguiti senza duplicare dati.
## Verifica post-patch
Controllo della vista operativa:
```sql
SELECT
Documento,
DataDocumento,
CodNazione,
NAZIONE,
Stato,
IDStato,
COUNT(DISTINCT Pallet) AS NumeroUDC
FROM dbo.py_ViewPackingListPrelievo
WHERE Documento = 244
GROUP BY
Documento,
DataDocumento,
CodNazione,
NAZIONE,
Stato,
IDStato;
```
Risultato ottenuto dopo la correzione:
```text
Documento: 244
CodNazione: NULL
NAZIONE: NULL
Stato: 4
IDStato: 0
Numero UDC: 26
```
Controllo dei quattro livelli principali:
```sql
SELECT COUNT(DISTINCT UDC) AS UDC
FROM dbo.py_vPreparaPackingListSAMA1
WHERE NUMDOC = 244 AND StatoDocumento = 'P';
SELECT COUNT(DISTINCT Pallet) AS UDC
FROM dbo.py_XMag_ViewPackingList
WHERE Documento = 244;
SELECT COUNT(DISTINCT Pallet) AS UDC
FROM dbo.py_ViewPackingListRestante
WHERE Documento = 244;
SELECT COUNT(DISTINCT Pallet) AS UDC
FROM dbo.py_ViewPackingListPrelievo
WHERE Documento = 244;
```
Tutte e quattro le query devono restituire `26` per il caso analizzato.
## Impatto e compatibilita'
- Nessuna riga SAM viene inserita, aggiornata o cancellata.
- Nessuna prenotazione viene modificata.
- Nessuna stored procedure di movimentazione viene modificata.
- Le picking list con nazione valida mantengono lo stesso risultato.
- Le picking list prima invisibili per nazione nulla diventano visibili.
- Gli oggetti legacy senza prefisso `py_` non vengono modificati.
## Ripristino concettuale
Per ripristinare il comportamento precedente occorrerebbe riportare il
collegamento `BAMTES -> NAZIONI` da `LEFT OUTER JOIN` a `INNER JOIN` nelle due
viste Python modificate. Tale rollback farebbe nuovamente scomparire tutti i
documenti con `IDNAZDEST` nullo e non e' quindi raccomandato come soluzione
operativa.
## Miglioramento futuro consigliato
Una diagnostica separata potrebbe mostrare le picking list con dati anagrafici
incompleti, senza impedirne l'uso:
```sql
SELECT ID, ANNDOC, NUMDOC, IDINDDEST, DESCRDEST
FROM SAMA1.dbo.BAMTES
WHERE STATO = 'P'
AND IDNAZDEST IS NULL;
```
In questo modo il WMS rimane operativo e l'amministratore puo' correggere
l'anagrafica SAM in un secondo momento.

View File

@@ -5,8 +5,8 @@ IF OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_BarcodePickingListSkip (
ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_py_BarcodePickingListSkip PRIMARY KEY,
Documento varchar(50) NOT NULL,
Pallet varchar(50) NOT NULL,
Documento varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
Pallet varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
IDStato int NOT NULL,
IDOperatore int NOT NULL,
DataOra datetime2(0) NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_DataOra DEFAULT SYSDATETIME(),

View File

@@ -0,0 +1,725 @@
/*
Patch cumulativa DB online - ramo Python WMS
Da eseguire una sola volta in SSMS sul database Mediseawall.
Questa patch NON modifica gli oggetti legacy usati dal programma C#.
Include:
- dbo.PyPickingListReservation
- dbo.py_XMag_ViewPackingList
- dbo.py_ViewPackingListRestante
- dbo.py_sp_xExePackingListPallet
- dbo.py_sp_xExePackingListPalletPrenota
- dbo.py_sp_ControllaPrenotazionePackingListPalletNew
- dbo.py_vPreparaPackingListSAMA1
- dbo.py_vPreparaPackingList
- dbo.py_XMag_ViewPackingListStorico
- dbo.py_BarcodePickingListSkip
- dbo.py_PickingListPrelievoSequenza
- dbo.py_ViewPackingListPrelievo
Include inoltre:
- intervallo operativo massimo di 30 giorni, filtrato dalla UI a 10/15/20/30;
- destinazione senza nazione ammessa nel ramo Python mediante LEFT JOIN;
- ordine predefinito con UDC Non scaff. prima degli scaffali;
- ordine personalizzabile per singolo Documento.
Nota storico:
dbo.py_vPreparaPackingListSAMA1 usa ANNDOC >= YEAR(GETDATE())
per mantenere visibili solo le picking list dall'inizio dell'anno corrente.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
/* ============================================================
1. Stato prenotazione picking list ramo Python
============================================================ */
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
/* ============================================================
2. Viste operative picking list ramo Python
============================================================ */
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
/* ============================================================
3. Stored procedure operative ramo Python
============================================================ */
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
/* ============================================================
4. Viste storico picking list ramo Python
============================================================ */
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.BAMTES
LEFT OUTER JOIN SAMA1.dbo.NAZIONI
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
/* ============================================================
5. Tabella salta UDC barcode picking list
============================================================ */
IF OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_BarcodePickingListSkip (
ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_py_BarcodePickingListSkip PRIMARY KEY,
Documento varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
Pallet varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
IDStato int NOT NULL,
IDOperatore int NOT NULL,
DataOra datetime2(0) NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_DataOra DEFAULT SYSDATETIME(),
Motivo nvarchar(200) NULL,
Risolto bit NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_Risolto DEFAULT 0,
RisoltoDa int NULL,
RisoltoDataOra datetime2(0) NULL
);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_py_BarcodePickingListSkip_Open'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE UNIQUE INDEX UX_py_BarcodePickingListSkip_Open
ON dbo.py_BarcodePickingListSkip (Documento, Pallet, IDStato)
WHERE Risolto = 0;
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_py_BarcodePickingListSkip_Queue'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE INDEX IX_py_BarcodePickingListSkip_Queue
ON dbo.py_BarcodePickingListSkip (IDStato, Documento, Pallet, Risolto);
END;
GO
/* ============================================================
6. Intervallo operativo 30 giorni e sequenza di prelievo
La vista di preparazione Python creata al punto 4 contiene sia P sia D.
Qui il ramo operativo viene ristretto alle sole liste P degli ultimi 30
giorni; la UI applica successivamente il valore scelto (10/15/20/30).
============================================================ */
CREATE OR ALTER VIEW dbo.py_XMag_ViewPackingList
AS
SELECT TOP (10000)
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.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 AS c
INNER JOIN dbo.XMag_GiacenzaPallet AS g
ON c.ID = g.IDCella
RIGHT OUTER JOIN dbo.py_vPreparaPackingList AS prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
LEFT JOIN dbo.PyPickingListReservation AS pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
WHERE prep.StatoDocumento = 'P'
AND prep.DATDOC > CAST((GETDATE() - 30) AS int)
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
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
CREATE OR ALTER VIEW dbo.py_ViewPackingListRestante
AS
SELECT
Pallet, Lotto, Articolo, Descrizione, Qta, Documento, DataDocumento,
CodNazione, NAZIONE, Stato, PalletCella, Magazzino, Area, Cella,
Ordinamento, Ubicazione, DEST, IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Cella <> 9999;
GO
IF OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_PickingListPrelievoSequenza (
ID bigint IDENTITY(1,1) NOT NULL
CONSTRAINT PK_py_PickingListPrelievoSequenza PRIMARY KEY,
Documento varchar(50) NOT NULL,
GruppoChiave varchar(160) NOT NULL,
GruppoEtichetta varchar(100) NOT NULL,
Ordine int NOT NULL,
InsUtente varchar(50) NULL,
InsDataOra datetime2(0) NOT NULL
CONSTRAINT DF_py_PickingListPrelievoSequenza_InsDataOra DEFAULT SYSDATETIME(),
ModUtente varchar(50) NULL,
ModDataOra datetime2(0) NULL,
DelUtente varchar(50) NULL,
DelDataOra datetime2(0) NULL,
CONSTRAINT CK_py_PickingListPrelievoSequenza_Ordine CHECK (Ordine >= 0)
);
END;
GO
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U')
AND name = N'UX_py_PickingListPrelievoSequenza_DocGruppo'
)
BEGIN
CREATE UNIQUE INDEX UX_py_PickingListPrelievoSequenza_DocGruppo
ON dbo.py_PickingListPrelievoSequenza (Documento, GruppoChiave)
WHERE DelDataOra IS NULL;
END;
GO
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U')
AND name = N'UX_py_PickingListPrelievoSequenza_DocOrdine'
)
BEGIN
CREATE UNIQUE INDEX UX_py_PickingListPrelievoSequenza_DocOrdine
ON dbo.py_PickingListPrelievoSequenza (Documento, Ordine)
WHERE DelDataOra IS NULL;
END;
GO
CREATE OR ALTER VIEW dbo.py_ViewPackingListPrelievo
AS
WITH Gruppi AS (
SELECT
pl.*,
CASE
WHEN pl.Cella = 1000 THEN 'NON_SCAFF'
ELSE CONCAT(
'M:', pl.Magazzino, '|A:', pl.Area, '|C:',
LTRIM(RTRIM(CASE
WHEN CHARINDEX(' - ', pl.Ubicazione) > 0
THEN LEFT(pl.Ubicazione, CHARINDEX(' - ', pl.Ubicazione) - 1)
ELSE pl.Ubicazione
END))
)
END AS GruppoPrelievoChiave,
CASE
WHEN pl.Cella = 1000 THEN 'Non scaff.'
ELSE LTRIM(RTRIM(CASE
WHEN CHARINDEX(' - ', pl.Ubicazione) > 0
THEN LEFT(pl.Ubicazione, CHARINDEX(' - ', pl.Ubicazione) - 1)
ELSE pl.Ubicazione
END))
END AS GruppoPrelievo,
CASE
WHEN pl.Cella = 1000 THEN 0
ELSE 1000 + ISNULL(pl.Ordinamento, 99999)
END AS OrdineGruppoDefault
FROM dbo.py_ViewPackingListRestante AS pl
), Sequenza AS (
SELECT
g.*,
s.Ordine AS OrdineGruppoConfigurato,
CASE WHEN EXISTS (
SELECT 1
FROM dbo.py_PickingListPrelievoSequenza AS any_s
WHERE any_s.Documento COLLATE Latin1_General_CI_AS =
CAST(g.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND any_s.DelDataOra IS NULL
) THEN 1 ELSE 0 END AS HaSequenzaPersonalizzata
FROM Gruppi AS g
LEFT JOIN dbo.py_PickingListPrelievoSequenza AS s
ON s.Documento COLLATE Latin1_General_CI_AS =
CAST(g.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.GruppoChiave COLLATE Latin1_General_CI_AS =
g.GruppoPrelievoChiave COLLATE Latin1_General_CI_AS
AND s.DelDataOra IS NULL
)
SELECT
Sequenza.*,
CASE
WHEN OrdineGruppoConfigurato IS NOT NULL THEN OrdineGruppoConfigurato
WHEN HaSequenzaPersonalizzata = 1 THEN 100000 + OrdineGruppoDefault
ELSE OrdineGruppoDefault
END AS OrdinePrelievoGruppo
FROM Sequenza;
GO
PRINT 'Patch cumulativa Python WMS completata.';
GO

View File

@@ -0,0 +1,190 @@
/*
Patch: intervallo giorni selezionabile in Gestione Picking List Python
Scopo:
- permettere alla UI Python di caricare picking list operative degli ultimi
10, 15, 20 o 30 giorni;
- mantenere 10 giorni come default applicativo;
- non escludere documenti validi con nazione di destinazione non valorizzata;
- non modificare gli oggetti legacy usati dal programma C#.
Oggetti modificati:
- dbo.py_XMag_ViewPackingList
- dbo.py_ViewPackingListRestante
Nota:
La vista legacy dbo.XMag_ViewPackingList dipende da dbo.vPreparaPackingListSAMA1,
che contiene il filtro fisso GETDATE()-10. Per questo la vista Python viene
ricalcolata direttamente dalle tabelle SAM con un orizzonte massimo di 30 giorni.
La UI applica poi il filtro effettivo scelto dall'operatore.
Il collegamento a NAZIONI e' intenzionalmente LEFT OUTER JOIN: un indirizzo
alternativo incompleto non deve rendere invisibile l'intera picking list.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingList]
AS
WITH PreparaPackingList 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.NAZIONI.CODICE AS Expr1,
SAMA1.dbo.MTRASP.CODICE + ' ' + SAMA1.dbo.NAZIONI.DESCR AS NAZIONE,
SAMA1.dbo.BAMTES.IDMTRASP
FROM SAMA1.dbo.BAMTES
LEFT OUTER JOIN SAMA1.dbo.NAZIONI
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 = 'P'
AND SAMA1.dbo.BAMTES.DATDOC > CAST((GETDATE() - 30) AS int)
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.NAZIONI.CODICE,
SAMA1.dbo.NAZIONI.DESCR,
SAMA1.dbo.BAMTES.IDMTRASP,
SAMA1.dbo.MTRASP.CODICE
),
Base AS (
SELECT
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.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
FROM dbo.Celle AS c
INNER JOIN dbo.XMag_GiacenzaPallet AS g
ON c.ID = g.IDCella
RIGHT OUTER JOIN PreparaPackingList AS prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
WHERE prep.NUMLOT <> ''
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
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
)
SELECT TOP 10000
Base.Pallet,
Base.Lotto,
Base.Articolo,
Base.Descrizione,
Base.Qta,
Base.Documento,
Base.DataDocumento,
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.PyPickingListReservation AS 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 VIEW [dbo].[py_ViewPackingListRestante]
AS
SELECT
Pallet,
Lotto,
Articolo,
Descrizione,
Qta,
Documento,
DataDocumento,
CodNazione,
NAZIONE,
Stato,
PalletCella,
Magazzino,
Area,
Cella,
Ordinamento,
Ubicazione,
DEST,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Cella <> 9999;
GO

View File

@@ -0,0 +1,217 @@
/*
Patch Python WMS: picking list con nazione di destinazione non valorizzata
Problema risolto:
- una testata BAMTES valida puo' avere IDNAZDEST NULL, in particolare quando
viene scelto un indirizzo di destinazione alternativo privo di nazione;
- l'INNER JOIN a SAMA1.dbo.NAZIONI eliminava l'intero documento;
- la picking list non compariva quindi in Gestione Picking List ne' nello
storico Python, anche se stato, data, righe, lotti e UDC erano validi.
Strategia:
- il collegamento BAMTES -> NAZIONI diventa LEFT OUTER JOIN;
- non viene inventato alcun codice nazione: Expr1 e NAZIONE restano NULL;
- il CASE gia' esistente assegna in questo caso lo Stato generico 4;
- nessun oggetto legacy usato dal programma C# viene modificato.
Oggetti Python modificati:
- dbo.py_vPreparaPackingListSAMA1 (storico e base condivisa);
- dbo.py_XMag_ViewPackingList (gestione operativa).
*/
SET XACT_ABORT ON;
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER VIEW dbo.py_vPreparaPackingListSAMA1
AS
SELECT
ls.NUMLOT,
a.CODICE,
fr.DESCR,
LEFT(ls.NUMSER, 6) AS UDC,
SUM(ls.QTTGIAI) AS Qta,
bt.NUMDOC,
bt.DATDOC,
bt.ID,
bt.DESCRDEST,
bt.STATO AS StatoDocumento,
n.CODICE AS Expr1,
mt.CODICE + ' ' + n.DESCR AS NAZIONE,
bt.IDMTRASP
FROM SAMA1.dbo.BAMTES AS bt
INNER JOIN SAMA1.dbo.FATRIG AS fr
ON fr.IDBAM = bt.ID
INNER JOIN SAMA1.dbo.LOTTIBF AS lb
ON lb.IDFATRIG = fr.ID
INNER JOIN SAMA1.dbo.LOTSER AS ls
ON ls.ID = lb.IDLOTSER
LEFT OUTER JOIN SAMA1.dbo.ARTICO AS a
ON a.ID = ls.IDARTICO
LEFT OUTER JOIN SAMA1.sam.EXTUC AS uc
ON uc.CODICE = LEFT(ls.NUMSER, 6)
LEFT OUTER JOIN SAMA1.dbo.MTRASP AS mt
ON mt.ID = bt.IDMTRASP
LEFT OUTER JOIN SAMA1.dbo.NAZIONI AS n
ON n.ID = bt.IDNAZDEST
WHERE bt.ANNDOC >= YEAR(GETDATE())
AND bt.STATO IN ('P', 'D')
AND ls.NUMLOT <> '00000000000'
GROUP BY
ls.NUMLOT,
a.CODICE,
fr.DESCR,
LEFT(ls.NUMSER, 6),
bt.NUMDOC,
bt.DATDOC,
bt.ID,
bt.DESCRDEST,
bt.STATO,
n.CODICE,
n.DESCR,
bt.IDMTRASP,
mt.CODICE;
GO
CREATE OR ALTER VIEW dbo.py_XMag_ViewPackingList
AS
WITH PreparaPackingList AS (
SELECT
ls.NUMLOT,
a.CODICE,
fr.DESCR,
LEFT(ls.NUMSER, 6) AS UDC,
SUM(ls.QTTGIAI) AS Qta,
bt.NUMDOC,
bt.DATDOC,
bt.ID,
bt.DESCRDEST,
n.CODICE AS Expr1,
mt.CODICE + ' ' + n.DESCR AS NAZIONE,
bt.IDMTRASP
FROM SAMA1.dbo.BAMTES AS bt
INNER JOIN SAMA1.dbo.FATRIG AS fr
ON fr.IDBAM = bt.ID
INNER JOIN SAMA1.dbo.LOTTIBF AS lb
ON lb.IDFATRIG = fr.ID
INNER JOIN SAMA1.dbo.LOTSER AS ls
ON ls.ID = lb.IDLOTSER
LEFT OUTER JOIN SAMA1.dbo.ARTICO AS a
ON a.ID = ls.IDARTICO
LEFT OUTER JOIN SAMA1.sam.EXTUC AS uc
ON uc.CODICE = LEFT(ls.NUMSER, 6)
LEFT OUTER JOIN SAMA1.dbo.MTRASP AS mt
ON mt.ID = bt.IDMTRASP
LEFT OUTER JOIN SAMA1.dbo.NAZIONI AS n
ON n.ID = bt.IDNAZDEST
WHERE bt.ANNDOC >= YEAR(GETDATE())
AND bt.STATO = 'P'
AND bt.DATDOC > CAST((GETDATE() - 30) AS int)
AND ls.NUMLOT <> '00000000000'
GROUP BY
ls.NUMLOT,
a.CODICE,
fr.DESCR,
LEFT(ls.NUMSER, 6),
bt.NUMDOC,
bt.DATDOC,
bt.ID,
bt.DESCRDEST,
n.CODICE,
n.DESCR,
bt.IDMTRASP,
mt.CODICE
),
Base AS (
SELECT
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.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
FROM dbo.Celle AS c
INNER JOIN dbo.XMag_GiacenzaPallet AS g
ON c.ID = g.IDCella
RIGHT OUTER JOIN PreparaPackingList AS prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
WHERE prep.NUMLOT <> ''
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
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
)
SELECT TOP (10000)
Base.Pallet,
Base.Lotto,
Base.Articolo,
Base.Descrizione,
Base.Qta,
Base.Documento,
Base.DataDocumento,
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.PyPickingListReservation AS 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
PRINT 'Patch nazione destinazione opzionale applicata alle viste Python.';
GO

View File

@@ -0,0 +1,136 @@
/*
Patch Python WMS: ordine di prelievo per singola picking list
Prerequisito:
- eseguire prima apply_pickinglist_days_filter_patch.sql, che espone
DataDocumento nelle viste operative Python.
Effetti:
- crea una tabella Python-only con la sequenza personalizzata per Documento;
- crea una vista Python-only con l'ordine effettivo condiviso da backoffice e barcode;
- senza configurazione applica il default: Non scaff. prima degli scaffali;
- con configurazione applica i gruppi scelti e accoda in modo sicuro quelli mancanti;
- non modifica tabelle, viste o stored procedure legacy usate dal programma C#.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_PickingListPrelievoSequenza (
ID bigint IDENTITY(1,1) NOT NULL
CONSTRAINT PK_py_PickingListPrelievoSequenza PRIMARY KEY,
Documento varchar(50) NOT NULL,
GruppoChiave varchar(160) NOT NULL,
GruppoEtichetta varchar(100) NOT NULL,
Ordine int NOT NULL,
InsUtente varchar(50) NULL,
InsDataOra datetime2(0) NOT NULL
CONSTRAINT DF_py_PickingListPrelievoSequenza_InsDataOra DEFAULT SYSDATETIME(),
ModUtente varchar(50) NULL,
ModDataOra datetime2(0) NULL,
DelUtente varchar(50) NULL,
DelDataOra datetime2(0) NULL,
CONSTRAINT CK_py_PickingListPrelievoSequenza_Ordine CHECK (Ordine >= 0)
);
END
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U')
AND name = N'UX_py_PickingListPrelievoSequenza_DocGruppo'
)
BEGIN
CREATE UNIQUE INDEX UX_py_PickingListPrelievoSequenza_DocGruppo
ON dbo.py_PickingListPrelievoSequenza (Documento, GruppoChiave)
WHERE DelDataOra IS NULL;
END
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U')
AND name = N'UX_py_PickingListPrelievoSequenza_DocOrdine'
)
BEGIN
CREATE UNIQUE INDEX UX_py_PickingListPrelievoSequenza_DocOrdine
ON dbo.py_PickingListPrelievoSequenza (Documento, Ordine)
WHERE DelDataOra IS NULL;
END
GO
CREATE OR ALTER VIEW dbo.py_ViewPackingListPrelievo
AS
WITH Gruppi AS (
SELECT
pl.*,
CASE
WHEN pl.Cella = 1000 THEN 'NON_SCAFF'
ELSE CONCAT(
'M:', pl.Magazzino,
'|A:', pl.Area,
'|C:',
LTRIM(RTRIM(
CASE
WHEN CHARINDEX(' - ', pl.Ubicazione) > 0
THEN LEFT(pl.Ubicazione, CHARINDEX(' - ', pl.Ubicazione) - 1)
ELSE pl.Ubicazione
END
))
)
END AS GruppoPrelievoChiave,
CASE
WHEN pl.Cella = 1000 THEN 'Non scaff.'
ELSE LTRIM(RTRIM(
CASE
WHEN CHARINDEX(' - ', pl.Ubicazione) > 0
THEN LEFT(pl.Ubicazione, CHARINDEX(' - ', pl.Ubicazione) - 1)
ELSE pl.Ubicazione
END
))
END AS GruppoPrelievo,
CASE
WHEN pl.Cella = 1000 THEN 0
ELSE 1000 + ISNULL(pl.Ordinamento, 99999)
END AS OrdineGruppoDefault
FROM dbo.py_ViewPackingListRestante AS pl
), Sequenza AS (
SELECT
g.*,
s.Ordine AS OrdineGruppoConfigurato,
CASE
WHEN EXISTS (
SELECT 1
FROM dbo.py_PickingListPrelievoSequenza AS any_s
WHERE any_s.Documento COLLATE Latin1_General_CI_AS =
CAST(g.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND any_s.DelDataOra IS NULL
) THEN 1
ELSE 0
END AS HaSequenzaPersonalizzata
FROM Gruppi AS g
LEFT JOIN dbo.py_PickingListPrelievoSequenza AS s
ON s.Documento COLLATE Latin1_General_CI_AS =
CAST(g.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.GruppoChiave COLLATE Latin1_General_CI_AS =
g.GruppoPrelievoChiave COLLATE Latin1_General_CI_AS
AND s.DelDataOra IS NULL
)
SELECT
Sequenza.*,
CASE
WHEN OrdineGruppoConfigurato IS NOT NULL THEN OrdineGruppoConfigurato
WHEN HaSequenzaPersonalizzata = 1 THEN 100000 + OrdineGruppoDefault
ELSE OrdineGruppoDefault
END AS OrdinePrelievoGruppo
FROM Sequenza;
GO
PRINT 'Patch sequenza prelievo Python applicata.';
GO

View File

@@ -10,9 +10,13 @@ cross-loop errors.
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import os
import time
import urllib.parse
from pathlib import Path
from typing import Any, Dict, Optional
from sqlalchemy import text
@@ -22,6 +26,59 @@ from version_info import module_version
__version__ = module_version(__name__)
QUERY_PROFILER_LOG = Path(__file__).with_name("warehouse_query_profiler.log")
QUERY_PROFILER_CONFIG_PATH = Path(__file__).with_name("db_connection.json")
def _load_query_profiler_config() -> dict[str, Any]:
"""Read profiler settings from db_connection.json, with env overrides."""
config: dict[str, Any] = {
"enabled": False,
"slow_ms": 0,
"sql_limit": 4000,
}
try:
data = json.loads(QUERY_PROFILER_CONFIG_PATH.read_text(encoding="utf-8"))
section = data.get("query_profiler") if isinstance(data, dict) else None
if isinstance(section, dict):
config.update(section)
except Exception:
pass
if "WAREHOUSE_QUERY_PROFILER" in os.environ:
config["enabled"] = os.environ.get("WAREHOUSE_QUERY_PROFILER", "1").strip().lower() not in {
"0",
"false",
"no",
"off",
}
if "WAREHOUSE_QUERY_PROFILER_SLOW_MS" in os.environ:
config["slow_ms"] = os.environ.get("WAREHOUSE_QUERY_PROFILER_SLOW_MS", "0")
if "WAREHOUSE_QUERY_PROFILER_SQL_LIMIT" in os.environ:
config["sql_limit"] = os.environ.get("WAREHOUSE_QUERY_PROFILER_SQL_LIMIT", "4000")
return config
def _query_profiler_enabled() -> bool:
value = _load_query_profiler_config().get("enabled", False)
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off"}
return bool(value)
def _query_profiler_slow_ms() -> float:
try:
return float(_load_query_profiler_config().get("slow_ms", 0) or 0)
except Exception:
return 0.0
def _query_profiler_sql_limit() -> int:
try:
return int(_load_query_profiler_config().get("sql_limit", 4000) or 4000)
except Exception:
return 4000
try:
import pyodbc
@@ -76,6 +133,83 @@ def make_mssql_dsn(
return f"mssql+aioodbc:///?odbc_connect={urllib.parse.quote_plus(odbc)}"
def _compact_sql(sql: str, *, limit: int | None = None) -> str:
"""Collapse SQL whitespace so profiler entries stay readable."""
if limit is None:
limit = _query_profiler_sql_limit()
text_value = " ".join(str(sql or "").split())
if limit > 0 and len(text_value) > limit:
return text_value[:limit] + "...<truncated>"
return text_value
def _profile_params(params: Optional[Dict[str, Any]]) -> str:
"""Serialize SQL parameters for diagnostics."""
if not params:
return "{}"
try:
return _dumps(params)
except Exception:
return repr(params)
def _query_caller() -> str:
"""Return the first external Python frame that triggered the DB call."""
current_file = Path(__file__).resolve()
for frame in inspect.stack(context=0)[2:]:
try:
frame_file = Path(frame.filename).resolve()
except Exception:
continue
if frame_file == current_file:
continue
return f"{frame_file.name}:{frame.lineno}:{frame.function}"
return "unknown"
def _append_query_profile(
*,
method: str,
elapsed_ms: float,
rows: int | None,
rowcount: int | None,
commit: bool,
ok: bool,
sql: str,
params: Optional[Dict[str, Any]],
caller: str,
error: str = "",
) -> None:
"""Append one query timing line to the local profiler log."""
if not _query_profiler_enabled():
return
slow_ms = _query_profiler_slow_ms()
if ok and slow_ms > 0 and elapsed_ms < slow_ms:
return
try:
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
row_info = f"rows={rows}" if rows is not None else f"rowcount={rowcount}"
lines = [
(
f"{timestamp} | {elapsed_ms:.3f} ms | {method} | "
f"{row_info} | commit={int(commit)} | ok={int(ok)} | caller={caller}"
),
f"PARAMS: {_profile_params(params)}",
f"SQL: {_compact_sql(sql)}",
]
if error:
lines.append(f"ERROR: {error}")
with QUERY_PROFILER_LOG.open("a", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n---\n")
except Exception:
# Profiling must never interfere with warehouse operations.
pass
class AsyncMSSQLClient:
"""Thin async query client for SQL Server.
@@ -152,23 +286,83 @@ class AsyncMSSQLClient:
"""
await self._ensure_engine()
t0 = time.perf_counter()
async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {})
rows = res.fetchall()
cols = list(res.keys())
caller = _query_caller()
try:
async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {})
rows = res.fetchall()
cols = list(res.keys())
except Exception as exc:
elapsed_ms = round((time.perf_counter() - t0) * 1000, 3)
_append_query_profile(
method="query_json",
elapsed_ms=elapsed_ms,
rows=None,
rowcount=None,
commit=commit,
ok=False,
sql=sql,
params=params,
caller=caller,
error=repr(exc),
)
raise
if as_dict_rows:
rows_out = [dict(zip(cols, row)) for row in rows]
else:
rows_out = [list(row) for row in rows]
elapsed_ms = round((time.perf_counter() - t0) * 1000, 3)
_append_query_profile(
method="query_json",
elapsed_ms=elapsed_ms,
rows=len(rows_out),
rowcount=None,
commit=commit,
ok=True,
sql=sql,
params=params,
caller=caller,
)
return {
"columns": cols,
"rows": rows_out,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 3),
"elapsed_ms": elapsed_ms,
}
async def exec(self, sql: str, params: Optional[Dict[str, Any]] = None, *, commit: bool = False) -> int:
"""Execute a DML statement and return its row count."""
await self._ensure_engine()
async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {})
return res.rowcount or 0
t0 = time.perf_counter()
caller = _query_caller()
try:
async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {})
rowcount = res.rowcount or 0
except Exception as exc:
elapsed_ms = round((time.perf_counter() - t0) * 1000, 3)
_append_query_profile(
method="exec",
elapsed_ms=elapsed_ms,
rows=None,
rowcount=None,
commit=commit,
ok=False,
sql=sql,
params=params,
caller=caller,
error=repr(exc),
)
raise
elapsed_ms = round((time.perf_counter() - t0) * 1000, 3)
_append_query_profile(
method="exec",
elapsed_ms=elapsed_ms,
rows=None,
rowcount=rowcount,
commit=commit,
ok=True,
sql=sql,
params=params,
caller=caller,
)
return rowcount

View File

@@ -68,6 +68,10 @@ class BarcodeClientApp:
self._auto_advance_id: str | None = None
self._pallet_auto_focus_id: str | None = None
self._paused_queue_id: int | None = None
self._paused_priority_state: BarcodeViewState | None = None
self._pallet_user_input_serial = 0
self._priority_loaded_input_serial = 0
self._priority_expected_pallet = ""
self._status_colors = {
"red": "#f4cccc",
"green": "#d9ead3",
@@ -237,16 +241,20 @@ class BarcodeClientApp:
parent.columnconfigure(1, weight=1)
if scanned:
self.pallet_entry = entry
self.pallet_entry.bind("<KeyPress>", self._on_pallet_keypress, add="+")
self.scanned_var.trace_add("write", lambda *_: self._on_scanned_var_changed())
else:
self.destination_entry = entry
self.destination_var.trace_add("write", lambda *_: self._limit_var(self.destination_var, 8))
self.destination_var.trace_add("write", lambda *_: self._on_destination_var_changed())
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 _on_pallet_keypress(self, _event=None) -> None:
self._pallet_user_input_serial += 1
def _on_scanned_var_changed(self) -> None:
self._limit_var(self.scanned_var, 8)
if self._pallet_auto_focus_id is not None:
@@ -260,6 +268,10 @@ class BarcodeClientApp:
return
self._pallet_auto_focus_id = self.root.after(80, self._auto_focus_destination_after_scan)
def _on_destination_var_changed(self) -> None:
self._limit_var(self.destination_var, 8)
self._refresh_action_labels()
def _auto_focus_destination_after_scan(self) -> None:
self._pallet_auto_focus_id = None
pallet = str(self.scanned_var.get() or "").strip()
@@ -269,6 +281,11 @@ class BarcodeClientApp:
return
if getattr(self.service.state, "mode", "") == "confirm":
return
if getattr(self.service.state, "mode", "") in ("priority_high", "priority_low"):
expected = str(getattr(self.service.state, "expected_pallet", "") or "").strip()
if expected and pallet == expected and self._is_fresh_priority_scan(expected):
self._submit()
return
if bool(getattr(self.service.state, "destination_readonly", False)):
return
self._focus_destination_input()
@@ -371,7 +388,9 @@ class BarcodeClientApp:
except Exception:
pass
self._pallet_auto_focus_id = None
self.queue_var.set(state.queue_label)
pause_context = self._paused_queue_id is not None and state.mode not in ("priority_high", "priority_low")
effective_queue_label = "Pausa PL" if pause_context else state.queue_label
self.queue_var.set(effective_queue_label)
self.destination_var.set(state.destination_barcode)
self.scanned_var.set(state.scanned_pallet)
self.info1_var.set(self._status_text_with_wait_hint(state))
@@ -380,25 +399,28 @@ class BarcodeClientApp:
self.info4_var.set(state.expected_pallet)
self.status_band.configure(bg=state.status_color or self._status_colors["red"])
if state.mode in ("priority_high", "priority_low"):
self._priority_loaded_input_serial = self._pallet_user_input_serial
self._priority_expected_pallet = str(state.expected_pallet or "").strip()
else:
self._priority_expected_pallet = ""
try:
if str(state.queue_label or "") != "Pausa PL":
if str(effective_queue_label or "") != "Pausa PL":
self._paused_queue_id = None
resumable_queue = self._resumable_queue_from_state(state)
if state.mode in ("priority_high", "priority_low") or resumable_queue is not None:
self.btn_submit.configure(text="[F3] Pausa PL")
else:
self.btn_submit.configure(text="[F3] Carica")
if state.mode in ("priority_high", "priority_low"):
self.btn_unload.configure(text="[F4] Salta UDC")
else:
self.btn_unload.configure(text="[F4] Scarica")
queue_buttons_state = "disabled" if str(state.queue_label or "") == "Pausa PL" else "normal"
self._refresh_action_labels(state)
queue_buttons_state = "disabled" if str(effective_queue_label or "") == "Pausa PL" else "normal"
self.btn_f1.configure(state=queue_buttons_state)
self.btn_f2.configure(state=queue_buttons_state)
except Exception:
pass
destination_readonly = bool(getattr(state, "destination_readonly", False))
destination_readonly = False if pause_context else bool(getattr(state, "destination_readonly", False))
try:
self.destination_entry.configure(state="normal")
if destination_readonly:
@@ -419,6 +441,22 @@ class BarcodeClientApp:
else:
self.root.after(20, self._focus_primary_input)
def _refresh_action_labels(self, state: BarcodeViewState | None = None) -> None:
current_state = state or self.service.state
try:
if current_state.mode in ("priority_high", "priority_low"):
self.btn_unload.configure(text="[F4] Salta UDC")
return
destination = str(self.destination_var.get() or "").strip()
if destination == self.NON_SCAFFALATA_BARCODE:
self.btn_unload.configure(text="[F4] Non scaff.")
elif destination:
self.btn_unload.configure(text="[F4] Dissocia")
else:
self.btn_unload.configure(text="[F4] Scarica")
except Exception:
pass
def _focus_primary_input(self) -> None:
try:
self.pallet_entry.focus_force()
@@ -448,6 +486,14 @@ class BarcodeClientApp:
except Exception:
pass
def _is_fresh_priority_scan(self, expected: str) -> bool:
mode = str(getattr(self.service.state, "mode", "") or "")
if mode not in ("priority_high", "priority_low"):
return False
if str(expected or "").strip() != self._priority_expected_pallet:
return False
return self._pallet_user_input_serial > self._priority_loaded_input_serial
def _on_pallet_enter(self, _event=None) -> str:
pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip()
@@ -455,7 +501,15 @@ class BarcodeClientApp:
return "break"
if destination in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
if bool(getattr(self.service.state, "destination_readonly", False)):
self._submit()
mode = str(getattr(self.service.state, "mode", "") or "")
if mode in ("priority_high", "priority_low"):
expected = str(getattr(self.service.state, "expected_pallet", "") or "").strip()
if expected and pallet == expected and self._is_fresh_priority_scan(expected):
self._submit()
else:
self._focus_primary_input()
else:
self._submit()
else:
self._focus_destination_input()
return "break"
@@ -465,11 +519,12 @@ class BarcodeClientApp:
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:
if not pallet:
self._focus_primary_input()
else:
self.info1_var.set("F3 per associare, F4 per disassociare")
self.status_band.configure(bg=self._status_colors["yellow"])
self._focus_destination_input()
return "break"
def _on_unload_key(self, _event=None) -> str:
@@ -478,6 +533,12 @@ class BarcodeClientApp:
def _on_escape_key(self, _event=None) -> str:
if self._is_priority_pause() and self._paused_queue_id is not None:
if self._paused_priority_state is not None:
state = self.service.resume_priority_state(self._paused_priority_state)
self._paused_priority_state = None
self._paused_queue_id = None
self._apply_state(state)
return "break"
self._start_queue(self._paused_queue_id, allow_during_pause=True)
return "break"
@@ -489,15 +550,18 @@ class BarcodeClientApp:
mode = getattr(self.service.state, "mode", "")
if mode == "priority_high":
self._paused_queue_id = 1
self._paused_priority_state = self.service.state
self._apply_state(self.service.begin_priority_pause(1))
return
if mode == "priority_low":
self._paused_queue_id = 0
self._paused_priority_state = self.service.state
self._apply_state(self.service.begin_priority_pause(0))
return
resumable_queue = self._resumable_queue_from_state(self.service.state)
if resumable_queue is not None:
self._paused_queue_id = resumable_queue
self._paused_priority_state = None
self._apply_state(self.service.begin_priority_pause(resumable_queue))
return
self._submit()
@@ -510,7 +574,10 @@ class BarcodeClientApp:
self._skip_current_picking_pallet()
return
if pallet:
self._submit_unload_with_source_check()
if destination == self.NON_SCAFFALATA_BARCODE:
self._submit()
else:
self._submit_unload_with_source_check()
return
if self._is_priority_pause():
return

View File

@@ -26,18 +26,21 @@ SELECT TOP (1)
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingList AS pl
FROM dbo.py_ViewPackingListPrelievo AS pl
WHERE Ordinamento > 0
AND IDStato = :id_stato
AND (:documento IS NULL OR pl.Documento = TRY_CONVERT(int, :documento))
AND NOT EXISTS (
SELECT 1
FROM dbo.py_BarcodePickingListSkip AS s
WHERE s.Documento = CAST(pl.Documento AS varchar(50))
AND s.Pallet = CAST(pl.Pallet AS varchar(50))
WHERE s.Documento COLLATE Latin1_General_CI_AS =
CAST(pl.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.Pallet COLLATE Latin1_General_CI_AS =
CAST(pl.Pallet AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.IDStato = pl.IDStato
AND s.Risolto = 0
)
ORDER BY Ordinamento;
ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet, Documento;
"""
SQL_PICKING_BY_PALLET = """
@@ -51,9 +54,10 @@ SELECT TOP (1)
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Pallet = :pallet
ORDER BY Ordinamento;
FROM dbo.py_ViewPackingListPrelievo
WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
AND (:documento IS NULL OR Documento = TRY_CONVERT(int, :documento))
ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet;
"""
SQL_TRACE_BY_PALLET = """
@@ -63,7 +67,7 @@ SELECT TOP (1)
Prodotto,
Descrizione
FROM dbo.vXTracciaProdotti
WHERE Pallet = :pallet
WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY Lotto;
"""
@@ -77,7 +81,7 @@ SELECT TOP (1)
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c
ON c.ID = g.IDCella
WHERE g.BarcodePallet = :pallet;
WHERE g.BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS;
"""
SQL_OPEN_LOCATIONS_BY_PALLET = """
@@ -90,7 +94,7 @@ SELECT
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c
ON c.ID = g.IDCella
WHERE g.BarcodePallet = :pallet
WHERE g.BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY g.IDCella;
"""
@@ -103,6 +107,34 @@ WHERE g.IDCella = :id_cella
ORDER BY g.BarcodePallet;
"""
SQL_CLOSED_PICKING_BY_PALLET = """
SELECT TOP (1)
g.BarcodePallet,
g.NumeroPallet,
g.IDMagazzino,
g.IDArea,
g.IDCella,
g.IDStato
FROM dbo.XMag_GiacenzaPallet AS g
WHERE g.IDCella <> 9999
AND g.BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
AND EXISTS (
SELECT 1
FROM SAMA1.dbo.LOTSER AS ls
INNER JOIN SAMA1.dbo.LOTTIBF AS lb
ON lb.IDLOTSER = ls.ID
INNER JOIN SAMA1.dbo.FATRIG AS fr
ON lb.IDFATRIG = fr.ID
INNER JOIN SAMA1.dbo.BAMTES AS bt
ON bt.ID = fr.IDBAM
WHERE bt.ANNDOC >= 2011
AND bt.STATO = 'D'
AND LEFT(ls.NUMSER, 6) COLLATE Latin1_General_CI_AS =
:pallet COLLATE Latin1_General_CI_AS
)
ORDER BY g.IDCella DESC;
"""
SQL_RESOLVE_PHYSICAL_CELL = """
DECLARE @raw int = TRY_CONVERT(int, :destination);
DECLARE @cell_id int =
@@ -127,6 +159,7 @@ SQL_LEGACY_MOVE = """
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RC int = 0;
DECLARE @RefreshPickingReservation bit = :refresh_picking_reservation;
EXEC dbo.sp_xMagGestioneMagazziniPallet
@IDOperatore = :id_operatore,
@@ -135,7 +168,10 @@ EXEC dbo.sp_xMagGestioneMagazziniPallet
@NumeroCella = :numero_cella,
@RC = @RC OUTPUT;
EXEC dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
IF @RefreshPickingReservation = 1
BEGIN
EXEC dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
END;
SELECT
@RC AS RC,
@@ -151,7 +187,7 @@ IF NOT EXISTS (
SELECT 1
FROM dbo.py_BarcodePickingListSkip
WHERE Documento = :documento
AND Pallet = :pallet
AND Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
AND IDStato = :id_stato
AND Risolto = 0
)
@@ -182,6 +218,47 @@ SELECT
:id_stato AS IDStato;
"""
SQL_ACTIVE_SKIPPED_DOCUMENT = """
SELECT TOP (1)
s.Documento,
COUNT(*) AS SkippedCount
FROM dbo.py_BarcodePickingListSkip AS s
WHERE s.IDStato = :id_stato
AND (:documento IS NULL OR s.Documento = :documento)
AND s.Risolto = 0
AND EXISTS (
SELECT 1
FROM dbo.py_ViewPackingListRestante AS pl
WHERE CAST(pl.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS =
s.Documento COLLATE Latin1_General_CI_AS
AND pl.IDStato = :id_stato
AND CAST(pl.Pallet AS varchar(50)) COLLATE Latin1_General_CI_AS =
s.Pallet COLLATE Latin1_General_CI_AS
)
GROUP BY s.Documento
ORDER BY MIN(s.DataOra);
"""
SQL_PICKING_DOCUMENT_REMAINING = """
SELECT COUNT(DISTINCT Pallet) AS RemainingCount
FROM dbo.py_XMag_ViewPackingListStorico
WHERE Documento = TRY_CONVERT(int, :documento)
AND Cella <> 9999;
"""
SQL_RELEASE_PICKING_DOCUMENT = """
SET NOCOUNT ON;
DECLARE @RC int = 0;
EXEC dbo.py_sp_xExePackingListPallet
@IDOperatore = :id_operatore,
@Documento = :documento,
@Azione = 'S',
@RC = @RC OUTPUT;
SELECT CAST(@RC AS int) AS RC;
"""
def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Convert ``query_json`` payloads to a list of row dictionaries."""
@@ -225,17 +302,21 @@ class BarcodeRepository:
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."""
async def fetch_next_picking(self, id_stato: int, documento: str | None = None) -> dict[str, Any] | None:
"""Select a document initially, then fetch only within that document."""
res = await self.db_client.query_json(SQL_NEXT_PICKING, {"id_stato": int(id_stato)})
res = await self.db_client.query_json(SQL_NEXT_PICKING, {
"id_stato": int(id_stato), "documento": documento,
})
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:
async def fetch_picking_by_pallet(self, pallet: str, documento: str | None = None) -> 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()})
res = await self.db_client.query_json(SQL_PICKING_BY_PALLET, {
"pallet": str(pallet or "").strip(), "documento": documento,
})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
@@ -265,6 +346,13 @@ class BarcodeRepository:
res = await self.db_client.query_json(SQL_OPEN_PALLETS_BY_CELL, {"id_cella": int(id_cella)})
return _rows_to_dicts(res)
async def fetch_closed_picking_by_pallet(self, pallet: str) -> dict[str, Any] | None:
"""Return a closed picking-list row for a pallet already considered shipped."""
res = await self.db_client.query_json(SQL_CLOSED_PICKING_BY_PALLET, {"pallet": str(pallet or "").strip()})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def skip_picking_pallet(
self,
*,
@@ -304,6 +392,43 @@ class BarcodeRepository:
),
)
async def fetch_active_skipped_document(self, id_stato: int, documento: str | None = None) -> dict[str, Any] | None:
"""Find skipped residuals, restricted to the active document when set."""
res = await self.db_client.query_json(SQL_ACTIVE_SKIPPED_DOCUMENT, {
"id_stato": int(id_stato), "documento": documento,
})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def count_picking_document_remaining(self, documento: str) -> int:
"""Check residuals independently of priority before declaring completion."""
res = await self.db_client.query_json(SQL_PICKING_DOCUMENT_REMAINING, {"documento": documento})
rows = _rows_to_dicts(res)
return int(rows[0]["RemainingCount"]) if rows else 0
async def release_picking_document(self, *, documento: str, operator_id: int) -> int:
"""Release a reserved picking-list document without closing its residual rows."""
params = {
"documento": str(documento or "").strip(),
"id_operatore": int(operator_id),
}
log_runtime_event(
"Barcode WMS",
f"PICKING AUTO RELEASE START documento={params['documento']} operator={params['id_operatore']}",
)
res = await self.db_client.query_json(SQL_RELEASE_PICKING_DOCUMENT, params, as_dict_rows=True, commit=True)
rows = _rows_to_dicts(res)
rc = 0
if rows:
try:
rc = int(rows[0].get("RC") or 0)
except Exception:
rc = 0
log_runtime_event("Barcode WMS", f"PICKING AUTO RELEASE END documento={params['documento']} rc={rc}")
return rc
async def resolve_physical_cell(self, destination: str) -> DestinationCell | None:
"""Accept either an internal cell ID or the scanned legacy cell barcode."""
@@ -326,6 +451,7 @@ class BarcodeRepository:
barcode_cella: str,
barcode_pallet: str,
numero_cella: int,
refresh_picking_reservation: bool = True,
) -> LegacyMoveResult:
"""Execute the same stored procedure used by the C# barcode form."""
@@ -334,6 +460,7 @@ class BarcodeRepository:
"barcode_cella": str(barcode_cella or "").strip(),
"barcode_pallet": str(barcode_pallet or "").strip(),
"numero_cella": int(numero_cella),
"refresh_picking_reservation": 1 if refresh_picking_reservation else 0,
}
log_runtime_event(
"Barcode WMS",
@@ -341,7 +468,8 @@ class BarcodeRepository:
"MOVE START "
f"operator={params['id_operatore']} "
f"cell={params['barcode_cella']} "
f"pallet={params['barcode_pallet']}"
f"pallet={params['barcode_pallet']} "
f"refresh_pl={params['refresh_picking_reservation']}"
),
)
try:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Literal
from barcode_repository import BarcodeRepository, LegacyMoveResult
@@ -52,14 +52,16 @@ class BarcodeService:
LIGHT_GREEN = "#d9ead3"
GREEN_YELLOW = "#e2f0cb"
CONVENTIONAL_LOCATION_BY_CELL = {
1000: "5E1.1",
9999: "7G.1.1",
1000: "5E.1.1 - Non scaffalata",
9999: "7G.1.1 - Spedita",
}
def __init__(self, repository: BarcodeRepository, operator_id: int):
self.repository = repository
self.operator_id = int(operator_id)
self._current_priority_state = -1
# Independent document identities survive confirmations, F1/F2 and pause.
self._priority_documents: dict[int, str] = {}
self._state = BarcodeViewState()
@property
@@ -72,6 +74,7 @@ class BarcodeService:
"""Return the client to its neutral state."""
self._current_priority_state = -1
self._priority_documents.clear()
self._state = BarcodeViewState()
return self._state
@@ -115,6 +118,16 @@ class BarcodeService:
)
return self._state
def resume_priority_state(self, state: BarcodeViewState) -> BarcodeViewState:
"""Restore the exact picking pallet that was visible before a local pause."""
if state.mode not in ("priority_high", "priority_low"):
return self._state
self._current_priority_state = 1 if state.mode == "priority_high" else 0
self._priority_documents[self._current_priority_state] = state.document
self._state = replace(state, scanned_pallet="", auto_advance_delay_ms=0)
return self._state
async def skip_current_picking_pallet(self) -> BarcodeActionResult:
"""Mark the currently proposed picking pallet as skipped and advance."""
@@ -154,23 +167,65 @@ class BarcodeService:
return BarcodeActionResult(True, self._state, self._state.status_text)
async def start_priority_queue(self, id_stato: int) -> BarcodeActionResult:
"""Load the next item of the selected legacy priority queue."""
"""Keep each F1/F2 cycle bound to its document until the cycle stops."""
row = await self.repository.fetch_next_picking(id_stato)
self._current_priority_state = int(id_stato)
id_stato = int(id_stato)
active_document = self._priority_documents.get(id_stato)
row = await self.repository.fetch_next_picking(id_stato, documento=active_document)
self._current_priority_state = id_stato
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
if not row:
skipped_doc = await self.repository.fetch_active_skipped_document(id_stato, documento=active_document)
if skipped_doc:
documento = str(skipped_doc.get("Documento") or "").strip()
skipped_count = int(skipped_doc.get("SkippedCount") or 0)
rc = await self.repository.release_picking_document(
documento=documento,
operator_id=self.operator_id,
)
if rc != 0:
raise RuntimeError(f"Impossibile sospendere PL {documento}: RC={rc}")
self._priority_documents.pop(id_stato, None)
self._current_priority_state = -1
self._state = BarcodeViewState(
mode="manual_unload",
queue_label=queue_label,
status_text=f"PL {documento} sospesa: {skipped_count} UDC saltate. Riprenota per completare.",
status_color=self.RED,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
)
return BarcodeActionResult(True, self._state)
status_text = "Pronto."
remaining = 0
if active_document:
remaining = await self.repository.count_picking_document_remaining(active_document)
status_text = (
f"PL {active_document}: {remaining} UDC residue, coda non disponibile. Verifica la prenotazione."
if remaining else f"PL {active_document} terminata. Premi F1 o F2 per iniziare."
)
log_runtime_event("Barcode WMS", f"PICKING CYCLE STOP documento={active_document} priority={id_stato} remaining={remaining}")
# A changed reservation or visibility filter must not silently
# replace an unfinished document on the next press of F1/F2.
if not remaining:
self._priority_documents.pop(id_stato, None)
self._current_priority_state = -1
self._state = BarcodeViewState(
mode="manual_unload",
queue_label=queue_label,
status_text="Pronto.",
status_text=status_text,
status_color=self.RED,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
)
return BarcodeActionResult(True, self._state)
document = str(row.get("Documento") or "").strip()
if not document or (active_document is not None and document != active_document):
raise RuntimeError(f"Cambio inatteso documento picking: atteso={active_document}, ricevuto={document}")
self._priority_documents[id_stato] = document
if active_document is None:
log_runtime_event("Barcode WMS", f"PICKING CYCLE START documento={document} priority={id_stato}")
customer = f"{row.get('CodNazione') or ''} - {row.get('NAZIONE') or ''}".strip(" -")
source_location = self._display_location(
cella=row.get("Cella"),
@@ -215,6 +270,11 @@ class BarcodeService:
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
if not is_picking_unload:
blocked = await self._block_closed_picking_free_move(pallet, destination)
if blocked is not None:
return blocked
target_barcode = destination
target_numero_cella = int(destination)
target_id_cella = 9999 if destination == self.SHIPPED_BARCODE else (1000 if destination == self.NON_SCAFFALATA_BARCODE else None)
@@ -254,7 +314,14 @@ class BarcodeService:
target_display = resolved_cell.ubicazione or destination
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
picking_before_move = await self.repository.fetch_picking_by_pallet(pallet)
picking_before_move = (
None
if current_location
else await self.repository.fetch_picking_by_pallet(
pallet,
documento=self._priority_documents.get(self._current_priority_state) if is_picking_unload else None,
)
)
if not current_location and not picking_before_move:
if is_direct_load:
trace_row = await self.repository.fetch_trace_by_pallet(pallet)
@@ -274,6 +341,7 @@ class BarcodeService:
barcode_cella=self.NON_SCAFFALATA_BARCODE,
barcode_pallet=pallet,
numero_cella=int(self.NON_SCAFFALATA_BARCODE),
refresh_picking_reservation=False,
)
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
try:
@@ -331,6 +399,7 @@ class BarcodeService:
barcode_cella=target_barcode,
barcode_pallet=pallet,
numero_cella=target_numero_cella,
refresh_picking_reservation=is_picking_unload,
)
final_location = await self.repository.fetch_current_location_by_pallet(pallet)
@@ -351,7 +420,7 @@ class BarcodeService:
destination_barcode=destination,
destination_display=target_display,
last_priority_state=self._current_priority_state,
auto_advance_delay_ms=5000 if (is_direct_unload or is_direct_load) else 3000 if is_picking_unload else 0,
auto_advance_delay_ms=3000 if is_picking_unload else 0,
)
except Exception as exc:
log_exception("Barcode WMS", exc, context=f"post move state pallet={pallet} destination={destination}")
@@ -377,6 +446,10 @@ class BarcodeService:
if not source.isdigit():
return BarcodeActionResult(False, self._state, "La cella da scaricare deve essere numerica.")
blocked = await self._block_closed_picking_free_move(pallet, source)
if blocked is not None:
return blocked
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
if not current_location:
self._state.scanned_pallet = pallet
@@ -449,7 +522,69 @@ class BarcodeService:
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
return await self.submit(scanned_pallet=pallet, destination_barcode=self.NON_SCAFFALATA_BARCODE)
await self.repository.execute_legacy_move(
operator_id=self.operator_id,
barcode_cella=self.NON_SCAFFALATA_BARCODE,
barcode_pallet=pallet,
numero_cella=int(self.NON_SCAFFALATA_BARCODE),
refresh_picking_reservation=False,
)
final_location = await self.repository.fetch_current_location_by_pallet(pallet)
try:
final_cell = int((final_location or {}).get("IDCella") or 0)
except Exception:
final_cell = 0
if final_cell != 1000:
self._state.scanned_pallet = pallet
self._state.destination_barcode = source
self._state.status_text = "Movimento non confermato: la UDC non risulta non scaffalata."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
try:
self._state = await self._build_post_move_state(
barcode_pallet=pallet,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_display=self.CONVENTIONAL_LOCATION_BY_CELL[1000],
last_priority_state=self._current_priority_state,
auto_advance_delay_ms=0,
)
except Exception as exc:
log_exception("Barcode WMS", exc, context=f"post source-check unload pallet={pallet} source={source}")
self._state = BarcodeViewState(
mode="confirm",
queue_label="Conferma movimento",
status_text="Movimento eseguito. Dettagli non aggiornati.",
status_color=self.GREEN_YELLOW,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
scanned_pallet=pallet,
)
return BarcodeActionResult(True, self._state, self._state.status_text)
async def _block_closed_picking_free_move(
self,
pallet: str,
destination_barcode: str,
) -> BarcodeActionResult | None:
closed_row = await self.repository.fetch_closed_picking_by_pallet(pallet)
if not closed_row:
return None
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED CLOSED_PICKING "
f"pallet={pallet} "
f"destination={destination_barcode} "
f"id_cella={closed_row.get('IDCella')} "
f"id_stato={closed_row.get('IDStato')}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination_barcode
self._state.status_text = "Errore: UDC gia' spedita, problema nella locazione o nella UDC."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
async def reconcile_after_submit_exception(
self,
@@ -505,7 +640,7 @@ class BarcodeService:
last_priority_state = self._current_priority_state
is_recovered_picking = bool(destination == self.SHIPPED_BARCODE and last_priority_state in (0, 1))
auto_advance_delay_ms = 3000 if is_recovered_picking else 5000
auto_advance_delay_ms = 3000 if is_recovered_picking else 0
state = await self._build_post_move_state(
barcode_pallet=pallet,
destination_barcode=destination,
@@ -542,7 +677,17 @@ class BarcodeService:
) -> BarcodeViewState:
"""Mirror the legacy confirmation flow after one stored-procedure move."""
picking_row = await self.repository.fetch_picking_by_pallet(barcode_pallet)
is_priority_shipping_confirmation = (
destination_barcode == self.SHIPPED_BARCODE
and last_priority_state in (0, 1)
)
picking_row = (
await self.repository.fetch_picking_by_pallet(
barcode_pallet, documento=self._priority_documents.get(last_priority_state),
)
if is_priority_shipping_confirmation
else None
)
if picking_row:
customer = f"{picking_row.get('CodNazione') or ''} - {picking_row.get('NAZIONE') or ''}".strip(" -")
source_location = self._display_location(
@@ -569,14 +714,20 @@ class BarcodeService:
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")
destination_label = self._display_destination_location(
destination_barcode=destination_barcode,
destination_display=destination_display,
)
return BarcodeViewState(
mode="confirm",
queue_label=queue_label,
status_text=(
"Ok Scarico" if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) else f"Ok Carico - {destination_display}"
f"Ok Scarico - {destination_label}"
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE)
else f"Ok Carico - {destination_label}"
),
status_color=self.GREEN_YELLOW,
source_location=str(destination_display or destination_barcode or ""),
source_location=destination_label,
document=(
self.CONVENTIONAL_LOCATION_BY_CELL[9999 if destination_barcode == self.SHIPPED_BARCODE else 1000]
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
@@ -607,6 +758,15 @@ class BarcodeService:
scanned_pallet=barcode_pallet,
)
def _display_destination_location(self, *, destination_barcode: str, destination_display: str) -> str:
"""Return the confirmation label for a movement destination."""
if destination_barcode == self.NON_SCAFFALATA_BARCODE:
return self.CONVENTIONAL_LOCATION_BY_CELL[1000]
if destination_barcode == self.SHIPPED_BARCODE:
return self.CONVENTIONAL_LOCATION_BY_CELL[9999]
return str(destination_display or destination_barcode or "")
def _display_location(self, *, cella: object, ubicazione: object) -> str:
"""Return the operator-facing location, honoring legacy conventional cells."""

View File

@@ -26,6 +26,11 @@ DEFAULT_DB_CONFIG: dict[str, Any] = {
"driver": "ODBC Driver 17 for SQL Server",
"trust_server_certificate": True,
"encrypt": "",
"query_profiler": {
"enabled": False,
"slow_ms": 0,
"sql_limit": 4000,
},
}

543
diagnostica.py Normal file
View File

@@ -0,0 +1,543 @@
"""Operational diagnostic checks for warehouse data quadrature."""
from __future__ import annotations
import tkinter as tk
from datetime import datetime
from tkinter import filedialog, 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 runtime_support import log_exception
from ui_tables import style_treeview, zebra_tag
from ui_theme import theme_color, theme_font, theme_section, theme_value
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
SQL_SHIPPED_NOT_DISASSOCIATED = """
SELECT DISTINCT
LTRIM(RTRIM(g.BarcodePallet)) AS UDC,
CAST('Spedita ma non disassociata' AS varchar(64)) AS Diagnostica,
CONCAT(
RTRIM(c.Corsia),
' - ',
RTRIM(CAST(c.Colonna AS varchar(32))),
' - ',
RTRIM(CAST(c.Fila AS varchar(32)))
) AS NomeSimbolicoCella
FROM dbo.XMag_GiacenzaPallet AS g
INNER JOIN dbo.Celle AS c
ON c.ID = g.IDCella
INNER JOIN dbo.py_XMag_ViewPackingListStorico AS chiuse
ON LTRIM(RTRIM(chiuse.Pallet)) COLLATE Latin1_General_CI_AS =
LTRIM(RTRIM(g.BarcodePallet)) COLLATE Latin1_General_CI_AS
WHERE chiuse.StatoDocumento = 'D'
AND g.IDCella <> 9999
ORDER BY NomeSimbolicoCella, UDC;
"""
SQL_MULTIPLE_UDC_BY_CELL = """
WITH celle_multiple AS (
SELECT
g.IDCella
FROM dbo.XMag_GiacenzaPallet AS g
WHERE g.IDCella NOT IN (1000, 9999)
GROUP BY g.IDCella
HAVING COUNT(DISTINCT LTRIM(RTRIM(g.BarcodePallet))) > 1
)
SELECT
CONCAT(
RTRIM(c.Corsia),
' - ',
RTRIM(CAST(c.Colonna AS varchar(32))),
' - ',
RTRIM(CAST(c.Fila AS varchar(32)))
) AS CELLA,
LTRIM(RTRIM(g.BarcodePallet)) AS UDC
FROM dbo.XMag_GiacenzaPallet AS g
INNER JOIN celle_multiple AS cm
ON cm.IDCella = g.IDCella
INNER JOIN dbo.Celle AS c
ON c.ID = g.IDCella
ORDER BY CELLA, UDC;
"""
SQL_SAM_UDC_NOT_IN_WMS = """
WITH recent_lotser AS (
SELECT TOP (5000)
ls.ID,
LEFT(ls.NUMSER, 6) AS UDC,
ls.NUMLOT AS Lotto,
ls.IDARTICO
FROM SAMA1.dbo.LOTSER AS ls
WHERE NULLIF(LTRIM(RTRIM(LEFT(ls.NUMSER, 6))), '') IS NOT NULL
AND TRY_CONVERT(int, LTRIM(RTRIM(LEFT(ls.NUMSER, 6)))) > 0
AND LEN(LTRIM(RTRIM(LEFT(ls.NUMSER, 6)))) = 6
AND ls.NUMLOT LIKE CONCAT('P', RIGHT(CONVERT(varchar(4), YEAR(GETDATE())), 2), '%')
ORDER BY ls.ID DESC
), sam_udc AS (
SELECT
LTRIM(RTRIM(r.UDC)) AS UDC,
MIN(r.Lotto) AS Lotto,
MIN(a.CODICE) AS Prodotto,
MIN(a.DESCR) AS Descrizione,
MAX(r.ID) AS UltimoIDLotSer
FROM recent_lotser AS r
INNER JOIN SAMA1.dbo.ARTICO AS a
ON a.ID = r.IDARTICO
GROUP BY LTRIM(RTRIM(r.UDC))
)
SELECT TOP (100)
s.UDC,
s.Lotto,
s.Prodotto,
s.Descrizione
FROM sam_udc AS s
WHERE NOT EXISTS (
SELECT 1
FROM dbo.MagazziniPallet AS mp
WHERE LTRIM(RTRIM(mp.Attributo)) COLLATE Latin1_General_CI_AS =
s.UDC COLLATE Latin1_General_CI_AS
)
ORDER BY s.UltimoIDLotSer DESC;
"""
SHIPPED_NOT_DISASSOCIATED_COLUMNS = (
("UDC", "diagnostics.col.udc", "UDC", 130, "w"),
(
"Diagnostica",
"diagnostics.col.shipped_not_disassociated",
"Spedita ma non disassociata",
280,
"w",
),
("NomeSimbolicoCella", "diagnostics.col.symbolic_cell", "Nome simbolico cella", 240, "w"),
)
MULTIPLE_UDC_COLUMNS = (
("CELLA", "diagnostics.col.cell", "CELLA", 220, "w"),
("UDC", "diagnostics.col.udc", "UDC", 160, "w"),
)
SAM_UDC_NOT_IN_WMS_COLUMNS = (
("UDC", "diagnostics.col.udc", "UDC", 130, "w"),
("Lotto", "diagnostics.col.lot", "Lotto", 160, "w"),
("Prodotto", "diagnostics.col.product", "Prodotto", 150, "w"),
("Descrizione", "diagnostics.col.description", "Descrizione", 360, "w"),
)
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 DiagnosticaWindow(ctk.CTkToplevel):
"""Window collecting read-only diagnostic queries."""
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("diagnostics_window", theme_section("search_window", {}))
self._locale_catalog = load_locale_catalog()
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
self._load_in_progress = False
self._current_check = ""
self.var_status = tk.StringVar(value="")
self._last_export_slug = "diagnostica"
self._current_columns = SHIPPED_NOT_DISASSOCIATED_COLUMNS
self._column_titles: dict[str, str] = {}
self._sort_column = ""
self._sort_descending = False
self.title(
versioned_title(
loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"),
__name__,
)
)
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x680")))
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()
def _is_alive(self) -> bool:
try:
return bool(self.winfo_exists())
except tk.TclError:
return False
def _hide_busy_safe(self) -> None:
try:
self._busy.hide()
except Exception as exc:
log_exception(__name__, exc, context="hide busy diagnostica")
def _build_ui(self) -> None:
self.grid_rowconfigure(2, 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)
button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))
ctk.CTkButton(
top,
text=loc_text(
"diagnostics.button.shipped_not_disassociated",
catalog=self._locale_catalog,
default="UDC spedite non disassociate",
),
command=self._load_shipped_not_disassociated,
font=button_font,
).grid(row=0, column=0, sticky="w", padx=(0, 8))
ctk.CTkButton(
top,
text=loc_text(
"diagnostics.button.multiple_udc_by_cell",
catalog=self._locale_catalog,
default="Celle con multiple UDC",
),
command=self._load_multiple_udc_by_cell,
font=button_font,
).grid(row=0, column=1, sticky="w", padx=(0, 8))
ctk.CTkButton(
top,
text=loc_text(
"diagnostics.button.sam_udc_not_in_wms",
catalog=self._locale_catalog,
default="Ultime UDC SAM non in WMS",
),
command=self._load_sam_udc_not_in_wms,
font=button_font,
).grid(row=0, column=2, sticky="w", padx=(0, 8))
self.btn_export = ctk.CTkButton(
top,
text=loc_text("diagnostics.button.export", catalog=self._locale_catalog, default="Esporta XLSX"),
command=self._export_xlsx,
font=button_font,
state="disabled",
)
self.btn_export.grid(row=0, column=4, sticky="e")
ctk.CTkLabel(
self,
textvariable=self.var_status,
anchor="w",
font=theme_font(self._theme, "status_font", ("Segoe UI", 10, "bold")),
).grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 6))
wrap = ctk.CTkFrame(self)
wrap.grid(row=2, column=0, sticky="nsew", padx=8, pady=(0, 8))
wrap.grid_rowconfigure(0, weight=1)
wrap.grid_columnconfigure(0, weight=1)
self.tree = ttk.Treeview(wrap, show="headings")
self._configure_tree_columns(self._current_columns)
style_treeview(
self.tree,
style_name="Diagnostics.Treeview",
rowheight=22,
font=("", 9),
heading_font=("", 9, "bold"),
)
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 _set_status(self, text: str) -> None:
self.var_status.set(text)
def _configure_tree_columns(self, columns) -> None:
self.tree.delete(*self.tree.get_children(""))
keys = tuple(str(col[0]) for col in columns)
self.tree.configure(columns=keys, show="headings")
self._sort_column = ""
self._sort_descending = False
self._column_titles = {}
for key, locale_key, default, width, anchor in columns:
title = loc_text(str(locale_key), catalog=self._locale_catalog, default=str(default))
self._column_titles[str(key)] = title
self.tree.heading(
key,
text=title,
command=lambda col=str(key): self._sort_by_column(col),
)
self.tree.column(key, width=int(width), anchor=str(anchor), stretch=True)
def _refresh_sort_headings(self) -> None:
for key, title in self._column_titles.items():
suffix = ""
if key == self._sort_column:
suffix = " v" if self._sort_descending else " ^"
self.tree.heading(key, text=f"{title}{suffix}", command=lambda col=key: self._sort_by_column(col))
def _sort_key(self, value: Any) -> tuple[int, Any]:
text = str(value or "").strip()
if not text:
return (2, "")
normalized = text.replace(".", "").replace(",", ".")
try:
return (0, float(normalized))
except ValueError:
return (1, text.casefold())
def _sort_by_column(self, column: str) -> None:
columns = list(self.tree["columns"])
if column not in columns:
return
descending = not self._sort_descending if self._sort_column == column else False
col_index = columns.index(column)
rows = []
for iid in self.tree.get_children(""):
values = self.tree.item(iid, "values")
value = values[col_index] if col_index < len(values) else ""
rows.append((self._sort_key(value), iid))
rows.sort(key=lambda item: item[0], reverse=descending)
for index, (_key, iid) in enumerate(rows):
self.tree.move(iid, "", index)
self.tree.item(iid, tags=(zebra_tag(index),))
self._sort_column = column
self._sort_descending = descending
self._refresh_sort_headings()
def _load_shipped_not_disassociated(self) -> None:
self._run_check(
key="shipped_not_disassociated",
sql=SQL_SHIPPED_NOT_DISASSOCIATED,
columns=SHIPPED_NOT_DISASSOCIATED_COLUMNS,
busy_text=loc_text(
"diagnostics.busy.shipped_not_disassociated",
catalog=self._locale_catalog,
default="Cerco UDC spedite ma ancora in cella...",
),
)
def _load_multiple_udc_by_cell(self) -> None:
self._run_check(
key="multiple_udc_by_cell",
sql=SQL_MULTIPLE_UDC_BY_CELL,
columns=MULTIPLE_UDC_COLUMNS,
busy_text=loc_text(
"diagnostics.busy.multiple_udc_by_cell",
catalog=self._locale_catalog,
default="Cerco celle con multiple UDC...",
),
)
def _load_sam_udc_not_in_wms(self) -> None:
self._run_check(
key="sam_udc_not_in_wms",
sql=SQL_SAM_UDC_NOT_IN_WMS,
columns=SAM_UDC_NOT_IN_WMS_COLUMNS,
busy_text=loc_text(
"diagnostics.busy.sam_udc_not_in_wms",
catalog=self._locale_catalog,
default="Cerco le ultime UDC presenti in SAM ma non ancora in WMS...",
),
)
def _run_check(self, *, key: str, sql: str, columns, busy_text: str) -> None:
if self._load_in_progress or not self._is_alive():
return
async def job():
return await self.db_client.query_json(sql, as_dict_rows=True)
self._load_in_progress = True
self._current_check = key
self._last_export_slug = key
self._current_columns = columns
self._configure_tree_columns(columns)
try:
self.btn_export.configure(state="disabled")
except Exception:
pass
try:
self._busy.show(busy_text)
self._async.run(job(), self._on_loaded, self._on_error)
except Exception as exc:
self._load_in_progress = False
self._hide_busy_safe()
log_exception(__name__, exc, context=f"avvio diagnostica {key}")
messagebox.showerror(
loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"),
str(exc),
parent=self if self._is_alive() else None,
)
def _on_loaded(self, res: dict[str, Any] | None) -> None:
self._load_in_progress = False
self._hide_busy_safe()
if not self._is_alive():
return
try:
rows = _rows_to_dicts(res)
self.tree.delete(*self.tree.get_children(""))
keys = [str(col[0]) for col in self._current_columns]
for index, row in enumerate(rows):
self.tree.insert(
"",
"end",
values=tuple(row.get(key) or "" for key in keys),
tags=(zebra_tag(index),),
)
try:
self.btn_export.configure(state="normal" if rows else "disabled")
except Exception:
pass
self._set_status(
loc_text(
"diagnostics.status.rows",
catalog=self._locale_catalog,
default="Righe trovate: {count}",
).format(count=len(rows))
)
except Exception as exc:
log_exception(__name__, exc, context="render diagnostica")
messagebox.showerror(
loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"),
str(exc),
parent=self,
)
def _on_error(self, exc: BaseException) -> None:
self._load_in_progress = False
self._hide_busy_safe()
log_exception(__name__, exc, context=f"query diagnostica {self._current_check}")
if not self._is_alive():
return
messagebox.showerror(
loc_text("diagnostics.title", catalog=self._locale_catalog, default="Diagnostica"),
str(exc),
parent=self,
)
def _export_xlsx(self) -> None:
"""Export the currently visible diagnostic grid to an Excel file."""
rows = [self.tree.item(iid, "values") for iid in self.tree.get_children("")]
if not rows:
messagebox.showinfo(
loc_text("diagnostics.export.title", catalog=self._locale_catalog, default="Esporta"),
loc_text(
"diagnostics.export.empty",
catalog=self._locale_catalog,
default="Non ci sono righe da esportare.",
),
parent=self,
)
return
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
from openpyxl.utils import get_column_letter
except Exception as exc:
messagebox.showerror(
loc_text("diagnostics.export.title", catalog=self._locale_catalog, default="Esporta"),
loc_text(
"diagnostics.export.dep",
catalog=self._locale_catalog,
default="Per l'esportazione serve 'openpyxl' (pip install openpyxl).",
).format(error=exc),
parent=self,
)
return
ts = datetime.now().strftime("%Y%m%d_%H%M")
default_name = f"{self._last_export_slug}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
parent=self,
title=loc_text("diagnostics.export.title", catalog=self._locale_catalog, default="Esporta"),
defaultextension=".xlsx",
filetypes=[("Excel Workbook", "*.xlsx")],
initialfile=default_name,
)
if not path:
return
try:
wb = Workbook()
ws = wb.active
ws.title = "Diagnostica"
columns = list(self.tree["columns"])
headers = [str(self.tree.heading(col, "text") or col) for col in columns]
for col_index, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_index, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
for row_index, row in enumerate(rows, start=2):
for col_index, value in enumerate(row, start=1):
ws.cell(row=row_index, column=col_index, value=value)
widths: dict[int, int] = {}
for row in ws.iter_rows(values_only=True):
for col_index, value in enumerate(row, start=1):
widths[col_index] = max(widths.get(col_index, 0), len("" if value is None else str(value)))
for col_index, width in widths.items():
ws.column_dimensions[get_column_letter(col_index)].width = min(max(width + 2, 12), 70)
wb.save(path)
messagebox.showinfo(
loc_text("diagnostics.export.title", catalog=self._locale_catalog, default="Esporta"),
loc_text("diagnostics.export.ok", catalog=self._locale_catalog, default="File creato:\n{path}").format(path=path),
parent=self,
)
except Exception as exc:
log_exception(__name__, exc, context="export diagnostica xlsx")
messagebox.showerror(
loc_text("diagnostics.export.title", catalog=self._locale_catalog, default="Esporta"),
loc_text(
"diagnostics.export.error",
catalog=self._locale_catalog,
default="Errore durante l'esportazione:\n{error}",
).format(error=exc),
parent=self,
)
def open_diagnostica_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
"""Open the diagnostics window."""
win = DiagnosticaWindow(parent, db_client, session=session)
place_window_fullsize_below_parent_later(parent, win)
win.bind("<Escape>", lambda _e: win.destroy())
return win

View File

@@ -0,0 +1,235 @@
# Diagnostica Scarico Celle Multiple
## Contesto
La finestra `Scarico` aperta da `Gestione layout` serve a gestire celle fisiche che contengono piu' UDC.
Nel database legacy non esisteva un vincolo rigido `una cella = una UDC`, quindi una cella rossa puo' contenere:
- UDC realmente compresenti nella stessa cella;
- UDC rimaste in cella anche se appartenenti a picking list gia' chiuse/spedite.
L'obiettivo della diagnostica non e' ricostruire tutta la storia della UDC, ma aiutare l'operatore a capire quali righe scaricare come spedite.
## Fonti Dati
La griglia della dialog parte dalla giacenza corrente:
```sql
dbo.XMag_GiacenzaPallet
```
Questa vista indica quali UDC risultano presenti nella cella aperta dal layout.
La diagnostica controlla anche:
```sql
dbo.XMag_GiacenzaPalletPlistChiuse
```
Questa vista non e' uno storico documentale puro. E' una vista ibrida che incrocia la giacenza corrente con i documenti SAM chiusi.
In pratica serve a individuare UDC ancora presenti in una cella fisica, ma appartenenti a una picking list/documento gia' chiuso.
## Conclusione Operativa
Per una cella fisica aperta da `Gestione layout`, i casi realmente utili sono solo due.
### 1. Nessuna Diagnostica
La UDC e' presente nella cella multipla e non compare in `XMag_GiacenzaPalletPlistChiuse`.
Significato:
```text
UDC normalmente presente in cella.
```
Non viene mostrato nessun messaggio nella colonna diagnostica.
### 2. Spedita Ma Non Scaricata Dalla Cella
La UDC e' presente nella cella fisica aperta, ma compare anche in `XMag_GiacenzaPalletPlistChiuse`.
Diagnostica mostrata:
```text
Motivo: spedita ma non scaricata dalla cella
```
Significato:
```text
La UDC appartiene a una picking list chiusa, ma non e' stata scaricata dalla cella fisica.
```
Operativamente questa UDC va selezionata e scaricata come spedita, quindi verso la cella virtuale:
```text
7G.1.1 / IDCella 9999 / barcode cella 9000000
```
## Casi Teorici Scartati Dalla Dialog
Durante l'analisi erano stati ipotizzati altri casi, ma sono stati esclusi dalla diagnostica della dialog per non confondere l'operatore.
### UDC Gia' In 7G.1.1
Se una UDC risulta davvero in `IDCella = 9999`, normalmente non dovrebbe comparire nella cella fisica aperta dal layout.
Se accadesse, sarebbe un'incoerenza tra viste o dati non sincronizzati.
Per questo la dialog di scarico da layout fisico non mostra piu' il messaggio:
```text
Motivo: spedita
```
### UDC In Picking List Chiusa Ma Non Spedita
Questo caso e' teoricamente possibile ma raro e ambiguo nel modello dati attuale.
Le viste disponibili non forniscono una distinzione documentale/storica abbastanza pulita da renderlo utile in questa dialog operativa.
Per questo la dialog non mostra piu':
```text
Motivo: in plist chiusa, non spedita e non scaricata dalla cella
```
Eventuali analisi piu' profonde vanno fatte nelle finestre di storico, non nella dialog rapida di scarico.
## Regola Finale Implementata
La diagnostica della dialog `Scarico` e' volutamente semplice:
```text
se UDC in XMag_GiacenzaPalletPlistChiuse:
"Motivo: spedita ma non scaricata dalla cella"
altrimenti:
diagnostica vuota
```
Questa scelta riduce il rischio di falsi positivi e mantiene chiaro il comportamento per l'operatore.
## Aggiornamento 04/07/2026 - Barcode Picking List
Durante i test sul barcode sono state introdotte alcune modifiche operative collegate alla gestione delle picking list, agli skip UDC e alla ripresa del lavoro dopo movimenti liberi.
### Anti Autosubmit Su Cambio Lista
Problema osservato:
```text
passando rapidamente da una picking list prenotata a una non prenotata,
il barcode poteva considerare come lettura valida un valore gia' presente nel campo Pallet
e scaricare automaticamente una UDC della nuova lista.
```
Correzione implementata:
```text
lo scarico automatico in picking list parte solo se il campo Pallet e' stato modificato
dall'operatore dopo il caricamento della UDC proposta.
```
In pratica, quando il programma carica una nuova UDC attesa, il valore visualizzato non puo' piu' generare da solo lo scarico. Serve una nuova lettura o digitazione effettiva.
### Skip UDC In Picking List
Durante l'evasione di una picking list, il tasto F4 assume il significato:
```text
[F4] Salta UDC
```
La UDC saltata viene registrata nella tabella:
```sql
dbo.py_BarcodePickingListSkip
```
Le righe aperte hanno:
```text
Risolto = 0
```
La query del barcode esclude le UDC saltate aperte, quindi la lista puo' proseguire sulle UDC successive senza chiudere definitivamente quelle non trovate.
### Fine Lista Con UDC Saltate
Quando una picking list non ha piu' UDC immediatamente lavorabili, ma contiene una o piu' UDC saltate, il barcode:
```text
non chiude la picking list;
rilascia la prenotazione;
lascia la lista riprenotabile;
mostra un messaggio di sospensione con numero UDC saltate.
```
La lista resta quindi aperta e potra' essere ripresa in un secondo momento.
### Riprenotazione E Ripresa UDC Saltate
Quando la picking list viene riprenotata, gli skip aperti del documento vengono marcati come risolti:
```sql
UPDATE dbo.py_BarcodePickingListSkip
SET Risolto = 1,
RisoltoDa = <operatore>,
RisoltoDataOra = SYSDATETIME()
WHERE Documento = <documento>
AND Risolto = 0;
```
Questo permette al barcode di riproporre le UDC saltate rimaste da evadere.
### Posizione UDC Dopo Movimenti Liberi
Una UDC saltata puo' essere movimentata in pausa tramite operazioni libere, ad esempio:
```text
versamento in una nuova cella fisica;
dissociazione verso 9001000 / Non scaffalata.
```
Alla ripresa della picking list, la posizione mostrata dal barcode riflette la giacenza corrente, perche' la vista operativa deriva `Cella` e `Ubicazione` da:
```sql
dbo.XMag_GiacenzaPallet
```
Quindi una UDC prima saltata e poi spostata verra' proposta con la nuova cella, oppure come:
```text
Non scaffalata
```
se e' stata dissociata verso la locazione virtuale `9001000`.
### Regola Conservata
Resta confermata la regola operativa:
```text
una UDC diventa Spedita / 7G.1.1 / 9000000 solo nel flusso picking list.
```
Le operazioni libere non spediscono una UDC. Possono solo versarla in una cella fisica oppure metterla in `9001000 / Non scaffalata`.
### File Coinvolti
Le modifiche principali sono nei moduli:
```text
barcode_client.py
barcode_repository.py
barcode_service.py
prenota_sprenota_sql.py
version_info.py
```
La patch DB cumulativa da usare sul database online e':
```text
apply_online_python_wms_full_patch.sql
```
La patch contiene gli oggetti SQL necessari al ramo Python e puo' essere eseguita una sola volta da SSMS.

View File

@@ -0,0 +1,65 @@
# Documento fisso durante il ciclo barcode F1/F2
## Difetto e requisito
Prima della correzione ogni avanzamento interrogava tutte le UDC con IDStato
uguale alla priorita' richiesta. Per F2, che include tutte le liste non
prenotate, il documento poteva quindi cambiare senza intervento dell'operatore.
Una volta proposta una lista, ogni successiva UDC del ciclo deve appartenere
a quel documento. F1 e F2 ricordano ciascuno la propria lista: F2 -> F1 -> F2
deve riportare al documento inizialmente assegnato a F2, anche se cambia
l'ordine globale delle UDC disponibili.
## Implementazione locale
- BarcodeService conserva un documento per priorita', separato dallo stato
visuale temporaneo di conferma o pausa.
- La prima richiesta senza documento usa il criterio di ordinamento vigente.
Non viene introdotta una selezione per data del documento.
- Le successive richieste applicano il filtro Documento nelle query di
avanzamento, ricerca skip e ricerca della UDC per conferma del movimento.
- Un documento differente ricevuto dal repository causa un errore esplicito.
- La pausa per movimenti liberi e il passaggio tra F1/F2 conservano i documenti.
- Quando termina il documento, il ciclo si ferma e mostra il relativo messaggio.
Una successiva pressione esplicita di un tasto priorita' puo' avviare un nuovo
ciclo; la fine del precedente non attiva automaticamente un altro documento.
- Quando rimangono soltanto UDC saltate, viene gestita la sospensione della
lista corrente senza passare a un'altra lista disponibile nella coda.
- Se cambiano prenotazione o visibilita' e ci sono ancora residui nello storico,
compare un messaggio di verifica e il documento viene mantenuto in memoria.
- Un errore della query non cancella il documento memorizzato.
Gli skip sono conteggiati soltanto se la relativa UDC e' ancora residua.
Le movimentazioni libere mantengono la loro logica; il filtro Documento e'
applicato soltanto alle ricerche nel contesto del picking.
## Limiti e installazione
Questa correzione riguarda la sessione del terminalino. La memoria dei documenti
viene azzerata con reset del servizio o riavvio del programma. Non introduce
ancora due prenotazioni persistenti o un percorso automatico comune per colonna:
questi appartengono alla successiva modifica funzionale da specificare.
Non serve una patch SQL: cambiano le SELECT eseguite dal codice Python, non gli
oggetti del database. Versioni: barcode_client 1.0.27 (titolo visibile),
barcode_repository 1.0.14, barcode_service 1.0.25.
## Verifiche
Esecuzione senza GUI e senza movimentazioni reali:
```bat
python -m unittest discover -s tests -p test_barcode_document_binding.py -v
```
Gli 11 test coprono avanzamento con documenti intercalati, passaggi ripetuti
F2/F1/F2, pausa con movimento libero, salto UDC, sospensione con soli skip,
fine lista, cambio prenotazione, errore query, rifiuto di documento inatteso,
isolamento della conferma e passaggio del parametro al repository.
Ulteriori 5 verifiche SELECT su SQL Server, con righe sintetiche in CTE,
hanno controllato selezione iniziale, filtro documento, assenza di fallback,
skip effettivamente residui e isolamento della conferma. Nessun dato reale
e' stato aggiornato per questi test. Il collaudo fisico sul terminalino resta
da effettuare.

View File

@@ -607,6 +607,7 @@ class LayoutWindow(ctk.CTkToplevel):
)
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
m.add_command(label="Codice cella", command=lambda: self._show_cell_code(r, c))
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)
@@ -648,6 +649,25 @@ class LayoutWindow(ctk.CTkToplevel):
except Exception:
return 0
def _show_cell_code(self, r: int, c: int) -> None:
"""Show the internal cell code for the selected layout cell."""
idcella = self._cell_id(r, c)
ubicazione = self._cell_label(r, c)
barcode_cella = self._cell_barcode(r, c)
if idcella <= 0:
messagebox.showwarning(
"Codice cella",
f"Codice cella non disponibile per {ubicazione}.",
parent=self,
)
return
messagebox.showinfo(
"Codice cella",
f"Ubicazione: {ubicazione}\nCodice cella: {idcella}\nBarcode cella: {barcode_cella or idcella}",
parent=self,
)
@_log_call()
def _prompt_carico(self, r: int, c: int):
"""Prompt the operator for a pallet barcode and move it to the selected cell."""
@@ -681,27 +701,7 @@ class LayoutWindow(ctk.CTkToplevel):
if stato <= 0:
self._toast("La cella selezionata non contiene alcuna UDC da scaricare.")
return
if stato >= 2:
self._open_scarico_dialog(r, c)
return
barcode = str(self.udc1[r][c] or "").strip()
if not barcode:
self._toast("UDC non disponibile per lo scarico.")
return
if not messagebox.askyesno(
"Scarico",
f"Scaricare l'UDC {barcode} da {self._cell_label(r, c)}?",
parent=self,
):
return
self._run_pallet_move(
barcode_pallet=barcode,
target_idcella=9999,
target_barcode_cella="9000000",
success_message=f"Scarico completato per {barcode}.",
busy_message=f"Scarico {barcode}...",
)
self._open_scarico_dialog(r, c)
@_log_call()
def _run_pallet_move(

View File

@@ -81,6 +81,7 @@ from ui_tables import (
from user_session import UserSession
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
from pickinglist_sequence import open_pickinglist_sequence_window
__version__ = module_version(__name__)
@@ -227,18 +228,21 @@ SELECT
MAX(Magazzino) AS Magazzino,
MAX(Area) AS Area,
MAX(Cella) AS Cella,
MIN(OrdinePrelievoGruppo) AS OrdinePrelievoGruppo,
MIN(Ordinamento) AS Ordinamento,
MAX(IDStato) AS IDStato
FROM dbo.py_ViewPackingListRestante
FROM dbo.py_ViewPackingListPrelievo
WHERE DataDocumento > CAST((GETDATE() - :days) AS int)
GROUP BY Documento, CodNazione, NAZIONE, Stato
ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato;
ORDER BY MIN(OrdinePrelievoGruppo), MIN(Ordinamento), Documento, NAZIONE, Stato;
"""
SQL_PL_DETAILS = """
SELECT *
FROM dbo.py_ViewPackingListRestante
FROM dbo.py_ViewPackingListPrelievo
WHERE Documento = :Documento
ORDER BY Ordinamento;
AND DataDocumento > CAST((GETDATE() - :days) AS int)
ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet;
"""
# -------------------- helpers --------------------
@@ -328,6 +332,8 @@ DET_COLS: List[ColSpec] = [
]
ROW_H = 28
RESERVED_IDSTATO_BG = "#dc2626"
RESERVED_IDSTATO_FG = "#ffffff"
# -------------------- Micro spinner (toolbar) --------------------
@@ -559,6 +565,8 @@ class ScrollTable(ctk.CTkFrame):
row_index: int,
anchors: Optional[List[str]] = None,
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
cell_bg: Optional[Dict[str, str]] = None,
cell_fg: Optional[Dict[str, str]] = None,
):
"""Append one row to the table body."""
row_bg = TABLE_ROW_EVEN if row_index % 2 == 0 else TABLE_ROW_ODD
@@ -568,8 +576,9 @@ class ScrollTable(ctk.CTkFrame):
row.pack_propagate(False)
for i, col in enumerate(self.columns):
holder_bg = (cell_bg or {}).get(col.key, row_bg)
holder = ctk.CTkFrame(
row, fg_color=row_bg,
row, fg_color=holder_bg,
width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR
)
@@ -581,10 +590,17 @@ class ScrollTable(ctk.CTkFrame):
cb = checkbox_builder(holder)
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
else:
ctk.CTkLabel(holder, text="", fg_color=row_bg).pack(fill="both")
ctk.CTkLabel(holder, text="", fg_color=holder_bg).pack(fill="both")
else:
anchor = (anchors[i] if anchors else col.anchor)
ctk.CTkLabel(holder, text=values[i], anchor=anchor, fg_color=row_bg, text_color="#111827").pack(
ctk.CTkLabel(
holder,
text=values[i],
anchor=anchor,
fg_color=holder_bg,
text_color=(cell_fg or {}).get(col.key, "#111827"),
font=("Segoe UI", 10, "bold") if col.key == "IDStato" and col.key in (cell_bg or {}) else None,
).pack(
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
)
@@ -637,6 +653,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.rows_models: list[PLRow] = []
self._detail_cache: Dict[Any, list] = {}
self.detail_doc = None
self.days_var = tk.StringVar(value="10")
self._detail_sort_key: Optional[str] = None
self._detail_sort_reverse = False
self._detail_sorting = False
@@ -687,6 +704,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
(loc_text("picking.button.reload", catalog=self._locale_catalog, default="Ricarica"), self.reload_from_db),
(loc_text("picking.button.prenota", catalog=self._locale_catalog, default="Prenota"), self.on_prenota),
(loc_text("picking.button.sprenota", catalog=self._locale_catalog, default="S-prenota"), self.on_sprenota),
(loc_text("picking.button.sequence", catalog=self._locale_catalog, default="Sequenza di prelievo"), self.on_sequence),
(loc_text("picking.button.export", catalog=self._locale_catalog, default="Esporta XLSX"), self.on_export)
]):
ctk.CTkButton(
@@ -696,6 +714,20 @@ class GestionePickingListFrame(ctk.CTkFrame):
font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")),
).grid(row=0, column=i, padx=6)
ctk.CTkLabel(
top,
text=loc_text("picking.label.days", catalog=self._locale_catalog, default="Giorni:"),
font=theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10)),
).grid(row=0, column=5, padx=(18, 4), sticky="e")
ctk.CTkOptionMenu(
top,
values=["10", "15", "20", "30"],
variable=self.days_var,
width=82,
command=lambda _value: self.reload_from_db(),
font=theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold")),
).grid(row=0, column=6, padx=(0, 6), sticky="w")
# --- micro spinner a destra della toolbar ---
self.spinner = ToolbarSpinner(top)
self.spinner.widget().grid(row=0, column=10, padx=(8,0)) # largo spazio a destra
@@ -876,10 +908,22 @@ class GestionePickingListFrame(ctk.CTkFrame):
try:
for idx, d in enumerate(rows):
row_widget = self.pl_table.b_inner.winfo_children()[idx]
if int(d.get("IDStato") or 0) == 1:
row_widget.configure(fg_color="#ffe6f2") # rosa tenue
else:
row_widget.configure(fg_color="transparent")
row_bg = TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
row_widget.configure(fg_color=row_bg)
row_children = row_widget.winfo_children()
if len(row_children) >= 5:
holder = row_children[4]
is_reserved = int(d.get("IDStato") or 0) == 1
holder_bg = RESERVED_IDSTATO_BG if is_reserved else row_bg
holder.configure(fg_color=holder_bg)
if holder.winfo_children():
lbl = holder.winfo_children()[0]
if hasattr(lbl, "configure"):
lbl.configure(
fg_color=holder_bg,
text_color=RESERVED_IDSTATO_FG if is_reserved else "#111827",
font=("Segoe UI", 10, "bold") if is_reserved else ("Segoe UI", 10),
)
except Exception:
pass
@@ -906,7 +950,9 @@ class GestionePickingListFrame(ctk.CTkFrame):
values=values,
row_index=r,
anchors=[c.anchor for c in PL_COLS],
checkbox_builder=model.build_checkbox
checkbox_builder=model.build_checkbox,
cell_bg={"IDStato": RESERVED_IDSTATO_BG} if int(d.get("IDStato") or 0) == 1 else None,
cell_fg={"IDStato": RESERVED_IDSTATO_FG} if int(d.get("IDStato") or 0) == 1 else None,
)
# 🎯 Colora dopo che la UI è resa → no balzi visivi
@@ -932,10 +978,20 @@ class GestionePickingListFrame(ctk.CTkFrame):
row_children = row_widget.winfo_children()
if len(row_children) >= 5:
holder = row_children[4]
holder.configure(fg_color=RESERVED_IDSTATO_BG if idstato == 1 else (
TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
))
if holder.winfo_children():
lbl = holder.winfo_children()[0]
if hasattr(lbl, "configure"):
lbl.configure(text=str(idstato))
lbl.configure(
text=str(idstato),
fg_color=RESERVED_IDSTATO_BG if idstato == 1 else (
TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
),
text_color=RESERVED_IDSTATO_FG if idstato == 1 else "#111827",
font=("Segoe UI", 10, "bold") if idstato == 1 else ("Segoe UI", 10),
)
except Exception:
pass
# differisci la colorazione (smooth)
@@ -962,6 +1018,15 @@ class GestionePickingListFrame(ctk.CTkFrame):
documento = _s(self.detail_doc)
return documento or None
def _selected_days(self) -> int:
"""Return the selected look-back interval for operational picking lists."""
try:
days = int(str(self.days_var.get()).strip())
except Exception:
days = 10
return days if days in (10, 15, 20, 30) else 10
# ----- eventi -----
@_log_call()
def on_row_checked(self, model: PLRow, is_checked: bool):
@@ -977,10 +1042,12 @@ class GestionePickingListFrame(ctk.CTkFrame):
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
days = self._selected_days()
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})
params = {"Documento": self.detail_doc, "days": days}
_log_sql("SQL_PL_DETAILS", SQL_PL_DETAILS, params)
return await self.db_client.query_json(SQL_PL_DETAILS, params)
def _ok(res):
# NON fermare lo spinner subito: lo farà _refresh_details_incremental
@@ -1022,9 +1089,11 @@ class GestionePickingListFrame(ctk.CTkFrame):
# document selected when it is still visible.
reselect_documento = self._selected_documento_for_reload()
self.spinner.start(" Carico…") # spinner ON
days = self._selected_days()
async def _job():
_log_sql("SQL_PL", SQL_PL, {})
return await self.db_client.query_json(SQL_PL, {})
params = {"days": days}
_log_sql("SQL_PL", SQL_PL, params)
return await self.db_client.query_json(SQL_PL, params)
def _on_success(res):
rows = _rows_to_dicts(res)
_log_dataset("SQL_PL", rows)
@@ -1120,6 +1189,45 @@ class GestionePickingListFrame(ctk.CTkFrame):
self._load_detail_sheet_data(self._detail_rows_to_sheet_data(rows))
# ----- azioni -----
@_log_call()
def on_sequence(self):
"""Open the pickup-sequence editor for the selected picking list."""
if not self._can("pickinglist.sequence"):
messagebox.showwarning(
loc_text("picking.sequence.title", catalog=self._locale_catalog, default="Sequenza di prelievo"),
loc_text("picking.sequence.msg.denied", catalog=self._locale_catalog, default="Permesso negato."),
parent=self,
)
return
model = self._get_selected_model()
if not model:
messagebox.showinfo(
loc_text("picking.sequence.title", catalog=self._locale_catalog, default="Sequenza di prelievo"),
loc_text(
"picking.sequence.msg.select",
catalog=self._locale_catalog,
default="Seleziona una Picking List prima di definire la sequenza.",
),
parent=self,
)
return
documento = _s(model.pl.get("Documento"))
def _on_saved():
self._detail_cache.pop(documento, None)
self.reload_from_db(reselect_documento=documento)
open_pickinglist_sequence_window(
self.winfo_toplevel(),
self.db_client,
documento=documento,
session=self.session,
on_saved=_on_saved,
)
@_log_call()
def on_prenota(self):
"""Reserve the selected picking list."""

View File

@@ -76,6 +76,12 @@ _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"
SHIPPED_IDCELLA = 9999
SHIPPED_BARCODE = "9000000"
SHIPPED_LABEL = "7G.1.1"
NON_SCAFF_IDCELLA = 1000
NON_SCAFF_BARCODE = "9001000"
NON_SCAFF_LABEL = "5E1.1"
def _session_login(session: UserSession | None, fallback: str | None = None) -> str:
@@ -198,28 +204,7 @@ last_move AS (
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 (
closed_plist AS (
SELECT DISTINCT
shipped.BarcodePallet
FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped
@@ -231,25 +216,19 @@ SELECT
cp.BarcodePallet AS UDC,
lm.ID AS SourceID,
lm.DataMagazzino AS LastEventAt,
:idcella AS CurrentIDCella,
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
WHEN :idcella <> 9999
AND closed_plist.BarcodePallet IS NOT NULL
THEN CAST(1 AS int)
ELSE CAST(0 AS int)
END AS IsMovedGhost
END AS IsMissingUnload
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 =
LEFT JOIN closed_plist
ON closed_plist.BarcodePallet COLLATE Latin1_General_CI_AS =
cp.BarcodePallet COLLATE Latin1_General_CI_AS
ORDER BY
lm.ID DESC,
@@ -413,19 +392,23 @@ class ScaricoRow:
udc: str
source_id: int | None
last_event_at: str
current_idcella: int
diagnostic_note: str
selected: tk.BooleanVar
def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str:
def _build_diagnostic_note(is_missing_unload: int | bool = False) -> 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)
if bool(is_missing_unload):
return "Motivo: spedita ma non scaricata dalla cella"
return ""
def _is_missing_unload_row(row: ScaricoRow) -> bool:
"""Return True when the row must be corrected only with shipped unload."""
return "spedita ma non scaricata dalla cella" in str(row.diagnostic_note or "").lower()
class ScaricoDialog(ctk.CTkToplevel):
@@ -478,8 +461,9 @@ class ScaricoDialog(ctk.CTkToplevel):
self.wait_visibility()
except Exception:
pass
self.lift()
self.focus_force()
if self.winfo_exists():
self.lift()
self.focus_force()
def _build_ui(self):
"""Build the compact modal layout."""
@@ -546,19 +530,27 @@ class ScaricoDialog(ctk.CTkToplevel):
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,
text=loc_text("scarico.button.shipped", catalog=self._locale_catalog, default="Scarico come spedita"),
command=self._on_scarica_spedita,
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.non_shelved", catalog=self._locale_catalog, default="Scarico come non scaff."),
command=self._on_scarica_non_scaff,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).grid(
row=0, column=2, 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
row=0, column=3, padx=(8, 8), pady=8
)
def _render_rows(self):
@@ -617,7 +609,13 @@ class ScaricoDialog(ctk.CTkToplevel):
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:
for (
udc,
source_id,
last_event_at,
current_idcella,
is_missing_unload,
) in rows:
if isinstance(last_event_at, datetime):
last_event = last_event_at.strftime("%d/%m/%Y %H:%M:%S")
else:
@@ -627,7 +625,8 @@ class ScaricoDialog(ctk.CTkToplevel):
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),
current_idcella=int(current_idcella or 0),
diagnostic_note=_build_diagnostic_note(is_missing_unload),
selected=tk.BooleanVar(value=False),
)
)
@@ -653,8 +652,9 @@ class ScaricoDialog(ctk.CTkToplevel):
)
@_log_call()
def _on_scarica(self):
"""Unload the UDCs selected by the user from the current cell."""
def _selected_rows_or_warn(self) -> list[ScaricoRow] | None:
"""Return selected rows or show the standard selection warning."""
selected = [row for row in self.rows if row.selected.get()]
if not selected:
messagebox.showinfo(
@@ -662,23 +662,97 @@ class ScaricoDialog(ctk.CTkToplevel):
loc_text("scarico.msg.select_one", catalog=self._locale_catalog, default="Seleziona almeno una UDC da scaricare."),
parent=self,
)
return selected
@_log_call()
def _on_scarica_spedita(self):
"""Unload selected UDCs to the shipped conventional location."""
self._scarica_selected_to_target(
target_idcella=SHIPPED_IDCELLA,
target_barcode_cella=SHIPPED_BARCODE,
target_label=SHIPPED_LABEL,
title="Scarico come spedita",
confirm_text="Scaricare come spedite {count} UDC da {ubicazione}?",
busy_message="Scarico UDC come spedite...",
block_shipped_for_non_scaff=False,
)
@_log_call()
def _on_scarica_non_scaff(self):
"""Unload selected UDCs to the non-shelved conventional location."""
self._scarica_selected_to_target(
target_idcella=NON_SCAFF_IDCELLA,
target_barcode_cella=NON_SCAFF_BARCODE,
target_label=NON_SCAFF_LABEL,
title="Scarico come non scaff.",
confirm_text="Scaricare come non scaffalate {count} UDC da {ubicazione}?",
busy_message="Scarico UDC come non scaffalate...",
block_shipped_for_non_scaff=True,
)
def _scarica_selected_to_target(
self,
*,
target_idcella: int,
target_barcode_cella: str,
target_label: str,
title: str,
confirm_text: str,
busy_message: str,
block_shipped_for_non_scaff: bool,
):
"""Move selected UDCs to one conventional unload location."""
selected = self._selected_rows_or_warn()
if not selected:
return
shipped_rows: list[ScaricoRow] = []
missing_unload_rows: list[ScaricoRow] = []
movable_rows = list(selected)
if block_shipped_for_non_scaff:
shipped_rows = [
row for row in selected
if int(row.current_idcella or 0) == SHIPPED_IDCELLA
]
missing_unload_rows = [row for row in selected if _is_missing_unload_row(row)]
blocked_udcs = {row.udc for row in shipped_rows + missing_unload_rows}
movable_rows = [row for row in selected if row.udc not in blocked_udcs]
if (shipped_rows or missing_unload_rows) and not movable_rows:
message_lines = ["Nessuna UDC movimentata."]
if shipped_rows:
message_lines.append(f"UDC gia' in {SHIPPED_LABEL}: " + ", ".join(row.udc for row in shipped_rows))
if missing_unload_rows:
message_lines.append(
"UDC spedite ma non scaricate: "
+ ", ".join(row.udc for row in missing_unload_rows)
)
message_lines.append("Usare 'Scarico come spedita'.")
messagebox.showwarning(
title,
"\n".join(message_lines),
parent=self,
)
return
if not messagebox.askyesno(
"Conferma scarico",
f"Scaricare {len(selected)} UDC da {self.ubicazione}?",
title,
confirm_text.format(count=len(movable_rows), ubicazione=self.ubicazione),
parent=self,
):
return
async def _job():
results: list[dict[str, Any]] = []
for row in selected:
for row in movable_rows:
result = await move_pallet_async(
self.db_client,
barcode_pallet=row.udc,
target_idcella=9999,
target_barcode_cella="9000000",
target_idcella=target_idcella,
target_barcode_cella=target_barcode_cella,
utente=_session_login(self.session),
)
results.append({"udc": row.udc, "affected": int(result.get("ok") or 0)})
@@ -690,33 +764,65 @@ class ScaricoDialog(ctk.CTkToplevel):
skipped = [item["udc"] for item in results if int(item.get("affected") or 0) <= 0]
if not done:
messagebox.showwarning(
"Scarica",
title,
"Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.",
parent=self,
)
return
if skipped:
shipped_note = (
f"\nGia' in {SHIPPED_LABEL} non toccate: " + ", ".join(row.udc for row in shipped_rows)
if shipped_rows
else ""
)
missing_unload_note = (
"\nSpedite ma non scaricate, usare 'Scarico come spedita': "
+ ", ".join(row.udc for row in missing_unload_rows)
if missing_unload_rows
else ""
)
target_note = (
f"\nUDC in {target_label}: " + ", ".join(done)
if done
else ""
)
messagebox.showwarning(
"Scarica",
title,
"Scarico parziale.\nCompletate: "
+ ", ".join(done)
+ "\nNon scaricate: "
+ ", ".join(skipped),
+ ", ".join(skipped)
+ shipped_note
+ missing_unload_note
+ target_note,
parent=self,
)
else:
messagebox.showinfo(
"Scarica",
"Scarico completato per:\n" + "\n".join(done),
parent=self,
)
msg = f"Scarico completato verso {target_label} per:\n" + "\n".join(done)
if shipped_rows:
msg += f"\n\nUDC gia' in {SHIPPED_LABEL} non toccate:\n" + "\n".join(row.udc for row in shipped_rows)
msg += f"\n\nUDC in {target_label}: " + ", ".join(done)
if missing_unload_rows:
msg += (
"\n\nUDC spedite ma non scaricate non toccate:\n"
+ "\n".join(row.udc for row in missing_unload_rows)
+ "\nUsare 'Scarico come spedita'."
)
messagebox.showinfo(title, msg, parent=self)
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="layout.scarico",
action="layout.scarico.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff",
outcome="ok",
target=self.ubicazione,
details={"scaricate": done, "saltate": skipped},
details={
"scaricate": done,
"saltate": skipped,
"target_idcella": target_idcella,
"target_barcode_cella": target_barcode_cella,
"gia_spedite": [row.udc for row in shipped_rows],
"spedite_non_scaricate": [row.udc for row in missing_unload_rows],
},
)
if self.on_completed:
self.on_completed()
@@ -727,7 +833,7 @@ class ScaricoDialog(ctk.CTkToplevel):
log_user_action(
self.session,
module=MODULE_LOG_NAME,
action="layout.scarico",
action="layout.scarico.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff",
outcome="error",
target=self.ubicazione,
details={"error": str(ex)},
@@ -743,7 +849,7 @@ class ScaricoDialog(ctk.CTkToplevel):
_ok,
_err,
busy=self._busy,
message="Scarico UDC...",
message=busy_message,
)
@_log_call()

View File

@@ -10,6 +10,7 @@
"launcher.history_udc": "Storico movimenti UDC",
"launcher.pickinglist": "Gestione Picking List",
"launcher.history_pickinglist": "Storico Picking List",
"launcher.diagnostics": "Diagnostica",
"launcher.arrange": "Ridisponi finestre",
"launcher.exit": "Esci",
"launcher.already_running_title": "Warehouse",
@@ -75,6 +76,27 @@
"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}",
"diagnostics.title": "Diagnostica",
"diagnostics.button.shipped_not_disassociated": "UDC spedite non disassociate",
"diagnostics.button.multiple_udc_by_cell": "Celle con multiple UDC",
"diagnostics.button.sam_udc_not_in_wms": "Ultime UDC SAM non in WMS",
"diagnostics.button.export": "Esporta XLSX",
"diagnostics.busy.shipped_not_disassociated": "Cerco UDC spedite ma ancora in cella...",
"diagnostics.busy.multiple_udc_by_cell": "Cerco celle con multiple UDC...",
"diagnostics.busy.sam_udc_not_in_wms": "Cerco le ultime UDC presenti in SAM ma non ancora in WMS...",
"diagnostics.status.rows": "Righe trovate: {count}",
"diagnostics.col.udc": "UDC",
"diagnostics.col.cell": "CELLA",
"diagnostics.col.lot": "Lotto",
"diagnostics.col.product": "Prodotto",
"diagnostics.col.description": "Descrizione",
"diagnostics.col.shipped_not_disassociated": "Spedita ma non disassociata",
"diagnostics.col.symbolic_cell": "Nome simbolico cella",
"diagnostics.export.title": "Esporta",
"diagnostics.export.empty": "Non ci sono righe da esportare.",
"diagnostics.export.dep": "Per l'esportazione serve 'openpyxl' (pip install openpyxl).",
"diagnostics.export.ok": "File creato:\n{path}",
"diagnostics.export.error": "Errore durante l'esportazione:\n{error}",
"reset.title": "Gestione Corsie - svuotamento celle per corsia",
"reset.label.aisle": "Corsia:",
"reset.button.refresh": "Carica",
@@ -99,7 +121,30 @@
"picking.button.reload": "Ricarica",
"picking.button.prenota": "Prenota",
"picking.button.sprenota": "S-prenota",
"picking.button.sequence": "Sequenza di prelievo",
"picking.button.export": "Esporta XLSX",
"picking.label.days": "Giorni:",
"picking.sequence.title": "Sequenza di prelievo",
"picking.sequence.document": "Picking List {documento}",
"picking.sequence.available": "Scaffali disponibili",
"picking.sequence.selected": "Ordine personalizzato",
"picking.sequence.hint": "Trascina gli scaffali a destra. Quelli non inseriti saranno accodati secondo l'ordine predefinito.",
"picking.sequence.default_hint": "Default: prima Non scaff., poi gli scaffali secondo l'ordine WMS.",
"picking.sequence.button.add": "Aggiungi >",
"picking.sequence.button.remove": "< Rimuovi",
"picking.sequence.button.up": "Su",
"picking.sequence.button.down": "Giu'",
"picking.sequence.button.save": "Salva",
"picking.sequence.button.reset": "Ripristina default",
"picking.sequence.button.close": "Chiudi",
"picking.sequence.busy.load": "Carico gli scaffali...",
"picking.sequence.busy.save": "Salvo la sequenza...",
"picking.sequence.msg.select": "Seleziona una Picking List prima di definire la sequenza.",
"picking.sequence.msg.denied": "Permesso negato.",
"picking.sequence.msg.saved": "Sequenza salvata.",
"picking.sequence.msg.reset": "Sequenza personalizzata rimossa. E' attivo l'ordine predefinito.",
"picking.sequence.msg.load_error": "Errore durante il caricamento:\n{error}",
"picking.sequence.msg.save_error": "Errore durante il salvataggio:\n{error}",
"scarico.title": "Scarica {ubicazione}",
"scarico.label.location": "Ubicazione: {ubicazione}",
"scarico.label.select": "Seleziona le UDC da scaricare",
@@ -107,6 +152,8 @@
"scarico.col.last_insert": "Ultimo inserimento",
"scarico.col.diagnostic": "Diagnostica",
"scarico.button.submit": "Scarica",
"scarico.button.shipped": "Scarico come spedita",
"scarico.button.non_shelved": "Scarico come non scaff.",
"scarico.button.close": "Chiudi",
"scarico.msg.title": "Scarica",
"scarico.msg.select_one": "Seleziona almeno una UDC da scaricare.",
@@ -123,6 +170,7 @@
"launcher.history_udc": "UDC Movement History",
"launcher.pickinglist": "Picking List Management",
"launcher.history_pickinglist": "Picking List History",
"launcher.diagnostics": "Diagnostics",
"launcher.arrange": "Arrange windows",
"launcher.exit": "Exit",
"launcher.already_running_title": "Warehouse",
@@ -188,6 +236,27 @@
"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}",
"diagnostics.title": "Diagnostics",
"diagnostics.button.shipped_not_disassociated": "Shipped UDC not disassociated",
"diagnostics.button.multiple_udc_by_cell": "Cells with multiple UDC",
"diagnostics.button.sam_udc_not_in_wms": "Latest UDC in SAM not in WMS",
"diagnostics.button.export": "Export XLSX",
"diagnostics.busy.shipped_not_disassociated": "Searching shipped UDC still assigned to cells...",
"diagnostics.busy.multiple_udc_by_cell": "Searching cells with multiple UDC...",
"diagnostics.busy.sam_udc_not_in_wms": "Searching latest UDC present in SAM but not yet in WMS...",
"diagnostics.status.rows": "Rows found: {count}",
"diagnostics.col.udc": "UDC",
"diagnostics.col.cell": "CELL",
"diagnostics.col.lot": "Lot",
"diagnostics.col.product": "Product",
"diagnostics.col.description": "Description",
"diagnostics.col.shipped_not_disassociated": "Shipped but not disassociated",
"diagnostics.col.symbolic_cell": "Symbolic cell name",
"diagnostics.export.title": "Export",
"diagnostics.export.empty": "There are no rows to export.",
"diagnostics.export.dep": "Export requires 'openpyxl' (pip install openpyxl).",
"diagnostics.export.ok": "File created:\n{path}",
"diagnostics.export.error": "Export failed:\n{error}",
"reset.title": "Aisle Management - empty cells by aisle",
"reset.label.aisle": "Aisle:",
"reset.button.refresh": "Load",
@@ -212,7 +281,30 @@
"picking.button.reload": "Reload",
"picking.button.prenota": "Reserve",
"picking.button.sprenota": "Unreserve",
"picking.button.sequence": "Pickup sequence",
"picking.button.export": "Export XLSX",
"picking.label.days": "Days:",
"picking.sequence.title": "Pickup sequence",
"picking.sequence.document": "Picking List {documento}",
"picking.sequence.available": "Available racks",
"picking.sequence.selected": "Custom order",
"picking.sequence.hint": "Drag racks to the right. Missing racks are appended using the default order.",
"picking.sequence.default_hint": "Default: non-shelved first, then racks in WMS order.",
"picking.sequence.button.add": "Add >",
"picking.sequence.button.remove": "< Remove",
"picking.sequence.button.up": "Up",
"picking.sequence.button.down": "Down",
"picking.sequence.button.save": "Save",
"picking.sequence.button.reset": "Restore default",
"picking.sequence.button.close": "Close",
"picking.sequence.busy.load": "Loading racks...",
"picking.sequence.busy.save": "Saving sequence...",
"picking.sequence.msg.select": "Select a Picking List before defining its sequence.",
"picking.sequence.msg.denied": "Permission denied.",
"picking.sequence.msg.saved": "Sequence saved.",
"picking.sequence.msg.reset": "Custom sequence removed. The default order is active.",
"picking.sequence.msg.load_error": "Load failed:\n{error}",
"picking.sequence.msg.save_error": "Save failed:\n{error}",
"scarico.title": "Unload {ubicazione}",
"scarico.label.location": "Location: {ubicazione}",
"scarico.label.select": "Select the UDCs to unload",
@@ -220,6 +312,8 @@
"scarico.col.last_insert": "Last insert",
"scarico.col.diagnostic": "Diagnostics",
"scarico.button.submit": "Unload",
"scarico.button.shipped": "Unload as shipped",
"scarico.button.non_shelved": "Unload as non-shelved",
"scarico.button.close": "Close",
"scarico.msg.title": "Unload",
"scarico.msg.select_one": "Select at least one UDC to unload.",

11
main.py
View File

@@ -22,6 +22,7 @@ from async_loop_singleton import get_global_loop, stop_global_loop
from async_msssql_query import AsyncMSSQLClient
from audit_log import log_session_event
from db_config import build_dsn_from_config, ensure_db_config
from diagnostica import open_diagnostica_window
from gestione_layout import open_layout_window
from gestione_pickinglist import open_pickinglist_window
from login_window import prompt_login
@@ -161,6 +162,7 @@ class Launcher(ctk.CTk):
"storico_udc",
"pickinglist",
"storico_pickinglist",
"diagnostica",
]
def __init__(self, session: UserSession, db_client: AsyncMSSQLClient):
@@ -268,6 +270,15 @@ class Launcher(ctk.CTk):
lambda: open_storico_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
"diagnostica",
loc_text("launcher.diagnostics", catalog=self._locale_catalog, default="Diagnostica"),
"launcher.open_diagnostics",
lambda: self._open_or_focus_child_window(
"diagnostica",
lambda: open_diagnostica_window(self, self.db_client, session=self.session),
),
),
(
"arrange",
loc_text("launcher.arrange", catalog=self._locale_catalog, default="Ridisponi finestre"),

414
ordinamento.md Normal file
View File

@@ -0,0 +1,414 @@
# Ordinamento di prelievo delle celle
## 1. Scopo del documento
Questo documento descrive il significato e l'origine del campo
`dbo.Celle.Ordinamento`, il modo in cui viene utilizzato per proporre le UDC al
barcode e le differenze riscontrate tra i due magazzini storici:
- Area 5, identificata nel database legacy come magazzino `MDE5`;
- Area 6, identificata nel database legacy come magazzino `MDE6`.
Nel linguaggio operativo si parla di Area 5 e Area 6. Nel modello legacy,
tuttavia, la tabella `Magazzini` contiene `MDE5` e `MDE6`, mentre la tabella
`Aree` contiene le singole corsie o scaffali. Gli ID della tabella `Aree` non
devono quindi essere confusi con i nomi operativi Area 5 e Area 6.
Le conclusioni riportate derivano da:
- struttura e dati correnti della tabella `dbo.Celle`;
- stored procedure legacy contenute in `script.sql`;
- viste usate dalle picking list;
- codice Python del barcode e della gestione picking list;
- confronto tra `ID`, ubicazione e `Ordinamento` delle celle reali.
## 2. Significato di `Celle.Ordinamento`
`Celle.Ordinamento` e' una priorita' numerica associata all'anagrafica della
cella. Rappresenta la posizione della cella nel percorso predefinito di
prelievo.
Il campo non indica:
- la quantita' di UDC presenti;
- la priorita' della picking list;
- lo stato libero o occupato della cella;
- l'ordine cronologico di inserimento delle UDC;
- l'ID della UDC;
- la distanza calcolata dinamicamente dal barcode.
Il valore non viene ricalcolato durante un versamento o un prelievo. E' un dato
statico dell'anagrafica della cella, salvo interventi amministrativi o
procedure specifiche di rinumerazione.
Il tipo SQL storico e' `float`, ma nei dati analizzati viene usato come numero
intero progressivo. Nella futura riprogettazione sarebbe preferibile un tipo
intero o una struttura esplicita di percorso.
## 3. Dove viene assegnato
La stored procedure legacy `dbo.spt_SaveCelle` riceve il parametro
`@Ordinamento` e lo salva direttamente:
```sql
UPDATE Celle
SET Ordinamento = @Ordinamento
WHERE ID = @ID;
```
Se la cella non esiste, la stessa stored procedure esegue una `INSERT` e
inserisce il valore ricevuto. La procedura non contiene una formula per
calcolarlo: chi crea o modifica la cella deve decidere il valore e passarlo.
Nel database non risultano trigger sulla tabella `Celle` che aggiornino
automaticamente `Ordinamento`.
Sono state individuate anche procedure legacy di supporto:
- `CreaNuoveCelle`;
- `CreaNuoveCelleMDE6`;
- `CreaNuoveCelleMDE6_BIS`;
- `sp_OrdinaCelle`;
- `spt_SaveCelle`.
Le procedure di creazione MDE6 impostano normalmente:
```sql
SET @Ordinamento = @ID;
```
La procedura `sp_OrdinaCelle` dimostra invece che il campo puo' essere
rinumerato in un secondo momento per rappresentare un percorso differente
dall'ID della cella.
## 4. Uso durante una picking list
Le viste operative associano ogni UDC alla cella corrente e recuperano
`Celle.Ordinamento`. Il barcode richiede la prima riga residua secondo l'ordine
effettivo.
Prima dell'introduzione della sequenza personalizzata, il criterio principale
era sostanzialmente:
```sql
ORDER BY Ordinamento;
```
Questo significa che il barcode non percorre tutte le celle del magazzino. La
vista contiene soltanto le UDC ancora presenti nella picking list: le celle che
non contengono una UDC richiesta vengono saltate automaticamente.
Esempio: se il percorso contiene le celle con ordine `1001`, `1002`, `1003` e
`1004`, ma la picking list contiene UDC soltanto nelle celle `1001` e `1004`, il
barcode propone direttamente la UDC di `1001` e poi quella di `1004`.
## 5. Ordinamento dell'Area 5 / MDE5
### 5.1 Caratteristica generale
Nell'Area 5 `Ordinamento` non coincide normalmente con l'ID della cella. Dai
dati emerge un percorso fisico costruito intenzionalmente, verosimilmente
hardcoded o rinumerato durante la configurazione iniziale del magazzino.
Le corsie principali coinvolte sono:
- `1A`;
- `2B`;
- `3C`;
- `4D`.
Il percorso iniziale delle corsie `1A` e `2B` e' a serpentina. Le due corsie
vengono trattate come lati o scaffali adiacenti di uno stesso tragitto fisico.
### 5.2 Esempio reale dell'inizio del percorso
```text
Ordine Ubicazione ID cella
1001 1A.1.e 1005
1002 1A.1.d 1004
1003 1A.1.c 1003
1004 1A.1.b 1002
1005 1A.1.a 1001
1007 2B.1.e 2005
1008 2B.1.d 2004
1009 2B.1.c 2003
1010 2B.1.b 2002
1011 2B.1.a 2001
1013 2B.2.e 2011
1014 2B.2.d 2010
1015 2B.2.c 2009
1016 2B.2.b 2008
1017 2B.2.a 2007
1019 1A.2.e 1011
1020 1A.2.d 1010
1021 1A.2.c 1009
1022 1A.2.b 1008
1023 1A.2.a 1007
```
La sequenza prosegue con lo stesso principio:
```text
1A campata 1
2B campata 1
2B campata 2
1A campata 2
1A campata 3
2B campata 3
2B campata 4
1A campata 4
...
```
### 5.3 Movimento verticale nella campata
All'interno di ciascuna campata l'ordine verificato e':
```text
e -> d -> c -> b -> a
```
Il percorso procede quindi dal livello identificato con `e` verso il livello
`a`. Non viene prelevata una sola UDC per campata per poi ricominciare il giro.
Vengono considerate, in sequenza, tutte le celle richieste della campata
corrente prima di passare alla campata successiva.
Se una campata contiene piu' UDC richieste in livelli diversi, il barcode le
propone secondo `e, d, c, b, a`. Se alcuni livelli non contengono UDC della
picking list, vengono semplicemente saltati.
### 5.4 Alternanza tra 1A e 2B
Il sistema non esaurisce tutto `1A` prima di iniziare `2B`. Alterna blocchi di
campate tra le due corsie per seguire un tragitto a serpentina ed evitare
ritorni inutili.
Il comportamento predefinito storico e' quindi:
- completare le UDC richieste nella campata corrente;
- passare alla campata fisicamente successiva del percorso;
- alternare `1A` e `2B` secondo la serpentina configurata;
- non tornare all'inizio dopo ogni singola UDC.
### 5.5 Passaggio a 3C e 4D
Le corsie `3C` e `4D` hanno valori di ordinamento molto piu' alti. Nei dati
analizzati:
```text
1A: minimo 1001, massimo 1275
2B: minimo 1007, massimo 1251
3C: minimo 10019, massimo 10227
4D: valori nell'intervallo 10055 - 10233 circa
```
Di conseguenza il percorso storico esaurisce normalmente il blocco `1A/2B`
prima di passare al blocco `3C/4D`.
La presenza di famiglie numeriche differenti indica che l'ordinamento
dell'Area 5 e' stato costruito o modificato in piu' fasi storiche.
### 5.6 Significato dei numeri mancanti
La progressione contiene intervalli non utilizzati, per esempio `1005` seguito
da `1007`. Il numero mancante non indica necessariamente una cella eliminata o
un errore. Puo' essere stato lasciato come separatore tra campate o derivare
dalla procedura di rinumerazione.
Il software deve usare il confronto numerico e non deve presumere che tutti i
valori siano consecutivi.
## 6. Ordinamento dell'Area 6 / MDE6
### 6.1 Caratteristica generale
Nell'Area 6 il modello e' piu' semplice. La maggior parte delle celle e' stata
generata con `Ordinamento` uguale all'ID della cella.
Le famiglie principali sono:
```text
1M -> celle e ordinamenti 5001, 5002, 5003, ...
2N -> celle e ordinamenti 6001, 6002, 6003, ...
3O -> celle e ordinamenti 7001, 7002, 7003, ...
4P -> celle e ordinamenti 8001, 8002, 8003, ...
```
Le procedure `CreaNuoveCelleMDE6` e `CreaNuoveCelleMDE6_BIS` creano le celle
con cicli annidati e assegnano esplicitamente:
```sql
SET @Ordinamento = @ID;
```
### 6.2 Ordine degli scaffali
Poiche' le famiglie numeriche sono separate, l'ordine globale predefinito e':
```text
1M -> 2N -> 3O -> 4P
```
Senza una sequenza personalizzata vengono quindi considerate prima tutte le
UDC richieste in `1M`, poi quelle in `2N`, successivamente `3O` e infine `4P`.
### 6.3 Ordine interno dello scaffale
Durante la generazione delle celle, i cicli legacy incrementano prima la
campata e, all'interno della campata, il piano. L'ordine risultante e':
```text
1M.1.a -> 1M.1.b -> 1M.1.c -> 1M.1.d -> 1M.1.e
1M.2.a -> 1M.2.b -> 1M.2.c -> 1M.2.d -> 1M.2.e
...
2N.1.a -> 2N.1.b -> ...
```
Quindi l'Area 6 differisce dall'Area 5 in due aspetti principali:
- non presenta l'alternanza a serpentina tra due corsie adiacenti;
- dentro la campata procede da `a` verso `e`, mentre nell'Area 5 verificata
procede da `e` verso `a`.
### 6.4 Limiti della ricostruzione
Il comportamento descritto deriva dalla procedura di generazione e dai valori
correnti. Eventuali celle aggiunte manualmente in tempi successivi possono
avere eccezioni. L'applicazione deve sempre leggere il valore effettivo dal
database e non ricostruirlo aritmeticamente dall'ubicazione.
## 7. Celle virtuali
Nel sistema legacy esistono due ubicazioni convenzionali:
```text
ID 1000 -> 5E.1.1 -> Non scaff. -> Ordinamento 99999
ID 9999 -> 7G.1.1 -> Spedita -> Ordinamento 0
```
Questi valori non rappresentano un percorso fisico:
- `5E.1.1` identifica UDC presenti nel magazzino ma non allocate in uno
scaffale fisico;
- `7G.1.1` identifica UDC spedite.
Storicamente `Non scaff.` aveva ordinamento `99999`, quindi finiva in fondo
alla picking list. Il nuovo requisito operativo stabilisce invece che le UDC
non scaffalate devono essere proposte per prime.
La nuova vista Python non modifica `Celle.Ordinamento`: assegna a `Non scaff.`
una precedenza logica di gruppo pari a zero. La cella `Spedita` non entra nella
vista residuale e quindi non viene proposta dal barcode.
## 8. Nuovo ordinamento predefinito Python
In assenza di una configurazione specifica per il documento, il nuovo ordine
effettivo e':
1. tutte le UDC `Non scaff.` ancora appartenenti alla picking list;
2. tutte le UDC nelle celle fisiche secondo `Celle.Ordinamento`;
3. codice pallet come criterio stabile in caso di parita'.
In forma semplificata:
```sql
ORDER BY
OrdinePrelievoGruppo,
Ordinamento,
Pallet;
```
Per le celle fisiche, se non esiste una personalizzazione,
`OrdinePrelievoGruppo` conserva la progressione storica. Il percorso dell'Area
5 rimane quindi a serpentina e quello dell'Area 6 rimane progressivo.
## 9. Effetto della sequenza personalizzata
La sequenza personalizzata opera a livello di gruppo o scaffale, non a livello
di singola cella.
Esempio di configurazione:
```text
1. Non scaff.
2. 3C
3. 1A
4. 2B
5. 4D
```
In questo caso il sistema esaurisce prima il gruppo `Non scaff.`, poi tutte le
UDC di `3C`, quindi tutte quelle di `1A` e cosi' via.
All'interno di ciascun gruppo continua a usare `Celle.Ordinamento`. Per
esempio, se `1A` viene posizionata prima di `2B`, non viene piu' conservata
l'alternanza storica tra `1A` e `2B`: vengono esaurite prima tutte le UDC di
`1A`, nel loro ordine interno, e successivamente quelle di `2B`.
Questa differenza e' intenzionale:
- ordine predefinito: conserva il percorso storico globale;
- ordine personalizzato: impone la precedenza degli scaffali scelta
dall'operatore e conserva l'ordine storico soltanto dentro ogni scaffale.
Se la sequenza personalizzata non include tutti i gruppi, quelli mancanti
vengono accodati secondo il percorso predefinito. Nessuna UDC viene esclusa.
## 10. UDC multiple, lotti e parita' di ordine
Piu' righe della vista possono riferirsi alla stessa UDC quando contiene piu'
lotti o articoli. Questo non significa che il magazziniere debba movimentare
fisicamente la stessa UDC piu' volte: l'unita' di movimentazione rimane il
pallet/UDC.
Piu' UDC possono inoltre risultare associate alla stessa cella nel modello
legacy. In quel caso condividono lo stesso `Ordinamento`. Il nuovo codice usa
anche `Pallet` come criterio finale per rendere stabile la sequenza, ma il
percorso fisico resta quello della cella.
## 11. Considerazioni per la futura riprogettazione
Nel futuro FlyWMS Core e' consigliabile separare esplicitamente:
- ordine fisico della cella dentro lo scaffale;
- ordine predefinito degli scaffali;
- percorso o strategia di visita del magazzino;
- ordine personalizzato per documento;
- priorita' operativa della picking list;
- eventuale ottimizzazione dinamica del percorso.
Il campo legacy `Celle.Ordinamento` incorpora piu' significati e dipende da
decisioni storiche non completamente documentate. Non dovrebbe essere copiato
alla lettera nel nuovo modello dati senza scomporne la semantica.
Una possibile struttura futura potrebbe prevedere:
- `Location.SequenceWithinRack` per l'ordine interno;
- `Rack.DefaultSequence` per l'ordine tra scaffali;
- `Route` e `RouteStep` per percorsi configurabili;
- `PickingDocumentRoute` per la personalizzazione della singola lista.
## 12. Riferimenti tecnici nel progetto
- `script.sql`: definizioni di `spt_SaveCelle`, `CreaNuoveCelleMDE6`,
`CreaNuoveCelleMDE6_BIS` e `sp_OrdinaCelle`;
- `barcode_repository.py`: interrogazione della prossima UDC;
- `gestione_pickinglist.py`: visualizzazione del dettaglio ordinato;
- `pickinglist_sequence.py`: gestione della sequenza per documento;
- `apply_pickinglist_sequence_patch.sql`: tabella e vista Python dedicate;
- `specifica_sequenza_prelievo.md`: specifica funzionale della nuova
configurazione.
## 13. Conclusione
L'Area 5 utilizza un percorso storico a serpentina, costruito per alternare
corsie e campate secondo la disposizione fisica. L'Area 6 utilizza invece una
progressione piu' semplice, sostanzialmente coincidente con gli ID creati in
sequenza.
Il nuovo software conserva entrambi i comportamenti come default, anticipa le
UDC non scaffalate e permette di sostituire l'ordine globale degli scaffali per
una singola picking list senza modificare l'anagrafica delle celle.

584
pickinglist_sequence.py Normal file
View File

@@ -0,0 +1,584 @@
"""Editor for the per-document picking-list pickup sequence.
The barcode client remains unaware of the editor: it reads the effective order
from ``dbo.py_ViewPackingListPrelievo``. This module only manages the Python-
specific configuration rows used by that view.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
import tkinter as tk
from tkinter import messagebox
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
from user_session import UserSession
from version_info import module_version, versioned_title
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
__version__ = module_version(__name__)
SQL_LOAD_GROUPS = """
SELECT
GruppoPrelievoChiave,
MAX(GruppoPrelievo) AS GruppoPrelievo,
MIN(OrdineGruppoDefault) AS OrdineGruppoDefault,
MIN(OrdineGruppoConfigurato) AS OrdineGruppoConfigurato
FROM dbo.py_ViewPackingListPrelievo
WHERE CAST(Documento AS varchar(50)) = :documento
AND Cella <> 9999
GROUP BY GruppoPrelievoChiave
ORDER BY
CASE WHEN MIN(OrdineGruppoConfigurato) IS NULL THEN 1 ELSE 0 END,
MIN(OrdineGruppoConfigurato),
MIN(OrdineGruppoDefault),
MAX(GruppoPrelievo);
"""
SQL_RESET_SEQUENCE = """
SET NOCOUNT ON;
SET XACT_ABORT ON;
UPDATE dbo.py_PickingListPrelievoSequenza
SET DelUtente = :utente,
DelDataOra = SYSDATETIME(),
ModUtente = :utente,
ModDataOra = SYSDATETIME()
WHERE Documento = :documento
AND DelDataOra IS NULL;
SELECT CAST(0 AS int) AS ElementiSalvati;
"""
def _rows_to_dicts(result: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Convert the shared database payload to dictionaries."""
if not isinstance(result, dict):
return []
rows = result.get("rows") or []
columns = result.get("columns") or []
if rows and isinstance(rows[0], dict):
return [dict(row) for row in rows]
return [
{str(columns[index]): row[index] for index in range(min(len(columns), len(row)))}
for row in rows
if isinstance(row, (list, tuple))
]
@dataclass(frozen=True)
class PickupGroup:
"""One rack group, or the conventional non-shelved group."""
key: str
label: str
default_order: int
class PickingListSequenceWindow(ctk.CTkToplevel):
"""Drag-and-drop editor for one picking-list pickup sequence."""
def __init__(
self,
parent: tk.Misc,
db_client,
*,
documento: str,
session: UserSession | None,
on_saved: Callable[[], None] | None = None,
):
super().__init__(parent)
self.db_client = db_client
self.documento = str(documento or "").strip()
self.session = session
self.on_saved = on_saved
self._catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog()
self._theme = theme_section("picking_sequence_window", {})
self.runner = AsyncRunner(self)
self.busy = InlineBusyOverlay(self, self._theme)
self.available: list[PickupGroup] = []
self.selected: list[PickupGroup] = []
self._selected_key: str | None = None
self._selected_side: str | None = None
self._drag_group: PickupGroup | None = None
self._drag_side: str | None = None
self.title(versioned_title(self._t("picking.sequence.title", "Sequenza di prelievo"), __name__))
self.geometry("780x560")
self.minsize(680, 480)
self.transient(parent)
self.configure(fg_color=theme_color(self._theme, "window_fg_color", ("#efefef", "#2f2f2f")))
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_layout()
self.withdraw()
self.after_idle(self._show_and_load)
def _t(self, key: str, default: str, **values: Any) -> str:
"""Return one localized and formatted label."""
value = loc_text(key, catalog=self._catalog, default=default)
try:
return value.format(**values)
except Exception:
return value
def _build_layout(self) -> None:
"""Build the two lists and the explicit keyboard-friendly controls."""
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(2, weight=1)
self.grid_rowconfigure(2, weight=1)
ctk.CTkLabel(
self,
text=self._t("picking.sequence.document", "Picking List {documento}", documento=self.documento),
font=theme_font(self._theme, "title_font", ("Segoe UI", 16, "bold")),
).grid(row=0, column=0, columnspan=3, padx=16, pady=(14, 4), sticky="w")
ctk.CTkLabel(
self,
text=self._t(
"picking.sequence.hint",
"Trascina gli scaffali a destra. Quelli non inseriti saranno accodati secondo l'ordine predefinito.",
),
anchor="w",
justify="left",
wraplength=720,
).grid(row=1, column=0, columnspan=3, padx=16, pady=(0, 10), sticky="ew")
self.available_panel, self.available_list = self._build_list_panel(
0, self._t("picking.sequence.available", "Scaffali disponibili")
)
controls = ctk.CTkFrame(self, fg_color="transparent")
controls.grid(row=2, column=1, padx=10, pady=4)
for label, command in (
(self._t("picking.sequence.button.add", "Aggiungi >"), self._add_selected),
(self._t("picking.sequence.button.remove", "< Rimuovi"), self._remove_selected),
(self._t("picking.sequence.button.up", "Su"), lambda: self._move_selected(-1)),
(self._t("picking.sequence.button.down", "Giu'"), lambda: self._move_selected(1)),
):
button = ctk.CTkButton(controls, text=label, width=120, command=command)
button.pack(pady=5)
tooltip_key = {
self._t("picking.sequence.button.add", "Aggiungi >"): "picking.sequence.add",
self._t("picking.sequence.button.remove", "< Rimuovi"): "picking.sequence.remove",
self._t("picking.sequence.button.up", "Su"): "picking.sequence.up",
self._t("picking.sequence.button.down", "Giu'"): "picking.sequence.down",
}.get(label, "")
self._attach_tooltip(button, tooltip_key)
self.selected_panel, self.selected_list = self._build_list_panel(
2, self._t("picking.sequence.selected", "Ordine personalizzato")
)
ctk.CTkLabel(
self,
text=self._t(
"picking.sequence.default_hint",
"Default: prima Non scaff., poi gli scaffali secondo l'ordine WMS.",
),
anchor="w",
text_color=theme_color(self._theme, "secondary_text_color", "#4b5563"),
).grid(row=3, column=0, columnspan=3, padx=16, pady=(8, 4), sticky="ew")
bottom = ctk.CTkFrame(self)
bottom.grid(row=4, column=0, columnspan=3, padx=16, pady=(6, 14), sticky="ew")
bottom.grid_columnconfigure(0, weight=1)
reset_button = ctk.CTkButton(
bottom,
text=self._t("picking.sequence.button.reset", "Ripristina default"),
command=self._reset_default,
fg_color=theme_color(self._theme, "reset_button_fg_color", "#6b7280"),
)
reset_button.grid(row=0, column=1, padx=5, pady=8)
self._attach_tooltip(reset_button, "picking.sequence.reset")
save_button = ctk.CTkButton(
bottom,
text=self._t("picking.sequence.button.save", "Salva"),
command=self._save,
)
save_button.grid(row=0, column=2, padx=5, pady=8)
self._attach_tooltip(save_button, "picking.sequence.save")
close_button = ctk.CTkButton(
bottom,
text=self._t("picking.sequence.button.close", "Chiudi"),
command=self.destroy,
)
close_button.grid(row=0, column=3, padx=5, pady=8)
self._attach_tooltip(close_button, "picking.sequence.close")
def _attach_tooltip(self, widget: tk.Misc, key: str) -> None:
"""Attach a localized tooltip when the catalog contains one."""
value = tooltip_text(key, catalog=self._tooltip_catalog) if key else ""
if value:
WidgetToolTip(widget, value)
def _build_list_panel(self, column: int, title: str):
"""Create one titled, scrollable group list."""
panel = ctk.CTkFrame(self)
panel.grid(row=2, column=column, padx=(16 if column == 0 else 0, 16 if column == 2 else 0), pady=4, sticky="nsew")
panel.grid_columnconfigure(0, weight=1)
panel.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(panel, text=title, font=("Segoe UI", 12, "bold")).grid(
row=0, column=0, padx=10, pady=(10, 6), sticky="w"
)
content = ctk.CTkScrollableFrame(panel, fg_color=theme_color(self._theme, "list_fg_color", "#f8fafc"))
content.grid(row=1, column=0, padx=8, pady=(0, 8), sticky="nsew")
content.grid_columnconfigure(0, weight=1)
return panel, content
def _show_and_load(self) -> None:
"""Center the ready window and start its asynchronous load."""
try:
self.update_idletasks()
parent = self.master
x = max(0, parent.winfo_rootx() + (parent.winfo_width() - self.winfo_width()) // 2)
y = max(0, parent.winfo_rooty() + (parent.winfo_height() - self.winfo_height()) // 2)
self.geometry(f"+{x}+{y}")
except Exception:
pass
self.deiconify()
self.lift()
self.focus_force()
self._load()
def _load(self) -> None:
"""Load available groups and the optional saved sequence."""
async def _job():
return await self.db_client.query_json(SQL_LOAD_GROUPS, {"documento": self.documento})
def _ok(result):
rows = _rows_to_dicts(result)
configured: list[tuple[int, PickupGroup]] = []
available: list[PickupGroup] = []
for row in rows:
group = PickupGroup(
key=str(row.get("GruppoPrelievoChiave") or "").strip(),
label=str(row.get("GruppoPrelievo") or "").strip(),
default_order=int(row.get("OrdineGruppoDefault") or 0),
)
configured_order = row.get("OrdineGruppoConfigurato")
if configured_order is None:
available.append(group)
else:
configured.append((int(configured_order), group))
self.available = sorted(available, key=lambda item: (item.default_order, item.label))
self.selected = [item for _order, item in sorted(configured, key=lambda pair: pair[0])]
self._render_lists()
def _error(exc):
messagebox.showerror(
self._t("picking.sequence.title", "Sequenza di prelievo"),
self._t("picking.sequence.msg.load_error", "Errore durante il caricamento:\n{error}", error=exc),
parent=self,
)
self.runner.run(
_job(),
on_success=_ok,
on_error=_error,
busy=self.busy,
message=self._t("picking.sequence.busy.load", "Carico gli scaffali..."),
)
def _render_lists(self) -> None:
"""Redraw list buttons after a move or a database load."""
for host in (self.available_list, self.selected_list):
for child in host.winfo_children():
child.destroy()
for row, group in enumerate(self.available):
self._render_group_button(self.available_list, group, "available", row)
for row, group in enumerate(self.selected):
self._render_group_button(self.selected_list, group, "selected", row)
def _render_group_button(self, host, group: PickupGroup, side: str, row: int) -> None:
"""Render one selectable and draggable group button."""
is_selected = self._selected_key == group.key and self._selected_side == side
prefix = f"{row + 1}. " if side == "selected" else ""
button = ctk.CTkButton(
host,
text=f"{prefix}{group.label}",
anchor="w",
height=34,
fg_color="#1976d2" if is_selected else theme_color(self._theme, "item_fg_color", "#2ebf74"),
command=lambda g=group, s=side: self._select(g, s),
)
button.grid(row=row, column=0, padx=4, pady=4, sticky="ew")
button.bind("<Double-Button-1>", lambda _event, g=group, s=side: self._toggle_group(g, s), add="+")
drag_widgets = [button]
for attr in ("_canvas", "_text_label", "_image_label"):
widget = getattr(button, attr, None)
if widget is not None:
drag_widgets.append(widget)
for widget in drag_widgets:
widget.bind("<ButtonPress-1>", lambda _event, g=group, s=side: self._start_drag(g, s), add="+")
widget.bind("<ButtonRelease-1>", self._finish_drag, add="+")
self._attach_tooltip(button, "picking.sequence.item")
def _select(self, group: PickupGroup, side: str) -> None:
"""Select one group for the explicit move controls."""
self._selected_key = group.key
self._selected_side = side
self._render_lists()
def _toggle_group(self, group: PickupGroup, side: str) -> None:
"""Move a double-clicked group to the opposite side."""
self._selected_key = group.key
self._selected_side = side
self._add_selected() if side == "available" else self._remove_selected()
def _selected_group(self) -> PickupGroup | None:
"""Return the group selected in the active side."""
source = self.available if self._selected_side == "available" else self.selected
return next((item for item in source if item.key == self._selected_key), None)
def _add_selected(self) -> None:
"""Append the selected available group to the custom sequence."""
if self._selected_side != "available":
return
group = self._selected_group()
if group is None:
return
self.available = [item for item in self.available if item.key != group.key]
self.selected.append(group)
self._selected_side = "selected"
self._render_lists()
def _remove_selected(self) -> None:
"""Return the selected configured group to the available list."""
if self._selected_side != "selected":
return
group = self._selected_group()
if group is None:
return
self.selected = [item for item in self.selected if item.key != group.key]
self.available.append(group)
self.available.sort(key=lambda item: (item.default_order, item.label))
self._selected_side = "available"
self._render_lists()
def _move_selected(self, offset: int) -> None:
"""Move one configured group up or down."""
if self._selected_side != "selected":
return
index = next((i for i, item in enumerate(self.selected) if item.key == self._selected_key), -1)
target = index + int(offset)
if index < 0 or target < 0 or target >= len(self.selected):
return
self.selected[index], self.selected[target] = self.selected[target], self.selected[index]
self._render_lists()
def _start_drag(self, group: PickupGroup, side: str) -> None:
"""Remember the item being dragged."""
self._drag_group = group
self._drag_side = side
@staticmethod
def _is_descendant(widget: tk.Misc | None, ancestor: tk.Misc) -> bool:
"""Return whether a widget belongs to one list panel."""
current = widget
while current is not None:
if current == ancestor:
return True
current = getattr(current, "master", None)
return False
def _finish_drag(self, event) -> None:
"""Drop the dragged group into a list and place it near the pointer."""
group = self._drag_group
if group is None:
return
self._drag_group = None
try:
target_widget = self.winfo_containing(event.x_root, event.y_root)
except Exception:
return
if self._is_descendant(target_widget, self.selected_panel):
self.available = [item for item in self.available if item.key != group.key]
self.selected = [item for item in self.selected if item.key != group.key]
target_y = event.y_root - self.selected_list.winfo_rooty()
row_height = 42
target_index = max(0, min(len(self.selected), target_y // row_height))
self.selected.insert(target_index, group)
self._selected_side = "selected"
elif self._is_descendant(target_widget, self.available_panel):
self.selected = [item for item in self.selected if item.key != group.key]
if not any(item.key == group.key for item in self.available):
self.available.append(group)
self.available.sort(key=lambda item: (item.default_order, item.label))
self._selected_side = "available"
else:
return
self._selected_key = group.key
self._render_lists()
def _save_sql(self) -> tuple[str, dict[str, Any]]:
"""Build one atomic soft-delete/insert batch using bound parameters."""
params: dict[str, Any] = {
"documento": self.documento,
"utente": self.session.login if self.session else "SYSTEM",
}
values: list[str] = []
for index, group in enumerate(self.selected):
params[f"key_{index}"] = group.key
params[f"label_{index}"] = group.label
params[f"order_{index}"] = index
values.append(
f"(:documento, :key_{index}, :label_{index}, :order_{index}, :utente, SYSDATETIME(), :utente, SYSDATETIME())"
)
insert = ""
if values:
insert = """
INSERT INTO dbo.py_PickingListPrelievoSequenza (
Documento, GruppoChiave, GruppoEtichetta, Ordine,
InsUtente, InsDataOra, ModUtente, ModDataOra
)
VALUES
""" + ",\n".join(values) + ";"
sql = f"""
SET NOCOUNT ON;
SET XACT_ABORT ON;
UPDATE dbo.py_PickingListPrelievoSequenza
SET DelUtente = :utente,
DelDataOra = SYSDATETIME(),
ModUtente = :utente,
ModDataOra = SYSDATETIME()
WHERE Documento = :documento
AND DelDataOra IS NULL;
{insert}
SELECT CAST({len(values)} AS int) AS ElementiSalvati;
"""
return sql, params
def _save(self) -> None:
"""Persist the configured prefix atomically."""
sql, params = self._save_sql()
async def _job():
return await self.db_client.query_json(sql, params, commit=True)
self.runner.run(
_job(),
on_success=lambda _result: self._save_completed("saved"),
on_error=self._save_error,
busy=self.busy,
message=self._t("picking.sequence.busy.save", "Salvo la sequenza..."),
)
def _reset_default(self) -> None:
"""Delete the custom sequence so the shared default becomes active."""
async def _job():
return await self.db_client.query_json(
SQL_RESET_SEQUENCE,
{
"documento": self.documento,
"utente": self.session.login if self.session else "SYSTEM",
},
commit=True,
)
self.runner.run(
_job(),
on_success=lambda _result: self._save_completed("reset"),
on_error=self._save_error,
busy=self.busy,
message=self._t("picking.sequence.busy.save", "Salvo la sequenza..."),
)
def _save_completed(self, mode: str) -> None:
"""Refresh the editor and notify the parent after a successful save."""
message_key = "picking.sequence.msg.reset" if mode == "reset" else "picking.sequence.msg.saved"
default = (
"Sequenza personalizzata rimossa. E' attivo l'ordine predefinito."
if mode == "reset"
else "Sequenza salvata."
)
messagebox.showinfo(self._t("picking.sequence.title", "Sequenza di prelievo"), self._t(message_key, default), parent=self)
if self.on_saved:
self.on_saved()
self._selected_key = None
self._selected_side = None
self._load()
def _save_error(self, exc: BaseException) -> None:
"""Show a localized save error."""
messagebox.showerror(
self._t("picking.sequence.title", "Sequenza di prelievo"),
self._t("picking.sequence.msg.save_error", "Errore durante il salvataggio:\n{error}", error=exc),
parent=self,
)
def open_pickinglist_sequence_window(
parent: tk.Misc,
db_client,
*,
documento: str,
session: UserSession | None,
on_saved: Callable[[], None] | None = None,
) -> PickingListSequenceWindow:
"""Open at most one sequence editor for the picking-list window."""
key = "_pickinglist_sequence_window_singleton"
existing = getattr(parent, key, None)
if existing is not None:
try:
if existing.winfo_exists() and existing.documento == str(documento).strip():
existing.on_saved = on_saved
existing.deiconify()
existing.lift()
existing.focus_force()
return existing
if existing.winfo_exists():
existing.destroy()
except Exception:
pass
window = PickingListSequenceWindow(
parent,
db_client,
documento=documento,
session=session,
on_saved=on_saved,
)
setattr(parent, key, window)
def _clear_reference() -> None:
try:
if getattr(parent, key, None) is window:
setattr(parent, key, None)
finally:
window.destroy()
window.protocol("WM_DELETE_WINDOW", _clear_reference)
return window

View File

@@ -303,6 +303,23 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
rc = int(rows[0].get("RC") or 0)
except Exception:
rc = 0
if rc == 0 and azione == "P":
resolve_sql = """
UPDATE dbo.py_BarcodePickingListSkip
SET Risolto = 1
WHERE Documento COLLATE Latin1_General_CI_AS =
CAST(:Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND Risolto = 0;
"""
_log_sql("resolve_skipped_pallets_on_prenota", resolve_sql, {"Documento": Documento})
if hasattr(db, "exec"):
await db.exec(resolve_sql, {"Documento": Documento}, commit=True)
else:
await db.query_json(
resolve_sql + "\nSELECT 0 AS RC;",
{"Documento": Documento},
commit=True,
)
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Stored procedure completata documento={Documento} azione={azione} rc={rc}")
return SPResult(rc=rc, message="", id_result=None)
except Exception as exc:

View File

@@ -0,0 +1,76 @@
/*
Rollback: intervallo giorni selezionabile in Gestione Picking List Python
Effetto:
- ripristina le viste Python precedenti, appoggiate a dbo.XMag_ViewPackingList;
- il filtro effettivo torna quindi a quello legacy fisso a 10 giorni.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
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 AS legacy
LEFT JOIN dbo.PyPickingListReservation AS 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

View File

@@ -0,0 +1,19 @@
/*
Rollback della sequenza personalizzata per picking list.
Attenzione:
- elimina le configurazioni di sequenza salvate nel ramo Python;
- non modifica gli oggetti legacy C#;
- dopo il rollback occorre usare una versione Python precedente alla feature.
*/
IF OBJECT_ID(N'dbo.py_ViewPackingListPrelievo', N'V') IS NOT NULL
DROP VIEW dbo.py_ViewPackingListPrelievo;
GO
IF OBJECT_ID(N'dbo.py_PickingListPrelievoSequenza', N'U') IS NOT NULL
DROP TABLE dbo.py_PickingListPrelievoSequenza;
GO
PRINT 'Rollback sequenza prelievo Python completato.';
GO

View File

@@ -0,0 +1,439 @@
# Prelievo guidato di due picking list sul terminalino
Data: 02/10/2026. Revisione: 0.3.
**Stato: specifica da rileggere e concordare prima dell'implementazione.**
La funzione di prelievo abbinato descritta qui non e' ancora implementata.
I punti indicati come aperti o proposti non sono decisioni gia' approvate.
## 1. Obiettivo operativo
Consentire al responsabile di prenotare una oppure due picking list dal
programma desktop. Quando sono due, il terminalino deve guidare un unico
percorso di prelievo che considera le UDC residue di entrambe.
L'operatore deve percorrere il magazzino seguendo l'ordine previsto, senza
ritornare alle prime colonne perche' una lista non conteneva UDC in quelle
posizioni. Il sistema sceglie di volta in volta il documento della prossima
UDC; l'operatore riconosce il cambio dal numero del documento e dal colore.
Il prelievo abbinato riguarda un operatore che lavora due documenti nello
stesso giro. La gestione di piu' operatori contemporanei richiede ulteriori
decisioni e non e' definita da questa specifica.
## 2. Termini usati
| Termine | Significato |
|---|---|
| Lista 1 / F1 | Prima lista assegnata alla coppia, con identita' stabile |
| Lista 2 / F2 | Seconda lista assegnata alla coppia, con identita' stabile |
| Lista proposta | Documento della prossima UDC indicata dal sistema |
| Colonna o campata | Colonna di uno specifico scaffale e magazzino |
| Cella | Singola posizione dentro la colonna, a un determinato livello |
| Percorso comune | Ordine delle posizioni applicato alle UDC di entrambe le liste |
| UDC residua | UDC ancora da prelevare, non gia' nella locazione spedita |
Area 5 e Area 6 sono i nomi operativi dei magazzini MDE5 e MDE6.
Non corrispondono necessariamente agli ID 5 e 6 della tabella legacy Aree.
La colonna 1 dello scaffale A e la colonna 1 dello scaffale B sono distinte.
## 3. Situazione attuale e difetto da non reintrodurre
La prenotazione Python attuale prevede un unico documento. Storicamente F1
interroga la priorita' prenotata e F2 quella non prenotata. Quest'ultima puo'
contenere piu' documenti, quindi non identifica di per se' una seconda lista.
E' stata introdotta nel codice locale una correzione che fissa il documento
del ciclo per ciascun tasto. Il passaggio F2 -> F1 -> F2 conserva il documento
precedentemente avviato con F2. Il dettaglio e' in
[fix_barcode_documento_fisso_f1_f2.md](fix_barcode_documento_fisso_f1_f2.md).
La nuova funzione estende il modello: entrambi i documenti devono essere
esplicitamente assegnati alla coppia. Il cambio automatico sara' ammesso
soltanto fra questi due documenti e per una ragione di percorso.
Nessuna terza lista deve subentrare silenziosamente.
## 4. Selezione e prenotazione sul desktop
### R01. Una o due liste
Gestione Picking List deve permettere di prenotare al massimo due liste per
il prelievo abbinato. Deve restare utilizzabile anche con una sola lista.
Il semplice clic per consultare il dettaglio non deve modificare implicitamente
una prenotazione: selezione per consultazione e assegnazione al giro devono
avere un comportamento chiaramente distinguibile.
### R02. Identita' della coppia
Il desktop deve rendere riconoscibile quale documento e' Lista 1/F1 e quale
e' Lista 2/F2. L'assegnazione non dipende dall'ordinamento momentaneo della
griglia o dalla posizione della prossima UDC.
Il gesto per assegnare i ruoli e' ancora da definire: ad esempio due slot
espliciti, oppure prima e seconda scelta rese chiaramente visibili.
### R02a. Le due liste devono appartenere alla stessa area
Il prelievo abbinato e' consentito esclusivamente fra due liste della stessa
area operativa: entrambe Area 5/MDE5 oppure entrambe Area 6/MDE6.
Non e' consentito abbinare una lista dell'Area 5 a una dell'Area 6.
Il sistema deve rifiutare l'abbinamento con un messaggio comprensibile,
senza salvare una coppia parziale o alterare le prenotazioni preesistenti.
Il vincolo deve essere verificato anche lato servizio/database, non soltanto
mediante la disponibilita' dei pulsanti nell'interfaccia desktop.
Prima dell'implementazione va definita la fonte autorevole dell'area della
lista, specialmente se contiene UDC non scaffalate o senza giacenza WMS.
La cella virtuale condivisa 1000 non dimostra da sola l'appartenenza all'area.
Non dedurre il magazzino dal numero documento o dagli ID legacy delle Aree.
Il trattamento delle liste internamente miste o con area indeterminata resta
da concordare; non considerarle implicitamente compatibili con entrambe.
### R03. Un'unica sequenza personalizzata
Con due liste selezionate, il comando Sequenza di prelievo riguarda entrambe.
L'editor deve presentare l'unione degli scaffali/gruppi presenti nelle due liste,
includendo quelli presenti soltanto in una di esse e il gruppo Non scaff.
Non si devono eseguire due ordinamenti personalizzati incompatibili nello
stesso giro. La sequenza della coppia deve costituire l'unico riferimento.
La scelta fra eventuali personalizzazioni gia' salvate sulle singole liste
resta aperta. Non e' ammessa una fusione silenziosa che produca un ordine
diverso da quello mostrato al responsabile.
## 5. Percorsi delle aree: conservare il comportamento esistente
### R04. Area 6 / MDE6
Conservare il percorso attuale: scaffali nell'ordine previsto, colonne in
ordine crescente all'interno dello scaffale e livelli dal pavimento verso
l'alto. Il documento [ordinamento.md](ordinamento.md) riporta per i dati
analizzati la sequenza interna a -> b -> c -> d -> e.
La modifica non deve rinumerare Celle.Ordinamento ne' inventare nuovi percorsi
per celle eventualmente configurate con eccezioni.
### R05. Area 5 / MDE5
Conservare l'ordinamento esistente delle celle. Nel default e' il percorso
storico rappresentato da Celle.Ordinamento, con serpentina fra scaffali.
Esempio documentato del blocco 1A/2B:
```text
1A.1 -> 2B.1 -> 2B.2 -> 1A.2 -> 1A.3 -> 2B.3 -> ...
```
All'interno delle campate analizzate l'ordine e' e -> d -> c -> b -> a,
quindi dall'alto verso il basso. E' confermato che anche la prima UDC
proposta deve rispettare questo verso: non partire dal pavimento in area 5.
Non sostituire questo percorso con un generico ordinamento numerico
scaffale/colonna/livello dell'area 6.
### R06. Effetto della personalizzazione
La personalizzazione mantiene il significato attuale: cambia la precedenza
dei gruppi/scaffali, mentre dentro ogni gruppo si conserva l'ordine delle celle.
Con due liste, la stessa scelta si applica all'intero contenuto della coppia.
Per esempio, porre tutto 1A prima di tutto 2B elimina intenzionalmente
l'alternanza fra quei due scaffali, come avviene gia' nella personalizzazione
attuale. Non sarebbe corretto promettere contemporaneamente tale precedenza
e il mantenimento della serpentina globale.
I gruppi omessi da una personalizzazione parziale devono essere accodati con
il criterio attuale, senza perdere le relative UDC.
## 6. Scelta della prossima UDC
### R07. Confronto continuo delle due liste
A ogni passo confrontare le UDC ancora lavorabili di entrambe le liste e
scegliere quella che viene prima nel percorso comune.
Questa regola vale all'avvio, fra colonne consecutive, dopo un movimento e
quando uno dei documenti non ha UDC nella colonna corrente.
Non si deve usare F1 come guida unica, cercando F2 soltanto nelle colonne
incontrate da F1: questo lascerebbe indietro le colonne presenti solo in F2.
### R08. Il percorso prevale sull'identita' della lista
Non esiste una precedenza fissa F1-prima-di-F2 nella stessa colonna.
Se nell'area 6 i livelli dal basso verso l'alto appartengono a F2, F1 e F2,
il prelievo deve seguire F2 -> F1 -> F2.
La precedente ipotesi di esaurire tutte le UDC F1 della colonna e solo dopo
quelle F2 e' superata: la scelta si compie sulla posizione a ogni passo.
### R09. Nessuna UDC persa e nessun documento estraneo
Il contenuto lavorato e' l'unione delle UDC residue delle due liste.
Le UDC su piu' righe lotto devono essere considerate una sola volta come
unita' fisica di prelievo. Ogni proposta deve mantenere l'identita' del
documento a cui appartiene.
Non introdurre la scelta automatica di una terza lista, neanche al termine
di una delle due. La gestione delle UDC saltate e' trattata al punto 10.
## 7. Avvio neutro e tasti F1/F2
### R10. Avvio scelto dal sistema
Quando il terminalino mostra Pronto per la coppia, il sistema deve avere gia'
individuato la prima proposta. L'operatore non decide arbitrariamente da quale
documento partire.
Se la prima UDC appartiene alla Lista 2, F2 e' abilitato e F1 disabilitato;
se appartiene alla Lista 1, vale il contrario. Il blocco deve valere sia per
i pulsanti sullo schermo sia per i tasti fisici.
### R11. Guida durante il giro
Nel prelievo abbinato deve essere selezionabile soltanto la lista proposta
dal percorso. Il cambio automatico verso l'altro documento non richiede una
nuova scelta dell'operatore.
La precedente modalita' manuale F1/F2 e il nuovo giro guidato non devono
essere confusi: nel giro guidato i due tasti identificano documenti stabili,
ma non autorizzano a scavalcare il percorso stabilito.
### Chiarimento confermato: la prima cella dipende dal percorso dell'area
Si parte dalla prima UDC secondo il percorso effettivo comune, rispettando
anche all'avvio l'ordine dell'area e l'eventuale personalizzazione.
La precedente espressione "cella piu' bassa" non e' una regola universale:
- Area 5: nella campata prevista dal percorso, prima la cella occupata
richiesta piu' in alto, poi quelle inferiori (e -> d -> c -> b -> a).
- Area 6: nella colonna prevista dal percorso, prima la cella occupata
richiesta piu' vicina al pavimento, poi quelle superiori (a -> b -> c -> d -> e).
Il confronto comprende entrambe le liste: in area 5, se F1 ha una UDC al
livello a e F2 ne ha una al livello d della stessa campata, si propone prima
F2/d e poi F1/a. Non conta quale documento occupa il ruolo di Lista 1.
Restano la serpentina del default area 5, l'ordine comune eventualmente
personalizzato e la precedenza delle Non scaff. descritta in R14.
## 8. Identificazione visiva sul terminalino
### R12. Colori stabili
| Documento assegnato | Colore di contesto |
|---|---|
| Lista 1 / F1 | Rosellino |
| Lista 2 / F2 | Giallino |
Il colore segue il documento e non la sua posizione nel giro: se inizia F2,
lo sfondo iniziale e' giallino. La stessa lista conserva il proprio colore
nei successivi passaggi.
Il numero della picking list e il ruolo Lista 1/Lista 2 devono essere leggibili
insieme all'UDC e alla cella proposta: il colore non deve essere l'unica
informazione per capire quale documento si sta lavorando.
La barra esito mantiene il significato funzionale dei propri colori
(successo, errore, attesa), distinguibile dallo sfondo di contesto.
Il layout deve restare utilizzabile sul terminalino 240 x 320.
## 9. Ciclo di scansione e conferma
### R13. Sequenza ordinaria
1. Il sistema propone UDC, cella sorgente e documento; applica il colore relativo.
2. L'operatore legge il barcode della UDC proposta.
3. Rimangono i controlli attuali di corrispondenza e avvio automatico della lettura.
4. Il movimento di picking porta la UDC in spedita, 7G.1.1, destinazione 9000000.
5. Il terminalino mostra l'esito positivo per i tre secondi previsti.
6. Il sistema propone la prossima UDC della coppia secondo il percorso comune.
7. Se cambia documento, aggiorna insieme colore, riferimento lista e tasti abilitati.
L'esito dei tre secondi appartiene al movimento appena concluso, non al
documento che verra' proposto dopo. Non deve comparire un contesto misto.
Un cambio programma della UDC visualizzata non deve simulare una scansione
dell'operatore: va preservata la protezione contro gli auto-submit accidentali.
Un errore o un esito incerto non deve avanzare il giro come se il movimento
fosse riuscito; restano necessarie le verifiche e il recupero gia' esistenti.
## 10. Non scaffalate, salti, pausa e fine giro
### R14. Non scaffalate
Conservare il comportamento corrente: Non scaff. precede gli scaffali nel
default, salvo diversa posizione assegnata nella sequenza personalizzata.
La regola deve considerare le UDC di entrambe le liste.
La cella virtuale e' condivisibile e non ha un livello fisico da confrontare.
Il criterio di parita' fra UDC non scaffalate delle due liste resta da
concordare; proposta: ordine stabile per ruolo lista e codice UDC.
### R15. UDC saltate: approfondimento rinviato
Il comando Salta UDC esiste gia'. L'utente ha chiesto di analizzarne in seguito
il comportamento nel nuovo giro abbinato, con l'intenzione di conservare
quanto possibile il funzionamento attuale.
Restano da decidere il termine della singola lista con soli skip, la sua
sprenotazione, la prosecuzione dell'altra lista e le modalita' di ripresa.
Saltare una UDC non deve comunque equivalere a spedirla o cancellarla dalla lista.
### Pausa e ripresa: comportamento da completare
Oggi Pausa PL consente movimenti liberi e ESC riporta al picking.
Nel nuovo giro occorre conservare identita' dei due documenti e percorso.
Va deciso se al rientro mantenere la UDC gia' proposta o ricalcolare la
prossima posizione quando nel frattempo cambiano le ubicazioni.
I movimenti liberi devono mantenere le proprie regole di carico e scarico.
### Fine di una lista: proposta ancora da confermare
Quando una lista non ha piu' UDC residue, proseguire con l'altra mantenendone
ruolo e colore originali. Non promuovere automaticamente F2 a F1 e non
aggiungere un terzo documento. Distinguere esaurimento reale e soli skip.
## 11. Esempi di percorso atteso
### Esempio A: colonne presenti soltanto in F2
Area 6, stesso scaffale, default: colonne crescenti e livelli dal basso
verso l'alto.
| Passo | Posizione | Lista proposta |
|---|---|---|
| 1 | Colonna 1, livello a | F2 |
| 2 | Colonna 2, livello a | F2 |
| 3 | Colonna 3, livello a | F1 |
| 4 | Colonna 3, livello b | F2 |
| 5 | Colonna 4, livello a | F2 |
| 6 | Colonna 5, livello a | F2 |
| 7 | Colonna 6, livello a | F1 |
F1 ha UDC solo ogni tre colonne. Le colonne intermedie di F2 vengono visitate
al momento corretto, senza attendere il termine di F1.
### Esempio B: alternanza verticale nella stessa colonna
| Passo | Posizione | Lista proposta |
|---|---|---|
| 1 | Colonna 3, livello a | F2 |
| 2 | Colonna 3, livello b | F1 |
| 3 | Colonna 3, livello d | F2 |
| 4 | Colonna 4, livello a | F1 |
Il passaggio F2 -> F1 -> F2 e' richiesto dal percorso; i documenti associati
ai tasti restano gli stessi.
### Esempio C: area 5 con default storico
| Ordinamento cella | Ubicazione | Lista proposta |
|---|---|---|
| 1001 | 1A.1.e | F2 |
| 1003 | 1A.1.c | F1 |
| 1007 | 2B.1.e | F1 |
| 1010 | 2B.1.b | F2 |
| 1013 | 2B.2.e | F2 |
| 1019 | 1A.2.e | F1 |
Si uniscono le due liste rispettando i valori del percorso storico; non si
raggruppano automaticamente tutte le colonne 1A prima di quelle 2B.
### Esempio D: ordine personalizzato della coppia
Il responsabile imposta Non scaff. -> 4P -> 1M. Il terminalino lavora tutte
le UDC richieste dalle due liste in Non scaff., poi quelle in 4P, poi quelle
in 1M, applicando dentro ciascun gruppo l'ordine delle celle previsto.
Gli eventuali gruppi non configurati vengono accodati, mai esclusi.
## 12. Garanzie e decisioni tecniche da tradurre in progetto
La prenotazione attuale a documento unico deve evolvere per rappresentare
esplicitamente due documenti con ruoli stabili e una sequenza comune.
Il formato delle nuove tabelle e le modifiche alle stored non sono ancora
definiti da questo documento.
La nuova prenotazione dovra' essere salvata in modo atomico, evitando che il
terminalino legga meta' coppia o una sequenza parzialmente aggiornata.
Le modifiche SQL dovranno avere script di installazione e di ripristino e
mantenere la separazione degli oggetti Python dagli oggetti legacy C#.
Sono ancora da specificare persistenza della ripresa dopo riavvio, modifiche
alla coppia durante un giro, modifiche della sequenza a giro iniziato e
comportamento con piu' terminalini. Non assumere che la memoria locale del
singolo client sia una soluzione completa per questi casi.
## 13. Dati locali predisposti per il collaudo
Il 01/10/2026 sono state riattivate nella copia locale quattro testate SAM
cambiando solo D in P, senza movimentare UDC. Con filtro 30 giorni:
| Lista | UDC residue | In celle fisiche | Non scaffalate nella vista |
|---|---:|---:|---:|
| 233 | 28 | 3 | 25 |
| 234 | 28 | 2 | 26 |
| 240 | 11 | 0 | 11 |
| 241 | 28 | 0 | 28 |
| 244, gia' attiva | 26 | 24 | 2 |
Le celle fisiche candidate sono risultate prive di altre UDC, anche esterne
alle cinque liste. Le UDC residue non sono condivise fra questi documenti.
La condivisione della locazione virtuale Non scaff. e' invece prevista.
Esempi utili in area 5: 234 in 1A.7.a e 244 in 1A.7.d; 233 in 1A.18.a e
244 in 1A.18.c. Sono celle distinte della stessa colonna.
La voce Non scaff. puo' derivare anche dal fallback di una UDC senza giacenza
WMS: non prova da sola l'esistenza di un versamento registrato nella cella 1000.
Questi dati non bastano a verificare tutte le alternanze verticali e le colonne
intermedie in area 6: occorrera' predisporre ulteriori casi controllati prima
del collaudo completo. Non creare documenti ERP fittizi o spostare UDC online
per ottenere tali scenari.
Preparazione e ripristino sono descritti in [sql_tests/README.md](sql_tests/README.md).
Il ripristino delle testate non annulla le movimentazioni fatte durante i test.
## 14. Criteri di accettazione
| Test | Risultato atteso |
|---|---|
| Una sola lista | Ordinamento attuale conservato |
| Due liste entrambe Area 5 oppure entrambe Area 6 | Abbinamento consentito, applicando il percorso della relativa area |
| Una lista Area 5 e una Area 6 | Abbinamento rifiutato senza modificare le prenotazioni esistenti |
| F2 precede F1 nel percorso | All'avvio solo F2 utilizzabile |
| F1 presente ogni tre colonne | Nessuna colonna intermedia F2 omessa |
| Livelli alternati F2/F1/F2 | Tre proposte in ordine fisico, con documenti corretti |
| Default area 5 | Serpentina attuale e livelli dall'alto verso il basso, anche all'avvio |
| Area 5: F1 in 1A.7.a, F2 in 1A.7.d | Prima F2/d, poi F1/a, senza precedenza fissa a F1 |
| Default area 6 | Percorso corrente per scaffale, colonne crescenti e livelli dal basso verso l'alto |
| Personalizzazione della coppia | Stesso ordine applicato a entrambe |
| Gruppo presente solo in F2 | Visibile nell'editor e incluso nel percorso |
| Gruppo omesso dalla personalizzazione | Accodato secondo il comportamento attuale |
| Tasto fisico della lista disabilitata | Nessun cambio documento e nessun movimento |
| Cambio automatico | Numero lista, colore e UDC aggiornati coerentemente |
| UDC con due lotti | Un solo prelievo fisico |
| Terza lista disponibile | Mai inserita automaticamente nel giro |
| Aggiornamento programma del campo UDC | Non interpretato come nuova lettura |
| Movimento fallito o esito incerto | Nessun avanzamento ingiustificato |
I casi relativi a skip, fine parziale, pausa, riavvio e modifiche concorrenti
andranno completati dopo la decisione delle relative regole.
## 15. Decisioni aperte per la revisione
L'avvio e' chiarito: si rispetta il percorso dell'area, dall'alto in area 5
e dal basso in area 6. Restano aperti i punti seguenti.
1. Assegnazione esplicita dei ruoli Lista 1 e Lista 2 sul desktop.
2. Gestione delle sequenze preesistenti quando si compone una nuova coppia.
3. Ordine fra UDC non scaffalate o con identico valore di percorso.
4. Salti, esaurimento con residui e riprenotazione della coppia o di una lista.
5. Ripresa dalla pausa e da un riavvio, con ubicazioni eventualmente cambiate.
6. Modifiche alla coppia/sequenza mentre il prelievo e' in corso.
7. Fonte autorevole dell'area di una lista e gestione di liste internamente
miste o con area indeterminata; le coppie Area 5/Area 6 sono vietate (R02a).
8. Condotta su dati anomali: stessa UDC in due liste o cella fisica con piu' UDC.
Il dataset di collaudo evita questi casi; la regola generale resta da definire.
Le risposte possono essere annotate direttamente in questa sezione prima di
passare al progetto tecnico e alle modifiche applicative.

View File

@@ -0,0 +1,74 @@
# Sequenza di prelievo per picking list
## Obiettivo
Consentire al responsabile di definire, per una singola picking list, l'ordine
dei gruppi di ubicazioni che il barcode deve seguire. Il barcode continua a
presentare una sola UDC alla volta e non espone la configurazione all'operatore.
## Ordine predefinito
In assenza di una configurazione personalizzata il sistema propone:
1. UDC `Non scaff.` nella cella convenzionale `1000` / `5E.1.1`.
2. UDC negli scaffali secondo il valore legacy `Celle.Ordinamento`.
L'ordinamento globale delle celle non viene modificato.
## Configurazione per documento
La finestra `Sequenza di prelievo` si apre dalla picking list selezionata in
`Gestione Picking List`. I gruppi disponibili sono `Non scaff.` e le corsie
effettivamente presenti nel contenuto residuo del documento.
L'utente puo' trascinare i gruppi nell'area destra, usare il doppio clic oppure
i pulsanti `Aggiungi`, `Rimuovi`, `Su` e `Giu'`. L'ordine dall'alto verso il
basso definisce la precedenza dei gruppi configurati.
Una sequenza puo' essere parziale: gli eventuali gruppi non inseriti vengono
accodati secondo l'ordine predefinito. In questo modo una configurazione non
puo' rendere invisibili UDC o scaffali aggiunti successivamente alla picking
list.
`Ripristina default` elimina la configurazione del documento e riattiva
immediatamente l'ordine standard.
## Persistenza e concorrenza
La tabella Python-only `dbo.py_PickingListPrelievoSequenza` memorizza una riga
per documento e gruppo, con ordine e campi di audit. Il salvataggio marca come
cancellata la configurazione precedente e inserisce quella nuova in una singola
transazione con `XACT_ABORT ON`, conservando quindi anche lo storico delle
variazioni.
Due picking list possono quindi avere sequenze diverse senza modificare le
celle, le altre liste o gli oggetti usati dal programma C#.
## Consumo dal barcode
La vista Python-only `dbo.py_ViewPackingListPrelievo` deriva dalla vista
residuale, esclude quindi le UDC gia' in `7G.1.1` e calcola
`OrdinePrelievoGruppo`. Sia la griglia di dettaglio sia il repository barcode
ordinano per:
1. `OrdinePrelievoGruppo`;
2. `Ordinamento` legacy della cella;
3. codice pallet come criterio stabile finale.
Pausa, ripresa, salto UDC, F1/F2 e prenotazione non vengono modificati. Quando
una UDC viene movimentata, la richiesta successiva rilegge la vista e usa la
sequenza corrente sul contenuto residuo.
## Installazione e rollback
Per un aggiornamento completo del database online usare
`apply_online_python_wms_full_patch.sql`.
Per applicare solo questa evoluzione a un database gia' aggiornato:
1. eseguire `apply_pickinglist_days_filter_patch.sql`;
2. eseguire `apply_pickinglist_sequence_patch.sql`.
Il rollback specifico e' `rollback_pickinglist_sequence_patch.sql`; dopo il
rollback deve essere ripristinata anche una versione applicativa precedente,
poiche' il nuovo codice interroga la vista di prelievo.

38
sql_tests/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Dati per il collaudo a due picking list, 01/10/2026
Questi script riguardano esclusivamente la copia di test su HOMESERVER.
Non includerli nella patch cumulativa per la produzione.
`riattiva_pickinglist_20261001.sql` cambia da D a P le testate SAM 233, 234,
240 e 241 dell'anno 2026. Mantiene date, righe, lotti e posizioni delle UDC.
Salva gli stati originali in Mediseawall.dbo.py_TestPickingListReactivation_20261001
e controlla le precondizioni nella stessa transazione dell'aggiornamento.
In Gestione Picking List selezionare **30 giorni** per vedere tutte le liste:
| Lista | UDC residue | In celle fisiche | Non scaffalate nella vista |
|---|---:|---:|---:|
| 233 | 28 | 3 | 25 |
| 234 | 28 | 2 | 26 |
| 240 | 11 | 0 | 11 |
| 241 | 28 | 0 | 28 |
| 244 (gia attiva) | 26 | 24 | 2 |
La 240 conserva altre 33 UDC gia spedite, escluse dalla vista operativa.
La qualifica non scaffalata deriva dalla vista: puo' comprendere UDC che non
hanno una giacenza WMS, mostrate mediante il fallback convenzionale 1000.
Nessuna cella fisica candidata risulta contenere altre UDC. La condivisione
della locazione virtuale non scaffalata e' ammessa. Le liste 233/234 e 244
contengono celle distinte nelle stesse colonne, utili per il futuro test del
cambio automatico fra documenti. Non sono stati creati movimenti di test.
`ripristina_testate_pickinglist_20261001.sql` ripristina solo gli stati SAM.
Non ripristina UDC movimentate, skip o sequenze creati durante le prove.
Per ripetere identicamente un test che modifica le giacenze occorre un backup
coerente dei due database, oppure un ripristino specifico dei dati del test.
La piccola tabella di backup degli stati non sostituisce tale backup completo.
Controllato anche il trigger SAM `StatoLottoRientrato`: il passaggio D -> P
non soddisfa la sua condizione e non modifica i lotti. Il ripristino P -> D
si ferma se esistono lotti marcati T, per evitare gli effetti di quel trigger.

View File

@@ -0,0 +1,82 @@
/* Solo copia locale di test HOMESERVER. Non distribuire come patch produzione.
Riattiva 233,234,240,241 conservando date, righe, UDC e posizioni attuali.
Salva i valori di testata per un rollback mirato; non e' un backup dei DB.
*/
USE Mediseawall;
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF CONVERT(nvarchar(128), SERVERPROPERTY('MachineName')) <> N'HOMESERVER'
THROW 51000, 'Script riservato alla copia di test su HOMESERVER.', 1;
BEGIN TRY
BEGIN TRANSACTION;
IF OBJECT_ID('dbo.py_TestPickingListReactivation_20261001', 'U') IS NOT NULL
THROW 51001, 'Preparazione gia eseguita: controllare il backup prima di ripetere.', 1;
DECLARE @Targets TABLE (ID int PRIMARY KEY, Documento int NOT NULL);
INSERT INTO @Targets VALUES (11882,233),(11883,234),(11890,240),(11891,241);
IF (SELECT COUNT(*) FROM SAMA1.dbo.BAMTES b WITH (UPDLOCK,HOLDLOCK)
JOIN @Targets t ON b.ID=t.ID AND b.NUMDOC=t.Documento
WHERE b.ANNDOC=2026 AND b.STATO='D'
AND b.DATDOC>CAST(GETDATE()-30 AS int)) <> 4
THROW 51002, 'Le quattro testate non corrispondono allo scenario atteso.', 1;
IF EXISTS (
SELECT 1 FROM dbo.py_XMag_ViewPackingListStorico h
JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella=h.Cella
WHERE h.Documento IN (233,234,240,241,244) AND h.Cella NOT IN (1000,9999)
AND LTRIM(RTRIM(g.BarcodePallet)) COLLATE Latin1_General_CI_AS <>
h.Pallet COLLATE Latin1_General_CI_AS
) THROW 51003, 'Cella fisica con altre UDC: preparazione annullata.', 1;
IF EXISTS (
SELECT Pallet FROM dbo.py_XMag_ViewPackingListStorico
WHERE Documento IN (233,234,240,241,244) AND Cella<>9999
GROUP BY Pallet HAVING COUNT(DISTINCT Documento)>1
) THROW 51004, 'Una UDC residua appartiene a piu liste candidate.', 1;
IF EXISTS (SELECT 1 FROM dbo.py_BarcodePickingListSkip
WHERE Documento IN ('233','234','240','241') AND Risolto=0)
OR EXISTS (SELECT 1 FROM dbo.py_PickingListPrelievoSequenza
WHERE Documento IN ('233','234','240','241') AND DelDataOra IS NULL)
OR EXISTS (SELECT 1 FROM dbo.PyPickingListReservation
WHERE NULLIF(LTRIM(RTRIM(Documento)),'') IS NOT NULL)
THROW 51005, 'Prenotazioni, skip o sequenze attive: verificare prima del test.', 1;
CREATE TABLE dbo.py_TestPickingListReactivation_20261001 (
BamtesID int NOT NULL PRIMARY KEY,
Anno smallint NOT NULL,
Documento int NOT NULL,
StatoOriginale char(1) NOT NULL,
DataDocumentoOriginale float NOT NULL,
ApplicataIl datetime2 NOT NULL,
RipristinataIl datetime2 NULL
);
INSERT INTO dbo.py_TestPickingListReactivation_20261001
(BamtesID,Anno,Documento,StatoOriginale,DataDocumentoOriginale,ApplicataIl)
SELECT b.ID,b.ANNDOC,b.NUMDOC,b.STATO,b.DATDOC,SYSDATETIME()
FROM SAMA1.dbo.BAMTES b JOIN @Targets t ON t.ID=b.ID;
UPDATE b SET STATO='P'
FROM SAMA1.dbo.BAMTES b JOIN @Targets t ON t.ID=b.ID;
IF @@ROWCOUNT<>4 THROW 51006, 'Numero di testate aggiornate inatteso.', 1;
IF (SELECT COUNT(DISTINCT Documento) FROM dbo.py_ViewPackingListPrelievo
WHERE Documento IN (233,234,240,241,244)
AND DataDocumento>CAST(GETDATE()-30 AS int))<>5
THROW 51007, 'Le cinque liste non risultano tutte visibili. Rollback.', 1;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
SELECT Documento,COUNT(DISTINCT Pallet) AS UdcResidue
FROM dbo.py_ViewPackingListPrelievo
WHERE Documento IN (233,234,240,241,244)
GROUP BY Documento ORDER BY Documento;

View File

@@ -0,0 +1,54 @@
/* Ripristina SOLO lo stato delle quattro testate riattivate per i test.
Non annulla movimentazioni effettuate durante il collaudo.
Eseguire sullo stesso DB locale con applicazioni ferme e liste sprenotate.
*/
USE Mediseawall;
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF CONVERT(nvarchar(128),SERVERPROPERTY('MachineName'))<>N'HOMESERVER'
THROW 51000, 'Script riservato alla copia di test su HOMESERVER.', 1;
IF OBJECT_ID('dbo.py_TestPickingListReactivation_20261001','U') IS NULL
THROW 51001, 'Backup delle testate non trovato.', 1;
BEGIN TRY
BEGIN TRANSACTION;
IF EXISTS (
SELECT 1 FROM dbo.PyPickingListReservation r
JOIN dbo.py_TestPickingListReactivation_20261001 s
ON r.Documento=CAST(s.Documento AS varchar(8))
WHERE s.RipristinataIl IS NULL
) THROW 51002, 'Sprenotare le liste di test prima del ripristino.', 1;
IF EXISTS (
SELECT 1 FROM dbo.py_TestPickingListReactivation_20261001 s
LEFT JOIN SAMA1.dbo.BAMTES b WITH (UPDLOCK,HOLDLOCK) ON b.ID=s.BamtesID
WHERE s.RipristinataIl IS NULL AND
(b.ID IS NULL OR b.ANNDOC<>s.Anno OR b.NUMDOC<>s.Documento
OR b.DATDOC<>s.DataDocumentoOriginale OR b.STATO IS NULL
OR b.STATO NOT IN ('P','D'))
) THROW 51003, 'Testate cambiate rispetto al backup: verificare manualmente.', 1;
-- Il trigger SAM StatoLottoRientrato modifica LOTSER.DESCR tornando in D.
-- In presenza di lotti marcati T fermarsi prima di attivarlo.
IF EXISTS (
SELECT 1 FROM dbo.py_TestPickingListReactivation_20261001 s
JOIN SAMA1.dbo.BAMTES b ON b.ID=s.BamtesID
JOIN SAMA1.dbo.FATRIG f ON f.IDBAM=b.ID
JOIN SAMA1.dbo.LOTTIBF lb ON lb.IDFATRIG=f.ID
JOIN SAMA1.dbo.LOTSER l ON l.ID=lb.IDLOTSER
WHERE s.RipristinataIl IS NULL AND b.STATO<>'D' AND l.DESCR='T'
) THROW 51004, 'Lotti rientrati marcati T: ripristino da valutare per il trigger SAM.', 1;
UPDATE b SET STATO=s.StatoOriginale
FROM SAMA1.dbo.BAMTES b
JOIN dbo.py_TestPickingListReactivation_20261001 s ON s.BamtesID=b.ID
WHERE s.RipristinataIl IS NULL;
UPDATE dbo.py_TestPickingListReactivation_20261001
SET RipristinataIl=SYSDATETIME() WHERE RipristinataIl IS NULL;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;

View File

@@ -93,7 +93,7 @@ SELECT
FROM meta m
LEFT JOIN agg a ON a.Documento = m.Documento
WHERE (:documento IS NULL OR CAST(m.Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
ORDER BY m.Documento DESC;
ORDER BY m.DataDocumento DESC, m.Documento DESC;
"""
# Detail query:

View File

@@ -0,0 +1,194 @@
"""Document isolation regressions; no GUI, DB connection or real movements."""
import unittest
from unittest.mock import AsyncMock, patch
from barcode_repository import BarcodeRepository, LegacyMoveResult
from barcode_service import BarcodeService
def row(doc, pallet, priority, order):
return dict(Documento=doc, Pallet=pallet, IDStato=priority,
Ordinamento=order, Cella=1101, Ubicazione="1A - 1 - a")
class PickingRepositoryFake:
def __init__(self):
# Without a document filter the sequence would be 172, 171, 172.
self.rows = [row("173", "600001", 1, 5), row("172", "600002", 0, 10),
row("171", "600003", 0, 20), row("172", "600004", 0, 30)]
self.skipped = set()
self.locations = {r["Pallet"]: r["Cella"] for r in self.rows}
self.released = []
async def fetch_next_picking(self, id_stato, documento=None):
candidates = [r for r in self.rows if r["IDStato"] == id_stato
and (documento is None or r["Documento"] == documento)
and r["Pallet"] not in self.skipped]
return min(candidates, key=lambda r: r["Ordinamento"]) if candidates else None
async def fetch_active_skipped_document(self, id_stato, documento=None):
candidates = [r for r in self.rows if r["IDStato"] == id_stato
and (documento is None or r["Documento"] == documento)
and r["Pallet"] in self.skipped]
return dict(Documento=candidates[0]["Documento"], SkippedCount=len(candidates)) if candidates else None
async def count_picking_document_remaining(self, documento):
return len({r["Pallet"] for r in self.rows if r["Documento"] == documento})
async def release_picking_document(self, *, documento, operator_id):
self.released.append(documento)
return 0
async def skip_picking_pallet(self, *, documento, pallet, **kwargs):
self.skipped.add(pallet)
async def fetch_picking_by_pallet(self, pallet, documento=None):
return next((r for r in self.rows if r["Pallet"] == pallet
and (documento is None or r["Documento"] == documento)), None)
async def fetch_trace_by_pallet(self, pallet):
return dict(Lotto="LOT", Prodotto="ART", Descrizione="Test")
async def fetch_current_location_by_pallet(self, pallet):
return dict(IDCella=self.locations[pallet])
async def fetch_open_locations_by_pallet(self, pallet):
return [await self.fetch_current_location_by_pallet(pallet)]
async def fetch_closed_picking_by_pallet(self, pallet):
return None
async def execute_legacy_move(self, *, barcode_pallet, barcode_cella, **kwargs):
target = 9999 if barcode_cella == "9000000" else 1000
self.locations[barcode_pallet] = target
if target == 9999:
self.rows = [r for r in self.rows if r["Pallet"] != barcode_pallet]
return LegacyMoveResult(0, barcode_cella, barcode_pallet, int(barcode_cella))
class DocumentBindingTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
logger = patch("barcode_service.log_runtime_event")
logger.start()
self.addCleanup(logger.stop)
self.repo = PickingRepositoryFake()
self.service = BarcodeService(self.repo, operator_id=1)
async def ship_current(self):
result = await self.service.submit(
scanned_pallet=self.service.state.expected_pallet, destination_barcode="9000000")
self.assertTrue(result.ok)
self.assertEqual(result.state.auto_advance_delay_ms, 3000)
async def test_auto_advance_never_interleaves_low_priority_documents(self):
await self.service.start_priority_queue(0)
await self.ship_current()
result = await self.service.start_priority_queue(0)
self.assertEqual((result.state.document, result.state.expected_pallet), ("172", "600004"))
async def test_f2_f1_f2_stays_bound_even_if_global_order_changes(self):
await self.service.start_priority_queue(0)
self.repo.rows[2]["Ordinamento"] = 1
for _ in range(5):
high = await self.service.start_priority_queue(1)
self.assertEqual(high.state.document, "173")
low = await self.service.start_priority_queue(0)
self.assertEqual((low.state.document, low.state.expected_pallet), ("172", "600002"))
async def test_pause_free_move_resume_and_switch_preserve_document(self):
await self.service.start_priority_queue(0)
paused = self.service.state
self.service.begin_priority_pause(0)
free = await self.service.submit(scanned_pallet="600003", destination_barcode="9001000")
self.assertTrue(free.ok)
self.service.resume_priority_state(paused)
await self.service.start_priority_queue(1)
low = await self.service.start_priority_queue(0)
self.assertEqual(low.state.document, "172")
self.assertEqual(low.state.expected_pallet, "600002")
async def test_skip_advances_only_within_active_document(self):
await self.service.start_priority_queue(0)
await self.service.skip_current_picking_pallet()
low = await self.service.start_priority_queue(0)
self.assertEqual(low.state.expected_pallet, "600004")
async def test_only_skipped_residuals_stop_even_with_other_lists_available(self):
await self.service.start_priority_queue(0)
await self.service.skip_current_picking_pallet()
await self.service.start_priority_queue(0)
await self.service.skip_current_picking_pallet()
result = await self.service.start_priority_queue(0)
self.assertEqual(result.state.mode, "manual_unload")
self.assertIn("PL 172 sospesa", result.state.status_text)
self.assertEqual(self.repo.released, ["172"])
async def test_completion_stops_before_next_document(self):
await self.service.start_priority_queue(0)
await self.ship_current()
await self.service.start_priority_queue(0)
await self.ship_current()
stopped = await self.service.start_priority_queue(0)
self.assertEqual(stopped.state.mode, "manual_unload")
self.assertIn("PL 172 terminata", stopped.state.status_text)
self.assertEqual(stopped.state.auto_advance_delay_ms, 0)
# A later explicit F2 can start another list, after the stop message.
next_list = await self.service.start_priority_queue(0)
self.assertEqual(next_list.state.document, "171")
async def test_priority_change_blocks_without_forgetting_previous_document(self):
await self.service.start_priority_queue(0)
for r in self.repo.rows:
if r["Documento"] == "172":
r["IDStato"] = 1
for _ in range(2):
stopped = await self.service.start_priority_queue(0)
self.assertIn("PL 172", stopped.state.status_text)
self.assertIn("Verifica la prenotazione", stopped.state.status_text)
for r in self.repo.rows:
if r["Documento"] == "172":
r["IDStato"] = 0
resumed = await self.service.start_priority_queue(0)
self.assertEqual(resumed.state.document, "172")
async def test_query_exception_keeps_document_for_retry(self):
await self.service.start_priority_queue(0)
with patch.object(self.repo, "fetch_next_picking", side_effect=TimeoutError):
with self.assertRaises(TimeoutError):
await self.service.start_priority_queue(0)
self.repo.rows[2]["Ordinamento"] = 1
result = await self.service.start_priority_queue(0)
self.assertEqual(result.state.document, "172")
async def test_wrong_document_from_repository_is_rejected(self):
await self.service.start_priority_queue(0)
with patch.object(self.repo, "fetch_next_picking", return_value=self.repo.rows[2]):
with self.assertRaisesRegex(RuntimeError, "Cambio inatteso documento"):
await self.service.start_priority_queue(0)
result = await self.service.start_priority_queue(0)
self.assertEqual(result.state.document, "172")
async def test_confirmation_cannot_display_another_document_for_same_pallet(self):
await self.service.start_priority_queue(0)
self.repo.rows.append(row("171", "600002", 0, 1))
self.repo.fetch_picking_by_pallet = AsyncMock(wraps=self.repo.fetch_picking_by_pallet)
state = await self.service._build_post_move_state(
barcode_pallet="600002", destination_barcode="9000000",
destination_display="7G.1.1", last_priority_state=0, auto_advance_delay_ms=3000)
self.assertEqual(state.document, "172")
self.repo.fetch_picking_by_pallet.assert_awaited_once_with("600002", documento="172")
async def test_repository_passes_document_to_all_related_queries(self):
db = type("DB", (), {"query_json": AsyncMock(return_value={"rows": []})})()
repo = BarcodeRepository(db)
await repo.fetch_next_picking(0, documento="172")
await repo.fetch_active_skipped_document(0, documento="172")
await repo.fetch_picking_by_pallet("600002", documento="172")
for call in db.query_json.await_args_list:
self.assertEqual(call.args[1]["documento"], "172")
self.assertIn(":documento", call.args[0])
if __name__ == "__main__":
unittest.main()

View File

@@ -8,6 +8,7 @@
"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.open_diagnostics": "Apre la diagnostica operativa con quadrature e controlli di coerenza sui dati di magazzino.",
"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.",
@@ -32,7 +33,15 @@
"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."
"multi_udc.export_xlsx": "Esporta in Excel il contenuto corrente della vista UDC fantasma.",
"picking.sequence.item": "Seleziona il gruppo. Puoi trascinarlo nell'altra area oppure usare doppio clic e pulsanti centrali.",
"picking.sequence.add": "Aggiunge il gruppo selezionato alla fine della sequenza personalizzata.",
"picking.sequence.remove": "Rimuove il gruppo dalla sequenza personalizzata e lo riporta tra quelli disponibili.",
"picking.sequence.up": "Sposta il gruppo selezionato una posizione verso l'alto.",
"picking.sequence.down": "Sposta il gruppo selezionato una posizione verso il basso.",
"picking.sequence.save": "Salva in modo atomico la sequenza per la picking list corrente.",
"picking.sequence.reset": "Elimina la personalizzazione e ripristina Non scaff. prima degli scaffali.",
"picking.sequence.close": "Chiude la finestra senza effettuare altri salvataggi."
},
"ENG": {
"launcher.open_reset_corsie": "Open the aisle management window to inspect an aisle and empty it in a controlled way.",
@@ -42,6 +51,7 @@
"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.open_diagnostics": "Open operational diagnostics with data consistency checks and warehouse quadratures.",
"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.",
@@ -66,6 +76,14 @@
"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."
"multi_udc.export_xlsx": "Export the current ghost UDC view to Excel.",
"picking.sequence.item": "Select the group. You can drag it to the other area or use double-click and the center buttons.",
"picking.sequence.add": "Append the selected group to the custom sequence.",
"picking.sequence.remove": "Remove the group from the custom sequence and return it to the available list.",
"picking.sequence.up": "Move the selected group one position up.",
"picking.sequence.down": "Move the selected group one position down.",
"picking.sequence.save": "Atomically save the sequence for the current picking list.",
"picking.sequence.reset": "Remove the customization and restore non-shelved before racks.",
"picking.sequence.close": "Close the window without further saves."
}
}

View File

@@ -301,6 +301,29 @@
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"picking_sequence_window": {
"window_fg_color": ["#eef2f3", "#2f2f2f"],
"list_fg_color": ["#f8fafc", "#363636"],
"item_fg_color": "#2ebf74",
"secondary_text_color": "#4b5563",
"reset_button_fg_color": "#6b7280",
"title_font": {
"family": "Segoe UI",
"size": 16,
"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],

View File

@@ -20,6 +20,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.open_history_udc",
"launcher.open_pickinglist",
"launcher.open_history_pickinglist",
"launcher.open_diagnostics",
"launcher.arrange_windows",
"launcher.exit",
"reset_corsie.view",
@@ -27,6 +28,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"non_shelved.view",
"history_udc.view",
"history_pickinglist.view",
"diagnostics.view",
"multi_udc.view",
"layout.view",
"layout.carico",
@@ -38,6 +40,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"pickinglist.view",
"pickinglist.prenota",
"pickinglist.sprenota",
"pickinglist.sequence",
}
)

View File

@@ -10,30 +10,32 @@ APP_VERSION = "1.0.0"
MODULE_VERSIONS: dict[str, str] = {
"app": APP_VERSION,
"async_loop_singleton": "1.0.0",
"async_msssql_query": "1.0.0",
"async_msssql_query": "1.0.1",
"audit_log": "1.0.0",
"main": "1.0.1",
"barcode_client": "1.0.20",
"barcode_repository": "1.0.6",
"barcode_service": "1.0.16",
"main": "1.0.2",
"barcode_client": "1.0.27",
"barcode_repository": "1.0.14",
"barcode_service": "1.0.25",
"busy_overlay": "1.0.0",
"db_config": "1.0.0",
"diagnostica": "1.0.4",
"gestione_aree": "1.0.1",
"gestione_layout": "1.0.0",
"gestione_pickinglist": "1.0.2",
"gestione_scarico": "1.0.0",
"gestione_layout": "1.0.2",
"gestione_pickinglist": "1.0.6",
"gestione_scarico": "1.0.8",
"locale_text": "1.0.0",
"login_window": "1.0.0",
"prenota_sprenota_sql": "1.0.0",
"prenota_sprenota_sql": "1.0.1",
"pickinglist_sequence": "1.0.0",
"reset_corsie": "1.0.0",
"runtime_support": "1.0.1",
"search_pallets": "1.0.0",
"storico_pickinglist": "1.0.3",
"storico_pickinglist": "1.0.4",
"storico_udc": "1.0.0",
"tooltips": "1.0.0",
"udc_non_scaffalate": "1.0.1",
"ui_theme": "1.0.0",
"user_session": "1.0.0",
"user_session": "1.0.2",
"view_celle_multi_udc": "1.0.0",
"window_placement": "1.0.0",
}