From 466778ae5f8a75dee688d79f8f9e22e8f35850b4 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Thu, 18 Jun 2026 16:13:47 +0200 Subject: [PATCH] Alpha6 barcode non scaffalate e bypass login --- barcode_client.py | 83 ++++++-- barcode_repository.py | 103 ++++++++- barcode_service.py | 113 +++++++--- docs/review/README.md | 44 ++++ docs/review/barcode_error_handling.md | 68 ++++++ docs/review/documentation_plan.md | 119 +++++++++++ docs/review/module_gestione_pickinglist.md | 154 ++++++++++++++ docs/review/module_prenota_sprenota_sql.md | 97 +++++++++ docs/review/module_storico_pickinglist.md | 150 +++++++++++++ docs/review/sql_pickinglist_reservation.md | 153 +++++++++++++ docs/review/sql_storico_pickinglist.md | 236 +++++++++++++++++++++ gestione_pickinglist.py | 18 +- main.py | 13 +- prenota_sprenota_sql.py | 4 + runtime_support.py | 81 +++++++ storico_pickinglist.py | 27 ++- udc_non_scaffalate.py | 187 ++++++++++++++++ user_session.py | 2 + version_info.py | 10 +- 19 files changed, 1614 insertions(+), 48 deletions(-) create mode 100644 docs/review/README.md create mode 100644 docs/review/barcode_error_handling.md create mode 100644 docs/review/documentation_plan.md create mode 100644 docs/review/module_gestione_pickinglist.md create mode 100644 docs/review/module_prenota_sprenota_sql.md create mode 100644 docs/review/module_storico_pickinglist.md create mode 100644 docs/review/sql_pickinglist_reservation.md create mode 100644 docs/review/sql_storico_pickinglist.md create mode 100644 udc_non_scaffalate.py diff --git a/barcode_client.py b/barcode_client.py index 7c2bbe8..4bed4a0 100644 --- a/barcode_client.py +++ b/barcode_client.py @@ -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,43 @@ 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.""" + 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 @@ -174,11 +196,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") @@ -274,7 +296,7 @@ class BarcodeClientApp: def _bind_keys(self) -> None: self.root.bind("", lambda _e: self._start_queue(1)) self.root.bind("", lambda _e: self._start_queue(0)) - self.root.bind("", lambda _e: self._begin_manual_unload()) + self.root.bind("", self._on_unload_key) self.pallet_entry.bind("", self._on_pallet_enter) self.destination_entry.bind("", self._on_destination_enter) @@ -318,7 +340,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 +348,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 +386,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,7 +405,21 @@ 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: @@ -425,11 +472,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 +517,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 +526,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) diff --git a/barcode_repository.py b/barcode_repository.py index 0436782..6ce3c70 100644 --- a/barcode_repository.py +++ b/barcode_repository.py @@ -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 diff --git a/barcode_service.py b/barcode_service.py index ab694a9..ef69bde 100644 --- a/barcode_service.py +++ b/barcode_service.py @@ -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( diff --git a/docs/review/README.md b/docs/review/README.md new file mode 100644 index 0000000..3e5e54b --- /dev/null +++ b/docs/review/README.md @@ -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` diff --git a/docs/review/barcode_error_handling.md b/docs/review/barcode_error_handling.md new file mode 100644 index 0000000..5e127b2 --- /dev/null +++ b/docs/review/barcode_error_handling.md @@ -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` diff --git a/docs/review/documentation_plan.md b/docs/review/documentation_plan.md new file mode 100644 index 0000000..32fd87c --- /dev/null +++ b/docs/review/documentation_plan.md @@ -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. diff --git a/docs/review/module_gestione_pickinglist.md b/docs/review/module_gestione_pickinglist.md new file mode 100644 index 0000000..cb936b6 --- /dev/null +++ b/docs/review/module_gestione_pickinglist.md @@ -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. diff --git a/docs/review/module_prenota_sprenota_sql.md b/docs/review/module_prenota_sprenota_sql.md new file mode 100644 index 0000000..57edc10 --- /dev/null +++ b/docs/review/module_prenota_sprenota_sql.md @@ -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. diff --git a/docs/review/module_storico_pickinglist.md b/docs/review/module_storico_pickinglist.md new file mode 100644 index 0000000..434b62f --- /dev/null +++ b/docs/review/module_storico_pickinglist.md @@ -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`. diff --git a/docs/review/sql_pickinglist_reservation.md b/docs/review/sql_pickinglist_reservation.md new file mode 100644 index 0000000..9622a14 --- /dev/null +++ b/docs/review/sql_pickinglist_reservation.md @@ -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. diff --git a/docs/review/sql_storico_pickinglist.md b/docs/review/sql_storico_pickinglist.md new file mode 100644 index 0000000..e74dad5 --- /dev/null +++ b/docs/review/sql_storico_pickinglist.md @@ -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; +``` diff --git a/gestione_pickinglist.py b/gestione_pickinglist.py index cfe6963..636b1f8 100644 --- a/gestione_pickinglist.py +++ b/gestione_pickinglist.py @@ -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): diff --git a/main.py b/main.py index 496f0f6..bdf0d71 100644 --- a/main.py +++ b/main.py @@ -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"), diff --git a/prenota_sprenota_sql.py b/prenota_sprenota_sql.py index 538e2f2..8e22b8c 100644 --- a/prenota_sprenota_sql.py +++ b/prenota_sprenota_sql.py @@ -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}, diff --git a/runtime_support.py b/runtime_support.py index 724aada..f30af11 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -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.""" diff --git a/storico_pickinglist.py b/storico_pickinglist.py index 7490a17..7004ece 100644 --- a/storico_pickinglist.py +++ b/storico_pickinglist.py @@ -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 @@ -21,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 @@ -85,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, @@ -455,12 +470,16 @@ class StoricoPickingListWindow(ctk.CTkToplevel): 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) @@ -496,11 +515,17 @@ class StoricoPickingListWindow(ctk.CTkToplevel): 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, diff --git a/udc_non_scaffalate.py b/udc_non_scaffalate.py new file mode 100644 index 0000000..b3b770e --- /dev/null +++ b/udc_non_scaffalate.py @@ -0,0 +1,187 @@ +"""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 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 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 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.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 _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: + async def job(): + return await self.db_client.query_json(SQL_NON_SCAFFALATE, as_dict_rows=True) + + 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) + + def _on_loaded(self, res: dict[str, Any] | None) -> None: + self._busy.hide() + 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),), + ) + + def _on_error(self, exc: BaseException) -> None: + self._busy.hide() + 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(win, parent) + return win diff --git a/user_session.py b/user_session.py index b8dd258..cdaec01 100644 --- a/user_session.py +++ b/user_session.py @@ -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", diff --git a/version_info.py b/version_info.py index 9e2fb35..03ac364 100644 --- a/version_info.py +++ b/version_info.py @@ -12,10 +12,10 @@ 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.10", + "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", @@ -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.3", "storico_udc": "1.0.0", "tooltips": "1.0.0", + "udc_non_scaffalate": "1.0.0", "ui_theme": "1.0.0", "user_session": "1.0.0", "view_celle_multi_udc": "1.0.0",