Aggiungi sequenza prelievo e gestisci destinazioni senza nazione

This commit is contained in:
2026-09-29 11:52:33 +02:00
parent 5dc26b21b1
commit 19ce7a7755
17 changed files with 2329 additions and 19 deletions

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

@@ -16,6 +16,14 @@ Include:
- 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())
@@ -362,8 +370,8 @@ SELECT
SAMA1.dbo.NAZIONI.CODICE AS Expr1,
SAMA1.dbo.MTRASP.CODICE + ' ' + SAMA1.dbo.NAZIONI.DESCR AS NAZIONE,
SAMA1.dbo.BAMTES.IDMTRASP
FROM SAMA1.dbo.NAZIONI
INNER JOIN SAMA1.dbo.BAMTES
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
@@ -527,5 +535,191 @@ BEGIN
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

@@ -26,7 +26,7 @@ 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 NOT EXISTS (
@@ -39,7 +39,7 @@ WHERE Ordinamento > 0
AND s.IDStato = pl.IDStato
AND s.Risolto = 0
)
ORDER BY Ordinamento;
ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet;
"""
SQL_PICKING_BY_PALLET = """
@@ -53,9 +53,9 @@ SELECT TOP (1)
Ubicazione,
Ordinamento,
IDStato
FROM dbo.py_XMag_ViewPackingList
FROM dbo.py_ViewPackingListPrelievo
WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY Ordinamento;
ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet;
"""
SQL_TRACE_BY_PALLET = """

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 --------------------
@@ -649,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
@@ -699,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(
@@ -708,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
@@ -998,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):
@@ -1013,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
@@ -1058,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)
@@ -1156,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

@@ -121,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",
@@ -258,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",

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

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

View File

@@ -33,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.",
@@ -68,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

@@ -40,6 +40,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"pickinglist.view",
"pickinglist.prenota",
"pickinglist.sprenota",
"pickinglist.sequence",
}
)

View File

@@ -14,18 +14,19 @@ MODULE_VERSIONS: dict[str, str] = {
"audit_log": "1.0.0",
"main": "1.0.2",
"barcode_client": "1.0.26",
"barcode_repository": "1.0.12",
"barcode_repository": "1.0.13",
"barcode_service": "1.0.24",
"busy_overlay": "1.0.0",
"db_config": "1.0.0",
"diagnostica": "1.0.4",
"gestione_aree": "1.0.1",
"gestione_layout": "1.0.2",
"gestione_pickinglist": "1.0.4",
"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.1",
"pickinglist_sequence": "1.0.0",
"reset_corsie": "1.0.0",
"runtime_support": "1.0.1",
"search_pallets": "1.0.0",
@@ -34,7 +35,7 @@ MODULE_VERSIONS: dict[str, str] = {
"tooltips": "1.0.0",
"udc_non_scaffalate": "1.0.1",
"ui_theme": "1.0.0",
"user_session": "1.0.1",
"user_session": "1.0.2",
"view_celle_multi_udc": "1.0.0",
"window_placement": "1.0.0",
}