Release storico UDC e picking list

This commit is contained in:
2026-06-03 11:41:25 +02:00
parent 4dabba8ce7
commit 998c28f956
28 changed files with 2021 additions and 42 deletions

61
INSTALLAZIONE.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,16 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
try:
import pyodbc
# The desktop app opens short-lived SQL connections from a background
# asyncio loop. ODBC pooling can keep native handles alive for a while after
# the GUI closes, which is especially visible with pythonw.
pyodbc.pooling = False
except Exception:
pyodbc = None # type: ignore[assignment]
try:
import orjson as _json

8
barcode.pyw Normal file
View File

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

View File

@@ -9,6 +9,10 @@ from concurrent.futures import Future
from tkinter import messagebox, ttk
from typing import Callable
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_barcode")
from async_loop_singleton import get_global_loop, stop_global_loop
from async_msssql_query import AsyncMSSQLClient
from barcode_repository import BarcodeRepository
@@ -17,13 +21,6 @@ from db_config import build_dsn_from_config, ensure_db_config
from login_window import prompt_login_compact
if sys.platform.startswith("win"):
try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
except Exception:
pass
class BarcodeClientApp:
"""Single-window Tk barcode client modeled after the C# legacy form."""
@@ -478,4 +475,4 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(run_with_fatal_log("Barcode WMS", main))

View File

@@ -21,7 +21,7 @@ SELECT TOP (1)
Ubicazione,
Ordinamento,
IDStato
FROM dbo.XMag_ViewPackingList
FROM dbo.py_XMag_ViewPackingList
WHERE Ordinamento > 0
AND IDStato = :id_stato
ORDER BY Ordinamento;
@@ -38,7 +38,7 @@ SELECT TOP (1)
Ubicazione,
Ordinamento,
IDStato
FROM dbo.XMag_ViewPackingList
FROM dbo.py_XMag_ViewPackingList
WHERE Pallet = :pallet
ORDER BY Ordinamento;
"""
@@ -65,6 +65,8 @@ EXEC dbo.sp_xMagGestioneMagazziniPallet
@NumeroCella = :numero_cella,
@RC = @RC OUTPUT;
EXEC dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
SELECT
@RC AS RC,
:barcode_cella AS BarcodeCella,

View File

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

View File

@@ -215,14 +215,14 @@ SELECT
MAX(Cella) AS Cella,
MIN(Ordinamento) AS Ordinamento,
MAX(IDStato) AS IDStato
FROM dbo.ViewPackingListRestante
FROM dbo.py_ViewPackingListRestante
GROUP BY Documento, CodNazione, NAZIONE, Stato
ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato;
"""
SQL_PL_DETAILS = """
SELECT *
FROM ViewPackingListRestante
FROM dbo.py_ViewPackingListRestante
WHERE Documento = :Documento
ORDER BY Ordinamento;
"""

View File

@@ -353,7 +353,7 @@ FROM (
WHERE c.ID = :target_idcella
) AS target;
EXEC dbo.sp_ControllaPrenotazionePackingListPalletNew;
EXEC dbo.py_sp_ControllaPrenotazionePackingListPalletNew;
SELECT
CAST(1 AS int) AS Ok,

View File

