From 742f6a9fe92be4f439f8694f6396da5307b67075 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Wed, 3 Jun 2026 11:41:25 +0200 Subject: [PATCH] Release storico UDC e picking list --- INSTALLAZIONE.md | 61 +++ apply_online_history_forms_patch.sql | 182 +++++++++ apply_python_parallel_pickinglist_patch.sql | 328 ++++++++++++++++ apply_python_pickinglist_history_views.sql | 158 ++++++++ async_loop_singleton.py | 24 +- async_msssql_query.py | 10 + barcode.pyw | 8 + barcode_client.py | 13 +- barcode_repository.py | 6 +- checklist_test_campo_pickinglist.md | 69 ++++ gestione_pickinglist.py | 4 +- gestione_scarico.py | 2 +- locale.json | 32 ++ main.py | 121 +++++- prenota_sprenota_sql.py | 8 +- requirements.txt | 12 +- rollback_online_history_forms_patch.sql | 24 ++ ...back_python_parallel_pickinglist_patch.sql | 35 ++ rollback_python_pickinglist_history_views.sql | 20 + runtime_support.py | 68 ++++ specifica_storico_pickinglist.md | 68 ++++ specifica_storico_udc.md | 53 +++ storico_pickinglist.py | 364 ++++++++++++++++++ storico_udc.py | 312 +++++++++++++++ tooltip.json | 4 + ui_theme.json | 65 ++++ user_session.py | 4 + warehouse.pyw | 8 + 28 files changed, 2021 insertions(+), 42 deletions(-) create mode 100644 INSTALLAZIONE.md create mode 100644 apply_online_history_forms_patch.sql create mode 100644 apply_python_parallel_pickinglist_patch.sql create mode 100644 apply_python_pickinglist_history_views.sql create mode 100644 barcode.pyw create mode 100644 checklist_test_campo_pickinglist.md create mode 100644 rollback_online_history_forms_patch.sql create mode 100644 rollback_python_parallel_pickinglist_patch.sql create mode 100644 rollback_python_pickinglist_history_views.sql create mode 100644 runtime_support.py create mode 100644 specifica_storico_pickinglist.md create mode 100644 specifica_storico_udc.md create mode 100644 storico_pickinglist.py create mode 100644 storico_udc.py create mode 100644 warehouse.pyw diff --git a/INSTALLAZIONE.md b/INSTALLAZIONE.md new file mode 100644 index 0000000..fe41b12 --- /dev/null +++ b/INSTALLAZIONE.md @@ -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#. diff --git a/apply_online_history_forms_patch.sql b/apply_online_history_forms_patch.sql new file mode 100644 index 0000000..eb26471 --- /dev/null +++ b/apply_online_history_forms_patch.sql @@ -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 diff --git a/apply_python_parallel_pickinglist_patch.sql b/apply_python_parallel_pickinglist_patch.sql new file mode 100644 index 0000000..23b98a0 --- /dev/null +++ b/apply_python_parallel_pickinglist_patch.sql @@ -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 diff --git a/apply_python_pickinglist_history_views.sql b/apply_python_pickinglist_history_views.sql new file mode 100644 index 0000000..f3ee304 --- /dev/null +++ b/apply_python_pickinglist_history_views.sql @@ -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 diff --git a/async_loop_singleton.py b/async_loop_singleton.py index 2a218cc..b9033f5 100644 --- a/async_loop_singleton.py +++ b/async_loop_singleton.py @@ -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 diff --git a/async_msssql_query.py b/async_msssql_query.py index 59ff1a7..ece9039 100644 --- a/async_msssql_query.py +++ b/async_msssql_query.py @@ -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 diff --git a/barcode.pyw b/barcode.pyw new file mode 100644 index 0000000..443cf77 --- /dev/null +++ b/barcode.pyw @@ -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)) diff --git a/barcode_client.py b/barcode_client.py index 0d510ee..3b1be4b 100644 --- a/barcode_client.py +++ b/barcode_client.py @@ -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)) diff --git a/barcode_repository.py b/barcode_repository.py index 1f72980..050ac81 100644 --- a/barcode_repository.py +++ b/barcode_repository.py @@ -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, diff --git a/checklist_test_campo_pickinglist.md b/checklist_test_campo_pickinglist.md new file mode 100644 index 0000000..572e311 --- /dev/null +++ b/checklist_test_campo_pickinglist.md @@ -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. diff --git a/gestione_pickinglist.py b/gestione_pickinglist.py index 1ddbe97..3bcff6f 100644 --- a/gestione_pickinglist.py +++ b/gestione_pickinglist.py @@ -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; """ diff --git a/gestione_scarico.py b/gestione_scarico.py index c005de5..d2d7c1f 100644 --- a/gestione_scarico.py +++ b/gestione_scarico.py @@ -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, diff --git a/locale.json b/locale.json index 244557f..fb7da2a 100644 --- a/locale.json +++ b/locale.json @@ -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", diff --git a/main.py b/main.py index ae4b16e..e880044 100644 --- a/main.py +++ b/main.py @@ -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)) diff --git a/prenota_sprenota_sql.py b/prenota_sprenota_sql.py index 302aa2a..ba68ad0 100644 --- a/prenota_sprenota_sql.py +++ b/prenota_sprenota_sql.py @@ -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: diff --git a/requirements.txt b/requirements.txt index 7758d53..cc7fb0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/rollback_online_history_forms_patch.sql b/rollback_online_history_forms_patch.sql new file mode 100644 index 0000000..3e18d31 --- /dev/null +++ b/rollback_online_history_forms_patch.sql @@ -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 diff --git a/rollback_python_parallel_pickinglist_patch.sql b/rollback_python_parallel_pickinglist_patch.sql new file mode 100644 index 0000000..1cf12e2 --- /dev/null +++ b/rollback_python_parallel_pickinglist_patch.sql @@ -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 diff --git a/rollback_python_pickinglist_history_views.sql b/rollback_python_pickinglist_history_views.sql new file mode 100644 index 0000000..74d523d --- /dev/null +++ b/rollback_python_pickinglist_history_views.sql @@ -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 diff --git a/runtime_support.py b/runtime_support.py new file mode 100644 index 0000000..79c079d --- /dev/null +++ b/runtime_support.py @@ -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 diff --git a/specifica_storico_pickinglist.md b/specifica_storico_pickinglist.md new file mode 100644 index 0000000..4237161 --- /dev/null +++ b/specifica_storico_pickinglist.md @@ -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. diff --git a/specifica_storico_udc.md b/specifica_storico_udc.md new file mode 100644 index 0000000..6422649 --- /dev/null +++ b/specifica_storico_udc.md @@ -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. diff --git a/storico_pickinglist.py b/storico_pickinglist.py new file mode 100644 index 0000000..3b46e67 --- /dev/null +++ b/storico_pickinglist.py @@ -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("<>", 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("", lambda _e: win.destroy()) + return win diff --git a/storico_udc.py b/storico_udc.py new file mode 100644 index 0000000..b341f9e --- /dev/null +++ b/storico_udc.py @@ -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("", lambda _e: win.destroy()) + return win diff --git a/tooltip.json b/tooltip.json index f917ea5..bf1c837 100644 --- a/tooltip.json +++ b/tooltip.json @@ -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.", diff --git a/ui_theme.json b/ui_theme.json index 40ecc8e..781f510 100644 --- a/ui_theme.json +++ b/ui_theme.json @@ -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] } } diff --git a/user_session.py b/user_session.py index a786adf..3a9bc8c 100644 --- a/user_session.py +++ b/user_session.py @@ -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", diff --git a/warehouse.pyw b/warehouse.pyw new file mode 100644 index 0000000..8acde11 --- /dev/null +++ b/warehouse.pyw @@ -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))