Compare commits

..

13 Commits

49 changed files with 4616 additions and 311 deletions

View File

@@ -0,0 +1,61 @@
# Installazione produzione - Warehouse/FlyWMS bridge
## Ordine consigliato
1. Fare backup del database `Mediseawall`.
2. Copiare il contenuto dello zip in una cartella locale, ad esempio `C:\flywms`.
3. Installare le dipendenze Python:
```bat
python -m pip install -r requirements.txt
```
4. In SSMS, sul database `Mediseawall`, lanciare:
```text
apply_python_parallel_pickinglist_patch.sql
apply_online_history_forms_patch.sql
```
## Cosa fanno gli script
- `apply_python_parallel_pickinglist_patch.sql` crea il ramo SQL Python per gestione picking list, senza modificare le stored procedure C# legacy.
- `apply_online_history_forms_patch.sql` crea le viste Python-only per "Storico Picking List".
- "Storico movimenti UDC" non richiede script dedicati: legge in sola lettura `MagazziniPallet`, `Celle` e `XMag_GiacenzaPalletPlistChiuse`.
## Rollback SQL
Se serve tornare indietro sugli oggetti Python, usare:
```text
rollback_online_history_forms_patch.sql
rollback_python_parallel_pickinglist_patch.sql
```
La tabella `dbo.PyPickingListReservation`, se creata, puo' rimanere anche in caso di rollback perche' il C# legacy non la usa.
## Avvio
Backoffice con console:
```bat
python main.py
```
Backoffice senza console:
```bat
pythonw warehouse.pyw
```
Barcode senza console:
```bat
pythonw barcode.pyw
```
Se si usa un collegamento Windows, impostare anche la cartella "Da" alla cartella dell'applicazione, ad esempio `C:\flywms`.
## File esclusi dal pacchetto
Il pacchetto non include `db_connection.json`, log, cache Python e vecchi zip locali. Alla prima apertura il programma chiedera' la configurazione DB se `db_connection.json` non esiste.

View File

@@ -0,0 +1,43 @@
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_BarcodePickingListSkip (
ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_py_BarcodePickingListSkip PRIMARY KEY,
Documento varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
Pallet varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
IDStato int NOT NULL,
IDOperatore int NOT NULL,
DataOra datetime2(0) NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_DataOra DEFAULT SYSDATETIME(),
Motivo nvarchar(200) NULL,
Risolto bit NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_Risolto DEFAULT 0,
RisoltoDa int NULL,
RisoltoDataOra datetime2(0) NULL
);
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_py_BarcodePickingListSkip_Open'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE UNIQUE INDEX UX_py_BarcodePickingListSkip_Open
ON dbo.py_BarcodePickingListSkip (Documento, Pallet, IDStato)
WHERE Risolto = 0;
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_py_BarcodePickingListSkip_Queue'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE INDEX IX_py_BarcodePickingListSkip_Queue
ON dbo.py_BarcodePickingListSkip (IDStato, Documento, Pallet, Risolto);
END;
COMMIT TRANSACTION;

View File

@@ -72,7 +72,7 @@ LEFT OUTER JOIN SAMA1.sam.EXTUC
LEFT OUTER JOIN SAMA1.dbo.MTRASP LEFT OUTER JOIN SAMA1.dbo.MTRASP
ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID
WHERE WHERE
SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE()) SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE()) - 1
AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D') AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D')
AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000' AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000'
GROUP BY GROUP BY

View File

@@ -0,0 +1,531 @@
/*
Patch cumulativa DB online - ramo Python WMS
Da eseguire una sola volta in SSMS sul database Mediseawall.
Questa patch NON modifica gli oggetti legacy usati dal programma C#.
Include:
- dbo.PyPickingListReservation
- dbo.py_XMag_ViewPackingList
- dbo.py_ViewPackingListRestante
- dbo.py_sp_xExePackingListPallet
- dbo.py_sp_xExePackingListPalletPrenota
- dbo.py_sp_ControllaPrenotazionePackingListPalletNew
- dbo.py_vPreparaPackingListSAMA1
- dbo.py_vPreparaPackingList
- dbo.py_XMag_ViewPackingListStorico
- dbo.py_BarcodePickingListSkip
Nota storico:
dbo.py_vPreparaPackingListSAMA1 usa ANNDOC >= YEAR(GETDATE()) - 1
per mantenere visibili anche le picking list dell'anno precedente.
*/
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
/* ============================================================
1. Stato prenotazione picking list ramo Python
============================================================ */
IF OBJECT_ID(N'dbo.PyPickingListReservation', N'U') IS NULL
BEGIN
CREATE TABLE [dbo].[PyPickingListReservation](
[ID] [tinyint] NOT NULL,
[Documento] [varchar](8) NULL,
[IDOperatore] [int] NULL,
[ModUtente] [varchar](50) NULL,
[ModDataOra] [datetime] NULL,
CONSTRAINT [PK_PyPickingListReservation] PRIMARY KEY CLUSTERED ([ID] ASC),
CONSTRAINT [CK_PyPickingListReservation_Singleton] CHECK ([ID] = 1)
) ON [PRIMARY];
END
GO
IF NOT EXISTS (SELECT 1 FROM dbo.PyPickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PyPickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END
GO
/* ============================================================
2. Viste operative picking list ramo Python
============================================================ */
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingList]
AS
SELECT TOP 10000
legacy.Pallet,
legacy.Lotto,
legacy.Articolo,
legacy.Descrizione,
legacy.Qta,
legacy.Documento,
legacy.CodNazione,
legacy.NAZIONE,
legacy.Stato,
legacy.PalletCella,
legacy.Magazzino,
legacy.Area,
legacy.Cella,
legacy.Ordinamento,
legacy.Ubicazione,
legacy.DEST,
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(legacy.Documento AS varchar(8))
THEN 1
ELSE 0
END AS IDStato
FROM dbo.XMag_ViewPackingList legacy
LEFT JOIN dbo.PyPickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
ORDER BY
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(legacy.Documento AS varchar(8))
THEN 1
ELSE 0
END DESC,
legacy.Documento,
legacy.Ordinamento;
GO
CREATE OR ALTER VIEW [dbo].[py_ViewPackingListRestante]
AS
SELECT
Pallet,
Lotto,
Articolo,
Descrizione,
Qta,
Documento,
CodNazione,
NAZIONE,
Stato,
PalletCella,
Magazzino,
Area,
Cella,
Ordinamento,
Ubicazione,
DEST,
IDStato
FROM dbo.py_XMag_ViewPackingList
WHERE Cella <> 9999;
GO
/* ============================================================
3. Stored procedure operative ramo Python
============================================================ */
CREATE OR ALTER PROCEDURE [dbo].[py_sp_xExePackingListPallet]
@IDOperatore int,
@Documento varchar(8),
@Azione char(1) = 'P',
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
DECLARE @DocumentoAttivo varchar(8) = NULL;
DECLARE @Description varchar(255) = '';
DECLARE @Message varchar(255) = '';
DECLARE @IDResult int = 0;
DECLARE @ID int = 0;
SELECT @Nominativo = [Login]
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
IF @Azione NOT IN ('P', 'S')
BEGIN
SET @RC = -10;
RETURN;
END;
IF NOT EXISTS (
SELECT 1
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
SET @RC = -20;
RETURN;
END;
IF NOT EXISTS (SELECT 1 FROM dbo.PyPickingListReservation WHERE ID = 1)
BEGIN
INSERT INTO dbo.PyPickingListReservation (ID, Documento, IDOperatore, ModUtente, ModDataOra)
VALUES (1, NULL, NULL, NULL, GETDATE());
END;
SELECT @DocumentoAttivo = NULLIF(LTRIM(RTRIM(Documento)), '')
FROM dbo.PyPickingListReservation
WHERE ID = 1;
DECLARE @TargetCelle TABLE (
IDCella int PRIMARY KEY
);
INSERT INTO @TargetCelle (IDCella)
SELECT DISTINCT Cella
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL;
IF @Azione = 'P'
BEGIN
IF @DocumentoAttivo = @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE ISNULL(c.IDStato, 0) <> 0;
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PyPickingListReservation
SET Documento = @Documento,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
SELECT TOP 1 @Description = NAZIONE
FROM dbo.py_XMag_ViewPackingList
WHERE CAST(Documento AS varchar(8)) = @Documento;
EXEC dbo.sp_LogPackingList
@ID = @ID,
@Code = @Documento,
@Description = @Description,
@Message = @Message OUTPUT,
@IDResult = @IDResult OUTPUT;
RETURN;
END;
IF @Azione = 'S'
BEGIN
IF ISNULL(@DocumentoAttivo, '') <> @Documento
RETURN;
UPDATE c
SET c.IDStato = 0,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
INNER JOIN @TargetCelle t ON t.IDCella = c.ID;
UPDATE dbo.PyPickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[py_sp_xExePackingListPalletPrenota]
@IDOperatore int,
@Documento varchar(8),
@RC int OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @RC = 0;
DECLARE @Nominativo varchar(50) = '';
SELECT @Nominativo = LOGIN
FROM dbo.Operatori
WHERE ID = @IDOperatore;
IF @Nominativo IS NULL
SET @Nominativo = 'SYSTEM';
UPDATE c
SET c.IDStato = 1,
c.ModUtente = @Nominativo,
c.ModDataOra = GETDATE()
FROM dbo.Celle c
WHERE c.ID IN (
SELECT DISTINCT Cella
FROM dbo.py_ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
AND Cella IS NOT NULL
);
END;
GO
CREATE OR ALTER PROCEDURE [dbo].[py_sp_ControllaPrenotazionePackingListPalletNew]
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Documento varchar(8) = NULL;
DECLARE @IDOperatore int = 0;
DECLARE @Nominativo varchar(50) = 'SYSTEM';
DECLARE @RC int = 0;
SELECT
@Documento = NULLIF(LTRIM(RTRIM(Documento)), ''),
@IDOperatore = ISNULL(IDOperatore, 0),
@Nominativo = ISNULL(NULLIF(LTRIM(RTRIM(ModUtente)), ''), 'SYSTEM')
FROM dbo.PyPickingListReservation
WHERE ID = 1;
IF ISNULL(@Documento, '') = ''
RETURN;
IF NOT EXISTS (
SELECT 1
FROM dbo.py_ViewPackingListRestante
WHERE CAST(Documento AS varchar(8)) = @Documento
)
BEGIN
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
UPDATE dbo.PyPickingListReservation
SET Documento = NULL,
IDOperatore = @IDOperatore,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ID = 1;
RETURN;
END;
UPDATE dbo.Celle
SET IDStato = 0,
ModUtente = @Nominativo,
ModDataOra = GETDATE()
WHERE ISNULL(IDStato, 0) <> 0;
IF @IDOperatore <= 0
BEGIN
SELECT TOP 1 @IDOperatore = ID
FROM dbo.Operatori
WHERE LOGIN = @Nominativo;
END;
EXEC dbo.py_sp_xExePackingListPalletPrenota
@IDOperatore = @IDOperatore,
@Documento = @Documento,
@RC = @RC OUTPUT;
END;
GO
/* ============================================================
4. Viste storico picking list ramo Python
============================================================ */
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingListSAMA1]
AS
SELECT
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6) AS UDC,
SUM(SAMA1.dbo.LOTSER.QTTGIAI) AS Qta,
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO AS StatoDocumento,
SAMA1.dbo.NAZIONI.CODICE AS Expr1,
SAMA1.dbo.MTRASP.CODICE + ' ' + SAMA1.dbo.NAZIONI.DESCR AS NAZIONE,
SAMA1.dbo.BAMTES.IDMTRASP
FROM SAMA1.dbo.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()) - 1
AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D')
AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000'
GROUP BY
SAMA1.dbo.LOTSER.NUMLOT,
SAMA1.dbo.ARTICO.CODICE,
SAMA1.dbo.FATRIG.DESCR,
LEFT(SAMA1.dbo.LOTSER.NUMSER, 6),
SAMA1.dbo.BAMTES.NUMDOC,
SAMA1.dbo.BAMTES.DATDOC,
SAMA1.dbo.BAMTES.ID,
SAMA1.dbo.BAMTES.DESCRDEST,
SAMA1.dbo.BAMTES.STATO,
SAMA1.dbo.NAZIONI.CODICE,
SAMA1.dbo.NAZIONI.DESCR,
SAMA1.dbo.BAMTES.IDMTRASP,
SAMA1.dbo.MTRASP.CODICE;
GO
CREATE OR ALTER VIEW [dbo].[py_vPreparaPackingList]
AS
SELECT
NUMLOT,
CODICE,
DESCR,
UDC,
Qta,
NUMDOC,
DATDOC,
ID,
DESCRDEST,
StatoDocumento,
Expr1,
NAZIONE
FROM dbo.py_vPreparaPackingListSAMA1
WHERE NUMLOT <> '';
GO
CREATE OR ALTER VIEW [dbo].[py_XMag_ViewPackingListStorico]
AS
SELECT TOP (100000)
prep.UDC AS Pallet,
prep.NUMLOT AS Lotto,
prep.CODICE AS Articolo,
prep.DESCR AS Descrizione,
prep.Qta,
prep.NUMDOC AS Documento,
prep.DATDOC AS DataDocumento,
prep.StatoDocumento,
prep.Expr1 AS CodNazione,
prep.NAZIONE,
CASE
WHEN prep.Expr1 = 'DE' THEN 10
WHEN prep.Expr1 = 'TH' THEN
CASE WHEN SUBSTRING(prep.DESCRDEST, 1, 2) = 'NA' THEN 11 ELSE 13 END
WHEN prep.Expr1 = 'MEX' THEN 12
ELSE 4
END AS Stato,
ISNULL(g.NumeroPallet, 0) AS PalletCella,
ISNULL(g.IDMagazzino, 1) AS Magazzino,
ISNULL(g.IDArea, 5) AS Area,
ISNULL(g.IDCella, 1000) AS Cella,
ISNULL(c.Ordinamento, 99999) AS Ordinamento,
ISNULL(c.Corsia + ' - ' + c.Colonna + ' - ' + c.Fila, 'Non scaff.') AS Ubicazione,
SUBSTRING(prep.DESCRDEST, 1, 2) AS DEST,
CASE
WHEN MAX(CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(prep.NUMDOC AS varchar(8))
THEN 1 ELSE 0 END) = 1
THEN 1
ELSE 0
END AS IDStato
FROM dbo.Celle c
INNER JOIN dbo.XMag_GiacenzaPallet g
ON c.ID = g.IDCella
RIGHT OUTER JOIN dbo.py_vPreparaPackingList prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
LEFT JOIN dbo.PyPickingListReservation pr
ON pr.ID = 1
AND NULLIF(LTRIM(RTRIM(pr.Documento)), '') IS NOT NULL
GROUP BY
prep.Expr1,
prep.NAZIONE,
prep.UDC,
prep.NUMDOC,
prep.DATDOC,
prep.StatoDocumento,
prep.NUMLOT,
prep.CODICE,
prep.DESCR,
prep.Qta,
g.NumeroPallet,
g.IDMagazzino,
g.IDArea,
g.IDCella,
c.Ordinamento,
c.Corsia,
c.Colonna,
c.Fila,
prep.DESCRDEST;
GO
/* ============================================================
5. Tabella salta UDC barcode picking list
============================================================ */
IF OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U') IS NULL
BEGIN
CREATE TABLE dbo.py_BarcodePickingListSkip (
ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_py_BarcodePickingListSkip PRIMARY KEY,
Documento varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
Pallet varchar(50) COLLATE Latin1_General_CI_AS NOT NULL,
IDStato int NOT NULL,
IDOperatore int NOT NULL,
DataOra datetime2(0) NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_DataOra DEFAULT SYSDATETIME(),
Motivo nvarchar(200) NULL,
Risolto bit NOT NULL CONSTRAINT DF_py_BarcodePickingListSkip_Risolto DEFAULT 0,
RisoltoDa int NULL,
RisoltoDataOra datetime2(0) NULL
);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_py_BarcodePickingListSkip_Open'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE UNIQUE INDEX UX_py_BarcodePickingListSkip_Open
ON dbo.py_BarcodePickingListSkip (Documento, Pallet, IDStato)
WHERE Risolto = 0;
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_py_BarcodePickingListSkip_Queue'
AND object_id = OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U')
)
BEGIN
CREATE INDEX IX_py_BarcodePickingListSkip_Queue
ON dbo.py_BarcodePickingListSkip (IDStato, Documento, Pallet, Risolto);
END;
GO
PRINT 'Patch cumulativa Python WMS completata.';
GO

View File

@@ -48,7 +48,7 @@ LEFT OUTER JOIN SAMA1.sam.EXTUC
LEFT OUTER JOIN SAMA1.dbo.MTRASP LEFT OUTER JOIN SAMA1.dbo.MTRASP
ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID ON SAMA1.dbo.BAMTES.IDMTRASP = SAMA1.dbo.MTRASP.ID
WHERE WHERE
SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE()) SAMA1.dbo.BAMTES.ANNDOC >= YEAR(GETDATE()) - 1
AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D') AND SAMA1.dbo.BAMTES.STATO IN ('P', 'D')
AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000' AND SAMA1.dbo.LOTSER.NUMLOT <> '00000000000'
GROUP BY GROUP BY

View File

@@ -9,6 +9,10 @@ import asyncio
import threading import threading
import contextlib import contextlib
from version_info import module_version
__version__ = module_version(__name__)
class _LoopHolder: class _LoopHolder:
"""Store the global loop instance and its worker thread.""" """Store the global loop instance and its worker thread."""

View File

@@ -18,6 +18,9 @@ from typing import Any, Dict, Optional
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from version_info import module_version
__version__ = module_version(__name__)
try: try:
import pyodbc import pyodbc

View File

@@ -8,7 +8,9 @@ from pathlib import Path
from typing import Any from typing import Any
from user_session import UserSession from user_session import UserSession
from version_info import module_version
__version__ = module_version(__name__)
AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log") AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log")
_LOGGER = logging.getLogger("warehouse_audit") _LOGGER = logging.getLogger("warehouse_audit")

View File

@@ -1,3 +1,9 @@
from pathlib import Path
import os
os.chdir(Path(__file__).resolve().parent)
from runtime_support import ensure_stdio, run_with_fatal_log from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_barcode") ensure_stdio("warehouse_barcode")

View File