@@ -7,7 +7,9 @@
"launcher.layout": "Gestione Layout",
"launcher.multi_udc": "UDC Fantasma",
"launcher.search": "Ricerca UDC",
"launcher.history_udc": "Storico movimenti UDC",
"launcher.pickinglist": "Gestione Picking List",
"launcher.history_pickinglist": "Storico Picking List",
"launcher.arrange": "Ridisponi finestre",
"launcher.exit": "Esci",
"launcher.already_running_title": "Warehouse",
@@ -59,6 +61,20 @@
"search.msg.no_results": "Nessuna corrispondenza trovata con le chiavi di ricerca inserite.",
"search.msg.error_title": "Errore ricerca",
"search.busy": "Cerco...",
"history.udc.title": "Storico movimenti UDC",
"history.udc.button.search": "Cerca",
"history.udc.busy": "Carico storico UDC...",
"history.udc.msg.title": "Storico UDC",
"history.udc.msg.empty": "Nessun movimento trovato.",
"history.udc.msg.error": "Errore ricerca:\n{error}",
"history.picking.title": "Storico Picking List",
"history.picking.button.reload": "Ricarica",
"history.picking.detail_title": "Dettaglio contenuto",
"history.picking.busy.master": "Carico storico picking list...",
"history.picking.busy.detail": "Carico dettaglio picking list...",
"history.picking.msg.title": "Storico Picking List",
"history.picking.msg.load_error": "Errore caricamento:\n{error}",
"history.picking.msg.detail_error": "Errore dettaglio:\n{error}",
"reset.title": "Gestione Corsie - svuotamento celle per corsia",
"reset.label.aisle": "Corsia:",
"reset.button.refresh": "Carica",
@@ -104,7 +120,9 @@
"launcher.layout": "Layout Management",
"launcher.multi_udc": "Ghost UDC",
"launcher.search": "UDC Search",
"launcher.history_udc": "UDC Movement History",
"launcher.pickinglist": "Picking List Management",
"launcher.history_pickinglist": "Picking List History",
"launcher.arrange": "Arrange windows",
"launcher.exit": "Exit",
"launcher.already_running_title": "Warehouse",
@@ -156,6 +174,20 @@
"search.msg.no_results": "No matches were found for the entered search keys.",
"search.msg.error_title": "Search error",
"search.busy": "Searching...",
"history.udc.title": "UDC Movement History",
"history.udc.button.search": "Search",
"history.udc.busy": "Loading UDC history...",
"history.udc.msg.title": "UDC History",
"history.udc.msg.empty": "No movement found.",
"history.udc.msg.error": "Search failed:\n{error}",
"history.picking.title": "Picking List History",
"history.picking.button.reload": "Reload",
"history.picking.detail_title": "Content detail",
"history.picking.busy.master": "Loading picking-list history...",
"history.picking.busy.detail": "Loading picking-list detail...",
"history.picking.msg.title": "Picking List History",
"history.picking.msg.load_error": "Load failed:\n{error}",
"history.picking.msg.detail_error": "Detail load failed:\n{error}",
"reset.title": "Aisle Management - empty cells by aisle",
"reset.label.aisle": "Aisle:",
"reset.button.refresh": "Load",

121
main.py
View File

