3 Commits

20 changed files with 1881 additions and 61 deletions

View File

@@ -9,7 +9,7 @@ from concurrent.futures import Future
from tkinter import messagebox, ttk
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")
@@ -19,21 +19,44 @@ from barcode_repository import BarcodeRepository
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
from db_config import build_dsn_from_config, ensure_db_config
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:
"""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_HEIGHT = 400
DESKTOP_THRESHOLD_WIDTH = 1024
DESKTOP_THRESHOLD_HEIGHT = 768
DESKTOP_WINDOW_WIDTH = 465
DESKTOP_WINDOW_HEIGHT = 531
def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop):
self.root = root
self.db_client = db_client
@@ -43,6 +66,7 @@ class BarcodeClientApp:
self.service = BarcodeService(self.repository, session.operator_id)
self._pending: Future | None = None
self._auto_advance_id: str | None = None
self._pallet_auto_focus_id: str | None = None
self._status_colors = {
"red": "#f4cccc",
"green": "#d9ead3",
@@ -174,11 +198,11 @@ class BarcodeClientApp:
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_submit = ttk.Button(buttons, text="[Ent] Salva", command=self._submit)
self.btn_submit = ttk.Button(buttons, text="[Ent] Carica", command=self._submit)
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.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.busy_cover = tk.Frame(self.root, bg="#d9d9d9")
@@ -212,7 +236,7 @@ class BarcodeClientApp:
parent.columnconfigure(1, weight=1)
if scanned:
self.pallet_entry = entry
self.scanned_var.trace_add("write", lambda *_: self._limit_var(self.scanned_var, 8))
self.scanned_var.trace_add("write", lambda *_: self._on_scanned_var_changed())
else:
self.destination_entry = entry
self.destination_var.trace_add("write", lambda *_: self._limit_var(self.destination_var, 8))
@@ -222,6 +246,32 @@ class BarcodeClientApp:
if len(value) > max_len:
variable.set(value[:max_len])
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 _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 bool(getattr(self.service.state, "destination_readonly", False)):
return
self._focus_destination_input()
def _apply_responsive_geometry(self) -> None:
"""Adapt the window size to barcode-sized or desktop-sized screens."""
@@ -274,7 +324,7 @@ class BarcodeClientApp:
def _bind_keys(self) -> None:
self.root.bind("<F1>", lambda _e: self._start_queue(1))
self.root.bind("<F2>", lambda _e: self._start_queue(0))
self.root.bind("<F4>", lambda _e: self._begin_manual_unload())
self.root.bind("<F4>", self._on_unload_key)
self.pallet_entry.bind("<Return>", self._on_pallet_enter)
self.destination_entry.bind("<Return>", self._on_destination_enter)
@@ -309,6 +359,12 @@ class BarcodeClientApp:
except Exception:
pass
self._auto_advance_id = None
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
self.queue_var.set(state.queue_label)
self.destination_var.set(state.destination_barcode)
self.scanned_var.set(state.scanned_pallet)
@@ -318,7 +374,7 @@ class BarcodeClientApp:
self.info4_var.set(state.expected_pallet)
self.status_band.configure(bg=state.status_color or self._status_colors["red"])
destination_readonly = state.mode in ("priority_high", "priority_low", "manual_unload")
destination_readonly = bool(getattr(state, "destination_readonly", False))
try:
self.destination_entry.configure(state="normal")
if destination_readonly:
@@ -326,10 +382,18 @@ class BarcodeClientApp:
except Exception:
pass
if state.mode == "confirm" and state.status_text == "Ok Scarico":
is_completed_move = (
str(state.status_text or "").startswith("Ok Scarico")
or str(state.status_text or "").startswith("Ok Carico")
)
if state.mode == "confirm" and is_completed_move:
next_queue = self._queue_id_from_label(state.queue_label)
if next_queue is not None:
self._auto_advance_id = self.root.after(1200, lambda q=next_queue: self._start_queue(q))
delay_ms = int(getattr(state, "auto_advance_delay_ms", 0) or 0)
if next_queue is not None and delay_ms > 0:
self._auto_advance_id = self.root.after(
delay_ms,
lambda q=next_queue: self._start_queue(q),
)
self.root.after(20, self._focus_primary_input)
@@ -356,8 +420,11 @@ class BarcodeClientApp:
destination = str(self.destination_var.get() or "").strip()
if not pallet:
return "break"
if destination == "9000000":
self._submit()
if destination in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
if bool(getattr(self.service.state, "destination_readonly", False)):
self._submit()
else:
self._focus_destination_input()
return "break"
self.destination_var.set("")
self._focus_destination_input()
@@ -372,13 +439,27 @@ class BarcodeClientApp:
self._focus_primary_input()
return "break"
def _on_unload_key(self, _event=None) -> str:
self._begin_manual_unload()
return "break"
def _begin_manual_unload(self) -> None:
pallet = str(self.scanned_var.get() or "").strip()
destination = str(self.destination_var.get() or "").strip()
if pallet and destination in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
# Legacy barcode flow: F4/Scarica confirms the prepared unload destination.
self._submit()
return
if pallet and not destination:
self.destination_var.set(self.NON_SCAFFALATA_BARCODE)
self._submit()
return
self._apply_state(self.service.begin_manual_unload())
def _start_queue(self, id_stato: int) -> None:
self._run_async(
lambda: self.service.start_priority_queue(id_stato),
busy_message="Carico la coda selezionata...",
busy_message="In preparazione...",
)
def _submit(self) -> None:
@@ -387,7 +468,7 @@ class BarcodeClientApp:
scanned_pallet=self.scanned_var.get(),
destination_barcode=self.destination_var.get(),
),
busy_message="Eseguo il movimento...",
busy_message="In esecuzione...",
)
def _run_async(self, coro_factory: Callable[[], object], busy_message: str) -> None:
@@ -425,11 +506,18 @@ class BarcodeClientApp:
try:
result = future.result()
except Exception as exc:
log_exception("Barcode WMS", exc, context="barcode async operation")
current = self.service.state
current.status_text = f"Errore operativo: {exc}"
current.status_text = "Transazione non completata, ripeti l'operazione."
current.status_color = "#f4cccc"
self._apply_state(current)
messagebox.showerror("Barcode WMS", f"Operazione fallita:\n{exc}", parent=self.root)
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,
)
return
if isinstance(result, BarcodeActionResult):
@@ -463,6 +551,7 @@ def main() -> int:
loop = get_global_loop()
bootstrap = tk.Tk()
bootstrap.withdraw()
configure_exception_logging("Barcode WMS", root=bootstrap, loop=loop)
config = ensure_db_config(loop, parent=bootstrap)
if not config:
@@ -471,7 +560,7 @@ def main() -> int:
return 1
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:
try:
fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop)

View File

@@ -7,6 +7,7 @@ possible while keeping SQL isolated from the UI.
from __future__ import annotations
from dataclasses import dataclass
from runtime_support import log_exception, log_runtime_event
from typing import Any
from version_info import module_version
@@ -58,8 +59,42 @@ WHERE Pallet = :pallet
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 = :pallet;
"""
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 = """
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RC int = 0;
EXEC dbo.sp_xMagGestioneMagazziniPallet
@@ -105,6 +140,16 @@ class LegacyMoveResult:
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:
"""Thin async repository used by the lightweight barcode client."""
@@ -132,6 +177,28 @@ class BarcodeRepository:
rows = _rows_to_dicts(res)
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 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(
self,
*,
@@ -148,12 +215,44 @@ class BarcodeRepository:
"barcode_pallet": str(barcode_pallet or "").strip(),
"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)
row = rows[0] if rows else {}
return LegacyMoveResult(
result = LegacyMoveResult(
rc=int(row.get("RC") or 0),
barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]),
barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]),
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,6 +6,7 @@ from dataclasses import dataclass
from typing import Literal
from barcode_repository import BarcodeRepository, LegacyMoveResult
from runtime_support import log_exception
from version_info import module_version
__version__ = module_version(__name__)
@@ -27,6 +28,8 @@ class BarcodeViewState:
expected_pallet: str = ""
destination_barcode: str = ""
scanned_pallet: str = ""
auto_advance_delay_ms: int = 0
destination_readonly: bool = False
@dataclass
@@ -41,6 +44,8 @@ class BarcodeActionResult:
class BarcodeService:
"""Faithful, but cleaner, port of the legacy barcode form logic."""
NON_SCAFFALATA_BARCODE = "9001000"
SHIPPED_BARCODE = "9000000"
GRAY = "#d9d9d9"
RED = "#f4cccc"
LIGHT_GREEN = "#d9ead3"
@@ -82,7 +87,7 @@ class BarcodeService:
return self._state
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._state = BarcodeViewState(
@@ -90,7 +95,8 @@ class BarcodeService:
queue_label="Prelievo diretto",
status_text="OP Scarico",
status_color=self.GRAY,
destination_barcode="9000000",
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=True,
)
return self._state
@@ -102,11 +108,12 @@ class BarcodeService:
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
if not row:
self._state = BarcodeViewState(
mode="priority_high" if int(id_stato) == 1 else "priority_low",
mode="manual_unload",
queue_label=queue_label,
status_text="Pronto.",
status_color=self.RED,
destination_barcode="9000000",
destination_barcode=self.NON_SCAFFALATA_BARCODE,
destination_readonly=False,
)
return BarcodeActionResult(True, self._state)
@@ -124,7 +131,8 @@ class BarcodeService:
document=str(row.get("Documento") or ""),
customer=customer,
expected_pallet=str(row.get("Pallet") or ""),
destination_barcode="9000000",
destination_barcode=self.SHIPPED_BARCODE,
destination_readonly=True,
)
return BarcodeActionResult(True, self._state)
@@ -140,31 +148,80 @@ class BarcodeService:
if not destination.isdigit():
return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.")
if self._state.mode in ("priority_high", "priority_low") and destination == "9000000":
expected = str(self._state.expected_pallet or "").strip()
if expected and expected != pallet:
is_priority_mode = self._state.mode in ("priority_high", "priority_low")
expected_before_move = str(self._state.expected_pallet or "").strip()
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.status_text = "Errata lettura: il pallet letto non coincide con quello atteso."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
move = await self.repository.execute_legacy_move(
operator_id=self.operator_id,
barcode_cella=destination,
barcode_pallet=pallet,
numero_cella=int(destination),
)
if move.rc != 0:
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:
self._state.scanned_pallet = pallet
self._state.status_text = f"Operazione non riuscita (RC={move.rc})."
self._state.status_text = "UDC non presente a magazzino."
self._state.status_color = self.RED
return BarcodeActionResult(False, self._state, self._state.status_text)
self._state = await self._build_post_move_state(
target_barcode = destination
target_numero_cella = int(destination)
target_id_cella = 9999 if destination == self.SHIPPED_BARCODE else (1000 if destination == self.NON_SCAFFALATA_BARCODE else None)
target_display = destination
if 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
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,
destination_barcode=destination,
last_priority_state=self._current_priority_state,
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=5000 if (is_direct_unload or is_direct_load) else 1200 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 _build_post_move_state(
@@ -172,7 +229,9 @@ class BarcodeService:
*,
barcode_pallet: str,
destination_barcode: str,
destination_display: str,
last_priority_state: int,
auto_advance_delay_ms: int,
) -> BarcodeViewState:
"""Mirror the legacy confirmation flow after one stored-procedure move."""
@@ -193,6 +252,8 @@ class BarcodeService:
customer=customer,
expected_pallet=str(picking_row.get("Pallet") or ""),
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)
@@ -205,27 +266,29 @@ class BarcodeService:
mode="confirm",
queue_label=queue_label,
status_text=(
"Ok Scarico" if destination_barcode == "9000000" else f"Ok Carico - {destination_barcode}"
"Ok Scarico" if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) else f"Ok Carico - {destination_display}"
),
status_color=self.GREEN_YELLOW,
source_location=str(destination_barcode or ""),
source_location=str(destination_display or destination_barcode or ""),
document=(
self.CONVENTIONAL_LOCATION_BY_CELL[9999]
if destination_barcode == "9000000" and last_priority_state in (0, 1)
self.CONVENTIONAL_LOCATION_BY_CELL[9999 if destination_barcode == self.SHIPPED_BARCODE else 1000]
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
else lotto
),
customer=(
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
),
expected_pallet=(
" - ".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
),
destination_barcode=destination_barcode,
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(

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,6 +19,7 @@ from typing import Any, Callable, Optional
import customtkinter as ctk
from async_loop_singleton import get_global_loop
from runtime_support import log_exception
from version_info import module_version
__version__ = module_version(__name__)
@@ -128,21 +129,53 @@ class AsyncRunner:
busy.show(message)
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():
if not _widget_alive():
return
if fut.done():
if busy:
busy.hide()
try:
busy.hide()
except Exception as ex:
log_exception(__name__, ex, context="AsyncRunner hide busy")
try:
res = fut.result()
except BaseException as ex:
if on_error:
self.widget.after(0, lambda e=ex: on_error(e))
else:
print("[AsyncRunner] Unhandled error:", repr(ex))
try:
self.widget.after(0, lambda e=ex: _dispatch_error(e))
except tk.TclError:
log_exception(__name__, ex, context="AsyncRunner error after destroyed")
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:
self.widget.after(60, _poll)
try:
self.widget.after(60, _poll)
except tk.TclError:
return
_poll()

View File

@@ -209,6 +209,9 @@ if _MODULE_LOG_ENABLED:
# -------------------- 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 = """
SELECT
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '')) AS Pallet,
@@ -963,7 +966,9 @@ class GestionePickingListFrame(ctk.CTkFrame):
@_log_call()
def on_row_checked(self, model: PLRow, is_checked: bool):
"""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:
for m in self.rows_models:
if m is not model and m.is_checked():
@@ -1012,6 +1017,9 @@ class GestionePickingListFrame(ctk.CTkFrame):
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
"""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
async def _job():
@@ -1126,6 +1134,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 0)
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:
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
return
@@ -1138,6 +1148,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.spinner.start(" Prenoto…")
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")
def _ok(res: SPResult):
@@ -1200,6 +1212,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 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:
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
return
@@ -1212,6 +1226,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.spinner.start(" S-prenoto…")
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")
def _ok(res: SPResult):