@@ -9,7 +9,7 @@ from concurrent.futures import Future
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
from typing import Callable from typing import Callable
from runtime_support import ensure_stdio, run_with_fatal_log from runtime_support import configure_exception_logging, ensure_stdio, log_exception, run_with_fatal_log
ensure_stdio("warehouse_barcode") ensure_stdio("warehouse_barcode")
@@ -19,18 +19,44 @@ from barcode_repository import BarcodeRepository
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
from db_config import build_dsn_from_config, ensure_db_config from db_config import build_dsn_from_config, ensure_db_config
from login_window import prompt_login_compact from login_window import prompt_login_compact
from user_session import create_user_session
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
BYPASS_LOGIN = True
BYPASS_LOGIN_USER = {
"operator_id": 4,
"login": "MAG1",
"nominativo": "MAG1",
"privilegio": 3,
"codice_unita": "U1",
}
def _build_bypass_session():
"""Create the temporary MAG1 session used while field testing the barcode."""
return create_user_session(
operator_id=int(BYPASS_LOGIN_USER["operator_id"]),
login=str(BYPASS_LOGIN_USER["login"]),
nominativo=str(BYPASS_LOGIN_USER["nominativo"]),
privilegio=int(BYPASS_LOGIN_USER["privilegio"]),
codice_unita=str(BYPASS_LOGIN_USER["codice_unita"]),
)
class BarcodeClientApp: class BarcodeClientApp:
"""Single-window Tk barcode client modeled after the C# legacy form.""" """Single-window Tk barcode client modeled after the C# legacy form."""
PALLET_BARCODE_LENGTH = 6
NON_SCAFFALATA_BARCODE = "9001000"
SHIPPED_BARCODE = "9000000"
BARCODE_MAX_WIDTH = 320 BARCODE_MAX_WIDTH = 320
BARCODE_MAX_HEIGHT = 400 BARCODE_MAX_HEIGHT = 400
DESKTOP_THRESHOLD_WIDTH = 1024 DESKTOP_THRESHOLD_WIDTH = 1024
DESKTOP_THRESHOLD_HEIGHT = 768 DESKTOP_THRESHOLD_HEIGHT = 768
DESKTOP_WINDOW_WIDTH = 465 DESKTOP_WINDOW_WIDTH = 465
DESKTOP_WINDOW_HEIGHT = 531 DESKTOP_WINDOW_HEIGHT = 531
def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop): def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop):
self.root = root self.root = root
self.db_client = db_client self.db_client = db_client
@@ -40,6 +66,11 @@ class BarcodeClientApp:
self.service = BarcodeService(self.repository, session.operator_id) self.service = BarcodeService(self.repository, session.operator_id)
self._pending: Future | None = None self._pending: Future | None = None
self._auto_advance_id: str | None = None self._auto_advance_id: str | None = None
self._pallet_auto_focus_id: str | None = None
self._paused_queue_id: int | None = None
self._pallet_user_input_serial = 0
self._priority_loaded_input_serial = 0
self._priority_expected_pallet = ""
self._status_colors = { self._status_colors = {
"red": "#f4cccc", "red": "#f4cccc",
"green": "#d9ead3", "green": "#d9ead3",
@@ -59,9 +90,11 @@ class BarcodeClientApp:
self._apply_state(self.service.state) self._apply_state(self.service.state)
self._bind_keys() self._bind_keys()
self.root.protocol("WM_DELETE_WINDOW", self._shutdown) self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
self.root.after(80, self._finalize_window_placement)
self.root.after(250, self._finalize_window_placement)
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.root.title("WMS") self.root.title(versioned_title("WMS", __name__))
self.root.configure(bg="#f1f1f1") self.root.configure(bg="#f1f1f1")
self._apply_responsive_geometry() self._apply_responsive_geometry()
@@ -169,11 +202,11 @@ class BarcodeClientApp:
self.btn_f1 = ttk.Button(buttons, text="[F1] H Priority", command=lambda: self._start_queue(1)) self.btn_f1 = ttk.Button(buttons, text="[F1] H Priority", command=lambda: self._start_queue(1))
self.btn_f1.grid(row=0, column=0, padx=(0, 4), pady=(0, button_pad_y), sticky="ew") self.btn_f1.grid(row=0, column=0, padx=(0, 4), pady=(0, button_pad_y), sticky="ew")
self.btn_submit = ttk.Button(buttons, text="[Ent] Salva", command=self._submit) self.btn_submit = ttk.Button(buttons, text="[F3] Carica", command=self._on_f3)
self.btn_submit.grid(row=0, column=1, padx=(4, 0), pady=(0, button_pad_y), sticky="ew") self.btn_submit.grid(row=0, column=1, padx=(4, 0), pady=(0, button_pad_y), sticky="ew")
self.btn_f2 = ttk.Button(buttons, text="[F2] L Priority", command=lambda: self._start_queue(0)) self.btn_f2 = ttk.Button(buttons, text="[F2] L Priority", command=lambda: self._start_queue(0))
self.btn_f2.grid(row=1, column=0, padx=(0, 4), sticky="ew") self.btn_f2.grid(row=1, column=0, padx=(0, 4), sticky="ew")
self.btn_unload = ttk.Button(buttons, text="[F4] Elimina", command=self._begin_manual_unload) self.btn_unload = ttk.Button(buttons, text="[F4] Scarica", command=self._begin_manual_unload)
self.btn_unload.grid(row=1, column=1, padx=(4, 0), sticky="ew") self.btn_unload.grid(row=1, column=1, padx=(4, 0), sticky="ew")
self.busy_cover = tk.Frame(self.root, bg="#d9d9d9") self.busy_cover = tk.Frame(self.root, bg="#d9d9d9")
@@ -207,16 +240,55 @@ class BarcodeClientApp:
parent.columnconfigure(1, weight=1) parent.columnconfigure(1, weight=1)
if scanned: if scanned:
self.pallet_entry = entry self.pallet_entry = entry
self.scanned_var.trace_add("write", lambda *_: self._limit_var(self.scanned_var, 8)) self.pallet_entry.bind("<KeyPress>", self._on_pallet_keypress, add="+")
self.scanned_var.trace_add("write", lambda *_: self._on_scanned_var_changed())
else: else:
self.destination_entry = entry self.destination_entry = entry
self.destination_var.trace_add("write", lambda *_: self._limit_var(self.destination_var, 8)) self.destination_var.trace_add("write", lambda *_: self._on_destination_var_changed())
def _limit_var(self, variable: tk.StringVar, max_len: int) -> None: def _limit_var(self, variable: tk.StringVar, max_len: int) -> None:
value = str(variable.get() or "") value = str(variable.get() or "")
if len(value) > max_len: if len(value) > max_len:
variable.set(value[:max_len]) variable.set(value[:max_len])
def _on_pallet_keypress(self, _event=None) -> None:
self._pallet_user_input_serial += 1
def _on_scanned_var_changed(self) -> None:
self._limit_var(self.scanned_var, 8)
if self._pallet_auto_focus_id is not None:
try:
self.root.after_cancel(self._pallet_auto_focus_id)
except Exception:
pass
self._pallet_auto_focus_id = None
pallet = str(self.scanned_var.get() or "").strip()
if len(pallet) < self.PALLET_BARCODE_LENGTH:
return
self._pallet_auto_focus_id = self.root.after(80, self._auto_focus_destination_after_scan)
def _on_destination_var_changed(self) -> None:
self._limit_var(self.destination_var, 8)
self._refresh_action_labels()
def _auto_focus_destination_after_scan(self) -> None:
self._pallet_auto_focus_id = None
pallet = str(self.scanned_var.get() or "").strip()
if not pallet:
return
if self._pending is not None and not self._pending.done():
return
if getattr(self.service.state, "mode", "") == "confirm":
return
if getattr(self.service.state, "mode", "") in ("priority_high", "priority_low"):
expected = str(getattr(self.service.state, "expected_pallet", "") or "").strip()
if expected and pallet == expected and self._is_fresh_priority_scan(expected):
self._submit()
return
if bool(getattr(self.service.state, "destination_readonly", False)):
return
self._focus_destination_input()
def _apply_responsive_geometry(self) -> None: def _apply_responsive_geometry(self) -> None:
"""Adapt the window size to barcode-sized or desktop-sized screens.""" """Adapt the window size to barcode-sized or desktop-sized screens."""
@@ -254,10 +326,24 @@ class BarcodeClientApp:
self.root.geometry(f"{width}x{height}+{x}+{y}") self.root.geometry(f"{width}x{height}+{x}+{y}")
def _finalize_window_placement(self) -> None:
"""Let Windows finish frame sizing, then snap the barcode window in place."""
try:
if self._is_barcode_desktop:
self.root.state("zoomed")
else:
self.root.geometry("+0+0")
self.root.update_idletasks()
except Exception:
pass
def _bind_keys(self) -> None: def _bind_keys(self) -> None:
self.root.bind("<F1>", lambda _e: self._start_queue(1)) self.root.bind("<F1>", lambda _e: self._start_queue(1))
self.root.bind("<F2>", lambda _e: self._start_queue(0)) self.root.bind("<F2>", lambda _e: self._start_queue(0))
self.root.bind("<F4>", lambda _e: self._begin_manual_unload()) self.root.bind("<F3>", self._on_f3_key)
self.root.bind("<F4>", self._on_unload_key)
self.root.bind("<Escape>", self._on_escape_key)
self.pallet_entry.bind("<Return>", self._on_pallet_enter) self.pallet_entry.bind("<Return>", self._on_pallet_enter)
self.destination_entry.bind("<Return>", self._on_destination_enter) self.destination_entry.bind("<Return>", self._on_destination_enter)
@@ -285,6 +371,9 @@ class BarcodeClientApp:
self.busy_bar.stop() self.busy_bar.stop()
self.busy_cover.place_forget() self.busy_cover.place_forget()
def _is_priority_pause(self) -> bool:
return str(self.queue_var.get() or "") == "Pausa PL"
def _apply_state(self, state: BarcodeViewState) -> None: def _apply_state(self, state: BarcodeViewState) -> None:
if self._auto_advance_id is not None: if self._auto_advance_id is not None:
try: try:
@@ -292,16 +381,45 @@ class BarcodeClientApp:
except Exception: except Exception:
pass pass
self._auto_advance_id = None self._auto_advance_id = None
self.queue_var.set(state.queue_label) if self._pallet_auto_focus_id is not None:
try:
self.root.after_cancel(self._pallet_auto_focus_id)
except Exception:
pass
self._pallet_auto_focus_id = None
pause_context = self._paused_queue_id is not None and state.mode not in ("priority_high", "priority_low")
effective_queue_label = "Pausa PL" if pause_context else state.queue_label
self.queue_var.set(effective_queue_label)
self.destination_var.set(state.destination_barcode) self.destination_var.set(state.destination_barcode)
self.scanned_var.set(state.scanned_pallet) self.scanned_var.set(state.scanned_pallet)
self.info1_var.set(state.status_text) self.info1_var.set(self._status_text_with_wait_hint(state))
self.info2_var.set(state.document) self.info2_var.set(state.document)
self.info3_var.set(state.customer) self.info3_var.set(state.customer)
self.info4_var.set(state.expected_pallet) self.info4_var.set(state.expected_pallet)
self.status_band.configure(bg=state.status_color or self._status_colors["red"]) self.status_band.configure(bg=state.status_color or self._status_colors["red"])
destination_readonly = state.mode in ("priority_high", "priority_low", "manual_unload") if state.mode in ("priority_high", "priority_low"):
self._priority_loaded_input_serial = self._pallet_user_input_serial
self._priority_expected_pallet = str(state.expected_pallet or "").strip()
else:
self._priority_expected_pallet = ""
try:
if str(effective_queue_label or "") != "Pausa PL":
self._paused_queue_id = None
resumable_queue = self._resumable_queue_from_state(state)
if state.mode in ("priority_high", "priority_low") or resumable_queue is not None:
self.btn_submit.configure(text="[F3] Pausa PL")
else:
self.btn_submit.configure(text="[F3] Carica")
self._refresh_action_labels(state)
queue_buttons_state = "disabled" if str(effective_queue_label or "") == "Pausa PL" else "normal"
self.btn_f1.configure(state=queue_buttons_state)
self.btn_f2.configure(state=queue_buttons_state)
except Exception:
pass
destination_readonly = False if pause_context else bool(getattr(state, "destination_readonly", False))
try: try:
self.destination_entry.configure(state="normal") self.destination_entry.configure(state="normal")
if destination_readonly: if destination_readonly:
@@ -309,12 +427,34 @@ class BarcodeClientApp:
except Exception: except Exception:
pass pass
if state.mode == "confirm" and state.status_text == "Ok Scarico": if state.mode == "confirm":
next_queue = self._queue_id_from_label(state.queue_label) next_queue = self._resumable_queue_from_state(state)
if next_queue is not None: if next_queue is not None:
self._auto_advance_id = self.root.after(1200, lambda q=next_queue: self._start_queue(q)) self._auto_advance_id = self.root.after(
int(getattr(state, "auto_advance_delay_ms", 0) or 0),
lambda q=next_queue: self._start_queue(q),
)
self.root.after(20, self._focus_primary_input) if bool(getattr(state, "focus_destination", False)):
self.root.after(20, self._focus_destination_input)
else:
self.root.after(20, self._focus_primary_input)
def _refresh_action_labels(self, state: BarcodeViewState | None = None) -> None:
current_state = state or self.service.state
try:
if current_state.mode in ("priority_high", "priority_low"):
self.btn_unload.configure(text="[F4] Salta UDC")
return
destination = str(self.destination_var.get() or "").strip()
if destination == self.NON_SCAFFALATA_BARCODE:
self.btn_unload.configure(text="[F4] Non scaff.")
elif destination:
self.btn_unload.configure(text="[F4] Dissocia")
else:
self.btn_unload.configure(text="[F4] Scarica")
except Exception:
pass
def _focus_primary_input(self) -> None: def _focus_primary_input(self) -> None:
try: try:
@@ -323,6 +463,17 @@ class BarcodeClientApp:
except Exception: except Exception:
pass pass
def _status_text_with_wait_hint(self, state: BarcodeViewState) -> str:
text = str(getattr(state, "status_text", "") or "")
delay_ms = int(getattr(state, "auto_advance_delay_ms", 0) or 0)
if delay_ms <= 0:
return text
seconds = max(1, round(delay_ms / 1000))
hint = f"Attendi {seconds} secondi..."
if hint.lower() in text.lower():
return text
return f"{text} - {hint}" if text else hint
def _focus_destination_input(self) -> None: def _focus_destination_input(self) -> None:
try: try:
self.destination_entry.configure(state="normal") self.destination_entry.configure(state="normal")
@@ -334,13 +485,32 @@ class BarcodeClientApp:
except Exception: except Exception:
pass pass
def _is_fresh_priority_scan(self, expected: str) -> bool:
mode = str(getattr(self.service.state, "mode", "") or "")
if mode not in ("priority_high", "priority_low"):
return False
if str(expected or "").strip() != self._priority_expected_pallet:
return False
return self._pallet_user_input_serial > self._priority_loaded_input_serial
def _on_pallet_enter(self, _event=None) -> str: def _on_pallet_enter(self, _event=None) -> str:
pallet = str(self.scanned_var.get() or "").strip() pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip() destination = str(self.destination_var.get() or "").strip()
if not pallet: if not pallet:
return "break" return "break"
if destination == "9000000": if destination in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
self._submit() if bool(getattr(self.service.state, "destination_readonly", False)):
mode = str(getattr(self.service.state, "mode", "") or "")
if mode in ("priority_high", "priority_low"):
expected = str(getattr(self.service.state, "expected_pallet", "") or "").strip()
if expected and pallet == expected and self._is_fresh_priority_scan(expected):
self._submit()
else:
self._focus_primary_input()
else:
self._submit()
else:
self._focus_destination_input()
return "break" return "break"
self.destination_var.set("") self.destination_var.set("")
self._focus_destination_input() self._focus_destination_input()
@@ -355,13 +525,59 @@ class BarcodeClientApp:
self._focus_primary_input() self._focus_primary_input()
return "break" return "break"
def _on_unload_key(self, _event=None) -> str:
self._begin_manual_unload()
return "break"
def _on_escape_key(self, _event=None) -> str:
if self._is_priority_pause() and self._paused_queue_id is not None:
self._start_queue(self._paused_queue_id, allow_during_pause=True)
return "break"
def _on_f3_key(self, _event=None) -> str:
self._on_f3()
return "break"
def _on_f3(self) -> None:
mode = getattr(self.service.state, "mode", "")
if mode == "priority_high":
self._paused_queue_id = 1
self._apply_state(self.service.begin_priority_pause(1))
return
if mode == "priority_low":
self._paused_queue_id = 0
self._apply_state(self.service.begin_priority_pause(0))
return
resumable_queue = self._resumable_queue_from_state(self.service.state)
if resumable_queue is not None:
self._paused_queue_id = resumable_queue
self._apply_state(self.service.begin_priority_pause(resumable_queue))
return
self._submit()
def _begin_manual_unload(self) -> None: def _begin_manual_unload(self) -> None:
pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip()
mode = getattr(self.service.state, "mode", "")
if mode in ("priority_high", "priority_low"):
self._skip_current_picking_pallet()
return
if pallet:
if destination == self.NON_SCAFFALATA_BARCODE:
self._submit()
else:
self._submit_unload_with_source_check()
return
if self._is_priority_pause():
return
self._apply_state(self.service.begin_manual_unload()) self._apply_state(self.service.begin_manual_unload())
def _start_queue(self, id_stato: int) -> None: def _start_queue(self, id_stato: int, *, allow_during_pause: bool = False) -> None:
if self._is_priority_pause() and not allow_during_pause:
return
self._run_async( self._run_async(
lambda: self.service.start_priority_queue(id_stato), lambda: self.service.start_priority_queue(id_stato),
busy_message="Carico la coda selezionata...", busy_message="In preparazione...",
) )
def _submit(self) -> None: def _submit(self) -> None:
@@ -370,7 +586,22 @@ class BarcodeClientApp:
scanned_pallet=self.scanned_var.get(), scanned_pallet=self.scanned_var.get(),
destination_barcode=self.destination_var.get(), destination_barcode=self.destination_var.get(),
), ),
busy_message="Eseguo il movimento...", busy_message="In esecuzione...",
)
def _submit_unload_with_source_check(self) -> None:
self._run_async(
lambda: self.service.submit_unload_with_source_check(
scanned_pallet=self.scanned_var.get(),
source_barcode=self.destination_var.get(),
),
busy_message="In esecuzione...",
)
def _skip_current_picking_pallet(self) -> None:
self._run_async(
lambda: self.service.skip_current_picking_pallet(),
busy_message="Registro salto UDC...",
) )
def _run_async(self, coro_factory: Callable[[], object], busy_message: str) -> None: def _run_async(self, coro_factory: Callable[[], object], busy_message: str) -> None:
@@ -394,6 +625,12 @@ class BarcodeClientApp:
return 0 return 0
return None return None
def _resumable_queue_from_state(self, state: BarcodeViewState) -> int | None:
delay_ms = int(getattr(state, "auto_advance_delay_ms", 0) or 0)
if delay_ms <= 0:
return None
return self._queue_id_from_label(str(getattr(state, "queue_label", "") or ""))
def _poll_future(self) -> None: def _poll_future(self) -> None:
if self._pending is None: if self._pending is None:
self._set_busy(False) self._set_busy(False)
@@ -408,11 +645,16 @@ class BarcodeClientApp:
try: try:
result = future.result() result = future.result()
except Exception as exc: except Exception as exc:
current = self.service.state log_exception("Barcode WMS", exc, context="barcode async operation")
current.status_text = f"Errore operativo: {exc}" self._set_busy(True, "Verifico esito...")
current.status_color = "#f4cccc" self._pending = asyncio.run_coroutine_threadsafe(
self._apply_state(current) self.service.reconcile_after_submit_exception(
messagebox.showerror("Barcode WMS", f"Operazione fallita:\n{exc}", parent=self.root) scanned_pallet=self.scanned_var.get(),
destination_barcode=self.destination_var.get(),
),
self.loop,
)
self.root.after(40, lambda err=exc: self._poll_recovery_future(err))
return return
if isinstance(result, BarcodeActionResult): if isinstance(result, BarcodeActionResult):
@@ -422,6 +664,46 @@ class BarcodeClientApp:
elif isinstance(result, BarcodeViewState): elif isinstance(result, BarcodeViewState):
self._apply_state(result) self._apply_state(result)
def _show_transaction_error(self) -> None:
messagebox.showerror(
"Barcode WMS",
"Operazione non completata.\n\n"
"Ripeti la lettura o avvisa il responsabile.\n"
"Il dettaglio tecnico e' stato scritto nel log.",
parent=self.root,
)
def _poll_recovery_future(self, original_exception: Exception) -> None:
if self._pending is None:
self._set_busy(False)
self._show_transaction_error()
return
if not self._pending.done():
self.root.after(40, lambda err=original_exception: self._poll_recovery_future(err))
return
future = self._pending
self._pending = None
self._set_busy(False)
try:
result = future.result()
except Exception as exc:
log_exception("Barcode WMS", exc, context=f"barcode recovery after {original_exception!r}")
current = self.service.state
current.status_text = "Transazione non completata, ripeti l'operazione."
current.status_color = "#f4cccc"
self._apply_state(current)
self._show_transaction_error()
return
if isinstance(result, BarcodeActionResult):
self._apply_state(result.state)
if not result.ok:
self.root.bell()
self._show_transaction_error()
elif isinstance(result, BarcodeViewState):
self._apply_state(result)
def _shutdown(self) -> None: def _shutdown(self) -> None:
try: try:
if self._pending is not None and not self._pending.done(): if self._pending is not None and not self._pending.done():
@@ -446,6 +728,7 @@ def main() -> int:
loop = get_global_loop() loop = get_global_loop()
bootstrap = tk.Tk() bootstrap = tk.Tk()
bootstrap.withdraw() bootstrap.withdraw()
configure_exception_logging("Barcode WMS", root=bootstrap, loop=loop)
config = ensure_db_config(loop, parent=bootstrap) config = ensure_db_config(loop, parent=bootstrap)
if not config: if not config:
@@ -454,7 +737,7 @@ def main() -> int:
return 1 return 1
db_client = AsyncMSSQLClient(build_dsn_from_config(config)) db_client = AsyncMSSQLClient(build_dsn_from_config(config))
session = prompt_login_compact(bootstrap, db_client) session = _build_bypass_session() if BYPASS_LOGIN else prompt_login_compact(bootstrap, db_client)
if session is None: if session is None:
try: try:
fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop) fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop)

View File