@@ -11,6 +11,10 @@ import sys
import tkinter as tk
import time
from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_main")
import customtkinter as ctk
from tkinter import messagebox
@@ -24,6 +28,8 @@ from login_window import prompt_login
from locale_text import load_locale_catalog, text as loc_text
from reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window
from storico_pickinglist import open_storico_pickinglist_window
from storico_udc import open_storico_udc_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
@@ -45,9 +51,9 @@ BYPASS_LOGIN_USER = {
"codice_unita": "U1",
}
# Create one global loop and make it the default everywhere.
# Create one global loop for database work. Tk must keep the main thread clean;
# callers schedule async jobs on this loop explicitly.
_loop = get_global_loop()
asyncio.set_event_loop(_loop)
def _noop(*args, **kwargs):
@@ -148,7 +154,9 @@ class Launcher(ctk.CTk):
"layout",
"multi_udc",
"search",
"storico_udc",
"pickinglist",
"storico_pickinglist",
]
def __init__(self, session: UserSession, db_client: AsyncMSSQLClient):
@@ -164,6 +172,9 @@ class Launcher(ctk.CTk):
self._is_cascading = False
self._focus_restore_pending: set[str] = set()
self._restore_suppressed_until = 0.0
self._exit_icon = self._make_exit_icon(
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
)
self.title(
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}"
)
@@ -185,45 +196,63 @@ class Launcher(ctk.CTk):
"reset_corsie",
loc_text("launcher.reset_corsie", catalog=self._locale_catalog, default="Gestione Corsie"),
"launcher.open_reset_corsie",
lambda: self._open_child_window(
lambda: self._open_or_focus_child_window(
"reset_corsie",
open_reset_corsie_window(self, self.db_client, session=self.session),
lambda: open_reset_corsie_window(self, self.db_client, session=self.session),
),
),
(
"layout",
loc_text("launcher.layout", catalog=self._locale_catalog, default="Gestione Layout"),
"launcher.open_layout",
lambda: self._open_child_window(
lambda: self._open_or_focus_child_window(
"layout",
open_layout_window(self, self.db_client, session=self.session),
lambda: open_layout_window(self, self.db_client, session=self.session),
),
),
(
"multi_udc",
loc_text("launcher.multi_udc", catalog=self._locale_catalog, default="UDC Fantasma"),
"launcher.open_multi_udc",
lambda: self._open_child_window(
lambda: self._open_or_focus_child_window(
"multi_udc",
open_celle_multiple_window(self, self.db_client, session=self.session),
lambda: open_celle_multiple_window(self, self.db_client, session=self.session),
),
),
(
"search",
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),
"launcher.open_search",
lambda: self._open_child_window(
lambda: self._open_or_focus_child_window(
"search",
open_search_window(self, self.db_client, session=self.session),
lambda: open_search_window(self, self.db_client, session=self.session),
),
),
(
"storico_udc",
loc_text("launcher.history_udc", catalog=self._locale_catalog, default="Storico movimenti UDC"),
"launcher.open_history_udc",
lambda: self._open_or_focus_child_window(
"storico_udc",
lambda: open_storico_udc_window(self, self.db_client, session=self.session),
),
),
(
"pickinglist",
loc_text("launcher.pickinglist", catalog=self._locale_catalog, default="Gestione Picking List"),
"launcher.open_pickinglist",
lambda: self._open_child_window(
lambda: self._open_or_focus_child_window(
"pickinglist",
open_pickinglist_window(self, self.db_client, session=self.session),
lambda: open_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
"storico_pickinglist",
loc_text("launcher.history_pickinglist", catalog=self._locale_catalog, default="Storico Picking List"),
"launcher.open_history_pickinglist",
lambda: self._open_or_focus_child_window(
"storico_pickinglist",
lambda: open_storico_pickinglist_window(self, self.db_client, session=self.session),
),
),
(
@@ -257,12 +286,23 @@ class Launcher(ctk.CTk):
for idx, (_key, label, permission, callback) in enumerate(actions):
row = 1 + (idx // max_buttons_per_row)
column = idx % max_buttons_per_row
text = label
button_options = {}
if _key == "exit":
row = 2
column = max_buttons_per_row - 1
text = label
button_options = {
"image": self._exit_icon,
"compound": "left",
}
button = ctk.CTkButton(
wrap,
text=label,
text=text,
font=theme_font(self._theme, "button_font", default=("Segoe UI", 11, "bold")),
state="normal" if self.session.can(permission) else "disabled",
command=callback,
**button_options,
)
button.grid(row=row, column=column, padx=button_padx, pady=button_pady, sticky="ew")
tip = tooltip_text(permission, catalog=self._tooltip_catalog)
@@ -282,6 +322,18 @@ class Launcher(ctk.CTk):
except Exception:
pass
def _make_exit_icon(self, *, color: str) -> tk.PhotoImage:
"""Create a small red X icon without adding image assets to the project."""
size = 14
image = tk.PhotoImage(width=size, height=size)
for offset in range(3, 11):
image.put(color, (offset, offset))
image.put(color, (offset + 1, offset))
image.put(color, (offset, size - 1 - offset))
image.put(color, (offset + 1, size - 1 - offset))
return image
def _apply_dynamic_geometry(self) -> None:
"""Size the launcher around its current content and keep it docked at the top."""
@@ -334,6 +386,34 @@ class Launcher(ctk.CTk):
except Exception:
pass
def _open_or_focus_child_window(self, key: str, factory) -> None:
"""Open one child per launcher key, or focus the existing window."""
self._child_windows_by_key = {
child_key: child
for child_key, child in self._child_windows_by_key.items()
if getattr(child, "winfo_exists", lambda: False)()
}
existing = self._child_windows_by_key.get(key)
if existing is not None and getattr(existing, "winfo_exists", lambda: False)():
try:
if hasattr(existing, "state") and existing.state() == "iconic":
existing.deiconify()
except Exception:
pass
try:
existing.lift()
existing.focus_force()
except Exception:
pass
try:
place_window_below_parent_later(self, existing)
except Exception:
pass
return
self._open_child_window(key, factory())
def _forget_child_window(self, key: str, window: tk.Misc, event_widget: tk.Misc | None = None) -> None:
"""Remove stale references to child windows that have been closed."""
@@ -460,7 +540,9 @@ class Launcher(ctk.CTk):
self.destroy()
if __name__ == "__main__":
def run_app() -> int:
"""Run the backoffice application entry point."""
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
if not _acquire_single_instance_mutex():
@@ -479,7 +561,7 @@ if __name__ == "__main__":
stop_global_loop()
except Exception:
pass
raise SystemExit(0)
return 0
db_cfg = ensure_db_config(_loop)
if db_cfg is None:
@@ -487,7 +569,7 @@ if __name__ == "__main__":
stop_global_loop()
except Exception:
pass
raise SystemExit(0)
return 0
db_app = AsyncMSSQLClient(build_dsn_from_config(db_cfg))
@@ -511,7 +593,7 @@ if __name__ == "__main__":
if session is None:
_shutdown_runtime(bootstrap=bootstrap, dispose_db=True)
raise SystemExit(0)
return 0
_destroy_tk_root(bootstrap)
@@ -519,3 +601,8 @@ if __name__ == "__main__":
Launcher(session, db_app).mainloop()
finally:
_shutdown_runtime(bootstrap=None, dispose_db=True)
return 0
if __name__ == "__main__":
raise SystemExit(run_with_fatal_log("Warehouse Backoffice", run_app))

View File

@@ -255,7 +255,7 @@ async def _execute(db, sql: str, params: Dict[str, Any]) -> int:
@_log_call()
async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, Azione: str = "P") -> SPResult:
"""Execute the original reservation stored procedure used by the C# client."""
"""Execute the Python-specific picking-list reservation stored procedure."""
try:
azione = str(Azione or "P").strip().upper()
if azione not in ("P", "S"):
@@ -268,7 +268,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
SET NOCOUNT ON;
DECLARE @RC int = 0;
EXEC dbo.sp_xExePackingListPallet
EXEC dbo.py_sp_xExePackingListPallet
@IDOperatore = :IDOperatore,
@Documento = :Documento,
@Azione = :Azione,
@@ -276,7 +276,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
SELECT CAST(@RC AS int) AS RC;
"""
_log_sql("sp_xExePackingListPallet", sql, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione})
_log_sql("py_sp_xExePackingListPallet", sql, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione})
if not hasattr(db, "query_json"):
raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.")
res = await db.query_json(
@@ -288,7 +288,7 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
rows = []
if isinstance(res, dict):
rows = res.get("rows", []) or []
_log_dataset("sp_xExePackingListPallet", rows)
_log_dataset("py_sp_xExePackingListPallet", rows)
rc = 0
if rows and isinstance(rows[0], dict):
try:

View File

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

View File

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

View File

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

View File

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

68
runtime_support.py Normal file
View File

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

View File

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

53
specifica_storico_udc.md Normal file
View File

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

364
storico_pickinglist.py Normal file
View File

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

312
storico_udc.py Normal file
View File

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

View File

@@ -5,7 +5,9 @@
"launcher.open_layout": "Apre la vista layout delle corsie con celle, UDC presenti e menu operativo contestuale.",
"launcher.open_multi_udc": "Apre la vista UDC fantasma per analizzare celle con piu' pallet e bonificare i candidati fantasma.",
"launcher.open_search": "Apre la ricerca UDC per trovare rapidamente una unita' di carico e verificarne la posizione.",
"launcher.open_history_udc": "Apre lo storico movimenti UDC per ricostruire carichi, scarichi, celle e utenti coinvolti.",
"launcher.open_pickinglist": "Apre la gestione delle picking list per prenotare, controllare e aggiornare le liste di prelievo.",
"launcher.open_history_pickinglist": "Apre lo storico picking list per consultare liste, stato operativo e dettaglio UDC.",
"launcher.arrange_windows": "Dispone in cascata le finestre aperte seguendo l'ordine dei pulsanti del launcher.",
"launcher.exit": "Chiude l'applicazione in modo pulito terminando la sessione utente e rilasciando la connessione condivisa al database.",
"dbconfig.heading": "Spiega che qui si inseriscono i dati minimi per permettere al programma di collegarsi al database del magazzino al primo avvio.",
@@ -37,7 +39,9 @@
"launcher.open_layout": "Open the aisle layout view with cells, present UDCs and the operational context menu.",
"launcher.open_multi_udc": "Open the ghost UDC view to inspect cells with multiple pallets and clean ghost candidates.",
"launcher.open_search": "Open the UDC search window to quickly locate a load unit and verify its position.",
"launcher.open_history_udc": "Open UDC movement history to review loads, unloads, cells and users involved.",
"launcher.open_pickinglist": "Open picking list management to reserve, inspect and update picking lists.",
"launcher.open_history_pickinglist": "Open picking-list history to inspect lists, operational status and UDC detail.",
"launcher.arrange_windows": "Arrange open windows in cascade order following the launcher buttons.",
"launcher.exit": "Close the application cleanly by ending the user session and releasing the shared database connection.",
"dbconfig.heading": "Explain that this form collects the minimum data needed to connect the program to the warehouse database on first startup.",

View File

@@ -23,6 +23,7 @@
"button_pady": 6,
"info_padx": 6,
"info_pady": [4, 2],
"exit_icon_color": "#ca3d3d",
"info_font": {
"family": "Segoe UI",
"size": 12,
@@ -208,6 +209,38 @@
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"history_udc_window": {
"window_geometry": "1100x720",
"window_minsize": [900, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"scarico_dialog": {
"header_font": {
"family": "Segoe UI",
@@ -267,5 +300,37 @@
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
},
"history_picking_window": {
"window_geometry": "1200x720",
"window_minsize": [980, 560],
"window_fg_color": ["#efefef", "#2f2f2f"],
"toolbar_frame_fg_color": ["#d7d7d7", "#3b3b3b"],
"toolbar_button_font": {
"family": "Segoe UI",
"size": 10,
"weight": "bold"
},
"toolbar_label_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"entry_font": {
"family": "Segoe UI",
"size": 10,
"weight": "normal"
},
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10,
"overlay_label_font": {
"family": "Segoe UI",
"size": 11,
"weight": "bold"
},
"overlay_progress_width": 220,
"overlay_label_padding": [18, 14, 18, 8],
"overlay_progress_padding": [18, 0, 18, 14]
}
}

View File

@@ -13,11 +13,15 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.open_layout",
"launcher.open_multi_udc",
"launcher.open_search",
"launcher.open_history_udc",
"launcher.open_pickinglist",
"launcher.open_history_pickinglist",
"launcher.arrange_windows",
"launcher.exit",
"reset_corsie.view",
"search.view",
"history_udc.view",
"history_pickinglist.view",
"multi_udc.view",
"layout.view",
"layout.carico",

8
warehouse.pyw Normal file
View File

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