13
main.py
View File

@@ -31,6 +31,7 @@ from search_pallets import open_search_window
from storico_pickinglist import open_storico_pickinglist_window
from storico_udc import open_storico_udc_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from udc_non_scaffalate import open_udc_non_scaffalate_window
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
from version_info import module_version, versioned_title
@@ -43,7 +44,7 @@ from window_placement import (
# Development shortcut: skip the login dialog and boot directly as MAG1.
# Set to False when you want to restore normal authentication.
BYPASS_LOGIN = False
BYPASS_LOGIN = True
__version__ = module_version(__name__)
BYPASS_LOGIN_USER = {
"operator_id": 4,
@@ -155,6 +156,7 @@ class Launcher(ctk.CTk):
"reset_corsie",
"layout",
"multi_udc",
"non_shelved",
"search",
"storico_udc",
"pickinglist",
@@ -221,6 +223,15 @@ class Launcher(ctk.CTk):
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",
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),

View File

@@ -262,6 +262,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
"""Execute the Python-specific picking-list reservation stored procedure."""
try:
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"):
return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None)
_MODULE_LOGGER.log(
@@ -283,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})
if not hasattr(db, "query_json"):
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(
sql,
{"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione},

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import sys
import threading
import traceback
from datetime import datetime
from pathlib import Path
@@ -17,6 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent
FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
TEMP_DIR = Path(tempfile.gettempdir())
_STDIO_HANDLES = []
_EXCEPTION_LOGGING_CONFIGURED: set[str] = set()
T = TypeVar("T")
@@ -55,6 +57,85 @@ def log_fatal(app_name: str, exc: BaseException) -> None:
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."""

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
@@ -11,6 +18,7 @@ import customtkinter as ctk
from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async
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_tables import merge_tags, style_treeview, zebra_tag
@@ -20,6 +28,10 @@ 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 = """
WITH base AS (
SELECT
@@ -84,6 +96,10 @@ WHERE (:documento IS NULL OR CAST(m.Documento AS varchar(32)) LIKE CONCAT('%', :
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 = """
SELECT
Documento,
@@ -159,6 +175,9 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
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(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")))
@@ -182,7 +201,7 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
)
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
top.grid_columnconfigure(3, weight=1)
top.grid_columnconfigure(4, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
@@ -200,6 +219,18 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
).grid(
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(
row=1,
@@ -266,12 +297,15 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _load_master(self) -> None:
params = {"documento": str(self.var_documento.get() or "").strip() or None}
previous_documento = self._selected_documento
async def _job():
return await self.db_client.query_json(SQL_STORICO_PL, params)
def _ok(res):
self._fill_master(_rows_to_dicts(res))
if previous_documento:
self._restore_master_selection(previous_documento)
def _err(ex):
messagebox.showerror(
@@ -299,6 +333,10 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_master(self, rows: list[dict[str, Any]]) -> None:
self.master_tree.delete(*self.master_tree.get_children(""))
self.detail_tree.delete(*self.detail_tree.get_children(""))
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
for index, row in enumerate(rows):
stato = str(row.get("StatoOperativo") or "")
is_open_with_shipped = (
@@ -335,17 +373,26 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _on_master_select(self, _event=None) -> None:
selected = self.master_tree.selection()
if not selected:
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
return
values = self.master_tree.item(selected[0], "values")
if not values:
return
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():
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
def _ok(res):
self._fill_detail(_rows_to_dicts(res))
self._update_residual_button()
def _err(ex):
messagebox.showerror(
@@ -372,6 +419,7 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
self.detail_tree.delete(*self.detail_tree.get_children(""))
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
@@ -394,6 +442,127 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
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:
"""Open the picking-list history window."""

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

View File

@@ -16,6 +16,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.open_layout",
"launcher.open_multi_udc",
"launcher.open_search",
"launcher.open_non_shelved",
"launcher.open_history_udc",
"launcher.open_pickinglist",
"launcher.open_history_pickinglist",
@@ -23,6 +24,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
"launcher.exit",
"reset_corsie.view",
"search.view",
"non_shelved.view",
"history_udc.view",
"history_pickinglist.view",
"multi_udc.view",

View File

@@ -12,13 +12,13 @@ MODULE_VERSIONS: dict[str, str] = {
"async_loop_singleton": "1.0.0",
"async_msssql_query": "1.0.0",
"audit_log": "1.0.0",
"main": "1.0.0",
"barcode_client": "1.0.0",
"barcode_repository": "1.0.0",
"barcode_service": "1.0.0",
"main": "1.0.1",
"barcode_client": "1.0.12",
"barcode_repository": "1.0.3",
"barcode_service": "1.0.7",
"busy_overlay": "1.0.0",
"db_config": "1.0.0",
"gestione_aree": "1.0.0",
"gestione_aree": "1.0.1",
"gestione_layout": "1.0.0",
"gestione_pickinglist": "1.0.2",
"gestione_scarico": "1.0.0",
@@ -26,10 +26,12 @@ MODULE_VERSIONS: dict[str, str] = {
"login_window": "1.0.0",
"prenota_sprenota_sql": "1.0.0",
"reset_corsie": "1.0.0",
"runtime_support": "1.0.1",
"search_pallets": "1.0.0",
"storico_pickinglist": "1.0.2",
"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",