@@ -7,8 +7,13 @@ possible while keeping SQL isolated from the UI.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from runtime_support import log_exception, log_runtime_event
from typing import Any from typing import Any
from version_info import module_version
__version__ = module_version(__name__)
SQL_NEXT_PICKING = """ SQL_NEXT_PICKING = """
SELECT TOP (1) SELECT TOP (1)
@@ -21,9 +26,19 @@ SELECT TOP (1)
Ubicazione, Ubicazione,
Ordinamento, Ordinamento,
IDStato IDStato
FROM dbo.py_XMag_ViewPackingList FROM dbo.py_XMag_ViewPackingList AS pl
WHERE Ordinamento > 0 WHERE Ordinamento > 0
AND IDStato = :id_stato AND IDStato = :id_stato
AND NOT EXISTS (
SELECT 1
FROM dbo.py_BarcodePickingListSkip AS s
WHERE s.Documento COLLATE Latin1_General_CI_AS =
CAST(pl.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.Pallet COLLATE Latin1_General_CI_AS =
CAST(pl.Pallet AS varchar(50)) COLLATE Latin1_General_CI_AS
AND s.IDStato = pl.IDStato
AND s.Risolto = 0
)
ORDER BY Ordinamento; ORDER BY Ordinamento;
""" """
@@ -39,7 +54,7 @@ SELECT TOP (1)
Ordinamento, Ordinamento,
IDStato IDStato
FROM dbo.py_XMag_ViewPackingList FROM dbo.py_XMag_ViewPackingList
WHERE Pallet = :pallet WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY Ordinamento; ORDER BY Ordinamento;
""" """
@@ -50,12 +65,82 @@ SELECT TOP (1)
Prodotto, Prodotto,
Descrizione Descrizione
FROM dbo.vXTracciaProdotti FROM dbo.vXTracciaProdotti
WHERE Pallet = :pallet WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY Lotto; ORDER BY Lotto;
""" """
SQL_CURRENT_LOCATION_BY_PALLET = """
SELECT TOP (1)
g.BarcodePallet,
g.IDCella,
RTRIM(c.Corsia) AS Corsia,
RTRIM(CAST(c.Colonna AS varchar(32))) AS Colonna,
RTRIM(CAST(c.Fila AS varchar(32))) AS Fila
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c
ON c.ID = g.IDCella
WHERE g.BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS;
"""
SQL_OPEN_LOCATIONS_BY_PALLET = """
SELECT
g.BarcodePallet,
g.IDCella,
RTRIM(c.Corsia) AS Corsia,
RTRIM(CAST(c.Colonna AS varchar(32))) AS Colonna,
RTRIM(CAST(c.Fila AS varchar(32))) AS Fila
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c
ON c.ID = g.IDCella
WHERE g.BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY g.IDCella;
"""
SQL_OPEN_PALLETS_BY_CELL = """
SELECT
g.BarcodePallet,
g.IDCella
FROM dbo.XMag_GiacenzaPallet AS g
WHERE g.IDCella = :id_cella
ORDER BY g.BarcodePallet;
"""
SQL_CLOSED_PICKING_BY_PALLET = """
SELECT TOP (1)
BarcodePallet,
NumeroPallet,
IDMagazzino,
IDArea,
IDCella,
IDStato
FROM dbo.XMag_GiacenzaPalletPlistChiuse
WHERE BarcodePallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
ORDER BY IDCella DESC;
"""
SQL_RESOLVE_PHYSICAL_CELL = """
DECLARE @raw int = TRY_CONVERT(int, :destination);
DECLARE @cell_id int =
CASE
WHEN @raw IS NULL THEN NULL
WHEN @raw >= 9000000 THEN @raw - 9000000
ELSE @raw
END;
SELECT TOP (1)
c.ID AS IDCella,
CAST(9000000 + c.ID AS varchar(8)) AS BarcodeCella,
9000000 + c.ID AS NumeroCella,
CONCAT(RTRIM(c.Corsia), '.', RTRIM(CAST(c.Colonna AS varchar(32))), '.', RTRIM(CAST(c.Fila AS varchar(32)))) AS Ubicazione
FROM dbo.Celle AS c
WHERE c.ID = @cell_id
AND c.ID <> 9999
AND c.DelDataOra IS NULL;
"""
SQL_LEGACY_MOVE = """ SQL_LEGACY_MOVE = """
SET NOCOUNT ON; SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RC int = 0; DECLARE @RC int = 0;
EXEC dbo.sp_xMagGestioneMagazziniPallet EXEC dbo.sp_xMagGestioneMagazziniPallet
@@ -74,6 +159,75 @@ SELECT
:numero_cella AS NumeroCella; :numero_cella AS NumeroCella;
""" """
SQL_SKIP_PICKING_PALLET = """
SET NOCOUNT ON;
IF NOT EXISTS (
SELECT 1
FROM dbo.py_BarcodePickingListSkip
WHERE Documento = :documento
AND Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS
AND IDStato = :id_stato
AND Risolto = 0
)
BEGIN
INSERT INTO dbo.py_BarcodePickingListSkip (
Documento,
Pallet,
IDStato,
IDOperatore,
DataOra,
Motivo,
Risolto
)
VALUES (
:documento,
:pallet,
:id_stato,
:id_operatore,
SYSDATETIME(),
:motivo,
0
);
END;
SELECT
:documento AS Documento,
:pallet AS Pallet,
:id_stato AS IDStato;
"""
SQL_ACTIVE_SKIPPED_DOCUMENT = """
SELECT TOP (1)
s.Documento,
COUNT(*) AS SkippedCount
FROM dbo.py_BarcodePickingListSkip AS s
WHERE s.IDStato = :id_stato
AND s.Risolto = 0
AND EXISTS (
SELECT 1
FROM dbo.py_ViewPackingListRestante AS pl
WHERE CAST(pl.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS =
s.Documento COLLATE Latin1_General_CI_AS
AND pl.IDStato = :id_stato
)
GROUP BY s.Documento
ORDER BY MIN(s.DataOra);
"""
SQL_RELEASE_PICKING_DOCUMENT = """
SET NOCOUNT ON;
DECLARE @RC int = 0;
EXEC dbo.py_sp_xExePackingListPallet
@IDOperatore = :id_operatore,
@Documento = :documento,
@Azione = 'S',
@RC = @RC OUTPUT;
SELECT CAST(@RC AS int) AS RC;
"""
def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]: def _rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Convert ``query_json`` payloads to a list of row dictionaries.""" """Convert ``query_json`` payloads to a list of row dictionaries."""
@@ -101,6 +255,16 @@ class LegacyMoveResult:
numero_cella: int numero_cella: int
@dataclass
class DestinationCell:
"""Resolved warehouse cell accepted by the legacy movement procedure."""
barcode_cella: str
numero_cella: int
id_cella: int
ubicazione: str
class BarcodeRepository: class BarcodeRepository:
"""Thin async repository used by the lightweight barcode client.""" """Thin async repository used by the lightweight barcode client."""
@@ -128,6 +292,115 @@ class BarcodeRepository:
rows = _rows_to_dicts(res) rows = _rows_to_dicts(res)
return rows[0] if rows else None return rows[0] if rows else None
async def fetch_current_location_by_pallet(self, pallet: str) -> dict[str, Any] | None:
"""Return the current stock location for a pallet, if it exists in giacenza."""
res = await self.db_client.query_json(SQL_CURRENT_LOCATION_BY_PALLET, {"pallet": str(pallet or "").strip()})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def fetch_open_locations_by_pallet(self, pallet: str) -> list[dict[str, Any]]:
"""Return all positive stock locations currently open for a pallet."""
res = await self.db_client.query_json(SQL_OPEN_LOCATIONS_BY_PALLET, {"pallet": str(pallet or "").strip()})
return _rows_to_dicts(res)
async def fetch_open_pallets_by_cell(self, id_cella: int) -> list[dict[str, Any]]:
"""Return pallets currently present in a physical cell."""
res = await self.db_client.query_json(SQL_OPEN_PALLETS_BY_CELL, {"id_cella": int(id_cella)})
return _rows_to_dicts(res)
async def fetch_closed_picking_by_pallet(self, pallet: str) -> dict[str, Any] | None:
"""Return a closed picking-list row for a pallet already considered shipped."""
res = await self.db_client.query_json(SQL_CLOSED_PICKING_BY_PALLET, {"pallet": str(pallet or "").strip()})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def skip_picking_pallet(
self,
*,
documento: str,
pallet: str,
id_stato: int,
operator_id: int,
motivo: str = "UDC non trovata dal magazziniere",
) -> None:
"""Persist one skipped picking-list pallet so the queue can advance."""
params = {
"documento": str(documento or "").strip(),
"pallet": str(pallet or "").strip(),
"id_stato": int(id_stato),
"id_operatore": int(operator_id),
"motivo": str(motivo or "").strip(),
}
log_runtime_event(
"Barcode WMS",
(
"PICKING SKIP START "
f"documento={params['documento']} "
f"pallet={params['pallet']} "
f"id_stato={params['id_stato']} "
f"operator={params['id_operatore']}"
),
)
await self.db_client.query_json(SQL_SKIP_PICKING_PALLET, params, commit=True)
log_runtime_event(
"Barcode WMS",
(
"PICKING SKIP OK "
f"documento={params['documento']} "
f"pallet={params['pallet']} "
f"id_stato={params['id_stato']}"
),
)
async def fetch_active_skipped_document(self, id_stato: int) -> dict[str, Any] | None:
"""Return a reserved document whose remaining pallets were all skipped."""
res = await self.db_client.query_json(SQL_ACTIVE_SKIPPED_DOCUMENT, {"id_stato": int(id_stato)})
rows = _rows_to_dicts(res)
return rows[0] if rows else None
async def release_picking_document(self, *, documento: str, operator_id: int) -> int:
"""Release a reserved picking-list document without closing its residual rows."""
params = {
"documento": str(documento or "").strip(),
"id_operatore": int(operator_id),
}
log_runtime_event(
"Barcode WMS",
f"PICKING AUTO RELEASE START documento={params['documento']} operator={params['id_operatore']}",
)
res = await self.db_client.query_json(SQL_RELEASE_PICKING_DOCUMENT, params, as_dict_rows=True, commit=True)
rows = _rows_to_dicts(res)
rc = 0
if rows:
try:
rc = int(rows[0].get("RC") or 0)
except Exception:
rc = 0
log_runtime_event("Barcode WMS", f"PICKING AUTO RELEASE END documento={params['documento']} rc={rc}")
return rc
async def resolve_physical_cell(self, destination: str) -> DestinationCell | None:
"""Accept either an internal cell ID or the scanned legacy cell barcode."""
res = await self.db_client.query_json(SQL_RESOLVE_PHYSICAL_CELL, {"destination": str(destination or "").strip()})
rows = _rows_to_dicts(res)
if not rows:
return None
row = rows[0]
return DestinationCell(
barcode_cella=str(row.get("BarcodeCella") or ""),
numero_cella=int(row.get("NumeroCella") or 0),
id_cella=int(row.get("IDCella") or 0),
ubicazione=str(row.get("Ubicazione") or ""),
)
async def execute_legacy_move( async def execute_legacy_move(
self, self,
*, *,
@@ -144,12 +417,44 @@ class BarcodeRepository:
"barcode_pallet": str(barcode_pallet or "").strip(), "barcode_pallet": str(barcode_pallet or "").strip(),
"numero_cella": int(numero_cella), "numero_cella": int(numero_cella),
} }
res = await self.db_client.query_json(SQL_LEGACY_MOVE, params, commit=True) log_runtime_event(
"Barcode WMS",
(
"MOVE START "
f"operator={params['id_operatore']} "
f"cell={params['barcode_cella']} "
f"pallet={params['barcode_pallet']}"
),
)
try:
res = await self.db_client.query_json(SQL_LEGACY_MOVE, params, commit=True)
except Exception as exc:
log_exception(
"Barcode WMS",
exc,
context=(
"execute_legacy_move "
f"operator={params['id_operatore']} "
f"cell={params['barcode_cella']} "
f"pallet={params['barcode_pallet']}"
),
)
raise
rows = _rows_to_dicts(res) rows = _rows_to_dicts(res)
row = rows[0] if rows else {} row = rows[0] if rows else {}
return LegacyMoveResult( result = LegacyMoveResult(
rc=int(row.get("RC") or 0), rc=int(row.get("RC") or 0),
barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]), barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]),
barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]), barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]),
numero_cella=int(row.get("NumeroCella") or params["numero_cella"]), numero_cella=int(row.get("NumeroCella") or params["numero_cella"]),
) )
log_runtime_event(
"Barcode WMS",
(
"MOVE OK "
f"rc={result.rc} "
f"cell={result.barcode_cella} "
f"pallet={result.barcode_pallet}"
),
)
return result

View File

@@ -6,7 +6,10 @@ from dataclasses import dataclass
from typing import Literal from typing import Literal
from barcode_repository import BarcodeRepository, LegacyMoveResult from barcode_repository import BarcodeRepository, LegacyMoveResult
from runtime_support import log_exception, log_runtime_event
from version_info import module_version
__version__ = module_version(__name__)
ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"] ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"]
@@ -25,6 +28,9 @@ class BarcodeViewState:
expected_pallet: str = "" expected_pallet: str = ""
destination_barcode: str = "" destination_barcode: str = ""
scanned_pallet: str = "" scanned_pallet: str = ""
auto_advance_delay_ms: int = 0
destination_readonly: bool = False
focus_destination: bool = False
@dataclass @dataclass
@@ -39,19 +45,21 @@ class BarcodeActionResult:
class BarcodeService: class BarcodeService:
"""Faithful, but cleaner, port of the legacy barcode form logic.""" """Faithful, but cleaner, port of the legacy barcode form logic."""
NON_SCAFFALATA_BARCODE = "9001000"
SHIPPED_BARCODE = "9000000"
GRAY = "#d9d9d9" GRAY = "#d9d9d9"
RED = "#f4cccc" RED = "#f4cccc"
LIGHT_GREEN = "#d9ead3" LIGHT_GREEN = "#d9ead3"
GREEN_YELLOW = "#e2f0cb" GREEN_YELLOW = "#e2f0cb"
CONVENTIONAL_LOCATION_BY_CELL = { CONVENTIONAL_LOCATION_BY_CELL = {
1000: "5E1.1", 1000: "5E.1.1 - Non scaffalata",
9999: "7G.1.1", 9999: "7G.1.1 - Spedita",
} }
def __init__(self, repository: BarcodeRepository, operator_id: int): def __init__(self, repository: BarcodeRepository, operator_id: int):
self.repository = repository self.repository = repository
self.operator_id = int(operator_id) self.operator_id = int(operator_id)
self._current_priority_state = 0 self._current_priority_state = -1
self._state = BarcodeViewState() self._state = BarcodeViewState()
@property @property
@@ -63,14 +71,14 @@ class BarcodeService:
def reset(self) -> BarcodeViewState: def reset(self) -> BarcodeViewState:
"""Return the client to its neutral state.""" """Return the client to its neutral state."""
self._current_priority_state = 0 self._current_priority_state = -1
self._state = BarcodeViewState() self._state = BarcodeViewState()
return self._state return self._state
def begin_manual_load(self) -> BarcodeViewState: def begin_manual_load(self) -> BarcodeViewState:
"""Prepare a real versamento into a physical warehouse cell.""" """Prepare a real versamento into a physical warehouse cell."""
self._current_priority_state = 0 self._current_priority_state = -1
self._state = BarcodeViewState( self._state = BarcodeViewState(
mode="manual_load", mode="manual_load",
queue_label="Versamento", queue_label="Versamento",
@@ -80,18 +88,71 @@ class BarcodeService:
return self._state return self._state
def begin_manual_unload(self) -> BarcodeViewState: def begin_manual_unload(self) -> BarcodeViewState:
"""Prepare a direct unload toward the virtual outbound cell 9000000.""" """Prepare a direct unload toward the conventional non-shelved cell."""
self._current_priority_state = 0 self._current_priority_state = -1
self._state = BarcodeViewState( self._state = BarcodeViewState(
mode="manual_unload", mode="manual_unload",
queue_label="Prelievo diretto", queue_label="Prelievo diretto",
status_text="OP Scarico", status_text="OP Scarico",
status_color=self.GRAY, status_color=self.GRAY,
destination_barcode="9000000", destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=True,
) )
return self._state return self._state
def begin_priority_pause(self, id_stato: int) -> BarcodeViewState:
"""Pause the active picking queue locally for exactly one free movement."""
self._current_priority_state = int(id_stato)
self._state = BarcodeViewState(
mode="manual_unload",
queue_label="Pausa PL",
status_text="Premi ESC per uscire dalla pausa",
status_color=self.GRAY,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
)
return self._state
async def skip_current_picking_pallet(self) -> BarcodeActionResult:
"""Mark the currently proposed picking pallet as skipped and advance."""
mode = str(self._state.mode or "")
if mode not in ("priority_high", "priority_low"):
return BarcodeActionResult(False, self._state, "Nessuna picking list attiva.")
id_stato = 1 if mode == "priority_high" else 0
documento = str(self._state.document or "").strip()
pallet = str(self._state.expected_pallet or "").strip()
if not documento or not pallet:
self._state.status_text = "Nessuna UDC da saltare."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
await self.repository.skip_picking_pallet(
documento=documento,
pallet=pallet,
id_stato=id_stato,
operator_id=self.operator_id,
)
queue_label = "Alta priorita' (F1)" if id_stato == 1 else "Bassa priorita' (F2)"
self._current_priority_state = id_stato
self._state = BarcodeViewState(
mode="confirm",
queue_label=queue_label,
status_text="UDC saltata - chiusura manuale richiesta",
status_color=self.RED,
source_location=self._state.source_location,
document=documento,
customer="UDC non trovata",
expected_pallet=pallet,
destination_barcode=self.SHIPPED_BARCODE,
auto_advance_delay_ms=1500,
destination_readonly=True,
)
return BarcodeActionResult(True, self._state, self._state.status_text)
async def start_priority_queue(self, id_stato: int) -> BarcodeActionResult: async def start_priority_queue(self, id_stato: int) -> BarcodeActionResult:
"""Load the next item of the selected legacy priority queue.""" """Load the next item of the selected legacy priority queue."""
@@ -99,12 +160,32 @@ class BarcodeService:
self._current_priority_state = int(id_stato) self._current_priority_state = int(id_stato)
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)" queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
if not row: if not row:
skipped_doc = await self.repository.fetch_active_skipped_document(int(id_stato))
if skipped_doc:
documento = str(skipped_doc.get("Documento") or "").strip()
skipped_count = int(skipped_doc.get("SkippedCount") or 0)
await self.repository.release_picking_document(
documento=documento,
operator_id=self.operator_id,
)
self._current_priority_state = -1
self._state = BarcodeViewState(
mode="manual_unload",
queue_label=queue_label,
status_text=f"PL {documento} sospesa: {skipped_count} UDC saltate. Riprenota per completare.",
status_color=self.RED,
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
)
return BarcodeActionResult(True, self._state)
self._current_priority_state = -1
self._state = BarcodeViewState( self._state = BarcodeViewState(
mode="priority_high" if int(id_stato) == 1 else "priority_low", mode="manual_unload",
queue_label=queue_label, queue_label=queue_label,
status_text="Pronto.", status_text="Pronto.",
status_color=self.RED, status_color=self.RED,
destination_barcode="9000000", destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
) )
return BarcodeActionResult(True, self._state) return BarcodeActionResult(True, self._state)
@@ -122,7 +203,8 @@ class BarcodeService:
document=str(row.get("Documento") or ""), document=str(row.get("Documento") or ""),
customer=customer, customer=customer,
expected_pallet=str(row.get("Pallet") or ""), expected_pallet=str(row.get("Pallet") or ""),
destination_barcode="9000000", destination_barcode=self.SHIPPED_BARCODE,
destination_readonly=True,
) )
return BarcodeActionResult(True, self._state) return BarcodeActionResult(True, self._state)
@@ -138,30 +220,365 @@ class BarcodeService:
if not destination.isdigit(): if not destination.isdigit():
return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.") return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.")
if self._state.mode in ("priority_high", "priority_low") and destination == "9000000": is_priority_mode = self._state.mode in ("priority_high", "priority_low")
expected = str(self._state.expected_pallet or "").strip() expected_before_move = str(self._state.expected_pallet or "").strip()
if expected and expected != pallet: is_picking_unload = bool(is_priority_mode and expected_before_move and destination == self.SHIPPED_BARCODE)
is_direct_unload = bool(destination == self.NON_SCAFFALATA_BARCODE and not is_picking_unload)
is_direct_load = bool(destination not in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and not is_picking_unload)
if is_priority_mode and destination == self.SHIPPED_BARCODE:
if expected_before_move and expected_before_move != pallet:
self._state.scanned_pallet = pallet self._state.scanned_pallet = pallet
self._state.status_text = "Errata lettura: il pallet letto non coincide con quello atteso." self._state.status_text = "Errata lettura: il pallet letto non coincide con quello atteso."
self._state.status_color = self.RED self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text) return BarcodeActionResult(False, self._state, self._state.status_text)
move = await self.repository.execute_legacy_move( if not is_picking_unload:
operator_id=self.operator_id, blocked = await self._block_closed_picking_free_move(pallet, destination)
barcode_cella=destination, if blocked is not None:
barcode_pallet=pallet, return blocked
numero_cella=int(destination),
) target_barcode = destination
if move.rc != 0: target_numero_cella = int(destination)
target_id_cella = 9999 if destination == self.SHIPPED_BARCODE else (1000 if destination == self.NON_SCAFFALATA_BARCODE else None)
target_display = destination
if is_direct_load:
resolved_cell = await self.repository.resolve_physical_cell(destination)
if not resolved_cell:
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = f"Cella non valida: {destination}."
self._state.status_color = self.RED
self._state.focus_destination = True
return BarcodeActionResult(False, self._state, self._state.status_text)
occupants = await self.repository.fetch_open_pallets_by_cell(resolved_cell.id_cella)
if occupants:
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED DESTINATION_OCCUPIED "
f"pallet={pallet} "
f"cell={resolved_cell.id_cella} "
f"occupants={','.join(str(row.get('BarcodePallet') or '') for row in occupants)}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Errore: cella scelta occupata, cambiare cella di destinazione."
self._state.status_color = self.RED
self._state.destination_readonly = False
self._state.focus_destination = True
return BarcodeActionResult(False, self._state, self._state.status_text)
target_barcode = resolved_cell.barcode_cella
target_numero_cella = resolved_cell.numero_cella
target_id_cella = resolved_cell.id_cella
target_display = resolved_cell.ubicazione or destination
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
picking_before_move = await self.repository.fetch_picking_by_pallet(pallet)
if not current_location and not picking_before_move:
if is_direct_load:
trace_row = await self.repository.fetch_trace_by_pallet(pallet)
if not trace_row:
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Errore: UDC non riconosciuta in ERP/SAM."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
log_runtime_event(
"Barcode WMS",
f"AUTO INTAKE NON_SHELVED pallet={pallet} before_cell={target_id_cella}",
)
await self.repository.execute_legacy_move(
operator_id=self.operator_id,
barcode_cella=self.NON_SCAFFALATA_BARCODE,
barcode_pallet=pallet,
numero_cella=int(self.NON_SCAFFALATA_BARCODE),
)
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
try:
current_id_cella = int((current_location or {}).get("IDCella") or 0)
except Exception:
current_id_cella = 0
if current_id_cella != 1000:
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Movimento non confermato: ingresso UDC non scaffalata non riuscito."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
else:
self._state.scanned_pallet = pallet
self._state.status_text = "UDC non presente a magazzino."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
open_locations = await self.repository.fetch_open_locations_by_pallet(pallet)
distinct_cells = sorted({
int(row.get("IDCella") or 0)
for row in open_locations
if row.get("IDCella") is not None
})
if len(distinct_cells) > 1:
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED MULTI_LOCATION "
f"pallet={pallet} "
f"cells={','.join(str(cell) for cell in distinct_cells)}"
),
)
self._state.scanned_pallet = pallet self._state.scanned_pallet = pallet
self._state.status_text = f"Operazione non riuscita (RC={move.rc})." self._state.destination_barcode = destination
self._state.status_text = "UDC presente in piu' celle. Movimento bloccato."
self._state.status_color = self.RED self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text) return BarcodeActionResult(False, self._state, self._state.status_text)
self._state = await self._build_post_move_state( if not is_direct_load and destination not in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
resolved_cell = await self.repository.resolve_physical_cell(destination)
if not resolved_cell:
self._state.scanned_pallet = pallet
self._state.status_text = f"Cella non valida: {destination}."
self._state.status_color = self.RED
self._state.focus_destination = True
return BarcodeActionResult(False, self._state, self._state.status_text)
target_barcode = resolved_cell.barcode_cella
target_numero_cella = resolved_cell.numero_cella
target_id_cella = resolved_cell.id_cella
target_display = resolved_cell.ubicazione or destination
move = await self.repository.execute_legacy_move(
operator_id=self.operator_id,
barcode_cella=target_barcode,
barcode_pallet=pallet,
numero_cella=target_numero_cella,
)
final_location = await self.repository.fetch_current_location_by_pallet(pallet)
try:
final_cell = int((final_location or {}).get("IDCella") or 0)
except Exception:
final_cell = 0
if target_id_cella is not None and final_cell != target_id_cella:
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Movimento non confermato: la UDC non risulta nella cella attesa."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
try:
self._state = await self._build_post_move_state(
barcode_pallet=pallet,
destination_barcode=destination,
destination_display=target_display,
last_priority_state=self._current_priority_state,
auto_advance_delay_ms=3000 if is_picking_unload else 0,
)
except Exception as exc:
log_exception("Barcode WMS", exc, context=f"post move state pallet={pallet} destination={destination}")
self._state = BarcodeViewState(
mode="confirm",
queue_label="Conferma movimento",
status_text="Movimento eseguito. Dettagli non aggiornati.",
status_color=self.GREEN_YELLOW,
destination_barcode=destination,
scanned_pallet=pallet,
)
return BarcodeActionResult(True, self._state, self._state.status_text)
async def submit_unload_with_source_check(self, *, scanned_pallet: str, source_barcode: str) -> BarcodeActionResult:
"""Unload to the non-shelved cell only if the scanned source cell matches DB."""
pallet = str(scanned_pallet or "").strip()
source = str(source_barcode or "").strip()
if not pallet:
return BarcodeActionResult(False, self._state, "Inserisci o leggi il pallet.")
if not source:
return BarcodeActionResult(False, self._state, "Leggi il codice della cella da scaricare.")
if not source.isdigit():
return BarcodeActionResult(False, self._state, "La cella da scaricare deve essere numerica.")
blocked = await self._block_closed_picking_free_move(pallet, source)
if blocked is not None:
return blocked
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
if not current_location:
self._state.scanned_pallet = pallet
self._state.status_text = "UDC non presente a magazzino."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
open_locations = await self.repository.fetch_open_locations_by_pallet(pallet)
distinct_cells = sorted({
int(row.get("IDCella") or 0)
for row in open_locations
if row.get("IDCella") is not None
})
if len(distinct_cells) > 1:
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED MULTI_LOCATION "
f"pallet={pallet} "
f"cells={','.join(str(cell) for cell in distinct_cells)}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = source
self._state.status_text = "UDC presente in piu' celle. Movimento bloccato."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
if source == self.NON_SCAFFALATA_BARCODE:
source_id_cella = 1000
else:
resolved_source = await self.repository.resolve_physical_cell(source)
if not resolved_source:
self._state.scanned_pallet = pallet
self._state.destination_barcode = source
self._state.status_text = f"Cella non valida: {source}."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
source_id_cella = int(resolved_source.id_cella)
current_id_cella = int((current_location or {}).get("IDCella") or 0)
if current_id_cella != source_id_cella:
if source == self.NON_SCAFFALATA_BARCODE and current_id_cella != 1000:
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED SOURCE_REQUIRED "
f"pallet={pallet} "
f"db_cell={current_id_cella} "
f"default_cell={source_id_cella}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = source
self._state.status_text = "Errore: leggi la cella da scaricare."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED SOURCE_MISMATCH "
f"pallet={pallet} "
f"db_cell={current_id_cella} "
f"scanned_cell={source_id_cella}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = source
self._state.status_text = "Errore: UDC in cella diversa, rileggi il codice della cella da scaricare."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
return await self.submit(scanned_pallet=pallet, destination_barcode=self.NON_SCAFFALATA_BARCODE)
async def _block_closed_picking_free_move(
self,
pallet: str,
destination_barcode: str,
) -> BarcodeActionResult | None:
closed_row = await self.repository.fetch_closed_picking_by_pallet(pallet)
if not closed_row:
return None
log_runtime_event(
"Barcode WMS",
(
"MOVE BLOCKED CLOSED_PICKING "
f"pallet={pallet} "
f"destination={destination_barcode} "
f"id_cella={closed_row.get('IDCella')} "
f"id_stato={closed_row.get('IDStato')}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination_barcode
self._state.status_text = "Errore: UDC gia' spedita, problema nella locazione o nella UDC."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
async def reconcile_after_submit_exception(
self,
*,
scanned_pallet: str,
destination_barcode: str,
) -> BarcodeActionResult:
"""Check whether a failed client round-trip actually committed on SQL Server."""
pallet = str(scanned_pallet or "").strip()
destination = str(destination_barcode or "").strip()
if not pallet or not destination or not destination.isdigit():
self._state.status_text = "Transazione non completata, ripeti l'operazione."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
target_id_cella = 9999 if destination == self.SHIPPED_BARCODE else (1000 if destination == self.NON_SCAFFALATA_BARCODE else None)
target_display = destination
if target_id_cella is None:
resolved_cell = await self.repository.resolve_physical_cell(destination)
if not resolved_cell:
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Transazione non completata, ripeti l'operazione."
self._state.status_color = self.RED
self._state.focus_destination = True
return BarcodeActionResult(False, self._state, self._state.status_text)
target_id_cella = int(resolved_cell.id_cella)
target_display = resolved_cell.ubicazione or destination
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
try:
current_id_cella = int((current_location or {}).get("IDCella") or 0)
except Exception:
current_id_cella = 0
if current_id_cella != target_id_cella:
log_runtime_event(
"Barcode WMS",
(
"MOVE RECOVERY NOT_CONFIRMED "
f"pallet={pallet} "
f"destination={destination} "
f"expected_cell={target_id_cella} "
f"actual_cell={current_id_cella}"
),
)
self._state.scanned_pallet = pallet
self._state.destination_barcode = destination
self._state.status_text = "Transazione non completata, ripeti l'operazione."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
last_priority_state = self._current_priority_state
is_recovered_picking = bool(destination == self.SHIPPED_BARCODE and last_priority_state in (0, 1))
auto_advance_delay_ms = 3000 if is_recovered_picking else 0
state = await self._build_post_move_state(
barcode_pallet=pallet, barcode_pallet=pallet,
destination_barcode=destination, destination_barcode=destination,
last_priority_state=self._current_priority_state, destination_display=target_display,
last_priority_state=last_priority_state,
auto_advance_delay_ms=auto_advance_delay_ms,
)
state.status_text = (
"Ok Scarico - risposta persa recuperata"
if is_recovered_picking
else "Movimento completato - risposta persa recuperata"
)
state.status_color = self.GREEN_YELLOW
self._state = state
log_runtime_event(
"Barcode WMS",
(
"MOVE RECOVERY CONFIRMED "
f"pallet={pallet} "
f"destination={destination} "
f"cell={target_id_cella}"
),
) )
return BarcodeActionResult(True, self._state, self._state.status_text) return BarcodeActionResult(True, self._state, self._state.status_text)
@@ -170,11 +587,21 @@ class BarcodeService:
*, *,
barcode_pallet: str, barcode_pallet: str,
destination_barcode: str, destination_barcode: str,
destination_display: str,
last_priority_state: int, last_priority_state: int,
auto_advance_delay_ms: int,
) -> BarcodeViewState: ) -> BarcodeViewState:
"""Mirror the legacy confirmation flow after one stored-procedure move.""" """Mirror the legacy confirmation flow after one stored-procedure move."""
picking_row = await self.repository.fetch_picking_by_pallet(barcode_pallet) is_priority_shipping_confirmation = (
destination_barcode == self.SHIPPED_BARCODE
and last_priority_state in (0, 1)
)
picking_row = (
await self.repository.fetch_picking_by_pallet(barcode_pallet)
if is_priority_shipping_confirmation
else None
)
if picking_row: if picking_row:
customer = f"{picking_row.get('CodNazione') or ''} - {picking_row.get('NAZIONE') or ''}".strip(" -") customer = f"{picking_row.get('CodNazione') or ''} - {picking_row.get('NAZIONE') or ''}".strip(" -")
source_location = self._display_location( source_location = self._display_location(
@@ -191,6 +618,8 @@ class BarcodeService:
customer=customer, customer=customer,
expected_pallet=str(picking_row.get("Pallet") or ""), expected_pallet=str(picking_row.get("Pallet") or ""),
destination_barcode=destination_barcode, destination_barcode=destination_barcode,
auto_advance_delay_ms=auto_advance_delay_ms,
destination_readonly=True,
) )
trace_row = await self.repository.fetch_trace_by_pallet(barcode_pallet) trace_row = await self.repository.fetch_trace_by_pallet(barcode_pallet)
@@ -199,31 +628,39 @@ class BarcodeService:
prodotto = str(trace_row.get("Prodotto") or "") prodotto = str(trace_row.get("Prodotto") or "")
descrizione = str(trace_row.get("Descrizione") or "") descrizione = str(trace_row.get("Descrizione") or "")
queue_label = "Alta priorita' (F1)" if last_priority_state == 1 else ("Bassa priorita' (F2)" if last_priority_state == 0 else "Conferma movimento") queue_label = "Alta priorita' (F1)" if last_priority_state == 1 else ("Bassa priorita' (F2)" if last_priority_state == 0 else "Conferma movimento")
destination_label = self._display_destination_location(
destination_barcode=destination_barcode,
destination_display=destination_display,
)
return BarcodeViewState( return BarcodeViewState(
mode="confirm", mode="confirm",
queue_label=queue_label, queue_label=queue_label,
status_text=( status_text=(
"Ok Scarico" if destination_barcode == "9000000" else f"Ok Carico - {destination_barcode}" f"Ok Scarico - {destination_label}"
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE)
else f"Ok Carico - {destination_label}"
), ),
status_color=self.GREEN_YELLOW, status_color=self.GREEN_YELLOW,
source_location=str(destination_barcode or ""), source_location=destination_label,
document=( document=(
self.CONVENTIONAL_LOCATION_BY_CELL[9999] self.CONVENTIONAL_LOCATION_BY_CELL[9999 if destination_barcode == self.SHIPPED_BARCODE else 1000]
if destination_barcode == "9000000" and last_priority_state in (0, 1) if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
else lotto else lotto
), ),
customer=( customer=(
lotto lotto
if destination_barcode == "9000000" and last_priority_state in (0, 1) if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
else prodotto else prodotto
), ),
expected_pallet=( expected_pallet=(
" - ".join(part for part in (prodotto, descrizione) if part) " - ".join(part for part in (prodotto, descrizione) if part)
if destination_barcode == "9000000" and last_priority_state in (0, 1) if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
else descrizione else descrizione
), ),
destination_barcode=destination_barcode, destination_barcode=destination_barcode,
scanned_pallet=barcode_pallet, scanned_pallet=barcode_pallet,
auto_advance_delay_ms=auto_advance_delay_ms,
destination_readonly=destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE),
) )
return BarcodeViewState( return BarcodeViewState(
@@ -235,6 +672,15 @@ class BarcodeService:
scanned_pallet=barcode_pallet, scanned_pallet=barcode_pallet,
) )
def _display_destination_location(self, *, destination_barcode: str, destination_display: str) -> str:
"""Return the confirmation label for a movement destination."""
if destination_barcode == self.NON_SCAFFALATA_BARCODE:
return self.CONVENTIONAL_LOCATION_BY_CELL[1000]
if destination_barcode == self.SHIPPED_BARCODE:
return self.CONVENTIONAL_LOCATION_BY_CELL[9999]
return str(destination_display or destination_barcode or "")
def _display_location(self, *, cella: object, ubicazione: object) -> str: def _display_location(self, *, cella: object, ubicazione: object) -> str:
"""Return the operator-facing location, honoring legacy conventional cells.""" """Return the operator-facing location, honoring legacy conventional cells."""

View File

@@ -6,6 +6,9 @@ import tkinter as tk
import customtkinter as ctk import customtkinter as ctk
from ui_theme import theme_color, theme_font, theme_padding, theme_value from ui_theme import theme_color, theme_font, theme_padding, theme_value
from version_info import module_version
__version__ = module_version(__name__)
class InlineBusyOverlay: class InlineBusyOverlay:

View File

@@ -0,0 +1,256 @@
# Prontuario Git per installare/aggiornare Warehouse da remoto
Repository Gitea:
https://gitea.alessandrobonvicini.it/administrator/ware_house.git
Cartella consigliata sul PC di produzione:
C:\flywms
Ramo principale da usare in produzione:
main
Ultimo tag stabile al momento della stesura:
alpha7-stabilizzazione-non-scaffalate
1. Prima installazione su PC nuovo
----------------------------------
Aprire PowerShell nella macchina di produzione ed eseguire:
cd C:\
git clone https://gitea.alessandrobonvicini.it/administrator/ware_house.git C:\flywms
cd C:\flywms
git switch main
git pull origin main
Installare le dipendenze Python:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
Avvio applicazione desktop:
python main.py
Oppure senza finestra console:
pythonw warehouse.pyw
Avvio applicazione barcode:
python barcode_client.py
Oppure senza finestra console:
pythonw barcode.pyw
2. Aggiornare un'installazione gia' esistente
--------------------------------------------
Sul PC di produzione:
cd C:\flywms
git fetch --all --tags
git switch main
git pull origin main
python -m pip install -r requirements.txt
Poi rilanciare l'applicazione.
2.bis Aggiornare produzione dopo merge su main gia' fatto
---------------------------------------------------------
Questo e' il caso piu' normale:
- sul PC di sviluppo hai gia' fatto merge del ramo di lavoro dentro main;
- hai gia' fatto push su Gitea;
- sul PC di produzione devi solo scaricare l'ultima versione ufficiale.
Sul PC di produzione eseguire:
cd C:\flywms
git fetch --all --tags
git switch main
git pull origin main
python -m pip install -r requirements.txt
Poi chiudere e riaprire l'applicazione.
Controllo consigliato dopo l'aggiornamento:
git log --oneline --decorate -3
git describe --tags --abbrev=0 main
3. Controllare che versione/tag si sta usando
---------------------------------------------
Dentro C:\flywms:
git status
git log --oneline --decorate -5
Vedere l'ultimo tag raggiungibile da main:
git describe --tags --abbrev=0 main
Vedere tutti i tag, dal piu' recente:
git tag --sort=-creatordate
Vedere graficamente branch, commit e tag:
git log --oneline --decorate --graph --all -20
4. Installare esattamente una versione taggata
---------------------------------------------
Usare questa modalita' quando si vuole installare una fotografia precisa e non
necessariamente l'ultima versione di main.
Esempio:
cd C:\
git clone --branch alpha7-stabilizzazione-non-scaffalate https://gitea.alessandrobonvicini.it/administrator/ware_house.git C:\flywms_alpha7
Nota:
Un tag e' una fotografia ferma. Non avanza con git pull come un branch.
Per produzione ordinaria e' piu' comodo usare main.
5. Portare l'ultimo sviluppo dentro main
---------------------------------------
Da fare sulla macchina di sviluppo, non sul PC di produzione.
Se l'ultima versione buona e' su sviluppo3 e vuoi renderla ufficiale su main:
cd C:\devel\python\ware_house
git fetch --all --tags
git status
git switch main
git pull origin main
git merge sviluppo3
git push origin main
Se vuoi anche creare un tag stabile:
git tag nome-del-tag
git push origin nome-del-tag
Esempio:
git tag alpha8-nome-breve-della-modifica
git push origin alpha8-nome-breve-della-modifica
6. Se il PC di produzione ha modifiche locali
---------------------------------------------
Prima di aggiornare controllare:
cd C:\flywms
git status
Se compaiono file modificati che non devono essere conservati, NON fare comandi
distruttivi senza sapere cosa sono. Prima copiare o rinominare i file modificati.
File che normalmente NON devono essere messi nel repository:
db_connection.json
*.log
__pycache__
*.pyc
*.zip
7. Script SQL
-------------
Gli script SQL sono nel repository e vanno lanciati in SSMS solo quando serve
preparare o aggiornare il database per le funzioni Python.
Patch principali:
apply_python_parallel_pickinglist_patch.sql
apply_python_pickinglist_history_views.sql
apply_online_history_forms_patch.sql
Rollback principali:
rollback_python_parallel_pickinglist_patch.sql
rollback_python_pickinglist_history_views.sql
rollback_online_history_forms_patch.sql
Nota:
Prima di lanciare patch SQL in produzione, fare sempre backup o snapshot del
database, oppure verificare di avere una procedura di rollback sicura.
8. Comando rapido piu' usato
----------------------------
Aggiornamento standard del PC di produzione:
cd C:\flywms
git fetch --all --tags
git switch main
git pull origin main
python -m pip install -r requirements.txt
9. Per vedere cosa ho installato
------------------------------------------------------
git log --oneline --decorate -3
git describe --tags --abbrev=0 main
10. Salvare questo prontuario su Gitea
-------------------------------------
Da fare sul PC di sviluppo, dentro la cartella del repository:
cd C:\devel\python\ware_house
git status
git add comandi_git_per_installare.txt
git commit -m "Aggiungi prontuario Git per installazione remota"
git push origin main
Dopo il push, il file sara' disponibile anche sui PC di produzione al prossimo:
cd C:\flywms
git pull origin main
11. Aggiornare il prontuario se lo modifico in locale
----------------------------------------------------
Se modifichi questo file sul PC di sviluppo e vuoi sincronizzarlo su Gitea:
cd C:\devel\python\ware_house
git status
git diff -- comandi_git_per_installare.txt
git add comandi_git_per_installare.txt
git commit -m "Aggiorna prontuario Git installazione"
git push origin main
Se invece lo hai modificato su un PC di produzione e vuoi prima recuperare
eventuali aggiornamenti online:
cd C:\flywms
git status
git pull origin main
Se Git segnala conflitti, fermarsi e risolverli prima di rilanciare
l'applicazione. Per evitare confusione, e' meglio modificare questo prontuario
solo sul PC di sviluppo e poi fare push su Gitea.

View File

@@ -13,6 +13,9 @@ from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_section, theme_value from ui_theme import theme_section, theme_value
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
CONFIG_PATH = Path(__file__).with_name("db_connection.json") CONFIG_PATH = Path(__file__).with_name("db_connection.json")
DEFAULT_DB_CONFIG: dict[str, Any] = { DEFAULT_DB_CONFIG: dict[str, Any] = {
@@ -101,7 +104,7 @@ class DatabaseConfigWindow(tk.Toplevel):
self.result_config: dict[str, Any] | None = None self.result_config: dict[str, Any] | None = None
merged = {**DEFAULT_DB_CONFIG, **(initial or {})} merged = {**DEFAULT_DB_CONFIG, **(initial or {})}
self.title(loc_text("dbconfig.title", catalog=self._locale_catalog, default="Configurazione Database")) self.title(versioned_title(loc_text("dbconfig.title", catalog=self._locale_catalog, default="Configurazione Database"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "520x360"))) self.geometry(str(theme_value(self._theme, "window_geometry", "520x360")))
self.resizable(False, False) self.resizable(False, False)
try: try:

View File

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

44
docs/review/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Documentazione review Warehouse / FlyWMS bridge
Questa cartella contiene la documentazione tecnica destinata alla review del codice Python e SQL.
## Obiettivi
- Descrivere il processo logico implementato da ogni modulo.
- Documentare firme, parametri, ritorni e responsabilita' di funzioni e classi.
- Documentare SQL, viste, stored procedure, tabelle coinvolte ed effetti collaterali.
- Aggiungere commenti inline nel codice solo dove il comportamento non e' auto-evidente.
## Criterio di documentazione
La documentazione e' divisa in tre livelli.
1. Processo logico: flussi operativi, eventi UI, query, effetti sul database.
2. API interna: classi, funzioni, parametri, valori di ritorno, errori, dipendenze.
3. Dettaglio implementativo: strutture dati, cicli, dedupliche, transazioni, side effect.
## Ambito escluso
Questa review non considera:
- `__pycache__`
- log applicativi
- zip di distribuzione
- cartelle `_package_*`
- cartella `trash`
- build HTML generata in `docs/_build`
## Primo lotto documentato
- [Modulo storico picking list](module_storico_pickinglist.md)
- [SQL storico picking list](sql_storico_pickinglist.md)
- [Modulo gestione picking list](module_gestione_pickinglist.md)
- [Modulo prenota/sprenota SQL](module_prenota_sprenota_sql.md)
- [SQL prenotazione picking list](sql_pickinglist_reservation.md)
## Prossimi lotti consigliati
1. `gestione_scarico.py`
2. `barcode_service.py` e `barcode_repository.py`
3. `main.py`, `login_window.py`, `db_config.py`
4. Moduli di visualizzazione: `gestione_layout.py`, `reset_corsie.py`, `search_pallets.py`, `view_celle_multi_udc.py`

View File

@@ -0,0 +1,68 @@
# Barcode: logging errori e transazioni
## Problema rilevato
Durante uno scarico picking list dal lettore barcode, l'operazione DB e' arrivata a buon fine ma il client ha mostrato un'eccezione. I file standard output/error erano vuoti.
Con `pythonw` non e' sufficiente affidarsi a stdout/stderr: eccezioni Tk, future asincrone o callback possono non arrivare al wrapper principale.
## Strategia adottata
Il client barcode ora registra errori in modo esplicito su `warehouse_fatal.log` e su `barcode_wms_launch.log`.
Canali coperti:
- `sys.excepthook`
- `threading.excepthook`
- `Tk.report_callback_exception`
- exception handler del loop `asyncio`
- eccezioni delle operazioni asincrone barcode
- errori del repository durante movimento DB
- errori post-movimento durante ricostruzione stato UI
## Transazione movimento DB
Il movimento barcode passa da `BarcodeRepository.execute_legacy_move`.
La batch SQL usa:
```sql
SET XACT_ABORT ON;
```
ed e' eseguita con:
```python
query_json(..., commit=True)
```
Questo significa:
- successo: commit;
- eccezione SQL/Python: rollback automatico della transazione SQLAlchemy;
- il valore `@RC` restituito dalla stored legacy non viene interpretato come errore.
Nota importante: in `sp_xMagGestioneMagazziniPallet`, quando il movimento va a buon fine, `@RC` viene valorizzato con `@@IDENTITY`, cioe' con l'identificativo del movimento inserito. Quindi un valore diverso da zero e' compatibile con un movimento riuscito e non deve causare rollback.
## Distinzione tra movimento e refresh UI
Dopo un movimento riuscito, il service ricostruisce il messaggio UI interrogando picking list e tracciabilita'.
Se questa fase post-movimento fallisce:
- il movimento resta valido;
- l'errore viene loggato;
- l'utente vede `Movimento eseguito. Dettagli non aggiornati.`;
- non viene mostrato il messaggio "transazione non completata".
Se invece fallisce la transazione vera:
- l'utente vede `Transazione non completata, ripeti l'operazione.`;
- il dettaglio tecnico viene scritto nel log.
## File principali
- `runtime_support.py`
- `barcode_client.py`
- `barcode_repository.py`
- `barcode_service.py`

View File

@@ -0,0 +1,119 @@
# Piano operativo documentazione review
## Strategia
La documentazione viene prodotta per lotti piccoli e verificabili. Ogni lotto deve contenere:
- descrizione del processo logico;
- tabella firme funzioni/classi;
- documentazione SQL collegata;
- eventuali commenti inline mirati;
- compilazione Python dei moduli modificati.
## Lotto 1 - Storico picking list
Stato: completato.
File documentati:
- `storico_pickinglist.py`
- `apply_python_pickinglist_history_views.sql`
- `apply_online_history_forms_patch.sql`
- query runtime `SQL_STORICO_PL`
- query runtime `SQL_STORICO_PL_DETAILS`
- movimento collegato `move_pallet_async`
Motivo priorita:
- contiene una nuova funzione con effetti sul database;
- gestisce casi critici `Chiusa ERP con residui`;
- usa convenzioni operative `1000 / Non scaff.` e `9999 / 7G.1.1`.
## Lotto 2 - Gestione picking list
Stato: completato.
File target:
- `gestione_pickinglist.py`
- `prenota_sprenota_sql.py`
- `apply_python_parallel_pickinglist_patch.sql`
- `rollback_python_parallel_pickinglist_patch.sql`
Aspetti da documentare:
- prenotazione/sprenotazione Python-only;
- differenza tra vista residua e vista storica;
- aggiornamento griglia alta/bassa;
- stato `IDStato`;
- separazione da C# legacy;
- limiti concorrenza.
## Lotto 3 - Movimento UDC e scarico
File target:
- `gestione_scarico.py`
- `storico_udc.py`
Aspetti da documentare:
- batch `SQL_SCARICA_UDC`;
- movimento `P` e `V`;
- audit utenti/date;
- cella source e target;
- fallback SPED in storico UDC.
## Lotto 4 - Barcode
File target:
- `barcode_client.py`
- `barcode_service.py`
- `barcode_repository.py`
Aspetti da documentare:
- stati operativi del barcode;
- F1/F2 e priorita picking list;
- gestione `9000000`;
- mappatura 1:1 con comportamento C#;
- stabilita di connessione DB.
## Lotto 5 - Avvio, autenticazione e configurazione
File target:
- `main.py`
- `login_window.py`
- `db_config.py`
- `runtime_support.py`
- `user_session.py`
Aspetti da documentare:
- single instance;
- login operatore;
- creazione configurazione DB;
- gestione errori in `pythonw`;
- shutdown ordinato.
## Lotto 6 - Visualizzazioni operative
File target:
- `gestione_layout.py`
- `reset_corsie.py`
- `search_pallets.py`
- `view_celle_multi_udc.py`
- `ui_tables.py`
- `ui_theme.py`
Aspetti da documentare:
- griglie e layout;
- query diagnostiche;
- overlay async;
- colori semantici;
- export XLSX;
- bonifica UDC fantasma.

View File

@@ -0,0 +1,154 @@
# Modulo `gestione_pickinglist.py`
## Scopo
Il modulo implementa la finestra operativa "Gestione Picking List". La finestra serve al backoffice/magazzino per:
- visualizzare le picking list ancora lavorabili;
- selezionare una sola lista alla volta;
- visualizzare le UDC residue della lista selezionata;
- prenotare una lista per darle priorita' alta sul barcode;
- s-prenotare la lista correntemente prenotata;
- mantenere UI reattiva tramite query asincrone, overlay e spinner.
La finestra lavora sulla vista residuale `dbo.py_ViewPackingListRestante`, quindi mostra cio' che resta da scaricare, non necessariamente il documento ERP completo. Il documento completo e' invece esposto dalla form "Storico Picking List".
## Processo logico
### Apertura finestra
1. `open_pickinglist_window` crea o riporta in primo piano una singola finestra.
2. La finestra viene inizialmente nascosta con `withdraw` e alpha `0.0`.
3. Viene creato `GestionePickingListFrame`.
4. Dopo la stabilizzazione layout, `_first_show` avvia il primo caricamento.
5. La finestra viene mostrata solo quando il layout e' pronto, riducendo flicker.
### Caricamento lista alta
1. `reload_from_db` esegue `SQL_PL`.
2. `SQL_PL` legge da `dbo.py_ViewPackingListRestante`.
3. Il risultato viene normalizzato con `_rows_to_dicts`.
4. `_refresh_mid_rows` ricrea la tabella alta.
5. Ogni riga e' rappresentata da un `PLRow`, che incapsula dizionario dati e checkbox.
### Selezione lista
1. Il click sul checkbox chiama `on_row_checked`.
2. La selezione e' esclusiva: eventuali altre righe selezionate vengono deselezionate.
3. Il documento selezionato viene salvato in `detail_doc`.
4. Viene eseguita `SQL_PL_DETAILS`.
5. Il dettaglio viene messo in cache in `_detail_cache`.
6. `_refresh_details_incremental` ordina e renderizza il dettaglio con `tksheet`.
### Ricarica
`reload_from_db` preserva il documento selezionato quando possibile. Questo e' importante per il caso operativo in cui il barcode sta scaricando UDC mentre l'operatore aggiorna la finestra.
Flusso:
1. `_selected_documento_for_reload` determina il documento da mantenere.
2. La griglia alta viene ricaricata.
3. `_reselect_documento_after_reload` riseleziona la stessa lista se ancora presente.
4. Il dettaglio viene ricaricato dalla vista residuale.
### Prenotazione
1. `on_prenota` verifica il permesso `pickinglist.prenota`.
2. Richiede una riga selezionata.
3. Se `IDStato == 1`, l'operazione e' no-op.
4. Verifica sessione operatore valida.
5. Chiama `sp_xExePackingListPallet_async(..., Azione="P")`.
6. Se `rc == 0`, logga audit e ricarica la griglia mantenendo il documento.
### S-prenotazione
1. `on_sprenota` verifica il permesso `pickinglist.sprenota`.
2. Richiede una riga selezionata.
3. Se `IDStato == 0`, l'operazione e' no-op.
4. Chiama `sp_xExePackingListPallet_async(..., Azione="S")`.
5. Se `rc == 0`, logga audit e ricarica.
## Semantica operativa
La UI non implementa toggle implicito. I pulsanti hanno semantica esplicita:
| Pulsante | Effetto |
| --- | --- |
| `Prenota` | porta la lista selezionata a priorita' alta se non gia' prenotata |
| `S-prenota` | libera la lista selezionata se e' la lista attiva |
Premere piu' volte `Prenota` sulla stessa lista gia' prenotata non la libera. Premere piu' volte `S-prenota` su lista non prenotata non la prenota.
## Strutture dati principali
| Nome | Tipo | Ruolo |
| --- | --- | --- |
| `rows_models` | `list[PLRow]` | modelli riga della griglia alta |
| `_detail_cache` | `dict[Any, list]` | cache righe dettaglio per documento |
| `detail_doc` | `Any` | documento correntemente selezionato |
| `_detail_sort_key` | `str | None` | colonna dettaglio ordinata |
| `_detail_sort_reverse` | `bool` | verso ordinamento dettaglio |
| `pl_table` | `ScrollTable` | tabella custom griglia alta |
| `detail_sheet` | `tksheet.Sheet` | griglia dettaglio ad alto volume |
| `spinner` | `ToolbarSpinner` | feedback leggero toolbar |
| `busy` | `InlineBusyOverlay` | overlay query/operazioni lente |
## Funzioni e classi principali
| Oggetto | Firma | Ruolo |
| --- | --- | --- |
| `_rows_to_dicts` | `_rows_to_dicts(res: Dict[str, Any]) -> List[Dict[str, Any]]` | normalizza payload DB |
| `_s` | `_s(v) -> str` | converte `None` in stringa vuota |
| `_first` | `_first(d: Dict[str, Any], keys: List[str], default: str = "")` | estrae il primo campo valorizzato |
| `ColSpec` | `dataclass(title, key, width, anchor)` | descrive una colonna tabellare |
| `ToolbarSpinner` | classe | animazione leggera toolbar |
| `ScrollTable` | classe | tabella custom per griglia alta |
| `PLRow` | classe | modello riga PL + checkbox |
| `GestionePickingListFrame` | classe | frame principale |
| `_build_detail_sheet` | `_build_detail_sheet(self)` | crea tksheet dettaglio |
| `_detail_rows_to_sheet_data` | `_detail_rows_to_sheet_data(self, rows)` | converte righe DB in righe tksheet |
| `_load_detail_sheet_data` | `_load_detail_sheet_data(self, data)` | applica headers, dati e zebra |
| `_refresh_mid_rows` | `_refresh_mid_rows(self, rows)` | ricrea griglia alta |
| `_get_selected_model` | `_get_selected_model(self) -> Optional[PLRow]` | ritorna riga selezionata |
| `on_row_checked` | `on_row_checked(self, model: PLRow, is_checked: bool)` | carica dettaglio della lista |
| `reload_from_db` | `reload_from_db(self, first=False, reselect_documento=None)` | aggiorna griglia alta |
| `_refresh_details` | `_refresh_details(self)` | ridisegna dettaglio da cache |
| `_refresh_details_incremental` | `_refresh_details_incremental(self, batch_size=25)` | render dettaglio con overlay |
| `on_prenota` | `on_prenota(self)` | prenota lista selezionata |
| `on_sprenota` | `on_sprenota(self)` | s-prenota lista selezionata |
| `create_frame` | `create_frame(parent, *, db_client=None, conn_str=None, session=None)` | factory frame |
| `open_pickinglist_window` | `open_pickinglist_window(parent, db_client, session=None) -> tk.Misc` | entry point finestra |
## Query runtime
### `SQL_PL`
Aggrega `dbo.py_ViewPackingListRestante` per documento. Conta UDC con:
```sql
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '')) AS Pallet
```
Questo evita il doppio conteggio di UDC multi-lotto.
### `SQL_PL_DETAILS`
Ritorna tutte le righe residuali della lista selezionata, ordinate per `Ordinamento`.
## Effetti sul database
Il modulo UI non aggiorna direttamente tabelle. Tutte le modifiche passano da `sp_xExePackingListPallet_async`, che esegue `dbo.py_sp_xExePackingListPallet`.
Effetti indiretti:
- aggiorna `dbo.PyPickingListReservation`;
- aggiorna `dbo.Celle.IDStato`;
- scrive audit applicativo con `log_user_action`;
- la stored puo' scrivere log legacy tramite `sp_LogPackingList`.
## Rischi e note review
- La vista residuale cambia mentre il barcode scarica UDC: per questo `Ricarica` conserva la selezione e ricarica il dettaglio.
- La tabella alta e il dettaglio possono non avere lo stesso numero di righe dello storico, perche' qui si vedono solo residui.
- La convivenza C#/Python dipende dalla presenza degli oggetti `py_*`.
- La selezione e' esclusiva lato UI, ma il secondo livello F2 barcode dipende dalla logica barcode/stato DB, non da una seconda prenotazione backoffice.

View File

@@ -0,0 +1,97 @@
# Modulo `prenota_sprenota_sql.py`
## Scopo
Il modulo contiene il port asincrono della stored procedure di prenotazione picking list. E' il ponte tra la UI Python e la stored SQL Python-only `dbo.py_sp_xExePackingListPallet`.
La funzione pubblica principale e':
```python
async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, Azione: str = "P") -> SPResult
```
## Motivazione
Durante la fase di test sul campo devono convivere:
- applicazione C# legacy;
- applicazione Python.
Per evitare collisioni, il Python non chiama `dbo.sp_xExePackingListPallet` legacy ma `dbo.py_sp_xExePackingListPallet`.
## Semantica parametro `Azione`
| Azione | Significato |
| --- | --- |
| `P` | prenota il documento |
| `S` | s-prenota il documento |
Ogni altra azione ritorna:
```python
SPResult(rc=-10, message="Azione non valida: ...")
```
## Classe `SPResult`
Firma:
```python
@dataclass
class SPResult:
rc: int = 0
message: Optional[str] = ""
id_result: Optional[int] = None
```
Campi:
| Campo | Ruolo |
| --- | --- |
| `rc` | codice ritorno della stored |
| `message` | messaggio errore o vuoto |
| `id_result` | predisposizione per ID risultato, non usato oggi |
## Funzioni helper
| Funzione | Firma | Ruolo |
| --- | --- | --- |
| `_query_one_value` | `_query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]` | helper generico scalar, oggi non centrale nel flusso |
| `_query_all` | `_query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]` | normalizza query multi-riga |
| `_execute` | `_execute(db, sql: str, params: Dict[str, Any]) -> int` | helper generico DML |
| `sp_xExePackingListPallet_async` | vedi sopra | esegue stored Python-only |
## Processo logico `sp_xExePackingListPallet_async`
1. Normalizza `Azione` a maiuscolo.
2. Valida `Azione in ("P", "S")`.
3. Costruisce una batch SQL con output parameter `@RC`.
4. Esegue:
```sql
EXEC dbo.py_sp_xExePackingListPallet
@IDOperatore = :IDOperatore,
@Documento = :Documento,
@Azione = :Azione,
@RC = @RC OUTPUT;
```
5. Legge il valore `RC`.
6. Ritorna `SPResult(rc=rc, message="", id_result=None)`.
7. In caso di eccezione ritorna `SPResult(rc=-1, message=str(exc), id_result=None)`.
## Commit
La chiamata usa:
```python
commit=True
```
Motivo: la stored modifica `PyPickingListReservation` e `Celle.IDStato`.
## Rischi e note review
- Gli helper `_query_one_value`, `_query_all`, `_execute` sono generici e non tutti sono essenziali al percorso attuale.
- La funzione cattura le eccezioni e ritorna `SPResult(rc=-1)`, quindi la UI deve controllare sempre `rc`.
- La transazione reale dipende dal comportamento di `query_json(..., commit=True)` nel client DB.

View File

@@ -0,0 +1,150 @@
# Modulo `storico_pickinglist.py`
## Scopo
Il modulo implementa la finestra "Storico Picking List". La finestra permette di:
- cercare picking list storiche per numero documento;
- visualizzare lo stato operativo sintetico di ciascuna lista;
- visualizzare il dettaglio UDC/lotto/articolo della lista selezionata;
- evidenziare liste chiuse, liste attive e casi anomali;
- versare in blocco in `7G.1.1` le UDC residue di una lista classificata come `Chiusa ERP con residui`.
Il modulo legge da viste SQL dedicate al ramo Python. Non usa le viste legacy C# per evitare collisioni durante il periodo in cui i due programmi convivono sullo stesso database.
## Dipendenze principali
- `customtkinter` e `tkinter.ttk`: UI e griglie.
- `AsyncRunner`: esecuzione asincrona delle query senza bloccare la GUI.
- `InlineBusyOverlay`: indicatore visivo durante query e movimenti.
- `move_pallet_async`: funzione centrale per registrare un movimento UDC verso una cella.
- `ui_tables`: stile comune delle griglie.
- `version_info`: versione visibile nel titolo finestra.
- `window_placement`: posizionamento della finestra rispetto al launcher.
## Processo logico
### Apertura finestra
1. `open_storico_pickinglist_window(parent, db_client, session)` crea `StoricoPickingListWindow`.
2. La finestra viene posizionata sotto il launcher con `place_window_fullsize_below_parent_later`.
3. Dopo 300 ms viene chiamato `_load_master`.
### Caricamento griglia alta
1. `_load_master` legge il filtro `Documento`.
2. Viene eseguita `SQL_STORICO_PL`.
3. `_rows_to_dicts` normalizza il payload del DB.
4. `_fill_master` svuota griglia alta e dettaglio.
5. Per ogni documento viene calcolato il tag visuale:
- `warning` se lista aperta con UDC gia' spedite;
- `done` se lista chiusa o esaurita;
- `active` se lista prenotata;
- zebra tag per alternanza righe.
6. La riga viene inserita nella griglia alta.
### Selezione documento
1. `_on_master_select` legge la riga selezionata.
2. Memorizza:
- `_selected_documento`
- `_selected_stato_operativo`
- `_detail_rows`
3. Abilita o disabilita il pulsante "Versa residui in 7G.1.1".
4. Esegue `SQL_STORICO_PL_DETAILS`.
5. `_fill_detail` carica la griglia bassa.
### Versamento residui in 7G.1.1
Il pulsante e' abilitato solo se `_selected_stato_operativo == "Chiusa ERP con residui"`.
Flusso:
1. `_ship_selected_residuals` verifica lo stato selezionato.
2. Calcola una stima delle UDC residue gia' presenti nel dettaglio UI.
3. Mostra una conferma all'operatore.
4. Dentro il job asincrono rilegge il dettaglio dal DB, cosi' evita dati UI obsoleti.
5. `_residual_pallets_from_rows` deduplica le UDC e scarta quelle gia' in `IDCella = 9999`.
6. Per ogni UDC residua chiama `move_pallet_async` con:
- `target_idcella = 9999`
- `target_barcode_cella = "9000000"`
- `utente = login sessione` oppure `warehouse_ui`
7. Al termine mostra un riepilogo.
8. Ricarica la griglia alta e tenta di ripristinare la selezione del documento.
## Stati operativi
La colonna `Stato` della griglia alta deriva da SQL:
| Stato operativo | Condizione |
| --- | --- |
| `Chiusa ERP con residui` | `StatoDocumento = 'D'` e almeno una UDC non e' in `9999` |
| `Chiusa` | `StatoDocumento = 'D'` e nessuna UDC residua |
| `Esaurita` | tutte le UDC risultano spedite |
| `In corso` | almeno una UDC e' spedita e almeno una resta da lavorare |
| `Da lavorare` | nessuna UDC ancora spedita |
## Strutture dati interne
| Nome | Tipo | Ruolo |
| --- | --- | --- |
| `var_documento` | `tk.StringVar` | filtro documento digitato dall'utente |
| `_selected_documento` | `str | None` | documento correntemente selezionato |
| `_selected_stato_operativo` | `str` | stato operativo della riga selezionata |
| `_detail_rows` | `list[dict[str, Any]]` | ultimo dettaglio normalizzato caricato nella griglia bassa |
| `master_tree` | `ttk.Treeview` | griglia alta documenti |
| `detail_tree` | `ttk.Treeview` | griglia bassa righe UDC |
| `btn_ship_residuals` | `ctk.CTkButton` | azione di versamento massivo residui |
## Funzioni e classi
| Oggetto | Firma | Ruolo |
| --- | --- | --- |
| `_rows_to_dicts` | `_rows_to_dicts(res: dict[str, Any] | None) -> list[dict[str, Any]]` | normalizza payload DB in lista di dizionari |
| `_format_date` | `_format_date(value: Any) -> str` | converte date SQL/SAMA in `dd/mm/yyyy` |
| `StoricoPickingListWindow.__init__` | `__init__(self, parent: tk.Widget, db_client, session=None)` | costruisce stato, tema, runner async e UI |
| `_build_ui` | `_build_ui(self) -> None` | crea toolbar, griglia alta e griglia dettaglio |
| `_make_tree` | `_make_tree(self, *, row: int, columns: tuple[str, ...], widths: dict[str, int]) -> ttk.Treeview` | factory griglia con stile comune |
| `_load_master` | `_load_master(self) -> None` | lancia query master asincrona |
| `_fill_master` | `_fill_master(self, rows: list[dict[str, Any]]) -> None` | popola griglia alta |
| `_on_master_select` | `_on_master_select(self, _event=None) -> None` | gestisce selezione documento e carica dettaglio |
| `_fill_detail` | `_fill_detail(self, rows: list[dict[str, Any]]) -> None` | popola griglia bassa |
| `_update_residual_button` | `_update_residual_button(self) -> None` | abilita azione solo su `Chiusa ERP con residui` |
| `_restore_master_selection` | `_restore_master_selection(self, documento: str) -> None` | riseleziona documento dopo reload |
| `_residual_pallets_from_rows` | `_residual_pallets_from_rows(self, rows: list[dict[str, Any]]) -> list[str]` | deduplica UDC residue non gia' spedite |
| `_operator_login` | `_operator_login(self) -> str` | determina utente per movimenti |
| `_ship_selected_residuals` | `_ship_selected_residuals(self) -> None` | esegue versamento massivo verso `7G.1.1` |
| `open_storico_pickinglist_window` | `open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc` | entry point chiamato dal launcher |
## Effetti sul database
Il modulo e' in sola lettura tranne `_ship_selected_residuals`.
`_ship_selected_residuals` non esegue SQL diretto di update. Delega ogni UDC a `move_pallet_async`, che:
- trova l'ultimo movimento `V` positivo della UDC;
- aggiorna `ModUtente` e `ModDataOra` della riga sorgente;
- inserisce un movimento `P` sulla cella sorgente;
- libera `Celle.IDStato` della cella sorgente;
- inserisce un movimento `V` sulla cella target `9999`;
- esegue `dbo.py_sp_ControllaPrenotazionePackingListPalletNew`.
## Rischi e note review
- Il versamento massivo non e' atomico a livello di intera lista: ogni UDC viene movimentata con una chiamata separata. Se una UDC fallisce, quelle precedenti potrebbero essere gia' state registrate.
- La rilettura del dettaglio riduce il rischio di dato UI obsoleto, ma non sostituisce un lock transazionale su documento.
- Le UDC multi-lotto sono deduplicate per `Pallet`, per evitare doppio movimento fisico.
- Le UDC gia' in `9999` vengono saltate.
- Le UDC non scaffalate risultano `IDCella = 1000`; vengono considerate residue e quindi movimentate verso `9999`.
## Test consigliati
1. Selezionare una lista `Da lavorare`: il pulsante deve restare disabilitato.
2. Selezionare una lista `Chiusa`: il pulsante deve restare disabilitato.
3. Selezionare una lista `Chiusa ERP con residui`: il pulsante deve abilitarsi.
4. Confermare il versamento su una lista con UDC non scaffalate.
5. Verificare in `Storico movimenti UDC` che ogni UDC abbia:
- ultimo passaggio sorgente;
- movimento `P` sulla sorgente;
- movimento `V` su `9999 / 7G.1.1`.
6. Ricaricare lo storico picking list: la lista non deve piu' risultare con residui se tutte le UDC sono in `9999`.

View File

@@ -0,0 +1,153 @@
# SQL prenotazione picking list Python-only
## Script
- `apply_python_parallel_pickinglist_patch.sql`
- `rollback_python_parallel_pickinglist_patch.sql`
## Obiettivo
Permettere a Python e C# di lavorare sullo stesso database senza condividere le stesse stored procedure operative.
Il C# continua a usare gli oggetti legacy:
- `dbo.XMag_ViewPackingList`
- `dbo.ViewPackingListRestante`
- `dbo.sp_xExePackingListPallet`
- `dbo.sp_xExePackingListPalletPrenota`
- `dbo.sp_ControllaPrenotazionePackingListPalletNew`
Il Python usa oggetti separati:
- `dbo.py_XMag_ViewPackingList`
- `dbo.py_ViewPackingListRestante`
- `dbo.py_sp_xExePackingListPallet`
- `dbo.py_sp_xExePackingListPalletPrenota`
- `dbo.py_sp_ControllaPrenotazionePackingListPalletNew`
- `dbo.PyPickingListReservation`
## Tabella `dbo.PyPickingListReservation`
### Scopo
Contiene la singola picking list prenotata dal programma Python.
### Schema
| Colonna | Tipo | Ruolo |
| --- | --- | --- |
| `ID` | `tinyint` | sempre `1`, enforced da check constraint |
| `Documento` | `varchar(8)` | documento prenotato |
| `IDOperatore` | `int` | operatore che ha fatto l'ultima azione |
| `ModUtente` | `varchar(50)` | login operatore |
| `ModDataOra` | `datetime` | timestamp modifica |
### Vincoli
- Primary key su `ID`.
- Check `ID = 1`.
Questa scelta implementa un singleton DB: il Python puo' avere una sola lista prenotata dal backoffice.
## Vista `dbo.py_XMag_ViewPackingList`
### Scopo
Replica la vista legacy `dbo.XMag_ViewPackingList` ma calcola `IDStato` usando `PyPickingListReservation`, non `Celle.IDStato` legacy.
### Logica `IDStato`
```sql
CASE
WHEN pr.Documento IS NOT NULL
AND pr.Documento = CAST(legacy.Documento AS varchar(8))
THEN 1
ELSE 0
END AS IDStato
```
## Vista `dbo.py_ViewPackingListRestante`
### Scopo
Espone solo le UDC non ancora spedite:
```sql
WHERE Cella <> 9999
```
La UI "Gestione Picking List" usa questa vista sia per la griglia alta sia per il dettaglio.
## Stored `dbo.py_sp_xExePackingListPallet`
### Parametri
| Parametro | Tipo | Ruolo |
| --- | --- | --- |
| `@IDOperatore` | `int` | operatore corrente |
| `@Documento` | `varchar(8)` | documento da prenotare/sprenotare |
| `@Azione` | `char(1)` | `P` prenota, `S` s-prenota |
| `@RC` | `int OUTPUT` | codice ritorno |
### Validazioni
- `@Azione` deve essere `P` o `S`, altrimenti `@RC = -10`.
- Il documento deve esistere in `py_XMag_ViewPackingList`, altrimenti `@RC = -20`.
- Se manca la riga singleton in `PyPickingListReservation`, viene creata.
### Prenotazione `P`
1. Se il documento e' gia' attivo, ritorna senza modifiche.
2. Azzera `IDStato` su tutte le celle con stato diverso da zero.
3. Mette `IDStato = 1` sulle celle del documento target.
4. Aggiorna `PyPickingListReservation` con documento, operatore e timestamp.
5. Scrive log tramite `sp_LogPackingList`.
### S-prenotazione `S`
1. Se il documento attivo non e' quello richiesto, ritorna senza modifiche.
2. Azzera `IDStato` sulle celle target.
3. Pulisce `PyPickingListReservation.Documento`.
## Stored `dbo.py_sp_xExePackingListPalletPrenota`
### Scopo
Mette `IDStato = 1` sulle celle ancora residue del documento. Viene usata dal controllo automatico prenotazione.
## Stored `dbo.py_sp_ControllaPrenotazionePackingListPalletNew`
### Scopo
Mantiene coerente la prenotazione mentre la picking list viene consumata dal barcode.
### Logica
1. Legge documento attivo da `PyPickingListReservation`.
2. Se non c'e' documento attivo, termina.
3. Se non esistono piu' righe residue in `py_ViewPackingListRestante`:
- azzera `Celle.IDStato`;
- svuota `PyPickingListReservation.Documento`;
- termina.
4. Se esistono ancora residui:
- azzera tutti gli `IDStato`;
- richiama `py_sp_xExePackingListPalletPrenota` per prenotare le celle residue.
## Rollback
`rollback_python_parallel_pickinglist_patch.sql` elimina:
- `py_sp_ControllaPrenotazionePackingListPalletNew`
- `py_sp_xExePackingListPalletPrenota`
- `py_sp_xExePackingListPallet`
- `py_ViewPackingListRestante`
- `py_XMag_ViewPackingList`
- `PyPickingListReservation`
Non tocca gli oggetti legacy C#.
## Note review
- La stored Python usa una prenotazione singleton, quindi non gestisce due liste prenotate backoffice.
- L'azzeramento globale di `Celle.IDStato` e' coerente con la semantica attuale, ma in futuro dovra' essere rivalutato se piu' operatori o magazzini lavorano in parallelo.
- Le UDC non scaffalate condividono il fallback dati del modello legacy; questo e' noto e andra' riprogettato nel nuovo schema FlyWMS.

View File

@@ -0,0 +1,236 @@
# SQL storico picking list
## Oggetti SQL coinvolti
La form "Storico Picking List" usa un ramo SQL Python-only:
- `dbo.py_vPreparaPackingListSAMA1`
- `dbo.py_vPreparaPackingList`
- `dbo.py_XMag_ViewPackingListStorico`
- `dbo.PyPickingListReservation`
Gli script principali sono:
- `apply_python_pickinglist_history_views.sql`
- `apply_online_history_forms_patch.sql`
- `rollback_python_pickinglist_history_views.sql`
- `rollback_online_history_forms_patch.sql`
## Motivazione del ramo Python-only
Il progetto deve convivere temporaneamente con l'applicazione C# legacy. Per questo lo storico Python non modifica:
- `dbo.vPreparaPackingListSAMA1`
- `dbo.vPreparaPackingList`
- `dbo.XMag_ViewPackingList`
- stored procedure legacy C#
Le viste prefissate `py_` permettono di evolvere il comportamento Python senza cambiare la semantica legacy.
## Vista `dbo.py_vPreparaPackingListSAMA1`
### Scopo
Legge dal database ERP `SAMA1` le informazioni documentali e di riga necessarie a ricostruire la picking list.
### Tabelle ERP coinvolte
- `SAMA1.dbo.LOTSER`
- `SAMA1.dbo.ARTICO`
- `SAMA1.dbo.FATRIG`
- `SAMA1.dbo.LOTTIBF`
- `SAMA1.dbo.BAMTES`
- `SAMA1.dbo.NAZIONI`
- `SAMA1.sam.EXTUC`
- `SAMA1.dbo.MTRASP`
### Campi principali
| Campo | Significato |
| --- | --- |
| `NUMLOT` | lotto |
| `CODICE` | codice articolo |
| `DESCR` | descrizione riga/articolo |
| `UDC` | prime 6 cifre di `LOTSER.NUMSER` |
| `Qta` | somma quantita giacente ERP |
| `NUMDOC` | numero documento/picking list |
| `DATDOC` | data documento ERP |
| `StatoDocumento` | stato ERP, filtrato su `P` o `D` |
| `NAZIONE` | trasporto + descrizione nazione |
### Filtri
- anno documento >= anno corrente;
- stato documento in `P`, `D`;
- lotto diverso da `00000000000`.
## Vista `dbo.py_vPreparaPackingList`
### Scopo
E' una vista di pulizia sopra `py_vPreparaPackingListSAMA1`.
### Filtro
Esclude righe con `NUMLOT = ''`.
## Vista `dbo.py_XMag_ViewPackingListStorico`
### Scopo
Unisce il contenuto ERP della picking list con la posizione WMS corrente della UDC.
### Tabelle WMS coinvolte
- `dbo.XMag_GiacenzaPallet`
- `dbo.Celle`
- `dbo.Aree`
- `dbo.PyPickingListReservation`
### Join principale
La vista usa una `RIGHT OUTER JOIN` tra posizione WMS e preparazione ERP:
```sql
RIGHT OUTER JOIN dbo.py_vPreparaPackingList prep
ON g.BarcodePallet COLLATE SQL_Latin1_General_CP1_CI_AS = prep.UDC
```
Questo garantisce che una UDC presente nella picking list ERP compaia anche se non e' scaffalata nel WMS.
### Convenzioni celle
| IDCella | Ubicazione | Significato |
| --- | --- | --- |
| `9999` | `7G - 1 - 1` | spedizione / UDC scaricata |
| `1000` | `Non scaff.` | fallback per UDC non trovata in `XMag_GiacenzaPallet` |
La vista usa:
```sql
ISNULL(g.IDCella, 1000) AS Cella
ISNULL(c.Corsia + ' - ' + c.Colonna + ' - ' + c.Fila, 'Non scaff.') AS Ubicazione
```
Quindi le UDC ERP non presenti in giacenza WMS sono rappresentate come non scaffalate.
## Query Python `SQL_STORICO_PL`
### Scopo
Costruisce la griglia alta della form.
### CTE
| CTE | Ruolo |
| --- | --- |
| `base` | normalizza `Pallet` in `PalletKey` |
| `pallets` | aggrega righe multi-lotto per singola UDC |
| `meta` | calcola metadati documento |
| `agg` | calcola totale UDC, UDC spedite e UDC residue |
### Stato operativo
La query produce `StatoOperativo` con questa logica:
```sql
CASE
WHEN m.StatoDocumento = 'D' AND COALESCE(a.RigheResidue, 0) > 0 THEN 'Chiusa ERP con residui'
WHEN m.StatoDocumento = 'D' THEN 'Chiusa'
WHEN COALESCE(a.RigheResidue, 0) = 0 THEN 'Esaurita'
WHEN COALESCE(a.RigheSpedite, 0) > 0 THEN 'In corso'
ELSE 'Da lavorare'
END
```
### Deduplica UDC
La deduplica avviene su `PalletKey`, non sulle righe. Questo evita che una UDC con piu' lotti venga contata come piu' pallet.
## Query Python `SQL_STORICO_PL_DETAILS`
### Scopo
Costruisce la griglia bassa della form.
### Output
| Campo | Ruolo |
| --- | --- |
| `Documento` | numero picking list |
| `Pallet` | UDC fisica |
| `Lotto` | lotto |
| `Articolo` | codice articolo |
| `Descrizione` | descrizione articolo/riga |
| `Qta` | quantita |
| `DataDocumento` | data ERP |
| `StatoDocumento` | stato ERP |
| `Cella` | cella WMS corrente/fallback |
| `Ubicazione` | descrizione cella |
| `Ordinamento` | ordine operativo |
| `IDStato` | prenotazione Python |
## Effetti SQL del versamento residui
La form non contiene direttamente update/insert SQL per il versamento residui. Chiama `move_pallet_async` in `gestione_scarico.py`.
La batch SQL associata e' `SQL_SCARICA_UDC` e produce:
1. ricerca dell'ultimo movimento `V` positivo della UDC;
2. update audit della riga sorgente;
3. insert movimento `P` sulla cella sorgente;
4. update `Celle.IDStato = 0` sulla cella sorgente;
5. insert movimento `V` sulla cella target;
6. esecuzione `dbo.py_sp_ControllaPrenotazionePackingListPalletNew`.
Per la funzione storico picking list il target e':
- `target_idcella = 9999`
- `target_barcode_cella = '9000000'`
## Note di consistenza dati
- Una UDC non scaffalata viene vista come `Cella = 1000`, quindi e' residua.
- Una UDC in `9999` non e' residua e viene esclusa dal movimento massivo.
- Una UDC multi-lotto puo' comparire piu' volte nel dettaglio ma deve generare un solo movimento fisico.
- Lo stato `Chiusa ERP con residui` evidenzia disallineamento tra documento ERP chiuso e WMS non completamente versato in spedizione.
## Query diagnostiche utili
Verificare righe documento:
```sql
SELECT *
FROM dbo.py_XMag_ViewPackingListStorico
WHERE Documento = 155
ORDER BY Ordinamento, Pallet;
```
Verificare UDC residue:
```sql
SELECT DISTINCT Pallet, Cella, Ubicazione
FROM dbo.py_XMag_ViewPackingListStorico
WHERE Documento = 155
AND ISNULL(Cella, 0) <> 9999
ORDER BY Pallet;
```
Verificare movimenti UDC dopo versamento:
```sql
SELECT
ID,
Tipo,
IDRiferimento,
Attributo,
IDCella,
DataMagazzino,
InsUtente,
InsDataOra,
ModUtente,
ModDataOra
FROM dbo.MagazziniPallet
WHERE Attributo = '655560'
ORDER BY ID;
```

View File

@@ -19,8 +19,10 @@ from typing import Any, Callable, Optional
import customtkinter as ctk import customtkinter as ctk
from async_loop_singleton import get_global_loop from async_loop_singleton import get_global_loop
from runtime_support import log_exception
from version_info import module_version
__VERSION__ = "GestioneAreeFrame v3.3.0-singleloop" __version__ = module_version(__name__)
try: try:
from async_msssql_query import AsyncMSSQLClient # noqa: F401 from async_msssql_query import AsyncMSSQLClient # noqa: F401
@@ -127,21 +129,53 @@ class AsyncRunner:
busy.show(message) busy.show(message)
fut = asyncio.run_coroutine_threadsafe(awaitable, self.loop) fut = asyncio.run_coroutine_threadsafe(awaitable, self.loop)
def _widget_alive() -> bool:
try:
return bool(self.widget.winfo_exists())
except tk.TclError:
return False
def _dispatch_success(res: Any) -> None:
try:
on_success(res)
except BaseException as ex:
log_exception(__name__, ex, context="AsyncRunner on_success")
def _dispatch_error(ex: BaseException) -> None:
if on_error:
try:
on_error(ex)
except BaseException as callback_ex:
log_exception(__name__, callback_ex, context="AsyncRunner on_error")
else:
log_exception(__name__, ex, context="AsyncRunner unhandled error")
def _poll(): def _poll():
if not _widget_alive():
return
if fut.done(): if fut.done():
if busy: if busy:
busy.hide() try:
busy.hide()
except Exception as ex:
log_exception(__name__, ex, context="AsyncRunner hide busy")
try: try:
res = fut.result() res = fut.result()
except BaseException as ex: except BaseException as ex:
if on_error: try:
self.widget.after(0, lambda e=ex: on_error(e)) self.widget.after(0, lambda e=ex: _dispatch_error(e))
else: except tk.TclError:
print("[AsyncRunner] Unhandled error:", repr(ex)) log_exception(__name__, ex, context="AsyncRunner error after destroyed")
else: else:
self.widget.after(0, lambda r=res: on_success(r)) try:
self.widget.after(0, lambda r=res: _dispatch_success(r))
except tk.TclError as ex:
log_exception(__name__, ex, context="AsyncRunner success after destroyed")
else: else:
self.widget.after(60, _poll) try:
self.widget.after(60, _poll)
except tk.TclError:
return
_poll() _poll()

View File

@@ -26,8 +26,11 @@ from tksheet import Sheet, natural_sort_key
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from user_session import UserSession from user_session import UserSession
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
try: try:
from loguru import logger from loguru import logger
except Exception: # pragma: no cover - fallback used only when Loguru is not available except Exception: # pragma: no cover - fallback used only when Loguru is not available
@@ -212,7 +215,7 @@ class LayoutWindow(ctk.CTkToplevel):
self._theme = theme_section("layout_window", {}) self._theme = theme_section("layout_window", {})
self._locale_catalog = load_locale_catalog() self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog() self._tooltip_catalog = load_tooltip_catalog()
self.title(loc_text("layout.title", catalog=self._locale_catalog, default="Layout corsie")) self.title(versioned_title(loc_text("layout.title", catalog=self._locale_catalog, default="Layout corsie"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740")))
minsize = theme_value(self._theme, "window_minsize", [980, 560]) minsize = theme_value(self._theme, "window_minsize", [980, 560])
self.minsize(int(minsize[0]), int(minsize[1])) self.minsize(int(minsize[0]), int(minsize[1]))
@@ -604,6 +607,7 @@ class LayoutWindow(ctk.CTkToplevel):
) )
m.add_separator() m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label)) m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
m.add_command(label="Codice cella", command=lambda: self._show_cell_code(r, c))
x = self.winfo_pointerx() if event is None else event.x_root x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y) m.tk_popup(x, y)
@@ -645,6 +649,25 @@ class LayoutWindow(ctk.CTkToplevel):
except Exception: except Exception:
return 0 return 0
def _show_cell_code(self, r: int, c: int) -> None:
"""Show the internal cell code for the selected layout cell."""
idcella = self._cell_id(r, c)
ubicazione = self._cell_label(r, c)
barcode_cella = self._cell_barcode(r, c)
if idcella <= 0:
messagebox.showwarning(
"Codice cella",
f"Codice cella non disponibile per {ubicazione}.",
parent=self,
)
return
messagebox.showinfo(
"Codice cella",
f"Ubicazione: {ubicazione}\nCodice cella: {idcella}\nBarcode cella: {barcode_cella or idcella}",
parent=self,
)
@_log_call() @_log_call()
def _prompt_carico(self, r: int, c: int): def _prompt_carico(self, r: int, c: int):
"""Prompt the operator for a pallet barcode and move it to the selected cell.""" """Prompt the operator for a pallet barcode and move it to the selected cell."""
@@ -678,27 +701,7 @@ class LayoutWindow(ctk.CTkToplevel):
if stato <= 0: if stato <= 0:
self._toast("La cella selezionata non contiene alcuna UDC da scaricare.") self._toast("La cella selezionata non contiene alcuna UDC da scaricare.")
return return
if stato >= 2: self._open_scarico_dialog(r, c)
self._open_scarico_dialog(r, c)
return
barcode = str(self.udc1[r][c] or "").strip()
if not barcode:
self._toast("UDC non disponibile per lo scarico.")
return
if not messagebox.askyesno(
"Scarico",
f"Scaricare l'UDC {barcode} da {self._cell_label(r, c)}?",
parent=self,
):
return
self._run_pallet_move(
barcode_pallet=barcode,
target_idcella=9999,
target_barcode_cella="9000000",
success_message=f"Scarico completato per {barcode}.",
busy_message=f"Scarico {barcode}...",
)
@_log_call() @_log_call()
def _run_pallet_move( def _run_pallet_move(

View File

@@ -70,9 +70,20 @@ from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import (
TABLE_HEADER_BG,
TABLE_HEADER_FG,
TABLE_ROW_EVEN,
TABLE_ROW_ODD,
apply_tksheet_visual_style,
apply_tksheet_zebra,
)
from user_session import UserSession from user_session import UserSession
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) === # === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
import asyncio import asyncio
try: try:
@@ -198,9 +209,12 @@ if _MODULE_LOG_ENABLED:
# -------------------- SQL -------------------- # -------------------- SQL --------------------
# Operational picking-list management uses the Python-only residual view.
# Unlike the historical view, this dataset intentionally contains only UDCs
# that still have to be exhausted by the warehouse workflow.
SQL_PL = """ SQL_PL = """
SELECT SELECT
COUNT(DISTINCT Pallet) AS Pallet, COUNT(DISTINCT NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '')) AS Pallet,
COUNT(DISTINCT Lotto) AS Lotto, COUNT(DISTINCT Lotto) AS Lotto,
COUNT(DISTINCT Articolo) AS Articolo, COUNT(DISTINCT Articolo) AS Articolo,
COUNT(DISTINCT Descrizione) AS Descrizione, COUNT(DISTINCT Descrizione) AS Descrizione,
@@ -314,6 +328,8 @@ DET_COLS: List[ColSpec] = [
] ]
ROW_H = 28 ROW_H = 28
RESERVED_IDSTATO_BG = "#dc2626"
RESERVED_IDSTATO_FG = "#ffffff"
# -------------------- Micro spinner (toolbar) -------------------- # -------------------- Micro spinner (toolbar) --------------------
@@ -388,13 +404,14 @@ class ScrollTable(ctk.CTkFrame):
self._sort_key: Optional[str] = None self._sort_key: Optional[str] = None
self._sort_reverse = False self._sort_reverse = False
self.total_w = sum(c.width for c in self.columns) self.total_w = sum(c.width for c in self.columns)
self._vbar_visible = False
self.grid_rowconfigure(1, weight=1) self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1)
# header # header
self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0) self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0, bg=TABLE_HEADER_BG)
self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color="#f3f3f3", self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color=TABLE_HEADER_BG,
height=ROW_H, width=self.total_w) height=ROW_H, width=self.total_w)
self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw", self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw",
width=self.total_w, height=ROW_H) width=self.total_w, height=ROW_H)
@@ -411,7 +428,6 @@ class ScrollTable(ctk.CTkFrame):
# scrollbars # scrollbars
self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview) self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview)
self.xbar = tk.Scrollbar(self, orient="horizontal", command=self._xscroll_both) self.xbar = tk.Scrollbar(self, orient="horizontal", command=self._xscroll_both)
self.vbar.grid(row=1, column=1, sticky="ns")
self.xbar.grid(row=2, column=0, sticky="ew") self.xbar.grid(row=2, column=0, sticky="ew")
# link scroll # link scroll
@@ -433,14 +449,14 @@ class ScrollTable(ctk.CTkFrame):
for w in self.h_inner.winfo_children(): for w in self.h_inner.winfo_children():
w.destroy() w.destroy()
row = ctk.CTkFrame(self.h_inner, fg_color="#f3f3f3", row = ctk.CTkFrame(self.h_inner, fg_color=TABLE_HEADER_BG,
height=ROW_H, width=self.total_w) height=ROW_H, width=self.total_w)
row.pack(fill="x", expand=False) row.pack(fill="x", expand=False)
row.pack_propagate(False) row.pack_propagate(False)
for col in self.columns: for col in self.columns:
holder = ctk.CTkFrame( holder = ctk.CTkFrame(
row, fg_color="#f3f3f3", row, fg_color=TABLE_HEADER_BG,
width=col.width, height=ROW_H, width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR border_width=1, border_color=self.GRID_COLOR
) )
@@ -451,7 +467,7 @@ class ScrollTable(ctk.CTkFrame):
if col.key == self._sort_key: if col.key == self._sort_key:
header_text = f"{col.title} {'' if self._sort_reverse else ''}" header_text = f"{col.title} {'' if self._sort_reverse else ''}"
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w") lbl = ctk.CTkLabel(holder, text=header_text, anchor="w", text_color=TABLE_HEADER_FG)
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY) lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
if self.on_header_click and col.key != "__check__": if self.on_header_click and col.key != "__check__":
@@ -472,10 +488,27 @@ class ScrollTable(ctk.CTkFrame):
"""Keep the scroll region aligned with the current body content width.""" """Keep the scroll region aligned with the current body content width."""
self.b_canvas.itemconfigure(self.body_window, width=self.total_w) self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
sr = self.b_canvas.bbox("all") sr = self.b_canvas.bbox("all")
content_height = int(sr[3]) if sr else 0
if sr: if sr:
self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3])) self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3]))
else: else:
self.b_canvas.configure(scrollregion=(0,0,self.total_w,0)) self.b_canvas.configure(scrollregion=(0,0,self.total_w,0))
self._update_vertical_scrollbar(content_height)
def _update_vertical_scrollbar(self, content_height: int):
"""Show the vertical scrollbar only when body rows exceed the visible area."""
try:
visible_height = max(1, int(self.b_canvas.winfo_height()))
except Exception:
visible_height = 1
needs_scroll = content_height > visible_height + 2
if needs_scroll and not self._vbar_visible:
self.vbar.grid(row=1, column=1, sticky="ns")
self._vbar_visible = True
elif not needs_scroll and self._vbar_visible:
self.vbar.grid_remove()
self._vbar_visible = False
self.b_canvas.yview_moveto(0)
def _on_body_configure(self): def _on_body_configure(self):
"""React to body resize events by syncing dimensions and header scroll.""" """React to body resize events by syncing dimensions and header scroll."""
@@ -510,8 +543,11 @@ class ScrollTable(ctk.CTkFrame):
delta = getattr(event, "delta", 0) delta = getattr(event, "delta", 0)
if delta == 0: if delta == 0:
return return
if not self._vbar_visible:
return "break"
step = -1 if delta > 0 else 1 step = -1 if delta > 0 else 1
self.b_canvas.yview_scroll(step, "units") self.b_canvas.yview_scroll(step, "units")
return "break"
def clear_rows(self): def clear_rows(self):
"""Remove all rendered body rows.""" """Remove all rendered body rows."""
@@ -525,16 +561,20 @@ class ScrollTable(ctk.CTkFrame):
row_index: int, row_index: int,
anchors: Optional[List[str]] = None, anchors: Optional[List[str]] = None,
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None, checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
cell_bg: Optional[Dict[str, str]] = None,
cell_fg: Optional[Dict[str, str]] = None,
): ):
"""Append one row to the table body.""" """Append one row to the table body."""
row = ctk.CTkFrame(self.b_inner, fg_color="transparent", row_bg = TABLE_ROW_EVEN if row_index % 2 == 0 else TABLE_ROW_ODD
row = ctk.CTkFrame(self.b_inner, fg_color=row_bg,
height=ROW_H, width=self.total_w) height=ROW_H, width=self.total_w)
row.pack(fill="x", expand=False) row.pack(fill="x", expand=False)
row.pack_propagate(False) row.pack_propagate(False)
for i, col in enumerate(self.columns): for i, col in enumerate(self.columns):
holder_bg = (cell_bg or {}).get(col.key, row_bg)
holder = ctk.CTkFrame( holder = ctk.CTkFrame(
row, fg_color="transparent", row, fg_color=holder_bg,
width=col.width, height=ROW_H, width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR border_width=1, border_color=self.GRID_COLOR
) )
@@ -546,10 +586,17 @@ class ScrollTable(ctk.CTkFrame):
cb = checkbox_builder(holder) cb = checkbox_builder(holder)
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w") cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
else: else:
ctk.CTkLabel(holder, text="").pack(fill="both") ctk.CTkLabel(holder, text="", fg_color=holder_bg).pack(fill="both")
else: else:
anchor = (anchors[i] if anchors else col.anchor) anchor = (anchors[i] if anchors else col.anchor)
ctk.CTkLabel(holder, text=values[i], anchor=anchor).pack( ctk.CTkLabel(
holder,
text=values[i],
anchor=anchor,
fg_color=holder_bg,
text_color=(cell_fg or {}).get(col.key, "#111827"),
font=("Segoe UI", 10, "bold") if col.key == "IDStato" and col.key in (cell_bg or {}) else None,
).pack(
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
) )
@@ -693,6 +740,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.detail_sheet.change_theme("light green") self.detail_sheet.change_theme("light green")
self.detail_sheet.enable_bindings("all") self.detail_sheet.enable_bindings("all")
self.detail_sheet.headers(self._detail_headers(), redraw=False) self.detail_sheet.headers(self._detail_headers(), redraw=False)
apply_tksheet_visual_style(self.detail_sheet)
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+") self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
self.detail_sheet.grid(row=0, column=0, sticky="nsew") self.detail_sheet.grid(row=0, column=0, sticky="nsew")
@@ -733,8 +781,10 @@ class GestionePickingListFrame(ctk.CTkFrame):
data, data,
reset_col_positions=True, reset_col_positions=True,
reset_row_positions=True, reset_row_positions=True,
redraw=True, redraw=False,
) )
apply_tksheet_visual_style(self.detail_sheet)
apply_tksheet_zebra(self.detail_sheet, len(data))
self.detail_sheet.set_all_column_widths() self.detail_sheet.set_all_column_widths()
def _detail_sort_value(self, row: Dict[str, Any], key: str): def _detail_sort_value(self, row: Dict[str, Any], key: str):
@@ -838,10 +888,22 @@ class GestionePickingListFrame(ctk.CTkFrame):
try: try:
for idx, d in enumerate(rows): for idx, d in enumerate(rows):
row_widget = self.pl_table.b_inner.winfo_children()[idx] row_widget = self.pl_table.b_inner.winfo_children()[idx]
if int(d.get("IDStato") or 0) == 1: row_bg = TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
row_widget.configure(fg_color="#ffe6f2") # rosa tenue row_widget.configure(fg_color=row_bg)
else: row_children = row_widget.winfo_children()
row_widget.configure(fg_color="transparent") if len(row_children) >= 5:
holder = row_children[4]
is_reserved = int(d.get("IDStato") or 0) == 1
holder_bg = RESERVED_IDSTATO_BG if is_reserved else row_bg
holder.configure(fg_color=holder_bg)
if holder.winfo_children():
lbl = holder.winfo_children()[0]
if hasattr(lbl, "configure"):
lbl.configure(
fg_color=holder_bg,
text_color=RESERVED_IDSTATO_FG if is_reserved else "#111827",
font=("Segoe UI", 10, "bold") if is_reserved else ("Segoe UI", 10),
)
except Exception: except Exception:
pass pass
@@ -868,7 +930,9 @@ class GestionePickingListFrame(ctk.CTkFrame):
values=values, values=values,
row_index=r, row_index=r,
anchors=[c.anchor for c in PL_COLS], anchors=[c.anchor for c in PL_COLS],
checkbox_builder=model.build_checkbox checkbox_builder=model.build_checkbox,
cell_bg={"IDStato": RESERVED_IDSTATO_BG} if int(d.get("IDStato") or 0) == 1 else None,
cell_fg={"IDStato": RESERVED_IDSTATO_FG} if int(d.get("IDStato") or 0) == 1 else None,
) )
# 🎯 Colora dopo che la UI è resa → no balzi visivi # 🎯 Colora dopo che la UI è resa → no balzi visivi
@@ -894,29 +958,53 @@ class GestionePickingListFrame(ctk.CTkFrame):
row_children = row_widget.winfo_children() row_children = row_widget.winfo_children()
if len(row_children) >= 5: if len(row_children) >= 5:
holder = row_children[4] holder = row_children[4]
holder.configure(fg_color=RESERVED_IDSTATO_BG if idstato == 1 else (
TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
))
if holder.winfo_children(): if holder.winfo_children():
lbl = holder.winfo_children()[0] lbl = holder.winfo_children()[0]
if hasattr(lbl, "configure"): if hasattr(lbl, "configure"):
lbl.configure(text=str(idstato)) lbl.configure(
text=str(idstato),
fg_color=RESERVED_IDSTATO_BG if idstato == 1 else (
TABLE_ROW_EVEN if idx % 2 == 0 else TABLE_ROW_ODD
),
text_color=RESERVED_IDSTATO_FG if idstato == 1 else "#111827",
font=("Segoe UI", 10, "bold") if idstato == 1 else ("Segoe UI", 10),
)
except Exception: except Exception:
pass pass
# differisci la colorazione (smooth) # differisci la colorazione (smooth)
self.after_idle(_paint) self.after_idle(_paint)
break break
def _reselect_documento_after_reload(self, documento: str): def _reselect_documento_after_reload(self, documento: str) -> bool:
"""(Opzionale) Dopo un reload DB, riseleziona la PL con lo stesso Documento.""" """After a reload, reselect the same document and reload its details."""
for m in self.rows_models: for m in self.rows_models:
if _s(m.pl.get("Documento")) == _s(documento): if _s(m.pl.get("Documento")) == _s(documento):
self._detail_cache.pop(documento, None)
m.set_checked(True) m.set_checked(True)
self.on_row_checked(m, True) self.on_row_checked(m, True)
break return True
return False
def _selected_documento_for_reload(self) -> str | None:
"""Return the document that should survive a toolbar reload."""
selected = self._get_selected_model()
if selected is not None:
documento = _s(selected.pl.get("Documento"))
return documento or None
documento = _s(self.detail_doc)
return documento or None
# ----- eventi ----- # ----- eventi -----
@_log_call() @_log_call()
def on_row_checked(self, model: PLRow, is_checked: bool): def on_row_checked(self, model: PLRow, is_checked: bool):
"""Handle row selection changes and refresh the detail section.""" """Handle row selection changes and refresh the detail section."""
# selezione esclusiva # Only one backoffice picking list can be selected at a time. Barcode
# F1/F2 priority behavior is derived downstream; this UI controls only
# the explicit Python reservation slot.
if is_checked: if is_checked:
for m in self.rows_models: for m in self.rows_models:
if m is not model and m.is_checked(): if m is not model and m.is_checked():
@@ -964,6 +1052,11 @@ class GestionePickingListFrame(ctk.CTkFrame):
@_log_call() @_log_call()
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None): def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
"""Load or reload the picking list summary table from the database.""" """Load or reload the picking list summary table from the database."""
if reselect_documento is None and not first:
# Preserve the operator context while the barcode may be consuming
# rows: Ricarica refreshes master and detail but keeps the selected
# document selected when it is still visible.
reselect_documento = self._selected_documento_for_reload()
self.spinner.start(" Carico…") # spinner ON self.spinner.start(" Carico…") # spinner ON
async def _job(): async def _job():
_log_sql("SQL_PL", SQL_PL, {}) _log_sql("SQL_PL", SQL_PL, {})
@@ -973,8 +1066,18 @@ class GestionePickingListFrame(ctk.CTkFrame):
_log_dataset("SQL_PL", rows) _log_dataset("SQL_PL", rows)
self._refresh_mid_rows(rows) self._refresh_mid_rows(rows)
if reselect_documento: if reselect_documento:
self.after_idle(lambda doc=reselect_documento: self._reselect_documento_after_reload(doc)) def _reselect_or_clear(doc=reselect_documento):
self.spinner.stop() # spinner OFF found = self._reselect_documento_after_reload(doc)
if not found:
self.detail_doc = None
self._draw_details_hint()
self.spinner.stop()
self.busy.hide()
self.after_idle(_reselect_or_clear)
else:
self.detail_doc = None
self._draw_details_hint()
self.spinner.stop() # spinner OFF
# se era il primo load, ripristina il cursore standard # se era il primo load, ripristina il cursore standard
if self._first_loading: if self._first_loading:
try: try:
@@ -1067,6 +1170,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
documento = _s(model.pl.get("Documento")) documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 0) current = int(model.pl.get("IDStato") or 0)
desired = 1 desired = 1
# Idempotent semantics: Prenota never toggles a list off. If the list
# is already reserved, S-prenota remains the only explicit release path.
if current == desired: if current == desired:
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.") messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
return return
@@ -1079,6 +1184,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.spinner.start(" Prenoto…") self.spinner.start(" Prenoto…")
async def _job(): async def _job():
# Use the Python-specific py_* stored procedure so C# legacy
# procedures can coexist unchanged on the same database.
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "P") return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "P")
def _ok(res: SPResult): def _ok(res: SPResult):
@@ -1141,6 +1248,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
documento = _s(model.pl.get("Documento")) documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 0) current = int(model.pl.get("IDStato") or 0)
desired = 0 desired = 0
# Idempotent semantics: S-prenota never reserves a list; if the list is
# already free, the operation is a no-op rather than an implicit toggle.
if current == desired: if current == desired:
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.") messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
return return
@@ -1153,6 +1262,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.spinner.start(" S-prenoto…") self.spinner.start(" S-prenoto…")
async def _job(): async def _job():
# Same Python-only procedure as Prenota, with action S to release
# the singleton reservation and clear affected cell states.
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "S") return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "S")
def _ok(res: SPResult): def _ok(res: SPResult):
@@ -1240,7 +1351,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N
win = ctk.CTkToplevel(parent) win = ctk.CTkToplevel(parent)
locale_catalog = load_locale_catalog() locale_catalog = load_locale_catalog()
win.title(loc_text("picking.title", catalog=locale_catalog, default="Gestione Picking List")) win.title(versioned_title(loc_text("picking.title", catalog=locale_catalog, default="Gestione Picking List"), __name__))
theme = theme_section("pickinglist_window", {}) theme = theme_section("pickinglist_window", {})
win.geometry(str(theme_value(theme, "window_geometry", "1200x700"))) win.geometry(str(theme_value(theme, "window_geometry", "1200x700")))
minsize = theme_value(theme, "window_minsize", [1000, 560]) minsize = theme_value(theme, "window_minsize", [1000, 560])

View File

@@ -20,7 +20,11 @@ from audit_log import log_user_action
from busy_overlay import InlineBusyOverlay from busy_overlay import InlineBusyOverlay
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import style_treeview, zebra_tag
from user_session import UserSession from user_session import UserSession
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
try: try:
from loguru import logger from loguru import logger
@@ -72,6 +76,12 @@ _MODULE_LOG_LEVEL = "DEBUG" if SCARICO_LOG_MODE.upper() == "DEBUG" else "INFO"
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME) _MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
_MODULE_LOGGING_CONFIGURED = False _MODULE_LOGGING_CONFIGURED = False
DEFAULT_SCARICO_USER = "warehouse_ui" DEFAULT_SCARICO_USER = "warehouse_ui"
SHIPPED_IDCELLA = 9999
SHIPPED_BARCODE = "9000000"
SHIPPED_LABEL = "7G.1.1"
NON_SCAFF_IDCELLA = 1000
NON_SCAFF_BARCODE = "9001000"
NON_SCAFF_LABEL = "5E1.1"
def _session_login(session: UserSession | None, fallback: str | None = None) -> str: def _session_login(session: UserSession | None, fallback: str | None = None) -> str:
@@ -194,28 +204,7 @@ last_move AS (
FROM dbo.MagazziniPallet mp FROM dbo.MagazziniPallet mp
JOIN last_in_cell lic ON lic.LastID = mp.ID JOIN last_in_cell lic ON lic.LastID = mp.ID
), ),
latest_any AS ( closed_plist AS (
SELECT
ranked.BarcodePallet,
ranked.IDCella
FROM (
SELECT
mp.Attributo AS BarcodePallet,
mp.IDCella,
ROW_NUMBER() OVER (
PARTITION BY mp.Attributo
ORDER BY mp.ID DESC
) AS rn
FROM dbo.MagazziniPallet mp
JOIN cell_pallets cp
ON cp.BarcodePallet COLLATE Latin1_General_CI_AS =
mp.Attributo COLLATE Latin1_General_CI_AS
WHERE mp.Tipo = 'V'
AND mp.PesoUnitario > 0
) ranked
WHERE ranked.rn = 1
),
shipped AS (
SELECT DISTINCT SELECT DISTINCT
shipped.BarcodePallet shipped.BarcodePallet
FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped
@@ -227,25 +216,19 @@ SELECT
cp.BarcodePallet AS UDC, cp.BarcodePallet AS UDC,
lm.ID AS SourceID, lm.ID AS SourceID,
lm.DataMagazzino AS LastEventAt, lm.DataMagazzino AS LastEventAt,
:idcella AS CurrentIDCella,
CASE CASE
WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int) WHEN :idcella <> 9999
ELSE CAST(0 AS int) AND closed_plist.BarcodePallet IS NOT NULL
END AS IsShippedGhost,
CASE
WHEN la.IDCella IS NOT NULL
AND la.IDCella <> :idcella
THEN CAST(1 AS int) THEN CAST(1 AS int)
ELSE CAST(0 AS int) ELSE CAST(0 AS int)
END AS IsMovedGhost END AS IsMissingUnload
FROM cell_pallets cp FROM cell_pallets cp
LEFT JOIN last_move lm LEFT JOIN last_move lm
ON lm.BarcodePallet COLLATE Latin1_General_CI_AS = ON lm.BarcodePallet COLLATE Latin1_General_CI_AS =
cp.BarcodePallet COLLATE Latin1_General_CI_AS cp.BarcodePallet COLLATE Latin1_General_CI_AS
LEFT JOIN latest_any la LEFT JOIN closed_plist
ON la.BarcodePallet COLLATE Latin1_General_CI_AS = ON closed_plist.BarcodePallet COLLATE Latin1_General_CI_AS =
cp.BarcodePallet COLLATE Latin1_General_CI_AS
LEFT JOIN shipped
ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS =
cp.BarcodePallet COLLATE Latin1_General_CI_AS cp.BarcodePallet COLLATE Latin1_General_CI_AS
ORDER BY ORDER BY
lm.ID DESC, lm.ID DESC,
@@ -409,19 +392,23 @@ class ScaricoRow:
udc: str udc: str
source_id: int | None source_id: int | None
last_event_at: str last_event_at: str
current_idcella: int
diagnostic_note: str diagnostic_note: str
selected: tk.BooleanVar selected: tk.BooleanVar
def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str: def _build_diagnostic_note(is_missing_unload: int | bool = False) -> str:
"""Translate low-level anomaly flags into one operator-facing note.""" """Translate low-level anomaly flags into one operator-facing note."""
notes: list[str] = [] if bool(is_missing_unload):
if bool(is_shipped): return "Motivo: spedita ma non scaricata dalla cella"
notes.append("Mancato scarico: spedita") return ""
if bool(is_moved):
notes.append("Mancato scarico: spostata")
return " | ".join(notes) def _is_missing_unload_row(row: ScaricoRow) -> bool:
"""Return True when the row must be corrected only with shipped unload."""
return "spedita ma non scaricata dalla cella" in str(row.diagnostic_note or "").lower()
class ScaricoDialog(ctk.CTkToplevel): class ScaricoDialog(ctk.CTkToplevel):
@@ -457,7 +444,7 @@ class ScaricoDialog(ctk.CTkToplevel):
self._async = AsyncRunner(self) self._async = AsyncRunner(self)
self.rows_tree: ttk.Treeview | None = None self.rows_tree: ttk.Treeview | None = None
self.title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione)) self.title(versioned_title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione), __name__))
self.resizable(False, False) self.resizable(False, False)
self.transient(parent) self.transient(parent)
self.protocol("WM_DELETE_WINDOW", self._close) self.protocol("WM_DELETE_WINDOW", self._close)
@@ -474,8 +461,9 @@ class ScaricoDialog(ctk.CTkToplevel):
self.wait_visibility() self.wait_visibility()
except Exception: except Exception:
pass pass
self.lift() if self.winfo_exists():
self.focus_force() self.lift()
self.focus_force()
def _build_ui(self): def _build_ui(self):
"""Build the compact modal layout.""" """Build the compact modal layout."""
@@ -508,10 +496,6 @@ class ScaricoDialog(ctk.CTkToplevel):
tree_host.grid_rowconfigure(0, weight=1) tree_host.grid_rowconfigure(0, weight=1)
tree_host.grid_columnconfigure(0, weight=1) tree_host.grid_columnconfigure(0, weight=1)
style = ttk.Style(self)
style.configure("Scarico.Treeview", rowheight=28, font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)))
style.configure("Scarico.Treeview.Heading", font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")))
self.rows_tree = ttk.Treeview( self.rows_tree = ttk.Treeview(
tree_host, tree_host,
columns=("sel", "udc", "last", "diag"), columns=("sel", "udc", "last", "diag"),
@@ -519,6 +503,13 @@ class ScaricoDialog(ctk.CTkToplevel):
style="Scarico.Treeview", style="Scarico.Treeview",
selectmode="none", selectmode="none",
) )
style_treeview(
self.rows_tree,
style_name="Scarico.Treeview",
rowheight=28,
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
heading_font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
)
self.rows_tree.heading("sel", text="Sel") self.rows_tree.heading("sel", text="Sel")
self.rows_tree.heading("udc", text=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC")) self.rows_tree.heading("udc", text=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC"))
self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento")) self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento"))
@@ -539,19 +530,27 @@ class ScaricoDialog(ctk.CTkToplevel):
actions.grid_columnconfigure(0, weight=1) actions.grid_columnconfigure(0, weight=1)
ctk.CTkButton( ctk.CTkButton(
actions, actions,
text=loc_text("scarico.button.submit", catalog=self._locale_catalog, default="Scarica"), text=loc_text("scarico.button.shipped", catalog=self._locale_catalog, default="Scarico come spedita"),
command=self._on_scarica, command=self._on_scarica_spedita,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")), font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).grid( ).grid(
row=0, column=1, padx=(8, 0), pady=8 row=0, column=1, padx=(8, 0), pady=8
) )
ctk.CTkButton(
actions,
text=loc_text("scarico.button.non_shelved", catalog=self._locale_catalog, default="Scarico come non scaff."),
command=self._on_scarica_non_scaff,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).grid(
row=0, column=2, padx=(8, 0), pady=8
)
ctk.CTkButton( ctk.CTkButton(
actions, actions,
text=loc_text("scarico.button.close", catalog=self._locale_catalog, default="Chiudi"), text=loc_text("scarico.button.close", catalog=self._locale_catalog, default="Chiudi"),
command=self._close, command=self._close,
font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")), font=theme_font(self._theme, "button_font", ("Segoe UI", 10, "bold")),
).grid( ).grid(
row=0, column=2, padx=(8, 8), pady=8 row=0, column=3, padx=(8, 8), pady=8
) )
def _render_rows(self): def _render_rows(self):
@@ -572,6 +571,7 @@ class ScaricoDialog(ctk.CTkToplevel):
row.last_event_at, row.last_event_at,
row.diagnostic_note or "", row.diagnostic_note or "",
), ),
tags=(zebra_tag(idx),),
) )
self.update_idletasks() self.update_idletasks()
@@ -609,7 +609,13 @@ class ScaricoDialog(ctk.CTkToplevel):
rows = res.get("rows", []) if isinstance(res, dict) else [] rows = res.get("rows", []) if isinstance(res, dict) else []
_log_dataset("scarico_load_rows", rows) _log_dataset("scarico_load_rows", rows)
self.rows = [] self.rows = []
for udc, source_id, last_event_at, is_shipped, is_moved in rows: for (
udc,
source_id,
last_event_at,
current_idcella,
is_missing_unload,
) in rows:
if isinstance(last_event_at, datetime): if isinstance(last_event_at, datetime):
last_event = last_event_at.strftime("%d/%m/%Y %H:%M:%S") last_event = last_event_at.strftime("%d/%m/%Y %H:%M:%S")
else: else:
@@ -619,7 +625,8 @@ class ScaricoDialog(ctk.CTkToplevel):
udc=str(udc or ""), udc=str(udc or ""),
source_id=int(source_id) if source_id is not None else None, source_id=int(source_id) if source_id is not None else None,
last_event_at=last_event, last_event_at=last_event,
diagnostic_note=_build_diagnostic_note(is_shipped, is_moved), current_idcella=int(current_idcella or 0),
diagnostic_note=_build_diagnostic_note(is_missing_unload),
selected=tk.BooleanVar(value=False), selected=tk.BooleanVar(value=False),
) )
) )
@@ -645,8 +652,9 @@ class ScaricoDialog(ctk.CTkToplevel):
) )
@_log_call() @_log_call()
def _on_scarica(self): def _selected_rows_or_warn(self) -> list[ScaricoRow] | None:
"""Unload the UDCs selected by the user from the current cell.""" """Return selected rows or show the standard selection warning."""
selected = [row for row in self.rows if row.selected.get()] selected = [row for row in self.rows if row.selected.get()]
if not selected: if not selected:
messagebox.showinfo( messagebox.showinfo(
@@ -654,23 +662,97 @@ class ScaricoDialog(ctk.CTkToplevel):
loc_text("scarico.msg.select_one", catalog=self._locale_catalog, default="Seleziona almeno una UDC da scaricare."), loc_text("scarico.msg.select_one", catalog=self._locale_catalog, default="Seleziona almeno una UDC da scaricare."),
parent=self, parent=self,
) )
return selected
@_log_call()
def _on_scarica_spedita(self):
"""Unload selected UDCs to the shipped conventional location."""
self._scarica_selected_to_target(
target_idcella=SHIPPED_IDCELLA,
target_barcode_cella=SHIPPED_BARCODE,
target_label=SHIPPED_LABEL,
title="Scarico come spedita",
confirm_text="Scaricare come spedite {count} UDC da {ubicazione}?",
busy_message="Scarico UDC come spedite...",
block_shipped_for_non_scaff=False,
)
@_log_call()
def _on_scarica_non_scaff(self):
"""Unload selected UDCs to the non-shelved conventional location."""
self._scarica_selected_to_target(
target_idcella=NON_SCAFF_IDCELLA,
target_barcode_cella=NON_SCAFF_BARCODE,
target_label=NON_SCAFF_LABEL,
title="Scarico come non scaff.",
confirm_text="Scaricare come non scaffalate {count} UDC da {ubicazione}?",
busy_message="Scarico UDC come non scaffalate...",
block_shipped_for_non_scaff=True,
)
def _scarica_selected_to_target(
self,
*,
target_idcella: int,
target_barcode_cella: str,
target_label: str,
title: str,
confirm_text: str,
busy_message: str,
block_shipped_for_non_scaff: bool,
):
"""Move selected UDCs to one conventional unload location."""
selected = self._selected_rows_or_warn()
if not selected:
return
shipped_rows: list[ScaricoRow] = []
missing_unload_rows: list[ScaricoRow] = []
movable_rows = list(selected)
if block_shipped_for_non_scaff:
shipped_rows = [
row for row in selected
if int(row.current_idcella or 0) == SHIPPED_IDCELLA
]
missing_unload_rows = [row for row in selected if _is_missing_unload_row(row)]
blocked_udcs = {row.udc for row in shipped_rows + missing_unload_rows}
movable_rows = [row for row in selected if row.udc not in blocked_udcs]
if (shipped_rows or missing_unload_rows) and not movable_rows:
message_lines = ["Nessuna UDC movimentata."]
if shipped_rows:
message_lines.append(f"UDC gia' in {SHIPPED_LABEL}: " + ", ".join(row.udc for row in shipped_rows))
if missing_unload_rows:
message_lines.append(
"UDC spedite ma non scaricate: "
+ ", ".join(row.udc for row in missing_unload_rows)
)
message_lines.append("Usare 'Scarico come spedita'.")
messagebox.showwarning(
title,
"\n".join(message_lines),
parent=self,
)
return return
if not messagebox.askyesno( if not messagebox.askyesno(
"Conferma scarico", title,
f"Scaricare {len(selected)} UDC da {self.ubicazione}?", confirm_text.format(count=len(movable_rows), ubicazione=self.ubicazione),
parent=self, parent=self,
): ):
return return
async def _job(): async def _job():
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for row in selected: for row in movable_rows:
result = await move_pallet_async( result = await move_pallet_async(
self.db_client, self.db_client,
barcode_pallet=row.udc, barcode_pallet=row.udc,
target_idcella=9999, target_idcella=target_idcella,
target_barcode_cella="9000000", target_barcode_cella=target_barcode_cella,
utente=_session_login(self.session), utente=_session_login(self.session),
) )
results.append({"udc": row.udc, "affected": int(result.get("ok") or 0)}) results.append({"udc": row.udc, "affected": int(result.get("ok") or 0)})
@@ -682,33 +764,65 @@ class ScaricoDialog(ctk.CTkToplevel):
skipped = [item["udc"] for item in results if int(item.get("affected") or 0) <= 0] skipped = [item["udc"] for item in results if int(item.get("affected") or 0) <= 0]
if not done: if not done:
messagebox.showwarning( messagebox.showwarning(
"Scarica", title,
"Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.", "Nessuna UDC e' stata scaricata. Verifica che le unita' siano ancora presenti in cella.",
parent=self, parent=self,
) )
return return
if skipped: if skipped:
shipped_note = (
f"\nGia' in {SHIPPED_LABEL} non toccate: " + ", ".join(row.udc for row in shipped_rows)
if shipped_rows
else ""
)
missing_unload_note = (
"\nSpedite ma non scaricate, usare 'Scarico come spedita': "
+ ", ".join(row.udc for row in missing_unload_rows)
if missing_unload_rows
else ""
)
target_note = (
f"\nUDC in {target_label}: " + ", ".join(done)
if done
else ""
)
messagebox.showwarning( messagebox.showwarning(
"Scarica", title,
"Scarico parziale.\nCompletate: " "Scarico parziale.\nCompletate: "
+ ", ".join(done) + ", ".join(done)
+ "\nNon scaricate: " + "\nNon scaricate: "
+ ", ".join(skipped), + ", ".join(skipped)
+ shipped_note
+ missing_unload_note
+ target_note,
parent=self, parent=self,
) )
else: else:
messagebox.showinfo( msg = f"Scarico completato verso {target_label} per:\n" + "\n".join(done)
"Scarica", if shipped_rows:
"Scarico completato per:\n" + "\n".join(done), msg += f"\n\nUDC gia' in {SHIPPED_LABEL} non toccate:\n" + "\n".join(row.udc for row in shipped_rows)
parent=self, msg += f"\n\nUDC in {target_label}: " + ", ".join(done)
) if missing_unload_rows:
msg += (
"\n\nUDC spedite ma non scaricate non toccate:\n"
+ "\n".join(row.udc for row in missing_unload_rows)
+ "\nUsare 'Scarico come spedita'."
)
messagebox.showinfo(title, msg, parent=self)
log_user_action( log_user_action(
self.session, self.session,
module=MODULE_LOG_NAME, module=MODULE_LOG_NAME,
action="layout.scarico", action="layout.scarico.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff",
outcome="ok", outcome="ok",
target=self.ubicazione, target=self.ubicazione,
details={"scaricate": done, "saltate": skipped}, details={
"scaricate": done,
"saltate": skipped,
"target_idcella": target_idcella,
"target_barcode_cella": target_barcode_cella,
"gia_spedite": [row.udc for row in shipped_rows],
"spedite_non_scaricate": [row.udc for row in missing_unload_rows],
},
) )
if self.on_completed: if self.on_completed:
self.on_completed() self.on_completed()
@@ -719,7 +833,7 @@ class ScaricoDialog(ctk.CTkToplevel):
log_user_action( log_user_action(
self.session, self.session,
module=MODULE_LOG_NAME, module=MODULE_LOG_NAME,
action="layout.scarico", action="layout.scarico.spedita" if int(target_idcella) == SHIPPED_IDCELLA else "layout.scarico.non_scaff",
outcome="error", outcome="error",
target=self.ubicazione, target=self.ubicazione,
details={"error": str(ex)}, details={"error": str(ex)},
@@ -735,7 +849,7 @@ class ScaricoDialog(ctk.CTkToplevel):
_ok, _ok,
_err, _err,
busy=self._busy, busy=self._busy,
message="Scarico UDC...", message=busy_message,
) )
@_log_call() @_log_call()

View File

@@ -107,6 +107,8 @@
"scarico.col.last_insert": "Ultimo inserimento", "scarico.col.last_insert": "Ultimo inserimento",
"scarico.col.diagnostic": "Diagnostica", "scarico.col.diagnostic": "Diagnostica",
"scarico.button.submit": "Scarica", "scarico.button.submit": "Scarica",
"scarico.button.shipped": "Scarico come spedita",
"scarico.button.non_shelved": "Scarico come non scaff.",
"scarico.button.close": "Chiudi", "scarico.button.close": "Chiudi",
"scarico.msg.title": "Scarica", "scarico.msg.title": "Scarica",
"scarico.msg.select_one": "Seleziona almeno una UDC da scaricare.", "scarico.msg.select_one": "Seleziona almeno una UDC da scaricare.",
@@ -220,6 +222,8 @@
"scarico.col.last_insert": "Last insert", "scarico.col.last_insert": "Last insert",
"scarico.col.diagnostic": "Diagnostics", "scarico.col.diagnostic": "Diagnostics",
"scarico.button.submit": "Unload", "scarico.button.submit": "Unload",
"scarico.button.shipped": "Unload as shipped",
"scarico.button.non_shelved": "Unload as non-shelved",
"scarico.button.close": "Close", "scarico.button.close": "Close",
"scarico.msg.title": "Unload", "scarico.msg.title": "Unload",
"scarico.msg.select_one": "Select at least one UDC to unload.", "scarico.msg.select_one": "Select at least one UDC to unload.",

View File

@@ -6,6 +6,9 @@ import json
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from version_info import module_version
__version__ = module_version(__name__)
_LOCALE_FILE = Path(__file__).with_name("locale.json") _LOCALE_FILE = Path(__file__).with_name("locale.json")

View File

@@ -11,6 +11,9 @@ from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_section, theme_value from ui_theme import theme_section, theme_value
from user_session import UserSession, create_user_session from user_session import UserSession, create_user_session
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
SQL_LOGIN = """ SQL_LOGIN = """
@@ -64,8 +67,8 @@ class LoginWindow(tk.Toplevel):
self._show_ready_after_id: str | None = None self._show_ready_after_id: str | None = None
self._clear_topmost_after_id: str | None = None self._clear_topmost_after_id: str | None = None
self.title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login")) self.title(versioned_title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"), __name__))
self.geometry("170x145+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x155+0+0"))) self.geometry("170x148+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x170+0+0")))
self.resizable(False, False) self.resizable(False, False)
try: try:
if parent is not None and parent.winfo_viewable(): if parent is not None and parent.winfo_viewable():
@@ -88,53 +91,59 @@ class LoginWindow(tk.Toplevel):
def _build_ui(self) -> None: def _build_ui(self) -> None:
"""Build the compact operator login form.""" """Build the compact operator login form."""
body = ttk.Frame(self, padding=8 if self.compact else 8) body = ttk.Frame(self, padding=6 if self.compact else 8)
body.pack(fill="both", expand=True) body.pack(fill="both", expand=True)
body.columnconfigure(1, weight=0) body.columnconfigure(1, weight=0)
row_offset = 0 row_offset = 0
ttk.Label(body, text="User:").grid(row=row_offset, column=0, sticky="w", padx=(0, 4), pady=4) ttk.Label(body, text="User:").grid(row=row_offset, column=0, sticky="w", padx=(0, 4), pady=(2, 3))
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9)) self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9))
self.login_entry.grid(row=row_offset, column=1, sticky="w", pady=4) self.login_entry.grid(row=row_offset, column=1, sticky="w", pady=(2, 3))
ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 4), pady=4) ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 4), pady=(2, 2))
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9)) self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9))
self.password_entry.grid(row=row_offset + 1, column=1, sticky="w", pady=4) self.password_entry.grid(row=row_offset + 1, column=1, sticky="w", pady=(2, 2))
if self.compact: if self.compact:
actions = ttk.Frame(body) actions = ttk.Frame(body)
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(6, 0)) actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(3, 0))
self._cancel_button = ttk.Button(
actions,
text="Annulla",
command=self._on_cancel,
)
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
self._login_button = ttk.Button( self._login_button = ttk.Button(
actions, actions,
text="OK", text="OK",
command=self._on_login, command=self._on_login,
) )
self._login_button.grid(row=0, column=0, sticky="ew") self._login_button.grid(row=0, column=0, sticky="ew")
else:
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
self.status_label.grid(row=2, column=0, columnspan=2, sticky="w", pady=(2, 2))
actions = ttk.Frame(body)
actions.grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0))
self._cancel_button = ttk.Button( self._cancel_button = ttk.Button(
actions, actions,
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"), text="Annulla",
command=self._on_cancel, command=self._on_cancel,
) )
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0)) self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(3, 0))
else:
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
self.status_label.grid(row=2, column=0, columnspan=2, sticky="w", pady=(1, 1))
actions = ttk.Frame(body)
actions.grid(row=3, column=0, columnspan=2, sticky="w", pady=(3, 0))
self._login_button = ttk.Button( self._login_button = ttk.Button(
actions, actions,
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"), text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"),
command=self._on_login, command=self._on_login,
) )
self._login_button.grid(row=0, column=0, sticky="ew") self._login_button.grid(row=0, column=0, sticky="ew")
self._cancel_button = ttk.Button(
actions,
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
command=self._on_cancel,
)
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(3, 0))
for widget in (self.login_entry, self.password_entry, self._login_button, self._cancel_button):
try:
widget.configure(takefocus=True)
except Exception:
pass
self.bind("<Return>", lambda _e: self._on_login()) self.bind("<Return>", lambda _e: self._on_login())
self.bind("<Escape>", lambda _e: self._on_cancel()) self.bind("<Escape>", lambda _e: self._on_cancel())

17
main.py
View File

@@ -31,8 +31,10 @@ from search_pallets import open_search_window
from storico_pickinglist import open_storico_pickinglist_window from storico_pickinglist import open_storico_pickinglist_window
from storico_udc import open_storico_udc_window from storico_udc import open_storico_udc_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from udc_non_scaffalate import open_udc_non_scaffalate_window
from ui_theme import theme_font, theme_section, theme_value from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session from user_session import UserSession, create_user_session
from version_info import module_version, versioned_title
from view_celle_multi_udc import open_celle_multiple_window from view_celle_multi_udc import open_celle_multiple_window
from window_placement import ( from window_placement import (
cascade_children_below_parent, cascade_children_below_parent,
@@ -42,7 +44,8 @@ from window_placement import (
# Development shortcut: skip the login dialog and boot directly as MAG1. # Development shortcut: skip the login dialog and boot directly as MAG1.
# Set to False when you want to restore normal authentication. # Set to False when you want to restore normal authentication.
BYPASS_LOGIN = False BYPASS_LOGIN = True
__version__ = module_version(__name__)
BYPASS_LOGIN_USER = { BYPASS_LOGIN_USER = {
"operator_id": 4, "operator_id": 4,
"login": "MAG1", "login": "MAG1",
@@ -153,6 +156,7 @@ class Launcher(ctk.CTk):
"reset_corsie", "reset_corsie",
"layout", "layout",
"multi_udc", "multi_udc",
"non_shelved",
"search", "search",
"storico_udc", "storico_udc",
"pickinglist", "pickinglist",
@@ -176,7 +180,7 @@ class Launcher(ctk.CTk):
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d")) color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
) )
self.title( self.title(
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}" f"{versioned_title(loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse'), __name__)} - {self.session.display_name}"
) )
self._apply_dynamic_geometry() self._apply_dynamic_geometry()
@@ -219,6 +223,15 @@ class Launcher(ctk.CTk):
lambda: open_celle_multiple_window(self, self.db_client, session=self.session), lambda: open_celle_multiple_window(self, self.db_client, session=self.session),
), ),
), ),
(
"non_shelved",
loc_text("launcher.non_shelved", catalog=self._locale_catalog, default="UDC non scaffalate"),
"launcher.open_non_shelved",
lambda: self._open_or_focus_child_window(
"non_shelved",
lambda: open_udc_non_scaffalate_window(self, self.db_client, session=self.session),
),
),
( (
"search", "search",
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"), loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),

View File

@@ -10,6 +10,10 @@ from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from version_info import module_version
__version__ = module_version(__name__)
try: try:
from loguru import logger from loguru import logger
except Exception: # pragma: no cover - safety fallback if dependency is missing locally except Exception: # pragma: no cover - safety fallback if dependency is missing locally
@@ -258,6 +262,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
"""Execute the Python-specific picking-list reservation stored procedure.""" """Execute the Python-specific picking-list reservation stored procedure."""
try: try:
azione = str(Azione or "P").strip().upper() azione = str(Azione or "P").strip().upper()
# The toolbar uses two explicit commands rather than a toggle:
# P = reserve the selected document, S = release it if it is active.
if azione not in ("P", "S"): if azione not in ("P", "S"):
return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None) return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None)
_MODULE_LOGGER.log( _MODULE_LOGGER.log(
@@ -279,6 +285,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
_log_sql("py_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"): if not hasattr(db, "query_json"):
raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.") raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.")
# commit=True is required because the stored procedure updates
# PyPickingListReservation and Celle.IDStato.
res = await db.query_json( res = await db.query_json(
sql, sql,
{"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione}, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione},
@@ -295,6 +303,23 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
rc = int(rows[0].get("RC") or 0) rc = int(rows[0].get("RC") or 0)
except Exception: except Exception:
rc = 0 rc = 0
if rc == 0 and azione == "P":
resolve_sql = """
UPDATE dbo.py_BarcodePickingListSkip
SET Risolto = 1
WHERE Documento COLLATE Latin1_General_CI_AS =
CAST(:Documento AS varchar(50)) COLLATE Latin1_General_CI_AS
AND Risolto = 0;
"""
_log_sql("resolve_skipped_pallets_on_prenota", resolve_sql, {"Documento": Documento})
if hasattr(db, "exec"):
await db.exec(resolve_sql, {"Documento": Documento}, commit=True)
else:
await db.query_json(
resolve_sql + "\nSELECT 0 AS RC;",
{"Documento": Documento},
commit=True,
)
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Stored procedure completata documento={Documento} azione={azione} rc={rc}") _MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"Stored procedure completata documento={Documento} azione={azione} rc={rc}")
return SPResult(rc=rc, message="", id_result=None) return SPResult(rc=rc, message="", id_result=None)
except Exception as exc: except Exception as exc:

View File

@@ -23,9 +23,13 @@ from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_color, theme_font, theme_padding, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import style_treeview, zebra_tag
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
try: try:
from loguru import logger from loguru import logger
except Exception: # pragma: no cover - fallback used only when Loguru is not available except Exception: # pragma: no cover - fallback used only when Loguru is not available
@@ -275,7 +279,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
super().__init__(parent) super().__init__(parent)
self._theme = theme_section("reset_corsie", {}) self._theme = theme_section("reset_corsie", {})
self._locale_catalog = load_locale_catalog() self._locale_catalog = load_locale_catalog()
self.title(loc_text("reset.title", catalog=self._locale_catalog, default="Gestione Corsie - svuotamento celle per corsia")) self.title(versioned_title(loc_text("reset.title", catalog=self._locale_catalog, default="Gestione Corsie - svuotamento celle per corsia"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680")))
minsize = theme_value(self._theme, "window_minsize", [880, 560]) minsize = theme_value(self._theme, "window_minsize", [880, 560])
self.minsize(int(minsize[0]), int(minsize[1])) self.minsize(int(minsize[0]), int(minsize[1]))
@@ -298,35 +302,6 @@ class ResetCorsieWindow(ctk.CTkToplevel):
def _setup_tree_style(self): def _setup_tree_style(self):
"""Apply a denser, spreadsheet-like style to the main result grid.""" """Apply a denser, spreadsheet-like style to the main result grid."""
style = ttk.Style(self)
style.configure(
"ResetCorsie.Treeview.Heading",
font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
background=theme_value(self._theme, "tree_heading_bg", "#b8c7db"),
foreground=theme_value(self._theme, "tree_heading_fg", "#10243e"),
relief="flat",
padding=theme_padding(self._theme, "tree_heading_padding", (8, 6)),
)
style.map(
"ResetCorsie.Treeview.Heading",
background=[("active", theme_value(self._theme, "tree_heading_bg_active", "#aebfd6"))],
relief=[("pressed", "groove"), ("!pressed", "flat")],
)
style.configure(
"ResetCorsie.Treeview",
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
rowheight=int(theme_value(self._theme, "tree_row_height", 30)),
background=theme_value(self._theme, "tree_body_bg", "#ffffff"),
fieldbackground=theme_value(self._theme, "tree_body_bg", "#ffffff"),
foreground=theme_value(self._theme, "tree_body_fg", "#1f1f1f"),
borderwidth=0,
)
style.map(
"ResetCorsie.Treeview",
background=[("selected", theme_value(self._theme, "tree_selected_bg", "#cfe4ff"))],
foreground=[("selected", theme_value(self._theme, "tree_selected_fg", "#10243e"))],
)
@_log_call() @_log_call()
def _build_ui(self): def _build_ui(self):
"""Create selectors, summary widgets and the occupied-cell grid.""" """Create selectors, summary widgets and the occupied-cell grid."""
@@ -411,8 +386,13 @@ class ResetCorsieWindow(ctk.CTkToplevel):
width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)), width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)),
anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")), anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")),
) )
self.tree.tag_configure("odd", background=theme_value(self._theme, "tree_row_odd_bg", "#ffffff")) style_treeview(
self.tree.tag_configure("even", background=theme_value(self._theme, "tree_row_even_bg", "#f3f6fb")) self.tree,
style_name="ResetCorsie.Treeview",
rowheight=int(theme_value(self._theme, "tree_row_height", 30)),
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
heading_font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
)
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview) sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview) sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
@@ -529,7 +509,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
for idx, (_idc, ubi, n) in enumerate(det_rows): for idx, (_idc, ubi, n) in enumerate(det_rows):
tag = "even" if idx % 2 else "odd" tag = zebra_tag(idx)
self.tree.insert("", "end", values=(ubi, n), tags=(tag,)) self.tree.insert("", "end", values=(ubi, n), tags=(tag,))
except Exception as ex: except Exception as ex:
_MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}") _MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}")

View File

@@ -0,0 +1,9 @@
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF OBJECT_ID(N'dbo.py_BarcodePickingListSkip', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.py_BarcodePickingListSkip;
END;
COMMIT TRANSACTION;

View File

@@ -3,28 +3,47 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import threading
import traceback import traceback
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import tempfile
from typing import Callable, TypeVar from typing import Callable, TypeVar
from version_info import module_version
__version__ = module_version(__name__)
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
FATAL_LOG = BASE_DIR / "warehouse_fatal.log" FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
TEMP_DIR = Path(tempfile.gettempdir())
_STDIO_HANDLES = [] _STDIO_HANDLES = []
_EXCEPTION_LOGGING_CONFIGURED: set[str] = set()
T = TypeVar("T") T = TypeVar("T")
def _open_log_file(path: Path):
"""Open a log path, falling back to the user temp directory when needed."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
return path.open("a", encoding="utf-8", buffering=1)
except Exception:
fallback = TEMP_DIR / path.name
fallback.parent.mkdir(parents=True, exist_ok=True)
return fallback.open("a", encoding="utf-8", buffering=1)
def ensure_stdio(app_name: str) -> None: def ensure_stdio(app_name: str) -> None:
"""Give ``pythonw`` a real stdout/stderr target before loggers are imported.""" """Give ``pythonw`` a real stdout/stderr target before loggers are imported."""
stamp = datetime.now().strftime("%Y%m%d") stamp = datetime.now().strftime("%Y%m%d")
if sys.stdout is None: if sys.stdout is None:
handle = (BASE_DIR / f"{app_name}_stdout_{stamp}.log").open("a", encoding="utf-8", buffering=1) handle = _open_log_file(BASE_DIR / f"{app_name}_stdout_{stamp}.log")
_STDIO_HANDLES.append(handle) _STDIO_HANDLES.append(handle)
sys.stdout = handle sys.stdout = handle
if sys.stderr is None: if sys.stderr is None:
handle = (BASE_DIR / f"{app_name}_stderr_{stamp}.log").open("a", encoding="utf-8", buffering=1) handle = _open_log_file(BASE_DIR / f"{app_name}_stderr_{stamp}.log")
_STDIO_HANDLES.append(handle) _STDIO_HANDLES.append(handle)
sys.stderr = handle sys.stderr = handle
@@ -32,12 +51,100 @@ def ensure_stdio(app_name: str) -> None:
def log_fatal(app_name: str, exc: BaseException) -> None: def log_fatal(app_name: str, exc: BaseException) -> None:
"""Write one startup/runtime crash to a persistent log file.""" """Write one startup/runtime crash to a persistent log file."""
with FATAL_LOG.open("a", encoding="utf-8") as handle: with _open_log_file(FATAL_LOG) as handle:
handle.write("\n" + "=" * 80 + "\n") handle.write("\n" + "=" * 80 + "\n")
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}\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__))) handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
def log_exception(app_name: str, exc: BaseException, *, context: str = "") -> None:
"""Write a handled or callback exception to the persistent fatal log."""
with _open_log_file(FATAL_LOG) as handle:
handle.write("\n" + "=" * 80 + "\n")
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}")
if context:
handle.write(f" | {context}")
handle.write("\n")
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
def configure_exception_logging(app_name: str, root=None, loop=None) -> None:
"""Install process, Tk and asyncio exception hooks for console-less apps."""
if app_name in _EXCEPTION_LOGGING_CONFIGURED:
return
_EXCEPTION_LOGGING_CONFIGURED.add(app_name)
previous_excepthook = sys.excepthook
def _sys_excepthook(exc_type, exc, tb):
log_exception(app_name, exc, context="sys.excepthook")
if previous_excepthook:
previous_excepthook(exc_type, exc, tb)
sys.excepthook = _sys_excepthook
if hasattr(threading, "excepthook"):
previous_threading_excepthook = threading.excepthook
def _threading_excepthook(args):
log_exception(app_name, args.exc_value, context=f"thread={getattr(args.thread, 'name', '')}")
previous_threading_excepthook(args)
threading.excepthook = _threading_excepthook
if root is not None:
def _tk_exception(exc_type, exc, tb):
log_exception(app_name, exc, context="tkinter callback")
try:
import tkinter.messagebox as messagebox
messagebox.showerror(
app_name,
"Errore applicativo registrato nei log.\n\n"
"Se l'operazione era in corso, ripeterla o avvisare il responsabile.",
parent=root,
)
except Exception:
pass
try:
root.report_callback_exception = _tk_exception
except Exception:
pass
if loop is not None:
previous_loop_handler = None
try:
previous_loop_handler = loop.get_exception_handler()
except Exception:
previous_loop_handler = None
def _loop_exception_handler(active_loop, context):
exc = context.get("exception")
if exc is not None:
log_exception(app_name, exc, context=f"asyncio: {context.get('message', '')}")
else:
log_runtime_event(app_name, f"ASYNCIO {context!r}")
if previous_loop_handler is not None:
previous_loop_handler(active_loop, context)
try:
loop.set_exception_handler(_loop_exception_handler)
except Exception:
pass
def log_runtime_event(app_name: str, message: str) -> None:
"""Write a lightweight launch/shutdown trace for console-less starts."""
safe_name = "".join(ch if ch.isalnum() or ch in ("_", "-") else "_" for ch in app_name.lower())
path = BASE_DIR / f"{safe_name}_launch.log"
with _open_log_file(path) as handle:
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {message}\n")
def show_fatal_message(app_name: str, exc: BaseException) -> None: def show_fatal_message(app_name: str, exc: BaseException) -> None:
"""Show a best-effort message box for console-less launches.""" """Show a best-effort message box for console-less launches."""
@@ -49,7 +156,10 @@ def show_fatal_message(app_name: str, exc: BaseException) -> None:
root.withdraw() root.withdraw()
messagebox.showerror( messagebox.showerror(
app_name, app_name,
f"Avvio non riuscito.\n\nDettaglio salvato in:\n{FATAL_LOG}\n\n{exc}", "Avvio non riuscito.\n\n"
f"Controlla i log in:\n{BASE_DIR}\n\n"
f"Se non ci sono, controlla anche:\n{TEMP_DIR}\n\n"
f"{exc}",
parent=root, parent=root,
) )
root.destroy() root.destroy()
@@ -61,8 +171,15 @@ def run_with_fatal_log(app_name: str, func: Callable[[], T]) -> T:
"""Run an app entry point and persist otherwise invisible ``pythonw`` crashes.""" """Run an app entry point and persist otherwise invisible ``pythonw`` crashes."""
try: try:
return func() log_runtime_event(
app_name,
f"START exe={sys.executable!r} version={sys.version.split()[0]} cwd={Path.cwd()} base={BASE_DIR} argv={sys.argv!r}",
)
result = func()
log_runtime_event(app_name, f"RETURN result={result!r}")
return result
except BaseException as exc: except BaseException as exc:
log_runtime_event(app_name, f"EXCEPTION type={type(exc).__name__} value={exc!r}")
log_fatal(app_name, exc) log_fatal(app_name, exc)
show_fatal_message(app_name, exc) show_fatal_message(app_name, exc)
raise raise

View File

@@ -11,9 +11,19 @@ from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import (
apply_tksheet_visual_style,
apply_tksheet_zebra,
merge_tags,
style_treeview,
zebra_tag,
)
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
__version__ = module_version(__name__)
try: try:
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Font from openpyxl.styles import Alignment, Font
@@ -85,7 +95,7 @@ class SearchWindow(ctk.CTkToplevel):
self._theme = theme_section("search_window", {}) self._theme = theme_section("search_window", {})
self._locale_catalog = load_locale_catalog() self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog() self._tooltip_catalog = load_tooltip_catalog()
self.title(loc_text("search.title", catalog=self._locale_catalog, default="Ricerca UDC/Lotto/Codice")) self.title(versioned_title(loc_text("search.title", catalog=self._locale_catalog, default="Ricerca UDC/Lotto/Codice"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
minsize = theme_value(self._theme, "window_minsize", [900, 560]) minsize = theme_value(self._theme, "window_minsize", [900, 560])
self.minsize(int(minsize[0]), int(minsize[1])) self.minsize(int(minsize[0]), int(minsize[1]))
@@ -174,17 +184,25 @@ class SearchWindow(ctk.CTkToplevel):
self.use_sheet = False self.use_sheet = False
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione") cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
self.tree = ttk.Treeview(wrap, columns=cols, show="headings") self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
self._style = ttk.Style(self) headings = {
try: "IDCella": ("IDCella", 90, "e"),
self._style.theme_use(self._style.theme_use()) "Ubicazione": ("Ubicazione", 150, "w"),
except Exception: "UDC": ("UDC / Barcode", 130, "w"),
pass "Lotto": ("Lotto", 130, "w"),
self._style.configure("Search.Treeview", rowheight=22, font=("", 9)) "Codice": ("Codice prodotto", 150, "w"),
self._style.configure("Search.Treeview.Heading", font=("", 9, "bold"), background="#F3F4F6") "Descrizione": ("Descrizione prodotto", 340, "w"),
self._style.map("Search.Treeview", background=[("selected", "#DCEBFF")]) }
self.tree.configure(style="Search.Treeview") for col in cols:
self.tree.tag_configure("even", background="#FFFFFF") text, width, anchor = headings[col]
self.tree.tag_configure("odd", background="#F7F9FC") self.tree.heading(col, text=text)
self.tree.column(col, width=width, anchor=anchor, stretch=True)
self._style = style_treeview(
self.tree,
style_name="Search.Treeview",
rowheight=22,
font=("", 9),
heading_font=("", 9, "bold"),
)
self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020") self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020")
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview) sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
@@ -209,7 +227,7 @@ class SearchWindow(ctk.CTkToplevel):
is9999 = int(vals[0]) == 9999 is9999 = int(vals[0]) == 9999
except Exception: except Exception:
is9999 = False is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,) tags = merge_tags(zebra, "id9999" if is9999 else "")
self.tree.item(iid, tags=tags) self.tree.item(iid, tags=tags)
def _export_xlsx(self): def _export_xlsx(self):
@@ -414,6 +432,8 @@ class SearchWindow(ctk.CTkToplevel):
hdrs = list(headers) hdrs = list(headers)
hdrs[c] = hdrs[c] + arrow hdrs[c] = hdrs[c] + arrow
self.sheet.headers(hdrs) self.sheet.headers(hdrs)
apply_tksheet_visual_style(self.sheet)
apply_tksheet_zebra(self.sheet, len(data))
except Exception: except Exception:
pass pass
@@ -449,7 +469,10 @@ class SearchWindow(ctk.CTkToplevel):
for row in rows: for row in rows:
idc, ubi, udc_v, lot_v, cod_v, desc_v = row idc, ubi, udc_v, lot_v, cod_v, desc_v = row
data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v]) data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v])
self.sheet.headers(["IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione"])
apply_tksheet_visual_style(self.sheet)
self.sheet.set_sheet_data(data) self.sheet.set_sheet_data(data)
apply_tksheet_zebra(self.sheet, len(data))
self.sheet.set_all_cell_sizes_to_text() self.sheet.set_all_cell_sizes_to_text()
except Exception: except Exception:
self.use_sheet = False self.use_sheet = False
@@ -458,12 +481,11 @@ class SearchWindow(ctk.CTkToplevel):
self.tree.delete(iid) self.tree.delete(iid)
for idx, row in enumerate(rows): for idx, row in enumerate(rows):
idc, ubi, udc_v, lot_v, cod_v, desc_v = row idc, ubi, udc_v, lot_v, cod_v, desc_v = row
zebra = "even" if idx % 2 == 0 else "odd"
try: try:
is9999 = int(idc) == 9999 is9999 = int(idc) == 9999
except Exception: except Exception:
is9999 = False is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,) tags = merge_tags(zebra_tag(idx), "id9999" if is9999 else "")
self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags) self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags)
if not rows: if not rows:

View File

@@ -1,4 +1,11 @@
"""Read-only picking-list history window.""" """Picking-list history window and controlled residual-shipment workflow.
The window is mostly read-only: it summarizes historical picking lists and shows
their UDC detail rows. The only write operation is the explicit, user-confirmed
"Versa residui in 7G.1.1" action for lists classified as closed with residuals.
That action delegates each UDC movement to ``move_pallet_async`` so the same
MagazziniPallet trace used by the rest of the WMS is preserved.
"""
from __future__ import annotations from __future__ import annotations
@@ -11,55 +18,88 @@ import customtkinter as ctk
from busy_overlay import InlineBusyOverlay from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import merge_tags, style_treeview, zebra_tag
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
# Master query:
# - reads the Python-only historical view, not the legacy C# views;
# - groups repeated lot rows by distinct UDC/PalletKey;
# - derives the operational status shown in the upper grid.
SQL_STORICO_PL = """ SQL_STORICO_PL = """
WITH base AS ( WITH base AS (
SELECT * SELECT
*,
NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '') AS PalletKey
FROM dbo.py_XMag_ViewPackingListStorico FROM dbo.py_XMag_ViewPackingListStorico
), ),
agg AS ( pallets AS (
SELECT
Documento,
PalletKey,
MAX(CASE WHEN Cella <> 9999 OR Cella IS NULL THEN 1 ELSE 0 END) AS HasResiduo,
MAX(CASE WHEN Cella = 9999 THEN 1 ELSE 0 END) AS HasSpedito
FROM base
WHERE PalletKey IS NOT NULL
GROUP BY Documento, PalletKey
),
meta AS (
SELECT SELECT
Documento, Documento,
MAX(DataDocumento) AS DataDocumento, MAX(DataDocumento) AS DataDocumento,
MAX(StatoDocumento) AS StatoDocumento, MAX(StatoDocumento) AS StatoDocumento,
MAX(NAZIONE) AS NAZIONE, MAX(NAZIONE) AS NAZIONE,
MAX(CodNazione) AS CodNazione, 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, COUNT(*) AS RigheTotali,
MIN(Ordinamento) AS PrimoOrdine, MIN(Ordinamento) AS PrimoOrdine,
MAX(IDStato) AS IDStato MAX(IDStato) AS IDStato
FROM base FROM base
GROUP BY Documento GROUP BY Documento
),
agg AS (
SELECT
Documento,
COUNT(*) AS TotUDC,
SUM(CASE WHEN p.HasResiduo = 0 AND p.HasSpedito = 1 THEN 1 ELSE 0 END) AS RigheSpedite,
SUM(CASE WHEN p.HasResiduo = 1 THEN 1 ELSE 0 END) AS RigheResidue
FROM pallets p
GROUP BY Documento
) )
SELECT SELECT
Documento, m.Documento,
DataDocumento, m.DataDocumento,
StatoDocumento, m.StatoDocumento,
NAZIONE, m.NAZIONE,
CodNazione, m.CodNazione,
TotUDC, COALESCE(a.TotUDC, 0) AS TotUDC,
RigheResidue, COALESCE(a.RigheResidue, 0) AS RigheResidue,
RigheSpedite, COALESCE(a.RigheSpedite, 0) AS RigheSpedite,
RigheTotali, m.RigheTotali,
CASE CASE
WHEN StatoDocumento = 'D' THEN 'Chiusa' WHEN m.StatoDocumento = 'D' AND COALESCE(a.RigheResidue, 0) > 0 THEN 'Chiusa ERP con residui'
WHEN RigheResidue = 0 THEN 'Esaurita' WHEN m.StatoDocumento = 'D' THEN 'Chiusa'
WHEN RigheSpedite > 0 THEN 'In corso' WHEN COALESCE(a.RigheResidue, 0) = 0 THEN 'Esaurita'
WHEN COALESCE(a.RigheSpedite, 0) > 0 THEN 'In corso'
ELSE 'Da lavorare' ELSE 'Da lavorare'
END AS StatoOperativo, END AS StatoOperativo,
IDStato, m.IDStato,
PrimoOrdine m.PrimoOrdine
FROM agg FROM meta m
WHERE (:documento IS NULL OR CAST(Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%')) LEFT JOIN agg a ON a.Documento = m.Documento
ORDER BY Documento DESC; WHERE (:documento IS NULL OR CAST(m.Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
ORDER BY m.Documento DESC;
""" """
# Detail query:
# - returns the ERP picking-list rows enriched with the current WMS cell;
# - may contain multiple rows for the same UDC when the pallet contains more lots;
# - is intentionally ordered by warehouse extraction order, then pallet.
SQL_STORICO_PL_DETAILS = """ SQL_STORICO_PL_DETAILS = """
SELECT SELECT
Documento, Documento,
@@ -135,8 +175,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
self._async = AsyncRunner(self) self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme) self._busy = InlineBusyOverlay(self, self._theme)
self.var_documento = tk.StringVar() self.var_documento = tk.StringVar()
self._selected_documento: str | None = None
self._selected_stato_operativo: str = ""
self._detail_rows: list[dict[str, Any]] = []
self.title(loc_text("history.picking.title", catalog=self._locale_catalog, default="Storico Picking List")) self.title(versioned_title(loc_text("history.picking.title", catalog=self._locale_catalog, default="Storico Picking List"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x720"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1200x720")))
minsize = theme_value(self._theme, "window_minsize", [980, 560]) minsize = theme_value(self._theme, "window_minsize", [980, 560])
self.minsize(int(minsize[0]), int(minsize[1])) self.minsize(int(minsize[0]), int(minsize[1]))
@@ -158,7 +201,7 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")), 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(row=0, column=0, sticky="ew", padx=8, pady=8)
top.grid_columnconfigure(3, weight=1) top.grid_columnconfigure(4, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10)) label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10)) entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
@@ -176,6 +219,18 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
).grid( ).grid(
row=0, column=2, sticky="w" row=0, column=2, sticky="w"
) )
self.btn_ship_residuals = ctk.CTkButton(
top,
text=loc_text(
"history.picking.button.ship_residuals",
catalog=self._locale_catalog,
default="Versa residui in 7G.1.1",
),
command=self._ship_selected_residuals,
state="disabled",
font=button_font,
)
self.btn_ship_residuals.grid(row=0, column=3, sticky="w", padx=(12, 0))
self.master_tree = self._make_tree( self.master_tree = self._make_tree(
row=1, row=1,
@@ -228,8 +283,10 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
for col in columns: for col in columns:
tree.heading(col, text=col) tree.heading(col, text=col)
tree.column(col, width=widths.get(col, 100), anchor="w") tree.column(col, width=widths.get(col, 100), anchor="w")
style_treeview(tree, style_name="HistoryPicking.Treeview", rowheight=24)
tree.tag_configure("done", background="#ECECEC") tree.tag_configure("done", background="#ECECEC")
tree.tag_configure("active", background="#EAF7EA") tree.tag_configure("active", background="#EAF7EA")
tree.tag_configure("warning", background="#FFE3B3")
sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview) sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview) sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set) tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
@@ -240,12 +297,15 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _load_master(self) -> None: def _load_master(self) -> None:
params = {"documento": str(self.var_documento.get() or "").strip() or None} params = {"documento": str(self.var_documento.get() or "").strip() or None}
previous_documento = self._selected_documento
async def _job(): async def _job():
return await self.db_client.query_json(SQL_STORICO_PL, params) return await self.db_client.query_json(SQL_STORICO_PL, params)
def _ok(res): def _ok(res):
self._fill_master(_rows_to_dicts(res)) self._fill_master(_rows_to_dicts(res))
if previous_documento:
self._restore_master_selection(previous_documento)
def _err(ex): def _err(ex):
messagebox.showerror( messagebox.showerror(
@@ -273,9 +333,25 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_master(self, rows: list[dict[str, Any]]) -> None: def _fill_master(self, rows: list[dict[str, Any]]) -> None:
self.master_tree.delete(*self.master_tree.get_children("")) self.master_tree.delete(*self.master_tree.get_children(""))
self.detail_tree.delete(*self.detail_tree.get_children("")) self.detail_tree.delete(*self.detail_tree.get_children(""))
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
for index, row in enumerate(rows): for index, row in enumerate(rows):
stato = str(row.get("StatoOperativo") or "") stato = str(row.get("StatoOperativo") or "")
tag = "done" if stato in {"Chiusa", "Esaurita"} else "active" if int(row.get("IDStato") or 0) == 1 else "" is_open_with_shipped = (
str(row.get("StatoDocumento") or "") == "P"
and int(row.get("RigheSpedite") or 0) > 0
)
tag = (
"warning"
if is_open_with_shipped
else "done"
if stato in {"Chiusa", "Esaurita"}
else "active"
if int(row.get("IDStato") or 0) == 1
else ""
)
self.master_tree.insert( self.master_tree.insert(
"", "",
"end", "end",
@@ -291,23 +367,32 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
stato, stato,
row.get("IDStato", ""), row.get("IDStato", ""),
), ),
tags=(tag,) if tag else (), tags=merge_tags(zebra_tag(index), tag),
) )
def _on_master_select(self, _event=None) -> None: def _on_master_select(self, _event=None) -> None:
selected = self.master_tree.selection() selected = self.master_tree.selection()
if not selected: if not selected:
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
return return
values = self.master_tree.item(selected[0], "values") values = self.master_tree.item(selected[0], "values")
if not values: if not values:
return return
documento = values[0] documento = values[0]
self._selected_documento = str(documento)
self._selected_stato_operativo = str(values[7] if len(values) > 7 else "")
self._detail_rows = []
self._update_residual_button()
async def _job(): async def _job():
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento}) return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
def _ok(res): def _ok(res):
self._fill_detail(_rows_to_dicts(res)) self._fill_detail(_rows_to_dicts(res))
self._update_residual_button()
def _err(ex): def _err(ex):
messagebox.showerror( messagebox.showerror(
@@ -334,8 +419,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_detail(self, rows: list[dict[str, Any]]) -> None: def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
self.detail_tree.delete(*self.detail_tree.get_children("")) self.detail_tree.delete(*self.detail_tree.get_children(""))
for row in rows: self._detail_rows = rows
for index, row in enumerate(rows):
is_open_shipped = str(row.get("StatoDocumento") or "") == "P" and int(row.get("Cella") or 0) == 9999
done = str(row.get("StatoDocumento") or "") == "D" or int(row.get("Cella") or 0) == 9999 done = str(row.get("StatoDocumento") or "") == "D" or int(row.get("Cella") or 0) == 9999
tag = "warning" if is_open_shipped else "done" if done else ""
self.detail_tree.insert( self.detail_tree.insert(
"", "",
"end", "end",
@@ -351,9 +439,130 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
row.get("Ubicazione", ""), row.get("Ubicazione", ""),
row.get("Ordinamento", ""), row.get("Ordinamento", ""),
), ),
tags=("done",) if done else (), tags=merge_tags(zebra_tag(index), tag),
) )
def _update_residual_button(self) -> None:
"""Enable the bulk shipment button only for closed picking lists with residual UDCs."""
enabled = self._selected_stato_operativo == "Chiusa ERP con residui"
try:
self.btn_ship_residuals.configure(state="normal" if enabled else "disabled")
except Exception:
pass
def _restore_master_selection(self, documento: str) -> None:
"""Re-select a document after a reload, when it is still visible."""
for iid in self.master_tree.get_children(""):
values = self.master_tree.item(iid, "values")
if values and str(values[0]) == str(documento):
self.master_tree.selection_set(iid)
self.master_tree.focus(iid)
self.master_tree.see(iid)
self._on_master_select()
return
def _residual_pallets_from_rows(self, rows: list[dict[str, Any]]) -> list[str]:
"""Return distinct residual UDCs that are not already in 7G.1.1."""
pallets: list[str] = []
seen: set[str] = set()
for row in rows:
pallet = str(row.get("Pallet") or "").strip()
# One UDC can appear on multiple detail rows when it contains
# multiple lots; movement must be executed once per physical UDC.
if not pallet or pallet in seen:
continue
try:
cella = int(row.get("Cella") or 0)
except Exception:
cella = 0
# IDCella 9999 is the conventional 7G.1.1 shipment cell. Rows
# already there are complete and must not receive another movement.
if cella == 9999:
continue
seen.add(pallet)
pallets.append(pallet)
return pallets
def _operator_login(self) -> str:
"""Return the user recorded on generated warehouse movements."""
login = str(getattr(self.session, "login", "") or "").strip()
return login or "warehouse_ui"
def _ship_selected_residuals(self) -> None:
"""Move all residual UDCs of the selected closed PL to the shipment cell 7G.1.1."""
if self._selected_stato_operativo != "Chiusa ERP con residui" or not self._selected_documento:
return
estimated = self._residual_pallets_from_rows(self._detail_rows)
count_text = str(len(estimated)) if estimated else "le"
if not messagebox.askyesno(
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
(
f"Documento {self._selected_documento}\n\n"
f"Verranno versate in 7G.1.1 {count_text} UDC residue della picking list chiusa.\n"
"L'operazione registra i movimenti nello storico UDC.\n\n"
"Procedere?"
),
parent=self,
):
return
documento = self._selected_documento
utente = self._operator_login()
async def _job():
# Re-read the detail inside the async job to avoid moving stale UI
# rows if another operator or barcode client changed the list after
# the user selected it.
detail_res = await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
detail_rows = _rows_to_dicts(detail_res)
pallets = self._residual_pallets_from_rows(detail_rows)
results: list[dict[str, Any]] = []
for pallet in pallets:
# Reuse the central movement primitive: it closes the current
# source V row with a P movement and inserts a new V on 9999,
# preserving the historical trail required by Storico UDC.
result = await move_pallet_async(
self.db_client,
barcode_pallet=pallet,
target_idcella=9999,
target_barcode_cella="9000000",
utente=utente,
)
results.append(result)
return {"pallets": pallets, "results": results}
def _ok(res):
pallets = res.get("pallets", []) if isinstance(res, dict) else []
results = res.get("results", []) if isinstance(res, dict) else []
moved = sum(1 for row in results if int(row.get("ok") or 0) == 1)
messagebox.showinfo(
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
f"Documento {documento}\nUDC residue trovate: {len(pallets)}\nUDC versate in 7G.1.1: {moved}",
parent=self,
)
self._selected_documento = str(documento)
self._load_master()
def _err(ex):
messagebox.showerror(
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
f"Versamento residui fallito:\n{ex}",
parent=self,
)
self._async.run(
_job(),
_ok,
_err,
busy=self._busy,
message=f"Verso residui PL {documento} in 7G.1.1...",
)
def open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc: def open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
"""Open the picking-list history window.""" """Open the picking-list history window."""

View File

@@ -12,8 +12,12 @@ from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import merge_tags, style_treeview, zebra_tag
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
SQL_STORICO_UDC = """ SQL_STORICO_UDC = """
WITH direct AS ( WITH direct AS (
@@ -155,7 +159,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
self._busy = InlineBusyOverlay(self, self._theme) self._busy = InlineBusyOverlay(self, self._theme)
self.var_udc = tk.StringVar(value=str(initial_udc or "")) 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.title(versioned_title(loc_text("history.udc.title", catalog=self._locale_catalog, default="Storico movimenti UDC"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
minsize = theme_value(self._theme, "window_minsize", [900, 560]) minsize = theme_value(self._theme, "window_minsize", [900, 560])
self.minsize(int(minsize[0]), int(minsize[1])) self.minsize(int(minsize[0]), int(minsize[1]))
@@ -218,6 +222,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
for col in cols: for col in cols:
self.tree.heading(col, text=col) self.tree.heading(col, text=col)
self.tree.column(col, width=90, anchor="w") self.tree.column(col, width=90, anchor="w")
style_treeview(self.tree, style_name="HistoryUDC.Treeview", rowheight=24)
self.tree.column("ID", width=70, anchor="e") self.tree.column("ID", width=70, anchor="e")
self.tree.column("Tipo", width=55, anchor="center") self.tree.column("Tipo", width=55, anchor="center")
self.tree.column("Rif", width=70, anchor="e") self.tree.column("Rif", width=70, anchor="e")
@@ -280,7 +285,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
value = row.get(name, "") value = row.get(name, "")
return "" if value is None else value return "" if value is None else value
for row in rows: for index, row in enumerate(rows):
tipo = str(row.get("Tipo") or "") tipo = str(row.get("Tipo") or "")
tag = "versamento" if tipo == "V" else "prelievo" if tipo == "P" else "spedita" if tipo == "SPED" else "" tag = "versamento" if tipo == "V" else "prelievo" if tipo == "P" else "spedita" if tipo == "SPED" else ""
self.tree.insert( self.tree.insert(
@@ -299,7 +304,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
_value(row, "ModUtente"), _value(row, "ModUtente"),
_value(row, "ModDataOra"), _value(row, "ModDataOra"),
), ),
tags=(tag,) if tag else (), tags=merge_tags(zebra_tag(index), tag),
) )

View File

@@ -6,6 +6,9 @@ import json
from pathlib import Path from pathlib import Path
import tkinter as tk import tkinter as tk
from version_info import module_version
__version__ = module_version(__name__)
_TOOLTIP_FILE = Path(__file__).with_name("tooltip.json") _TOOLTIP_FILE = Path(__file__).with_name("tooltip.json")

230
udc_non_scaffalate.py Normal file
View File

@@ -0,0 +1,230 @@
"""Read-only list of pallets currently parked in the non-shelved virtual cell."""
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 runtime_support import log_exception
from ui_tables import style_treeview, zebra_tag
from ui_theme import theme_color, theme_font, theme_section, theme_value
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
SQL_NON_SCAFFALATE = """
WITH trace_rows AS (
SELECT
Pallet,
MIN(Lotto) AS Lotto,
MIN(Prodotto) AS Prodotto,
MIN(Descrizione) AS Descrizione
FROM dbo.vXTracciaProdotti
GROUP BY Pallet
)
SELECT
g.BarcodePallet AS UDC,
g.IDCella,
CONCAT(RTRIM(c.Corsia), '.', RTRIM(CAST(c.Colonna AS varchar(32))), '.', RTRIM(CAST(c.Fila AS varchar(32)))) AS Ubicazione,
t.Lotto,
t.Prodotto,
t.Descrizione
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c
ON c.ID = g.IDCella
LEFT JOIN trace_rows AS t
ON t.Pallet COLLATE Latin1_General_CI_AS =
g.BarcodePallet COLLATE Latin1_General_CI_AS
WHERE g.IDCella = 1000
ORDER BY g.BarcodePallet;
"""
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 UDCNonScaffalateWindow(ctk.CTkToplevel):
"""Window showing current UDCs in the conventional 5E1.1 non-shelved cell."""
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("non_shelved_window", theme_section("search_window", {}))
self._locale_catalog = load_locale_catalog()
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
self._load_in_progress = False
self.title(versioned_title(loc_text("non_shelved.title", catalog=self._locale_catalog, default="UDC non scaffalate"), __name__))
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x650")))
minsize = theme_value(self._theme, "window_minsize", [820, 520])
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(250, self._load)
def _is_alive(self) -> bool:
try:
return bool(self.winfo_exists())
except tk.TclError:
return False
def _hide_busy_safe(self) -> None:
try:
self._busy.hide()
except Exception as exc:
log_exception(__name__, exc, context="hide busy UDC non scaffalate")
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(2, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10, "bold"))
button_font = theme_font(self._theme, "toolbar_button_font", ("Segoe UI", 10, "bold"))
ctk.CTkLabel(
top,
text=loc_text("non_shelved.subtitle", catalog=self._locale_catalog, default="Cella convenzionale: 5E1.1 (codice 1000 / barcode 9001000)"),
font=label_font,
).grid(row=0, column=0, sticky="w", padx=(0, 12))
ctk.CTkButton(
top,
text=loc_text("non_shelved.refresh", catalog=self._locale_catalog, default="Aggiorna"),
command=self._load,
font=button_font,
).grid(row=0, column=1, 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 = ("UDC", "IDCella", "Ubicazione", "Lotto", "Prodotto", "Descrizione")
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
headings = {
"UDC": ("UDC", 120, "w"),
"IDCella": ("IDCella", 80, "e"),
"Ubicazione": ("Ubicazione", 130, "w"),
"Lotto": ("Lotto", 140, "w"),
"Prodotto": ("Prodotto", 150, "w"),
"Descrizione": ("Descrizione", 340, "w"),
}
for col in cols:
text, width, anchor = headings[col]
self.tree.heading(col, text=text)
self.tree.column(col, width=width, anchor=anchor, stretch=True)
style_treeview(
self.tree,
style_name="NonShelved.Treeview",
rowheight=22,
font=("", 9),
heading_font=("", 9, "bold"),
)
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
self.tree.grid(row=0, column=0, sticky="nsew")
sy.grid(row=0, column=1, sticky="ns")
sx.grid(row=1, column=0, sticky="ew")
def _load(self) -> None:
if self._load_in_progress or not self._is_alive():
return
async def job():
return await self.db_client.query_json(SQL_NON_SCAFFALATE, as_dict_rows=True)
self._load_in_progress = True
try:
self._busy.show(loc_text("non_shelved.busy", catalog=self._locale_catalog, default="Carico UDC non scaffalate..."))
self._async.run(job(), self._on_loaded, self._on_error)
except Exception as exc:
self._load_in_progress = False
self._hide_busy_safe()
log_exception(__name__, exc, context="avvio caricamento UDC non scaffalate")
messagebox.showerror(
loc_text("non_shelved.title", catalog=self._locale_catalog, default="UDC non scaffalate"),
str(exc),
parent=self if self._is_alive() else None,
)
def _on_loaded(self, res: dict[str, Any] | None) -> None:
self._load_in_progress = False
self._hide_busy_safe()
if not self._is_alive():
return
try:
rows = _rows_to_dicts(res)
self.tree.delete(*self.tree.get_children(""))
for index, row in enumerate(rows):
self.tree.insert(
"",
"end",
values=(
row.get("UDC") or "",
row.get("IDCella") or "",
row.get("Ubicazione") or "",
row.get("Lotto") or "",
row.get("Prodotto") or "",
row.get("Descrizione") or "",
),
tags=(zebra_tag(index),),
)
except Exception as exc:
log_exception(__name__, exc, context="render UDC non scaffalate")
messagebox.showerror(
loc_text("non_shelved.title", catalog=self._locale_catalog, default="UDC non scaffalate"),
str(exc),
parent=self,
)
def _on_error(self, exc: BaseException) -> None:
self._load_in_progress = False
self._hide_busy_safe()
log_exception(__name__, exc, context="query UDC non scaffalate")
if not self._is_alive():
return
messagebox.showerror(
loc_text("non_shelved.title", catalog=self._locale_catalog, default="UDC non scaffalate"),
str(exc),
parent=self,
)
def open_udc_non_scaffalate_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
"""Open the non-shelved UDC window."""
win = UDCNonScaffalateWindow(parent, db_client, session=session)
place_window_fullsize_below_parent_later(parent, win)
return win

108
ui_tables.py Normal file
View File

@@ -0,0 +1,108 @@
"""Shared visual helpers for data grids."""
from __future__ import annotations
from tkinter import ttk
from typing import Any
TABLE_ROW_EVEN = "#FFFFFF"
TABLE_ROW_ODD = "#F4F6F8"
TABLE_HEADER_BG = "#D1D5DB"
TABLE_HEADER_FG = "#111827"
TABLE_SELECTED_BG = "#DCEBFF"
TABLE_SELECTED_FG = "#111827"
def style_treeview(
tree: ttk.Treeview,
*,
style_name: str,
rowheight: int = 24,
font: Any = ("Segoe UI", 9),
heading_font: Any = ("Segoe UI", 9, "bold"),
) -> ttk.Style:
"""Apply a consistent high-contrast header and zebra-ready style."""
style = ttk.Style(tree)
style.configure(style_name, rowheight=rowheight, font=font)
style.configure(
f"{style_name}.Heading",
font=heading_font,
background=TABLE_HEADER_BG,
foreground=TABLE_HEADER_FG,
relief="flat",
)
style.map(
f"{style_name}.Heading",
background=[("active", TABLE_HEADER_BG), ("pressed", TABLE_HEADER_BG)],
foreground=[("active", TABLE_HEADER_FG), ("pressed", TABLE_HEADER_FG)],
)
style.map(
style_name,
background=[("selected", TABLE_SELECTED_BG)],
foreground=[("selected", TABLE_SELECTED_FG)],
)
tree.configure(style=style_name)
configure_treeview_zebra_tags(tree)
return style
def configure_treeview_zebra_tags(tree: ttk.Treeview) -> None:
"""Register alternating row color tags on a Treeview."""
tree.tag_configure("even", background=TABLE_ROW_EVEN)
tree.tag_configure("odd", background=TABLE_ROW_ODD)
def zebra_tag(index: int) -> str:
"""Return the alternating row tag for the given zero-based index."""
return "even" if index % 2 == 0 else "odd"
def merge_tags(*tags: str) -> tuple[str, ...]:
"""Return non-empty tags preserving order."""
return tuple(tag for tag in tags if tag)
def apply_tksheet_visual_style(sheet: Any) -> None:
"""Apply best-effort header contrast and zebra rows to a tksheet widget."""
try:
sheet.set_options(
header_bg=TABLE_HEADER_BG,
header_fg=TABLE_HEADER_FG,
header_selected_cells_bg=TABLE_HEADER_BG,
header_selected_cells_fg=TABLE_HEADER_FG,
table_bg=TABLE_ROW_EVEN,
table_fg="#111827",
selected_rows_bg=TABLE_SELECTED_BG,
selected_rows_fg=TABLE_SELECTED_FG,
selected_cells_bg=TABLE_SELECTED_BG,
selected_cells_fg=TABLE_SELECTED_FG,
)
except Exception:
pass
def apply_tksheet_zebra(sheet: Any, row_count: int) -> None:
"""Apply alternating row colors to a tksheet widget when supported."""
try:
sheet.dehighlight_rows(redraw=False)
except Exception:
pass
for row_index in range(row_count):
try:
sheet.highlight_rows(
rows=[row_index],
bg=TABLE_ROW_EVEN if row_index % 2 == 0 else TABLE_ROW_ODD,
redraw=False,
)
except Exception:
break
try:
sheet.redraw()
except Exception:
pass

View File

@@ -164,7 +164,7 @@
} }
}, },
"login_window": { "login_window": {
"window_geometry": "165x155+0+0", "window_geometry": "165x170+0+0",
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"], "overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
"overlay_panel_fg_color": ["#f2f2f2", "#353535"], "overlay_panel_fg_color": ["#f2f2f2", "#353535"],
"overlay_panel_corner_radius": 10, "overlay_panel_corner_radius": 10,

View File

@@ -12,6 +12,10 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from version_info import module_version
__version__ = module_version(__name__)
THEME_PATH = Path(__file__).with_name("ui_theme.json") THEME_PATH = Path(__file__).with_name("ui_theme.json")

View File

@@ -6,6 +6,9 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import FrozenSet from typing import FrozenSet
from version_info import module_version
__version__ = module_version(__name__)
ALL_ACTIONS: FrozenSet[str] = frozenset( ALL_ACTIONS: FrozenSet[str] = frozenset(
{ {
@@ -13,6 +16,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.open_layout", "launcher.open_layout",
"launcher.open_multi_udc", "launcher.open_multi_udc",
"launcher.open_search", "launcher.open_search",
"launcher.open_non_shelved",
"launcher.open_history_udc", "launcher.open_history_udc",
"launcher.open_pickinglist", "launcher.open_pickinglist",
"launcher.open_history_pickinglist", "launcher.open_history_pickinglist",
@@ -20,6 +24,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.exit", "launcher.exit",
"reset_corsie.view", "reset_corsie.view",
"search.view", "search.view",
"non_shelved.view",
"history_udc.view", "history_udc.view",
"history_pickinglist.view", "history_pickinglist.view",
"multi_udc.view", "multi_udc.view",

73
version_info.py Normal file
View File

@@ -0,0 +1,73 @@
"""Application and module version registry for Warehouse/FlyWMS bridge."""
from __future__ import annotations
from pathlib import Path
APP_VERSION = "1.0.0"
MODULE_VERSIONS: dict[str, str] = {
"app": APP_VERSION,
"async_loop_singleton": "1.0.0",
"async_msssql_query": "1.0.0",
"audit_log": "1.0.0",
"main": "1.0.1",
"barcode_client": "1.0.24",
"barcode_repository": "1.0.10",
"barcode_service": "1.0.22",
"busy_overlay": "1.0.0",
"db_config": "1.0.0",
"gestione_aree": "1.0.1",
"gestione_layout": "1.0.2",
"gestione_pickinglist": "1.0.4",
"gestione_scarico": "1.0.8",
"locale_text": "1.0.0",
"login_window": "1.0.0",
"prenota_sprenota_sql": "1.0.1",
"reset_corsie": "1.0.0",
"runtime_support": "1.0.1",
"search_pallets": "1.0.0",
"storico_pickinglist": "1.0.3",
"storico_udc": "1.0.0",
"tooltips": "1.0.0",
"udc_non_scaffalate": "1.0.1",
"ui_theme": "1.0.0",
"user_session": "1.0.0",
"view_celle_multi_udc": "1.0.0",
"window_placement": "1.0.0",
}
def module_key(module_name: str) -> str:
"""Return the stable registry key for a module name or path."""
name = str(module_name or "").replace("\\", "/")
stem = Path(name).stem if "/" in name or "." not in name else name.rsplit(".", 1)[-1]
return stem or "app"
def module_version(module_name: str | None = None) -> str:
"""Return the version for a module, falling back to the app version."""
if not module_name:
return APP_VERSION
return MODULE_VERSIONS.get(module_key(module_name), APP_VERSION)
def version_label(module_name: str | None = None) -> str:
"""Return the standard visual label used in window titles."""
return f"ver. {module_version(module_name)}"
def versioned_title(title: str, module_name: str | None = None) -> str:
"""Append the standard version label to a window title."""
clean_title = str(title or "").strip()
label = version_label(module_name)
if not clean_title:
return label
if label.lower() in clean_title.lower():
return clean_title
return f"{clean_title} - {label}"

View File

@@ -22,8 +22,12 @@ from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_color, theme_font, theme_section, theme_value from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import merge_tags, style_treeview, zebra_tag
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
try: try:
from loguru import logger from loguru import logger
except Exception: # pragma: no cover - fallback used only when Loguru is not available except Exception: # pragma: no cover - fallback used only when Loguru is not available
@@ -436,7 +440,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
self._theme = theme_section("multi_udc", {}) self._theme = theme_section("multi_udc", {})
self._locale_catalog = load_locale_catalog() self._locale_catalog = load_locale_catalog()
self._tooltip_catalog = load_tooltip_catalog() self._tooltip_catalog = load_tooltip_catalog()
self.title(loc_text("multi.title", catalog=self._locale_catalog, default="Celle con piu' pallet")) self.title(versioned_title(loc_text("multi.title", catalog=self._locale_catalog, default="Celle con piu' pallet"), __name__))
self.session = session self.session = session
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700"))) self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700")))
minsize = theme_value(self._theme, "window_minsize", [900, 550]) minsize = theme_value(self._theme, "window_minsize", [900, 550])
@@ -507,6 +511,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
self.tree.column("col2", width=250, anchor="w") self.tree.column("col2", width=250, anchor="w")
self.tree.column("col3", width=120, anchor="w") self.tree.column("col3", width=120, anchor="w")
self.tree.column("col4", width=260, anchor="w") self.tree.column("col4", width=260, anchor="w")
style_treeview(self.tree, style_name="MultiUDC.Treeview", rowheight=24)
y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set) self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
@@ -542,6 +547,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
): ):
self.sum_tbl.heading(key, text=title) self.sum_tbl.heading(key, text=title)
self.sum_tbl.column(key, width=width, anchor=anchor) self.sum_tbl.column(key, width=width, anchor=anchor)
style_treeview(self.sum_tbl, style_name="MultiUDCSummary.Treeview", rowheight=24)
y2 = ttk.Scrollbar(inner, orient="vertical", command=self.sum_tbl.yview) y2 = ttk.Scrollbar(inner, orient="vertical", command=self.sum_tbl.yview)
x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview) x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview)
self.sum_tbl.configure(yscrollcommand=y2.set, xscrollcommand=x2.set) self.sum_tbl.configure(yscrollcommand=y2.set, xscrollcommand=x2.set)
@@ -660,7 +666,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
"""Populate root tree nodes after the aisle query completes.""" """Populate root tree nodes after the aisle query completes."""
rows = _json_obj(res).get("rows", []) rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_corsie", rows) _log_dataset("multi_udc_corsie", rows)
for row in rows: for index, row in enumerate(rows):
corsia = row.get("Corsia") corsia = row.get("Corsia")
if not corsia: if not corsia:
continue continue
@@ -672,7 +678,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
text=self._format_corsia_text(corsia), text=self._format_corsia_text(corsia),
values=("", "", ""), values=("", "", ""),
open=False, open=False,
tags=("corsia",), tags=merge_tags(zebra_tag(index), "corsia"),
) )
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", "")) self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
@@ -724,7 +730,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
if not rows: if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", "")) self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", ""))
return return
for row in rows: for index, row in enumerate(rows):
idc = row["IDCella"] idc = row["IDCella"]
ubi = row["Ubicazione"] ubi = row["Ubicazione"]
corsia = row.get("Corsia") corsia = row.get("Corsia")
@@ -741,7 +747,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
text=label, text=label,
values=(f"IDCella {idc}", "", ""), values=(f"IDCella {idc}", "", ""),
open=False, open=False,
tags=("cella", f"corsia:{corsia}"), tags=merge_tags(zebra_tag(index), "cella", f"corsia:{corsia}"),
) )
if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)): if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)):
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", "")) self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
@@ -781,7 +787,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
idcella_txt = self.tree.item(parent_iid, "values")[0] idcella_txt = self.tree.item(parent_iid, "values")[0]
idcella_num = int(idcella_txt.split()[-1]) if idcella_txt else None idcella_num = int(idcella_txt.split()[-1]) if idcella_txt else None
for row in rows: for index, row in enumerate(rows):
pallet = row.get("Pallet", "") pallet = row.get("Pallet", "")
desc = row.get("Descrizione", "") desc = row.get("Descrizione", "")
lotto = row.get("Lotto", "") lotto = row.get("Lotto", "")
@@ -813,7 +819,13 @@ class CelleMultipleWindow(ctk.CTkToplevel):
iid=leaf_id, iid=leaf_id,
text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys), text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys),
values=(desc, lotto, causale), values=(desc, lotto, causale),
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"), tags=merge_tags(
zebra_tag(index),
"pallet",
f"corsia:{corsia_val}",
f"ubicazione:{cella_ubi}",
f"idcella:{idcella_num}",
),
) )
@_log_call() @_log_call()
@@ -987,11 +999,12 @@ class CelleMultipleWindow(ctk.CTkToplevel):
_log_dataset("multi_udc_riepilogo", rows) _log_dataset("multi_udc_riepilogo", rows)
for item in self.sum_tbl.get_children(): for item in self.sum_tbl.get_children():
self.sum_tbl.delete(item) self.sum_tbl.delete(item)
for row in rows: for index, row in enumerate(rows):
self.sum_tbl.insert( self.sum_tbl.insert(
"", "",
"end", "end",
values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"), values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"),
tags=(zebra_tag(index),),
) )
def expand_all(self): def expand_all(self):

View File

@@ -1,3 +1,9 @@
from pathlib import Path
import os
os.chdir(Path(__file__).resolve().parent)
from runtime_support import ensure_stdio, run_with_fatal_log from runtime_support import ensure_stdio, run_with_fatal_log
ensure_stdio("warehouse_main") ensure_stdio("warehouse_main")

View File

@@ -8,6 +8,9 @@ import math
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from version_info import module_version
__version__ = module_version(__name__)
MODULE_LOG_NAME = "window_placement" MODULE_LOG_NAME = "window_placement"
MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log") MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log")