Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 466778ae5f | |||
| cc9680c49a | |||
| be7ce700d1 | |||
| 29900b8b09 | |||
| 8f9957a2db |
61
INSTALL_PRODUZIONE_20260610.md
Normal file
61
INSTALL_PRODUZIONE_20260610.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Installazione produzione - Warehouse/FlyWMS bridge
|
||||||
|
|
||||||
|
## Ordine consigliato
|
||||||
|
|
||||||
|
1. Fare backup del database `Mediseawall`.
|
||||||
|
2. Copiare il contenuto dello zip in una cartella locale, ad esempio `C:\flywms`.
|
||||||
|
3. Installare le dipendenze Python:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In SSMS, sul database `Mediseawall`, lanciare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apply_python_parallel_pickinglist_patch.sql
|
||||||
|
apply_online_history_forms_patch.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cosa fanno gli script
|
||||||
|
|
||||||
|
- `apply_python_parallel_pickinglist_patch.sql` crea il ramo SQL Python per gestione picking list, senza modificare le stored procedure C# legacy.
|
||||||
|
- `apply_online_history_forms_patch.sql` crea le viste Python-only per "Storico Picking List".
|
||||||
|
- "Storico movimenti UDC" non richiede script dedicati: legge in sola lettura `MagazziniPallet`, `Celle` e `XMag_GiacenzaPalletPlistChiuse`.
|
||||||
|
|
||||||
|
## Rollback SQL
|
||||||
|
|
||||||
|
Se serve tornare indietro sugli oggetti Python, usare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rollback_online_history_forms_patch.sql
|
||||||
|
rollback_python_parallel_pickinglist_patch.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
La tabella `dbo.PyPickingListReservation`, se creata, puo' rimanere anche in caso di rollback perche' il C# legacy non la usa.
|
||||||
|
|
||||||
|
## Avvio
|
||||||
|
|
||||||
|
Backoffice con console:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Backoffice senza console:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
pythonw warehouse.pyw
|
||||||
|
```
|
||||||
|
|
||||||
|
Barcode senza console:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
pythonw barcode.pyw
|
||||||
|
```
|
||||||
|
|
||||||
|
Se si usa un collegamento Windows, impostare anche la cartella "Da" alla cartella dell'applicazione, ad esempio `C:\flywms`.
|
||||||
|
|
||||||
|
## File esclusi dal pacchetto
|
||||||
|
|
||||||
|
Il pacchetto non include `db_connection.json`, log, cache Python e vecchi zip locali. Alla prima apertura il programma chiedera' la configurazione DB se `db_connection.json` non esiste.
|
||||||
@@ -9,6 +9,10 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _LoopHolder:
|
class _LoopHolder:
|
||||||
"""Store the global loop instance and its worker thread."""
|
"""Store the global loop instance and its worker thread."""
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from typing import Any, Dict, Optional
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyodbc
|
import pyodbc
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from user_session import UserSession
|
from user_session import UserSession
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log")
|
AUDIT_LOG_PATH = Path(__file__).with_name("warehouse_audit.log")
|
||||||
_LOGGER = logging.getLogger("warehouse_audit")
|
_LOGGER = logging.getLogger("warehouse_audit")
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
os.chdir(Path(__file__).resolve().parent)
|
||||||
|
|
||||||
from runtime_support import ensure_stdio, run_with_fatal_log
|
from runtime_support import ensure_stdio, run_with_fatal_log
|
||||||
|
|
||||||
ensure_stdio("warehouse_barcode")
|
ensure_stdio("warehouse_barcode")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from concurrent.futures import Future
|
|||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from runtime_support import ensure_stdio, run_with_fatal_log
|
from runtime_support import configure_exception_logging, ensure_stdio, log_exception, run_with_fatal_log
|
||||||
|
|
||||||
ensure_stdio("warehouse_barcode")
|
ensure_stdio("warehouse_barcode")
|
||||||
|
|
||||||
@@ -19,18 +19,43 @@ from barcode_repository import BarcodeRepository
|
|||||||
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
|
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
|
||||||
from db_config import build_dsn_from_config, ensure_db_config
|
from db_config import build_dsn_from_config, ensure_db_config
|
||||||
from login_window import prompt_login_compact
|
from login_window import prompt_login_compact
|
||||||
|
from user_session import create_user_session
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
BYPASS_LOGIN = True
|
||||||
|
BYPASS_LOGIN_USER = {
|
||||||
|
"operator_id": 4,
|
||||||
|
"login": "MAG1",
|
||||||
|
"nominativo": "MAG1",
|
||||||
|
"privilegio": 3,
|
||||||
|
"codice_unita": "U1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bypass_session():
|
||||||
|
"""Create the temporary MAG1 session used while field testing the barcode."""
|
||||||
|
|
||||||
|
return create_user_session(
|
||||||
|
operator_id=int(BYPASS_LOGIN_USER["operator_id"]),
|
||||||
|
login=str(BYPASS_LOGIN_USER["login"]),
|
||||||
|
nominativo=str(BYPASS_LOGIN_USER["nominativo"]),
|
||||||
|
privilegio=int(BYPASS_LOGIN_USER["privilegio"]),
|
||||||
|
codice_unita=str(BYPASS_LOGIN_USER["codice_unita"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BarcodeClientApp:
|
class BarcodeClientApp:
|
||||||
"""Single-window Tk barcode client modeled after the C# legacy form."""
|
"""Single-window Tk barcode client modeled after the C# legacy form."""
|
||||||
|
|
||||||
|
NON_SCAFFALATA_BARCODE = "9001000"
|
||||||
|
SHIPPED_BARCODE = "9000000"
|
||||||
BARCODE_MAX_WIDTH = 320
|
BARCODE_MAX_WIDTH = 320
|
||||||
BARCODE_MAX_HEIGHT = 400
|
BARCODE_MAX_HEIGHT = 400
|
||||||
DESKTOP_THRESHOLD_WIDTH = 1024
|
DESKTOP_THRESHOLD_WIDTH = 1024
|
||||||
DESKTOP_THRESHOLD_HEIGHT = 768
|
DESKTOP_THRESHOLD_HEIGHT = 768
|
||||||
DESKTOP_WINDOW_WIDTH = 465
|
DESKTOP_WINDOW_WIDTH = 465
|
||||||
DESKTOP_WINDOW_HEIGHT = 531
|
DESKTOP_WINDOW_HEIGHT = 531
|
||||||
|
|
||||||
def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop):
|
def __init__(self, root: tk.Tk, db_client: AsyncMSSQLClient, session, loop: asyncio.AbstractEventLoop):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.db_client = db_client
|
self.db_client = db_client
|
||||||
@@ -59,9 +84,11 @@ class BarcodeClientApp:
|
|||||||
self._apply_state(self.service.state)
|
self._apply_state(self.service.state)
|
||||||
self._bind_keys()
|
self._bind_keys()
|
||||||
self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
|
self.root.protocol("WM_DELETE_WINDOW", self._shutdown)
|
||||||
|
self.root.after(80, self._finalize_window_placement)
|
||||||
|
self.root.after(250, self._finalize_window_placement)
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
self.root.title("WMS")
|
self.root.title(versioned_title("WMS", __name__))
|
||||||
self.root.configure(bg="#f1f1f1")
|
self.root.configure(bg="#f1f1f1")
|
||||||
self._apply_responsive_geometry()
|
self._apply_responsive_geometry()
|
||||||
|
|
||||||
@@ -169,11 +196,11 @@ class BarcodeClientApp:
|
|||||||
|
|
||||||
self.btn_f1 = ttk.Button(buttons, text="[F1] H Priority", command=lambda: self._start_queue(1))
|
self.btn_f1 = ttk.Button(buttons, text="[F1] H Priority", command=lambda: self._start_queue(1))
|
||||||
self.btn_f1.grid(row=0, column=0, padx=(0, 4), pady=(0, button_pad_y), sticky="ew")
|
self.btn_f1.grid(row=0, column=0, padx=(0, 4), pady=(0, button_pad_y), sticky="ew")
|
||||||
self.btn_submit = ttk.Button(buttons, text="[Ent] Salva", command=self._submit)
|
self.btn_submit = ttk.Button(buttons, text="[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_submit.grid(row=0, column=1, padx=(4, 0), pady=(0, button_pad_y), sticky="ew")
|
||||||
self.btn_f2 = ttk.Button(buttons, text="[F2] L Priority", command=lambda: self._start_queue(0))
|
self.btn_f2 = ttk.Button(buttons, text="[F2] L Priority", command=lambda: self._start_queue(0))
|
||||||
self.btn_f2.grid(row=1, column=0, padx=(0, 4), sticky="ew")
|
self.btn_f2.grid(row=1, column=0, padx=(0, 4), sticky="ew")
|
||||||
self.btn_unload = ttk.Button(buttons, text="[F4] Elimina", command=self._begin_manual_unload)
|
self.btn_unload = ttk.Button(buttons, text="[F4] Scarica", command=self._begin_manual_unload)
|
||||||
self.btn_unload.grid(row=1, column=1, padx=(4, 0), sticky="ew")
|
self.btn_unload.grid(row=1, column=1, padx=(4, 0), sticky="ew")
|
||||||
|
|
||||||
self.busy_cover = tk.Frame(self.root, bg="#d9d9d9")
|
self.busy_cover = tk.Frame(self.root, bg="#d9d9d9")
|
||||||
@@ -254,10 +281,22 @@ class BarcodeClientApp:
|
|||||||
|
|
||||||
self.root.geometry(f"{width}x{height}+{x}+{y}")
|
self.root.geometry(f"{width}x{height}+{x}+{y}")
|
||||||
|
|
||||||
|
def _finalize_window_placement(self) -> None:
|
||||||
|
"""Let Windows finish frame sizing, then snap the barcode window in place."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._is_barcode_desktop:
|
||||||
|
self.root.state("zoomed")
|
||||||
|
else:
|
||||||
|
self.root.geometry("+0+0")
|
||||||
|
self.root.update_idletasks()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _bind_keys(self) -> None:
|
def _bind_keys(self) -> None:
|
||||||
self.root.bind("<F1>", lambda _e: self._start_queue(1))
|
self.root.bind("<F1>", lambda _e: self._start_queue(1))
|
||||||
self.root.bind("<F2>", lambda _e: self._start_queue(0))
|
self.root.bind("<F2>", lambda _e: self._start_queue(0))
|
||||||
self.root.bind("<F4>", lambda _e: self._begin_manual_unload())
|
self.root.bind("<F4>", self._on_unload_key)
|
||||||
self.pallet_entry.bind("<Return>", self._on_pallet_enter)
|
self.pallet_entry.bind("<Return>", self._on_pallet_enter)
|
||||||
self.destination_entry.bind("<Return>", self._on_destination_enter)
|
self.destination_entry.bind("<Return>", self._on_destination_enter)
|
||||||
|
|
||||||
@@ -301,7 +340,7 @@ class BarcodeClientApp:
|
|||||||
self.info4_var.set(state.expected_pallet)
|
self.info4_var.set(state.expected_pallet)
|
||||||
self.status_band.configure(bg=state.status_color or self._status_colors["red"])
|
self.status_band.configure(bg=state.status_color or self._status_colors["red"])
|
||||||
|
|
||||||
destination_readonly = state.mode in ("priority_high", "priority_low", "manual_unload")
|
destination_readonly = bool(getattr(state, "destination_readonly", False))
|
||||||
try:
|
try:
|
||||||
self.destination_entry.configure(state="normal")
|
self.destination_entry.configure(state="normal")
|
||||||
if destination_readonly:
|
if destination_readonly:
|
||||||
@@ -309,10 +348,18 @@ class BarcodeClientApp:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
next_queue = self._queue_id_from_label(state.queue_label)
|
||||||
if next_queue is not None:
|
delay_ms = int(getattr(state, "auto_advance_delay_ms", 0) or 0)
|
||||||
self._auto_advance_id = self.root.after(1200, lambda q=next_queue: self._start_queue(q))
|
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)
|
self.root.after(20, self._focus_primary_input)
|
||||||
|
|
||||||
@@ -339,8 +386,11 @@ class BarcodeClientApp:
|
|||||||
destination = str(self.destination_var.get() or "").strip()
|
destination = str(self.destination_var.get() or "").strip()
|
||||||
if not pallet:
|
if not pallet:
|
||||||
return "break"
|
return "break"
|
||||||
if destination == "9000000":
|
if destination in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE):
|
||||||
self._submit()
|
if bool(getattr(self.service.state, "destination_readonly", False)):
|
||||||
|
self._submit()
|
||||||
|
else:
|
||||||
|
self._focus_destination_input()
|
||||||
return "break"
|
return "break"
|
||||||
self.destination_var.set("")
|
self.destination_var.set("")
|
||||||
self._focus_destination_input()
|
self._focus_destination_input()
|
||||||
@@ -355,7 +405,21 @@ class BarcodeClientApp:
|
|||||||
self._focus_primary_input()
|
self._focus_primary_input()
|
||||||
return "break"
|
return "break"
|
||||||
|
|
||||||
|
def _on_unload_key(self, _event=None) -> str:
|
||||||
|
self._begin_manual_unload()
|
||||||
|
return "break"
|
||||||
|
|
||||||
def _begin_manual_unload(self) -> None:
|
def _begin_manual_unload(self) -> None:
|
||||||
|
pallet = str(self.scanned_var.get() or "").strip()
|
||||||
|
destination = str(self.destination_var.get() or "").strip()
|
||||||
|
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())
|
self._apply_state(self.service.begin_manual_unload())
|
||||||
|
|
||||||
def _start_queue(self, id_stato: int) -> None:
|
def _start_queue(self, id_stato: int) -> None:
|
||||||
@@ -408,11 +472,18 @@ class BarcodeClientApp:
|
|||||||
try:
|
try:
|
||||||
result = future.result()
|
result = future.result()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_exception("Barcode WMS", exc, context="barcode async operation")
|
||||||
current = self.service.state
|
current = self.service.state
|
||||||
current.status_text = f"Errore operativo: {exc}"
|
current.status_text = "Transazione non completata, ripeti l'operazione."
|
||||||
current.status_color = "#f4cccc"
|
current.status_color = "#f4cccc"
|
||||||
self._apply_state(current)
|
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
|
return
|
||||||
|
|
||||||
if isinstance(result, BarcodeActionResult):
|
if isinstance(result, BarcodeActionResult):
|
||||||
@@ -446,6 +517,7 @@ def main() -> int:
|
|||||||
loop = get_global_loop()
|
loop = get_global_loop()
|
||||||
bootstrap = tk.Tk()
|
bootstrap = tk.Tk()
|
||||||
bootstrap.withdraw()
|
bootstrap.withdraw()
|
||||||
|
configure_exception_logging("Barcode WMS", root=bootstrap, loop=loop)
|
||||||
|
|
||||||
config = ensure_db_config(loop, parent=bootstrap)
|
config = ensure_db_config(loop, parent=bootstrap)
|
||||||
if not config:
|
if not config:
|
||||||
@@ -454,7 +526,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
db_client = AsyncMSSQLClient(build_dsn_from_config(config))
|
db_client = AsyncMSSQLClient(build_dsn_from_config(config))
|
||||||
session = prompt_login_compact(bootstrap, db_client)
|
session = _build_bypass_session() if BYPASS_LOGIN else prompt_login_compact(bootstrap, db_client)
|
||||||
if session is None:
|
if session is None:
|
||||||
try:
|
try:
|
||||||
fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop)
|
fut = asyncio.run_coroutine_threadsafe(db_client.dispose(), loop)
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ possible while keeping SQL isolated from the UI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from runtime_support import log_exception, log_runtime_event
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
SQL_NEXT_PICKING = """
|
SQL_NEXT_PICKING = """
|
||||||
SELECT TOP (1)
|
SELECT TOP (1)
|
||||||
@@ -54,8 +59,42 @@ WHERE Pallet = :pallet
|
|||||||
ORDER BY Lotto;
|
ORDER BY Lotto;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SQL_CURRENT_LOCATION_BY_PALLET = """
|
||||||
|
SELECT TOP (1)
|
||||||
|
g.BarcodePallet,
|
||||||
|
g.IDCella,
|
||||||
|
RTRIM(c.Corsia) AS Corsia,
|
||||||
|
RTRIM(CAST(c.Colonna AS varchar(32))) AS Colonna,
|
||||||
|
RTRIM(CAST(c.Fila AS varchar(32))) AS Fila
|
||||||
|
FROM dbo.XMag_GiacenzaPallet AS g
|
||||||
|
LEFT JOIN dbo.Celle AS c
|
||||||
|
ON c.ID = g.IDCella
|
||||||
|
WHERE g.BarcodePallet = :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 = """
|
SQL_LEGACY_MOVE = """
|
||||||
SET NOCOUNT ON;
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
DECLARE @RC int = 0;
|
DECLARE @RC int = 0;
|
||||||
|
|
||||||
EXEC dbo.sp_xMagGestioneMagazziniPallet
|
EXEC dbo.sp_xMagGestioneMagazziniPallet
|
||||||
@@ -101,6 +140,16 @@ class LegacyMoveResult:
|
|||||||
numero_cella: int
|
numero_cella: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DestinationCell:
|
||||||
|
"""Resolved warehouse cell accepted by the legacy movement procedure."""
|
||||||
|
|
||||||
|
barcode_cella: str
|
||||||
|
numero_cella: int
|
||||||
|
id_cella: int
|
||||||
|
ubicazione: str
|
||||||
|
|
||||||
|
|
||||||
class BarcodeRepository:
|
class BarcodeRepository:
|
||||||
"""Thin async repository used by the lightweight barcode client."""
|
"""Thin async repository used by the lightweight barcode client."""
|
||||||
|
|
||||||
@@ -128,6 +177,28 @@ class BarcodeRepository:
|
|||||||
rows = _rows_to_dicts(res)
|
rows = _rows_to_dicts(res)
|
||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
async def fetch_current_location_by_pallet(self, pallet: str) -> dict[str, Any] | None:
|
||||||
|
"""Return the current stock location for a pallet, if it exists in giacenza."""
|
||||||
|
|
||||||
|
res = await self.db_client.query_json(SQL_CURRENT_LOCATION_BY_PALLET, {"pallet": str(pallet or "").strip()})
|
||||||
|
rows = _rows_to_dicts(res)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
async def resolve_physical_cell(self, destination: str) -> DestinationCell | None:
|
||||||
|
"""Accept either an internal cell ID or the scanned legacy cell barcode."""
|
||||||
|
|
||||||
|
res = await self.db_client.query_json(SQL_RESOLVE_PHYSICAL_CELL, {"destination": str(destination or "").strip()})
|
||||||
|
rows = _rows_to_dicts(res)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
row = rows[0]
|
||||||
|
return DestinationCell(
|
||||||
|
barcode_cella=str(row.get("BarcodeCella") or ""),
|
||||||
|
numero_cella=int(row.get("NumeroCella") or 0),
|
||||||
|
id_cella=int(row.get("IDCella") or 0),
|
||||||
|
ubicazione=str(row.get("Ubicazione") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
async def execute_legacy_move(
|
async def execute_legacy_move(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -144,12 +215,44 @@ class BarcodeRepository:
|
|||||||
"barcode_pallet": str(barcode_pallet or "").strip(),
|
"barcode_pallet": str(barcode_pallet or "").strip(),
|
||||||
"numero_cella": int(numero_cella),
|
"numero_cella": int(numero_cella),
|
||||||
}
|
}
|
||||||
res = await self.db_client.query_json(SQL_LEGACY_MOVE, params, commit=True)
|
log_runtime_event(
|
||||||
|
"Barcode WMS",
|
||||||
|
(
|
||||||
|
"MOVE START "
|
||||||
|
f"operator={params['id_operatore']} "
|
||||||
|
f"cell={params['barcode_cella']} "
|
||||||
|
f"pallet={params['barcode_pallet']}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
res = await self.db_client.query_json(SQL_LEGACY_MOVE, params, commit=True)
|
||||||
|
except Exception as exc:
|
||||||
|
log_exception(
|
||||||
|
"Barcode WMS",
|
||||||
|
exc,
|
||||||
|
context=(
|
||||||
|
"execute_legacy_move "
|
||||||
|
f"operator={params['id_operatore']} "
|
||||||
|
f"cell={params['barcode_cella']} "
|
||||||
|
f"pallet={params['barcode_pallet']}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise
|
||||||
rows = _rows_to_dicts(res)
|
rows = _rows_to_dicts(res)
|
||||||
row = rows[0] if rows else {}
|
row = rows[0] if rows else {}
|
||||||
return LegacyMoveResult(
|
result = LegacyMoveResult(
|
||||||
rc=int(row.get("RC") or 0),
|
rc=int(row.get("RC") or 0),
|
||||||
barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]),
|
barcode_cella=str(row.get("BarcodeCella") or params["barcode_cella"]),
|
||||||
barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]),
|
barcode_pallet=str(row.get("BarcodePallet") or params["barcode_pallet"]),
|
||||||
numero_cella=int(row.get("NumeroCella") or params["numero_cella"]),
|
numero_cella=int(row.get("NumeroCella") or params["numero_cella"]),
|
||||||
)
|
)
|
||||||
|
log_runtime_event(
|
||||||
|
"Barcode WMS",
|
||||||
|
(
|
||||||
|
"MOVE OK "
|
||||||
|
f"rc={result.rc} "
|
||||||
|
f"cell={result.barcode_cella} "
|
||||||
|
f"pallet={result.barcode_pallet}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from dataclasses import dataclass
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from barcode_repository import BarcodeRepository, LegacyMoveResult
|
from barcode_repository import BarcodeRepository, LegacyMoveResult
|
||||||
|
from runtime_support import log_exception
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"]
|
ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"]
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ class BarcodeViewState:
|
|||||||
expected_pallet: str = ""
|
expected_pallet: str = ""
|
||||||
destination_barcode: str = ""
|
destination_barcode: str = ""
|
||||||
scanned_pallet: str = ""
|
scanned_pallet: str = ""
|
||||||
|
auto_advance_delay_ms: int = 0
|
||||||
|
destination_readonly: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -39,6 +44,8 @@ class BarcodeActionResult:
|
|||||||
class BarcodeService:
|
class BarcodeService:
|
||||||
"""Faithful, but cleaner, port of the legacy barcode form logic."""
|
"""Faithful, but cleaner, port of the legacy barcode form logic."""
|
||||||
|
|
||||||
|
NON_SCAFFALATA_BARCODE = "9001000"
|
||||||
|
SHIPPED_BARCODE = "9000000"
|
||||||
GRAY = "#d9d9d9"
|
GRAY = "#d9d9d9"
|
||||||
RED = "#f4cccc"
|
RED = "#f4cccc"
|
||||||
LIGHT_GREEN = "#d9ead3"
|
LIGHT_GREEN = "#d9ead3"
|
||||||
@@ -80,7 +87,7 @@ class BarcodeService:
|
|||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def begin_manual_unload(self) -> BarcodeViewState:
|
def begin_manual_unload(self) -> BarcodeViewState:
|
||||||
"""Prepare a direct unload toward the virtual outbound cell 9000000."""
|
"""Prepare a direct unload toward the conventional non-shelved cell."""
|
||||||
|
|
||||||
self._current_priority_state = 0
|
self._current_priority_state = 0
|
||||||
self._state = BarcodeViewState(
|
self._state = BarcodeViewState(
|
||||||
@@ -88,7 +95,8 @@ class BarcodeService:
|
|||||||
queue_label="Prelievo diretto",
|
queue_label="Prelievo diretto",
|
||||||
status_text="OP Scarico",
|
status_text="OP Scarico",
|
||||||
status_color=self.GRAY,
|
status_color=self.GRAY,
|
||||||
destination_barcode="9000000",
|
destination_barcode=self.NON_SCAFFALATA_BARCODE,
|
||||||
|
destination_readonly=True,
|
||||||
)
|
)
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
@@ -100,11 +108,12 @@ class BarcodeService:
|
|||||||
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
|
queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)"
|
||||||
if not row:
|
if not row:
|
||||||
self._state = BarcodeViewState(
|
self._state = BarcodeViewState(
|
||||||
mode="priority_high" if int(id_stato) == 1 else "priority_low",
|
mode="manual_unload",
|
||||||
queue_label=queue_label,
|
queue_label=queue_label,
|
||||||
status_text="Pronto.",
|
status_text="Pronto.",
|
||||||
status_color=self.RED,
|
status_color=self.RED,
|
||||||
destination_barcode="9000000",
|
destination_barcode=self.NON_SCAFFALATA_BARCODE,
|
||||||
|
destination_readonly=False,
|
||||||
)
|
)
|
||||||
return BarcodeActionResult(True, self._state)
|
return BarcodeActionResult(True, self._state)
|
||||||
|
|
||||||
@@ -122,7 +131,8 @@ class BarcodeService:
|
|||||||
document=str(row.get("Documento") or ""),
|
document=str(row.get("Documento") or ""),
|
||||||
customer=customer,
|
customer=customer,
|
||||||
expected_pallet=str(row.get("Pallet") or ""),
|
expected_pallet=str(row.get("Pallet") or ""),
|
||||||
destination_barcode="9000000",
|
destination_barcode=self.SHIPPED_BARCODE,
|
||||||
|
destination_readonly=True,
|
||||||
)
|
)
|
||||||
return BarcodeActionResult(True, self._state)
|
return BarcodeActionResult(True, self._state)
|
||||||
|
|
||||||
@@ -138,31 +148,80 @@ class BarcodeService:
|
|||||||
if not destination.isdigit():
|
if not destination.isdigit():
|
||||||
return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.")
|
return BarcodeActionResult(False, self._state, "La destinazione deve essere numerica.")
|
||||||
|
|
||||||
if self._state.mode in ("priority_high", "priority_low") and destination == "9000000":
|
is_priority_mode = self._state.mode in ("priority_high", "priority_low")
|
||||||
expected = str(self._state.expected_pallet or "").strip()
|
expected_before_move = str(self._state.expected_pallet or "").strip()
|
||||||
if expected and expected != pallet:
|
is_picking_unload = bool(is_priority_mode and expected_before_move and destination == self.SHIPPED_BARCODE)
|
||||||
|
is_direct_unload = bool(destination == self.NON_SCAFFALATA_BARCODE and not is_picking_unload)
|
||||||
|
is_direct_load = bool(destination not in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and not is_picking_unload)
|
||||||
|
|
||||||
|
if is_priority_mode and destination == self.SHIPPED_BARCODE:
|
||||||
|
if expected_before_move and expected_before_move != pallet:
|
||||||
self._state.scanned_pallet = pallet
|
self._state.scanned_pallet = pallet
|
||||||
self._state.status_text = "Errata lettura: il pallet letto non coincide con quello atteso."
|
self._state.status_text = "Errata lettura: il pallet letto non coincide con quello atteso."
|
||||||
self._state.status_color = self.RED
|
self._state.status_color = self.RED
|
||||||
return BarcodeActionResult(False, self._state, self._state.status_text)
|
return BarcodeActionResult(False, self._state, self._state.status_text)
|
||||||
|
|
||||||
move = await self.repository.execute_legacy_move(
|
current_location = await self.repository.fetch_current_location_by_pallet(pallet)
|
||||||
operator_id=self.operator_id,
|
picking_before_move = await self.repository.fetch_picking_by_pallet(pallet)
|
||||||
barcode_cella=destination,
|
if not current_location and not picking_before_move:
|
||||||
barcode_pallet=pallet,
|
|
||||||
numero_cella=int(destination),
|
|
||||||
)
|
|
||||||
if move.rc != 0:
|
|
||||||
self._state.scanned_pallet = pallet
|
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
|
self._state.status_color = self.RED
|
||||||
return BarcodeActionResult(False, self._state, self._state.status_text)
|
return BarcodeActionResult(False, self._state, self._state.status_text)
|
||||||
|
|
||||||
self._state = await self._build_post_move_state(
|
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,
|
barcode_pallet=pallet,
|
||||||
destination_barcode=destination,
|
numero_cella=target_numero_cella,
|
||||||
last_priority_state=self._current_priority_state,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
return BarcodeActionResult(True, self._state, self._state.status_text)
|
||||||
|
|
||||||
async def _build_post_move_state(
|
async def _build_post_move_state(
|
||||||
@@ -170,7 +229,9 @@ class BarcodeService:
|
|||||||
*,
|
*,
|
||||||
barcode_pallet: str,
|
barcode_pallet: str,
|
||||||
destination_barcode: str,
|
destination_barcode: str,
|
||||||
|
destination_display: str,
|
||||||
last_priority_state: int,
|
last_priority_state: int,
|
||||||
|
auto_advance_delay_ms: int,
|
||||||
) -> BarcodeViewState:
|
) -> BarcodeViewState:
|
||||||
"""Mirror the legacy confirmation flow after one stored-procedure move."""
|
"""Mirror the legacy confirmation flow after one stored-procedure move."""
|
||||||
|
|
||||||
@@ -191,6 +252,8 @@ class BarcodeService:
|
|||||||
customer=customer,
|
customer=customer,
|
||||||
expected_pallet=str(picking_row.get("Pallet") or ""),
|
expected_pallet=str(picking_row.get("Pallet") or ""),
|
||||||
destination_barcode=destination_barcode,
|
destination_barcode=destination_barcode,
|
||||||
|
auto_advance_delay_ms=auto_advance_delay_ms,
|
||||||
|
destination_readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
trace_row = await self.repository.fetch_trace_by_pallet(barcode_pallet)
|
trace_row = await self.repository.fetch_trace_by_pallet(barcode_pallet)
|
||||||
@@ -203,27 +266,29 @@ class BarcodeService:
|
|||||||
mode="confirm",
|
mode="confirm",
|
||||||
queue_label=queue_label,
|
queue_label=queue_label,
|
||||||
status_text=(
|
status_text=(
|
||||||
"Ok Scarico" if destination_barcode == "9000000" else f"Ok Carico - {destination_barcode}"
|
"Ok Scarico" if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) else f"Ok Carico - {destination_display}"
|
||||||
),
|
),
|
||||||
status_color=self.GREEN_YELLOW,
|
status_color=self.GREEN_YELLOW,
|
||||||
source_location=str(destination_barcode or ""),
|
source_location=str(destination_display or destination_barcode or ""),
|
||||||
document=(
|
document=(
|
||||||
self.CONVENTIONAL_LOCATION_BY_CELL[9999]
|
self.CONVENTIONAL_LOCATION_BY_CELL[9999 if destination_barcode == self.SHIPPED_BARCODE else 1000]
|
||||||
if destination_barcode == "9000000" and last_priority_state in (0, 1)
|
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
|
||||||
else lotto
|
else lotto
|
||||||
),
|
),
|
||||||
customer=(
|
customer=(
|
||||||
lotto
|
lotto
|
||||||
if destination_barcode == "9000000" and last_priority_state in (0, 1)
|
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
|
||||||
else prodotto
|
else prodotto
|
||||||
),
|
),
|
||||||
expected_pallet=(
|
expected_pallet=(
|
||||||
" - ".join(part for part in (prodotto, descrizione) if part)
|
" - ".join(part for part in (prodotto, descrizione) if part)
|
||||||
if destination_barcode == "9000000" and last_priority_state in (0, 1)
|
if destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE) and last_priority_state in (0, 1)
|
||||||
else descrizione
|
else descrizione
|
||||||
),
|
),
|
||||||
destination_barcode=destination_barcode,
|
destination_barcode=destination_barcode,
|
||||||
scanned_pallet=barcode_pallet,
|
scanned_pallet=barcode_pallet,
|
||||||
|
auto_advance_delay_ms=auto_advance_delay_ms,
|
||||||
|
destination_readonly=destination_barcode in (self.NON_SCAFFALATA_BARCODE, self.SHIPPED_BARCODE),
|
||||||
)
|
)
|
||||||
|
|
||||||
return BarcodeViewState(
|
return BarcodeViewState(
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import tkinter as tk
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from ui_theme import theme_color, theme_font, theme_padding, theme_value
|
from ui_theme import theme_color, theme_font, theme_padding, theme_value
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
class InlineBusyOverlay:
|
class InlineBusyOverlay:
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
|
|||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
from ui_theme import theme_section, theme_value
|
from ui_theme import theme_section, theme_value
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
CONFIG_PATH = Path(__file__).with_name("db_connection.json")
|
CONFIG_PATH = Path(__file__).with_name("db_connection.json")
|
||||||
DEFAULT_DB_CONFIG: dict[str, Any] = {
|
DEFAULT_DB_CONFIG: dict[str, Any] = {
|
||||||
@@ -101,7 +104,7 @@ class DatabaseConfigWindow(tk.Toplevel):
|
|||||||
self.result_config: dict[str, Any] | None = None
|
self.result_config: dict[str, Any] | None = None
|
||||||
merged = {**DEFAULT_DB_CONFIG, **(initial or {})}
|
merged = {**DEFAULT_DB_CONFIG, **(initial or {})}
|
||||||
|
|
||||||
self.title(loc_text("dbconfig.title", catalog=self._locale_catalog, default="Configurazione Database"))
|
self.title(versioned_title(loc_text("dbconfig.title", catalog=self._locale_catalog, default="Configurazione Database"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "520x360")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "520x360")))
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
try:
|
try:
|
||||||
|
|||||||
44
docs/review/README.md
Normal file
44
docs/review/README.md
Normal 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`
|
||||||
68
docs/review/barcode_error_handling.md
Normal file
68
docs/review/barcode_error_handling.md
Normal 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`
|
||||||
119
docs/review/documentation_plan.md
Normal file
119
docs/review/documentation_plan.md
Normal 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.
|
||||||
154
docs/review/module_gestione_pickinglist.md
Normal file
154
docs/review/module_gestione_pickinglist.md
Normal 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.
|
||||||
97
docs/review/module_prenota_sprenota_sql.md
Normal file
97
docs/review/module_prenota_sprenota_sql.md
Normal 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.
|
||||||
150
docs/review/module_storico_pickinglist.md
Normal file
150
docs/review/module_storico_pickinglist.md
Normal 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`.
|
||||||
153
docs/review/sql_pickinglist_reservation.md
Normal file
153
docs/review/sql_pickinglist_reservation.md
Normal 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.
|
||||||
236
docs/review/sql_storico_pickinglist.md
Normal file
236
docs/review/sql_storico_pickinglist.md
Normal 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;
|
||||||
|
```
|
||||||
@@ -19,8 +19,9 @@ from typing import Any, Callable, Optional
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from async_loop_singleton import get_global_loop
|
from async_loop_singleton import get_global_loop
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
__VERSION__ = "GestioneAreeFrame v3.3.0-singleloop"
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from async_msssql_query import AsyncMSSQLClient # noqa: F401
|
from async_msssql_query import AsyncMSSQLClient # noqa: F401
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ from tksheet import Sheet, natural_sort_key
|
|||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
from user_session import UserSession
|
from user_session import UserSession
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
||||||
@@ -212,7 +215,7 @@ class LayoutWindow(ctk.CTkToplevel):
|
|||||||
self._theme = theme_section("layout_window", {})
|
self._theme = theme_section("layout_window", {})
|
||||||
self._locale_catalog = load_locale_catalog()
|
self._locale_catalog = load_locale_catalog()
|
||||||
self._tooltip_catalog = load_tooltip_catalog()
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
self.title(loc_text("layout.title", catalog=self._locale_catalog, default="Layout corsie"))
|
self.title(versioned_title(loc_text("layout.title", catalog=self._locale_catalog, default="Layout corsie"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x740")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [980, 560])
|
minsize = theme_value(self._theme, "window_minsize", [980, 560])
|
||||||
self.minsize(int(minsize[0]), int(minsize[1]))
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
|
|||||||
@@ -70,9 +70,20 @@ from busy_overlay import InlineBusyOverlay
|
|||||||
from gestione_aree import AsyncRunner
|
from gestione_aree import AsyncRunner
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import (
|
||||||
|
TABLE_HEADER_BG,
|
||||||
|
TABLE_HEADER_FG,
|
||||||
|
TABLE_ROW_EVEN,
|
||||||
|
TABLE_ROW_ODD,
|
||||||
|
apply_tksheet_visual_style,
|
||||||
|
apply_tksheet_zebra,
|
||||||
|
)
|
||||||
from user_session import UserSession
|
from user_session import UserSession
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
||||||
import asyncio
|
import asyncio
|
||||||
try:
|
try:
|
||||||
@@ -198,9 +209,12 @@ if _MODULE_LOG_ENABLED:
|
|||||||
|
|
||||||
|
|
||||||
# -------------------- SQL --------------------
|
# -------------------- SQL --------------------
|
||||||
|
# Operational picking-list management uses the Python-only residual view.
|
||||||
|
# Unlike the historical view, this dataset intentionally contains only UDCs
|
||||||
|
# that still have to be exhausted by the warehouse workflow.
|
||||||
SQL_PL = """
|
SQL_PL = """
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT Pallet) AS Pallet,
|
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '')) AS Pallet,
|
||||||
COUNT(DISTINCT Lotto) AS Lotto,
|
COUNT(DISTINCT Lotto) AS Lotto,
|
||||||
COUNT(DISTINCT Articolo) AS Articolo,
|
COUNT(DISTINCT Articolo) AS Articolo,
|
||||||
COUNT(DISTINCT Descrizione) AS Descrizione,
|
COUNT(DISTINCT Descrizione) AS Descrizione,
|
||||||
@@ -388,13 +402,14 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
self._sort_key: Optional[str] = None
|
self._sort_key: Optional[str] = None
|
||||||
self._sort_reverse = False
|
self._sort_reverse = False
|
||||||
self.total_w = sum(c.width for c in self.columns)
|
self.total_w = sum(c.width for c in self.columns)
|
||||||
|
self._vbar_visible = False
|
||||||
|
|
||||||
self.grid_rowconfigure(1, weight=1)
|
self.grid_rowconfigure(1, weight=1)
|
||||||
self.grid_columnconfigure(0, weight=1)
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
# header
|
# header
|
||||||
self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0)
|
self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0, bg=TABLE_HEADER_BG)
|
||||||
self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color="#f3f3f3",
|
self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color=TABLE_HEADER_BG,
|
||||||
height=ROW_H, width=self.total_w)
|
height=ROW_H, width=self.total_w)
|
||||||
self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw",
|
self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw",
|
||||||
width=self.total_w, height=ROW_H)
|
width=self.total_w, height=ROW_H)
|
||||||
@@ -411,7 +426,6 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
# scrollbars
|
# scrollbars
|
||||||
self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview)
|
self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview)
|
||||||
self.xbar = tk.Scrollbar(self, orient="horizontal", command=self._xscroll_both)
|
self.xbar = tk.Scrollbar(self, orient="horizontal", command=self._xscroll_both)
|
||||||
self.vbar.grid(row=1, column=1, sticky="ns")
|
|
||||||
self.xbar.grid(row=2, column=0, sticky="ew")
|
self.xbar.grid(row=2, column=0, sticky="ew")
|
||||||
|
|
||||||
# link scroll
|
# link scroll
|
||||||
@@ -433,14 +447,14 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
for w in self.h_inner.winfo_children():
|
for w in self.h_inner.winfo_children():
|
||||||
w.destroy()
|
w.destroy()
|
||||||
|
|
||||||
row = ctk.CTkFrame(self.h_inner, fg_color="#f3f3f3",
|
row = ctk.CTkFrame(self.h_inner, fg_color=TABLE_HEADER_BG,
|
||||||
height=ROW_H, width=self.total_w)
|
height=ROW_H, width=self.total_w)
|
||||||
row.pack(fill="x", expand=False)
|
row.pack(fill="x", expand=False)
|
||||||
row.pack_propagate(False)
|
row.pack_propagate(False)
|
||||||
|
|
||||||
for col in self.columns:
|
for col in self.columns:
|
||||||
holder = ctk.CTkFrame(
|
holder = ctk.CTkFrame(
|
||||||
row, fg_color="#f3f3f3",
|
row, fg_color=TABLE_HEADER_BG,
|
||||||
width=col.width, height=ROW_H,
|
width=col.width, height=ROW_H,
|
||||||
border_width=1, border_color=self.GRID_COLOR
|
border_width=1, border_color=self.GRID_COLOR
|
||||||
)
|
)
|
||||||
@@ -451,7 +465,7 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
if col.key == self._sort_key:
|
if col.key == self._sort_key:
|
||||||
header_text = f"{col.title} {'↓' if self._sort_reverse else '↑'}"
|
header_text = f"{col.title} {'↓' if self._sort_reverse else '↑'}"
|
||||||
|
|
||||||
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w")
|
lbl = ctk.CTkLabel(holder, text=header_text, anchor="w", text_color=TABLE_HEADER_FG)
|
||||||
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
|
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
|
||||||
|
|
||||||
if self.on_header_click and col.key != "__check__":
|
if self.on_header_click and col.key != "__check__":
|
||||||
@@ -472,10 +486,27 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
"""Keep the scroll region aligned with the current body content width."""
|
"""Keep the scroll region aligned with the current body content width."""
|
||||||
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
|
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
|
||||||
sr = self.b_canvas.bbox("all")
|
sr = self.b_canvas.bbox("all")
|
||||||
|
content_height = int(sr[3]) if sr else 0
|
||||||
if sr:
|
if sr:
|
||||||
self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3]))
|
self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3]))
|
||||||
else:
|
else:
|
||||||
self.b_canvas.configure(scrollregion=(0,0,self.total_w,0))
|
self.b_canvas.configure(scrollregion=(0,0,self.total_w,0))
|
||||||
|
self._update_vertical_scrollbar(content_height)
|
||||||
|
|
||||||
|
def _update_vertical_scrollbar(self, content_height: int):
|
||||||
|
"""Show the vertical scrollbar only when body rows exceed the visible area."""
|
||||||
|
try:
|
||||||
|
visible_height = max(1, int(self.b_canvas.winfo_height()))
|
||||||
|
except Exception:
|
||||||
|
visible_height = 1
|
||||||
|
needs_scroll = content_height > visible_height + 2
|
||||||
|
if needs_scroll and not self._vbar_visible:
|
||||||
|
self.vbar.grid(row=1, column=1, sticky="ns")
|
||||||
|
self._vbar_visible = True
|
||||||
|
elif not needs_scroll and self._vbar_visible:
|
||||||
|
self.vbar.grid_remove()
|
||||||
|
self._vbar_visible = False
|
||||||
|
self.b_canvas.yview_moveto(0)
|
||||||
|
|
||||||
def _on_body_configure(self):
|
def _on_body_configure(self):
|
||||||
"""React to body resize events by syncing dimensions and header scroll."""
|
"""React to body resize events by syncing dimensions and header scroll."""
|
||||||
@@ -510,8 +541,11 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
delta = getattr(event, "delta", 0)
|
delta = getattr(event, "delta", 0)
|
||||||
if delta == 0:
|
if delta == 0:
|
||||||
return
|
return
|
||||||
|
if not self._vbar_visible:
|
||||||
|
return "break"
|
||||||
step = -1 if delta > 0 else 1
|
step = -1 if delta > 0 else 1
|
||||||
self.b_canvas.yview_scroll(step, "units")
|
self.b_canvas.yview_scroll(step, "units")
|
||||||
|
return "break"
|
||||||
|
|
||||||
def clear_rows(self):
|
def clear_rows(self):
|
||||||
"""Remove all rendered body rows."""
|
"""Remove all rendered body rows."""
|
||||||
@@ -527,14 +561,15 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
|
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
|
||||||
):
|
):
|
||||||
"""Append one row to the table body."""
|
"""Append one row to the table body."""
|
||||||
row = ctk.CTkFrame(self.b_inner, fg_color="transparent",
|
row_bg = TABLE_ROW_EVEN if row_index % 2 == 0 else TABLE_ROW_ODD
|
||||||
|
row = ctk.CTkFrame(self.b_inner, fg_color=row_bg,
|
||||||
height=ROW_H, width=self.total_w)
|
height=ROW_H, width=self.total_w)
|
||||||
row.pack(fill="x", expand=False)
|
row.pack(fill="x", expand=False)
|
||||||
row.pack_propagate(False)
|
row.pack_propagate(False)
|
||||||
|
|
||||||
for i, col in enumerate(self.columns):
|
for i, col in enumerate(self.columns):
|
||||||
holder = ctk.CTkFrame(
|
holder = ctk.CTkFrame(
|
||||||
row, fg_color="transparent",
|
row, fg_color=row_bg,
|
||||||
width=col.width, height=ROW_H,
|
width=col.width, height=ROW_H,
|
||||||
border_width=1, border_color=self.GRID_COLOR
|
border_width=1, border_color=self.GRID_COLOR
|
||||||
)
|
)
|
||||||
@@ -546,10 +581,10 @@ class ScrollTable(ctk.CTkFrame):
|
|||||||
cb = checkbox_builder(holder)
|
cb = checkbox_builder(holder)
|
||||||
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
|
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
|
||||||
else:
|
else:
|
||||||
ctk.CTkLabel(holder, text="").pack(fill="both")
|
ctk.CTkLabel(holder, text="", fg_color=row_bg).pack(fill="both")
|
||||||
else:
|
else:
|
||||||
anchor = (anchors[i] if anchors else col.anchor)
|
anchor = (anchors[i] if anchors else col.anchor)
|
||||||
ctk.CTkLabel(holder, text=values[i], anchor=anchor).pack(
|
ctk.CTkLabel(holder, text=values[i], anchor=anchor, fg_color=row_bg, text_color="#111827").pack(
|
||||||
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
|
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -693,6 +728,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
self.detail_sheet.change_theme("light green")
|
self.detail_sheet.change_theme("light green")
|
||||||
self.detail_sheet.enable_bindings("all")
|
self.detail_sheet.enable_bindings("all")
|
||||||
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
self.detail_sheet.headers(self._detail_headers(), redraw=False)
|
||||||
|
apply_tksheet_visual_style(self.detail_sheet)
|
||||||
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
|
self.detail_sheet.bind("<ButtonRelease-1>", self._on_detail_sheet_left_click, add="+")
|
||||||
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
|
self.detail_sheet.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
@@ -733,8 +769,10 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
data,
|
data,
|
||||||
reset_col_positions=True,
|
reset_col_positions=True,
|
||||||
reset_row_positions=True,
|
reset_row_positions=True,
|
||||||
redraw=True,
|
redraw=False,
|
||||||
)
|
)
|
||||||
|
apply_tksheet_visual_style(self.detail_sheet)
|
||||||
|
apply_tksheet_zebra(self.detail_sheet, len(data))
|
||||||
self.detail_sheet.set_all_column_widths()
|
self.detail_sheet.set_all_column_widths()
|
||||||
|
|
||||||
def _detail_sort_value(self, row: Dict[str, Any], key: str):
|
def _detail_sort_value(self, row: Dict[str, Any], key: str):
|
||||||
@@ -904,19 +942,33 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
self.after_idle(_paint)
|
self.after_idle(_paint)
|
||||||
break
|
break
|
||||||
|
|
||||||
def _reselect_documento_after_reload(self, documento: str):
|
def _reselect_documento_after_reload(self, documento: str) -> bool:
|
||||||
"""(Opzionale) Dopo un reload DB, riseleziona la PL con lo stesso Documento."""
|
"""After a reload, reselect the same document and reload its details."""
|
||||||
for m in self.rows_models:
|
for m in self.rows_models:
|
||||||
if _s(m.pl.get("Documento")) == _s(documento):
|
if _s(m.pl.get("Documento")) == _s(documento):
|
||||||
|
self._detail_cache.pop(documento, None)
|
||||||
m.set_checked(True)
|
m.set_checked(True)
|
||||||
self.on_row_checked(m, True)
|
self.on_row_checked(m, True)
|
||||||
break
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _selected_documento_for_reload(self) -> str | None:
|
||||||
|
"""Return the document that should survive a toolbar reload."""
|
||||||
|
|
||||||
|
selected = self._get_selected_model()
|
||||||
|
if selected is not None:
|
||||||
|
documento = _s(selected.pl.get("Documento"))
|
||||||
|
return documento or None
|
||||||
|
documento = _s(self.detail_doc)
|
||||||
|
return documento or None
|
||||||
|
|
||||||
# ----- eventi -----
|
# ----- eventi -----
|
||||||
@_log_call()
|
@_log_call()
|
||||||
def on_row_checked(self, model: PLRow, is_checked: bool):
|
def on_row_checked(self, model: PLRow, is_checked: bool):
|
||||||
"""Handle row selection changes and refresh the detail section."""
|
"""Handle row selection changes and refresh the detail section."""
|
||||||
# selezione esclusiva
|
# Only one backoffice picking list can be selected at a time. Barcode
|
||||||
|
# F1/F2 priority behavior is derived downstream; this UI controls only
|
||||||
|
# the explicit Python reservation slot.
|
||||||
if is_checked:
|
if is_checked:
|
||||||
for m in self.rows_models:
|
for m in self.rows_models:
|
||||||
if m is not model and m.is_checked():
|
if m is not model and m.is_checked():
|
||||||
@@ -964,6 +1016,11 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
@_log_call()
|
@_log_call()
|
||||||
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
|
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
|
||||||
"""Load or reload the picking list summary table from the database."""
|
"""Load or reload the picking list summary table from the database."""
|
||||||
|
if reselect_documento is None and not first:
|
||||||
|
# Preserve the operator context while the barcode may be consuming
|
||||||
|
# rows: Ricarica refreshes master and detail but keeps the selected
|
||||||
|
# document selected when it is still visible.
|
||||||
|
reselect_documento = self._selected_documento_for_reload()
|
||||||
self.spinner.start(" Carico…") # spinner ON
|
self.spinner.start(" Carico…") # spinner ON
|
||||||
async def _job():
|
async def _job():
|
||||||
_log_sql("SQL_PL", SQL_PL, {})
|
_log_sql("SQL_PL", SQL_PL, {})
|
||||||
@@ -973,8 +1030,18 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
_log_dataset("SQL_PL", rows)
|
_log_dataset("SQL_PL", rows)
|
||||||
self._refresh_mid_rows(rows)
|
self._refresh_mid_rows(rows)
|
||||||
if reselect_documento:
|
if reselect_documento:
|
||||||
self.after_idle(lambda doc=reselect_documento: self._reselect_documento_after_reload(doc))
|
def _reselect_or_clear(doc=reselect_documento):
|
||||||
self.spinner.stop() # spinner OFF
|
found = self._reselect_documento_after_reload(doc)
|
||||||
|
if not found:
|
||||||
|
self.detail_doc = None
|
||||||
|
self._draw_details_hint()
|
||||||
|
self.spinner.stop()
|
||||||
|
self.busy.hide()
|
||||||
|
self.after_idle(_reselect_or_clear)
|
||||||
|
else:
|
||||||
|
self.detail_doc = None
|
||||||
|
self._draw_details_hint()
|
||||||
|
self.spinner.stop() # spinner OFF
|
||||||
# se era il primo load, ripristina il cursore standard
|
# se era il primo load, ripristina il cursore standard
|
||||||
if self._first_loading:
|
if self._first_loading:
|
||||||
try:
|
try:
|
||||||
@@ -1067,6 +1134,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
documento = _s(model.pl.get("Documento"))
|
documento = _s(model.pl.get("Documento"))
|
||||||
current = int(model.pl.get("IDStato") or 0)
|
current = int(model.pl.get("IDStato") or 0)
|
||||||
desired = 1
|
desired = 1
|
||||||
|
# Idempotent semantics: Prenota never toggles a list off. If the list
|
||||||
|
# is already reserved, S-prenota remains the only explicit release path.
|
||||||
if current == desired:
|
if current == desired:
|
||||||
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
|
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
|
||||||
return
|
return
|
||||||
@@ -1079,6 +1148,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
self.spinner.start(" Prenoto…")
|
self.spinner.start(" Prenoto…")
|
||||||
|
|
||||||
async def _job():
|
async def _job():
|
||||||
|
# Use the Python-specific py_* stored procedure so C# legacy
|
||||||
|
# procedures can coexist unchanged on the same database.
|
||||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "P")
|
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "P")
|
||||||
|
|
||||||
def _ok(res: SPResult):
|
def _ok(res: SPResult):
|
||||||
@@ -1141,6 +1212,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
documento = _s(model.pl.get("Documento"))
|
documento = _s(model.pl.get("Documento"))
|
||||||
current = int(model.pl.get("IDStato") or 0)
|
current = int(model.pl.get("IDStato") or 0)
|
||||||
desired = 0
|
desired = 0
|
||||||
|
# Idempotent semantics: S-prenota never reserves a list; if the list is
|
||||||
|
# already free, the operation is a no-op rather than an implicit toggle.
|
||||||
if current == desired:
|
if current == desired:
|
||||||
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
|
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
|
||||||
return
|
return
|
||||||
@@ -1153,6 +1226,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
|||||||
self.spinner.start(" S-prenoto…")
|
self.spinner.start(" S-prenoto…")
|
||||||
|
|
||||||
async def _job():
|
async def _job():
|
||||||
|
# Same Python-only procedure as Prenota, with action S to release
|
||||||
|
# the singleton reservation and clear affected cell states.
|
||||||
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "S")
|
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento, "S")
|
||||||
|
|
||||||
def _ok(res: SPResult):
|
def _ok(res: SPResult):
|
||||||
@@ -1240,7 +1315,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N
|
|||||||
|
|
||||||
win = ctk.CTkToplevel(parent)
|
win = ctk.CTkToplevel(parent)
|
||||||
locale_catalog = load_locale_catalog()
|
locale_catalog = load_locale_catalog()
|
||||||
win.title(loc_text("picking.title", catalog=locale_catalog, default="Gestione Picking List"))
|
win.title(versioned_title(loc_text("picking.title", catalog=locale_catalog, default="Gestione Picking List"), __name__))
|
||||||
theme = theme_section("pickinglist_window", {})
|
theme = theme_section("pickinglist_window", {})
|
||||||
win.geometry(str(theme_value(theme, "window_geometry", "1200x700")))
|
win.geometry(str(theme_value(theme, "window_geometry", "1200x700")))
|
||||||
minsize = theme_value(theme, "window_minsize", [1000, 560])
|
minsize = theme_value(theme, "window_minsize", [1000, 560])
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from audit_log import log_user_action
|
|||||||
from busy_overlay import InlineBusyOverlay
|
from busy_overlay import InlineBusyOverlay
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import style_treeview, zebra_tag
|
||||||
from user_session import UserSession
|
from user_session import UserSession
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -457,7 +461,7 @@ class ScaricoDialog(ctk.CTkToplevel):
|
|||||||
self._async = AsyncRunner(self)
|
self._async = AsyncRunner(self)
|
||||||
self.rows_tree: ttk.Treeview | None = None
|
self.rows_tree: ttk.Treeview | None = None
|
||||||
|
|
||||||
self.title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione))
|
self.title(versioned_title(loc_text("scarico.title", catalog=self._locale_catalog, default="Scarica {ubicazione}").format(ubicazione=ubicazione), __name__))
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
self.transient(parent)
|
self.transient(parent)
|
||||||
self.protocol("WM_DELETE_WINDOW", self._close)
|
self.protocol("WM_DELETE_WINDOW", self._close)
|
||||||
@@ -508,10 +512,6 @@ class ScaricoDialog(ctk.CTkToplevel):
|
|||||||
tree_host.grid_rowconfigure(0, weight=1)
|
tree_host.grid_rowconfigure(0, weight=1)
|
||||||
tree_host.grid_columnconfigure(0, weight=1)
|
tree_host.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
style = ttk.Style(self)
|
|
||||||
style.configure("Scarico.Treeview", rowheight=28, font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)))
|
|
||||||
style.configure("Scarico.Treeview.Heading", font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")))
|
|
||||||
|
|
||||||
self.rows_tree = ttk.Treeview(
|
self.rows_tree = ttk.Treeview(
|
||||||
tree_host,
|
tree_host,
|
||||||
columns=("sel", "udc", "last", "diag"),
|
columns=("sel", "udc", "last", "diag"),
|
||||||
@@ -519,6 +519,13 @@ class ScaricoDialog(ctk.CTkToplevel):
|
|||||||
style="Scarico.Treeview",
|
style="Scarico.Treeview",
|
||||||
selectmode="none",
|
selectmode="none",
|
||||||
)
|
)
|
||||||
|
style_treeview(
|
||||||
|
self.rows_tree,
|
||||||
|
style_name="Scarico.Treeview",
|
||||||
|
rowheight=28,
|
||||||
|
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
|
||||||
|
heading_font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
|
||||||
|
)
|
||||||
self.rows_tree.heading("sel", text="Sel")
|
self.rows_tree.heading("sel", text="Sel")
|
||||||
self.rows_tree.heading("udc", text=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC"))
|
self.rows_tree.heading("udc", text=loc_text("scarico.col.udc", catalog=self._locale_catalog, default="UDC"))
|
||||||
self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento"))
|
self.rows_tree.heading("last", text=loc_text("scarico.col.last_insert", catalog=self._locale_catalog, default="Ultimo inserimento"))
|
||||||
@@ -572,6 +579,7 @@ class ScaricoDialog(ctk.CTkToplevel):
|
|||||||
row.last_event_at,
|
row.last_event_at,
|
||||||
row.diagnostic_note or "",
|
row.diagnostic_note or "",
|
||||||
),
|
),
|
||||||
|
tags=(zebra_tag(idx),),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import json
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
_LOCALE_FILE = Path(__file__).with_name("locale.json")
|
_LOCALE_FILE = Path(__file__).with_name("locale.json")
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from gestione_aree import AsyncRunner
|
|||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_section, theme_value
|
from ui_theme import theme_section, theme_value
|
||||||
from user_session import UserSession, create_user_session
|
from user_session import UserSession, create_user_session
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
SQL_LOGIN = """
|
SQL_LOGIN = """
|
||||||
@@ -64,8 +67,8 @@ class LoginWindow(tk.Toplevel):
|
|||||||
self._show_ready_after_id: str | None = None
|
self._show_ready_after_id: str | None = None
|
||||||
self._clear_topmost_after_id: str | None = None
|
self._clear_topmost_after_id: str | None = None
|
||||||
|
|
||||||
self.title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"))
|
self.title(versioned_title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"), __name__))
|
||||||
self.geometry("170x145+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x155+0+0")))
|
self.geometry("170x148+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x170+0+0")))
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
try:
|
try:
|
||||||
if parent is not None and parent.winfo_viewable():
|
if parent is not None and parent.winfo_viewable():
|
||||||
@@ -88,53 +91,59 @@ class LoginWindow(tk.Toplevel):
|
|||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
"""Build the compact operator login form."""
|
"""Build the compact operator login form."""
|
||||||
|
|
||||||
body = ttk.Frame(self, padding=8 if self.compact else 8)
|
body = ttk.Frame(self, padding=6 if self.compact else 8)
|
||||||
body.pack(fill="both", expand=True)
|
body.pack(fill="both", expand=True)
|
||||||
body.columnconfigure(1, weight=0)
|
body.columnconfigure(1, weight=0)
|
||||||
|
|
||||||
row_offset = 0
|
row_offset = 0
|
||||||
|
|
||||||
ttk.Label(body, text="User:").grid(row=row_offset, column=0, sticky="w", padx=(0, 4), pady=4)
|
ttk.Label(body, text="User:").grid(row=row_offset, column=0, sticky="w", padx=(0, 4), pady=(2, 3))
|
||||||
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9))
|
self.login_entry = ttk.Entry(body, textvariable=self.login_var, width=10, font=("Segoe UI", 10 if self.compact else 9))
|
||||||
self.login_entry.grid(row=row_offset, column=1, sticky="w", pady=4)
|
self.login_entry.grid(row=row_offset, column=1, sticky="w", pady=(2, 3))
|
||||||
|
|
||||||
ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 4), pady=4)
|
ttk.Label(body, text="Pwd:").grid(row=row_offset + 1, column=0, sticky="w", padx=(0, 4), pady=(2, 2))
|
||||||
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9))
|
self.password_entry = ttk.Entry(body, textvariable=self.password_var, width=10, show="*", font=("Segoe UI", 10 if self.compact else 9))
|
||||||
self.password_entry.grid(row=row_offset + 1, column=1, sticky="w", pady=4)
|
self.password_entry.grid(row=row_offset + 1, column=1, sticky="w", pady=(2, 2))
|
||||||
|
|
||||||
if self.compact:
|
if self.compact:
|
||||||
actions = ttk.Frame(body)
|
actions = ttk.Frame(body)
|
||||||
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(6, 0))
|
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(3, 0))
|
||||||
self._cancel_button = ttk.Button(
|
|
||||||
actions,
|
|
||||||
text="Annulla",
|
|
||||||
command=self._on_cancel,
|
|
||||||
)
|
|
||||||
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
|
|
||||||
self._login_button = ttk.Button(
|
self._login_button = ttk.Button(
|
||||||
actions,
|
actions,
|
||||||
text="OK",
|
text="OK",
|
||||||
command=self._on_login,
|
command=self._on_login,
|
||||||
)
|
)
|
||||||
self._login_button.grid(row=0, column=0, sticky="ew")
|
self._login_button.grid(row=0, column=0, sticky="ew")
|
||||||
else:
|
|
||||||
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
|
|
||||||
self.status_label.grid(row=2, column=0, columnspan=2, sticky="w", pady=(2, 2))
|
|
||||||
|
|
||||||
actions = ttk.Frame(body)
|
|
||||||
actions.grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
|
||||||
self._cancel_button = ttk.Button(
|
self._cancel_button = ttk.Button(
|
||||||
actions,
|
actions,
|
||||||
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
|
text="Annulla",
|
||||||
command=self._on_cancel,
|
command=self._on_cancel,
|
||||||
)
|
)
|
||||||
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(4, 0))
|
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(3, 0))
|
||||||
|
else:
|
||||||
|
self.status_label = ttk.Label(body, textvariable=self._status_var, foreground="#555555")
|
||||||
|
self.status_label.grid(row=2, column=0, columnspan=2, sticky="w", pady=(1, 1))
|
||||||
|
|
||||||
|
actions = ttk.Frame(body)
|
||||||
|
actions.grid(row=3, column=0, columnspan=2, sticky="w", pady=(3, 0))
|
||||||
self._login_button = ttk.Button(
|
self._login_button = ttk.Button(
|
||||||
actions,
|
actions,
|
||||||
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"),
|
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"),
|
||||||
command=self._on_login,
|
command=self._on_login,
|
||||||
)
|
)
|
||||||
self._login_button.grid(row=0, column=0, sticky="ew")
|
self._login_button.grid(row=0, column=0, sticky="ew")
|
||||||
|
self._cancel_button = ttk.Button(
|
||||||
|
actions,
|
||||||
|
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
|
||||||
|
command=self._on_cancel,
|
||||||
|
)
|
||||||
|
self._cancel_button.grid(row=1, column=0, sticky="ew", pady=(3, 0))
|
||||||
|
|
||||||
|
for widget in (self.login_entry, self.password_entry, self._login_button, self._cancel_button):
|
||||||
|
try:
|
||||||
|
widget.configure(takefocus=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self.bind("<Return>", lambda _e: self._on_login())
|
self.bind("<Return>", lambda _e: self._on_login())
|
||||||
self.bind("<Escape>", lambda _e: self._on_cancel())
|
self.bind("<Escape>", lambda _e: self._on_cancel())
|
||||||
|
|||||||
17
main.py
17
main.py
@@ -31,8 +31,10 @@ from search_pallets import open_search_window
|
|||||||
from storico_pickinglist import open_storico_pickinglist_window
|
from storico_pickinglist import open_storico_pickinglist_window
|
||||||
from storico_udc import open_storico_udc_window
|
from storico_udc import open_storico_udc_window
|
||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
|
from udc_non_scaffalate import open_udc_non_scaffalate_window
|
||||||
from ui_theme import theme_font, theme_section, theme_value
|
from ui_theme import theme_font, theme_section, theme_value
|
||||||
from user_session import UserSession, create_user_session
|
from user_session import UserSession, create_user_session
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from view_celle_multi_udc import open_celle_multiple_window
|
from view_celle_multi_udc import open_celle_multiple_window
|
||||||
from window_placement import (
|
from window_placement import (
|
||||||
cascade_children_below_parent,
|
cascade_children_below_parent,
|
||||||
@@ -42,7 +44,8 @@ from window_placement import (
|
|||||||
|
|
||||||
# Development shortcut: skip the login dialog and boot directly as MAG1.
|
# Development shortcut: skip the login dialog and boot directly as MAG1.
|
||||||
# Set to False when you want to restore normal authentication.
|
# Set to False when you want to restore normal authentication.
|
||||||
BYPASS_LOGIN = False
|
BYPASS_LOGIN = True
|
||||||
|
__version__ = module_version(__name__)
|
||||||
BYPASS_LOGIN_USER = {
|
BYPASS_LOGIN_USER = {
|
||||||
"operator_id": 4,
|
"operator_id": 4,
|
||||||
"login": "MAG1",
|
"login": "MAG1",
|
||||||
@@ -153,6 +156,7 @@ class Launcher(ctk.CTk):
|
|||||||
"reset_corsie",
|
"reset_corsie",
|
||||||
"layout",
|
"layout",
|
||||||
"multi_udc",
|
"multi_udc",
|
||||||
|
"non_shelved",
|
||||||
"search",
|
"search",
|
||||||
"storico_udc",
|
"storico_udc",
|
||||||
"pickinglist",
|
"pickinglist",
|
||||||
@@ -176,7 +180,7 @@ class Launcher(ctk.CTk):
|
|||||||
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
|
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
|
||||||
)
|
)
|
||||||
self.title(
|
self.title(
|
||||||
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}"
|
f"{versioned_title(loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse'), __name__)} - {self.session.display_name}"
|
||||||
)
|
)
|
||||||
self._apply_dynamic_geometry()
|
self._apply_dynamic_geometry()
|
||||||
|
|
||||||
@@ -219,6 +223,15 @@ class Launcher(ctk.CTk):
|
|||||||
lambda: open_celle_multiple_window(self, self.db_client, session=self.session),
|
lambda: open_celle_multiple_window(self, self.db_client, session=self.session),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"non_shelved",
|
||||||
|
loc_text("launcher.non_shelved", catalog=self._locale_catalog, default="UDC non scaffalate"),
|
||||||
|
"launcher.open_non_shelved",
|
||||||
|
lambda: self._open_or_focus_child_window(
|
||||||
|
"non_shelved",
|
||||||
|
lambda: open_udc_non_scaffalate_window(self, self.db_client, session=self.session),
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"search",
|
"search",
|
||||||
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),
|
loc_text("launcher.search", catalog=self._locale_catalog, default="Ricerca UDC"),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from functools import wraps
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
|
except Exception: # pragma: no cover - safety fallback if dependency is missing locally
|
||||||
@@ -258,6 +262,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
|
|||||||
"""Execute the Python-specific picking-list reservation stored procedure."""
|
"""Execute the Python-specific picking-list reservation stored procedure."""
|
||||||
try:
|
try:
|
||||||
azione = str(Azione or "P").strip().upper()
|
azione = str(Azione or "P").strip().upper()
|
||||||
|
# The toolbar uses two explicit commands rather than a toggle:
|
||||||
|
# P = reserve the selected document, S = release it if it is active.
|
||||||
if azione not in ("P", "S"):
|
if azione not in ("P", "S"):
|
||||||
return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None)
|
return SPResult(rc=-10, message=f"Azione non valida: {Azione}", id_result=None)
|
||||||
_MODULE_LOGGER.log(
|
_MODULE_LOGGER.log(
|
||||||
@@ -279,6 +285,8 @@ async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str, A
|
|||||||
_log_sql("py_sp_xExePackingListPallet", sql, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione})
|
_log_sql("py_sp_xExePackingListPallet", sql, {"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione})
|
||||||
if not hasattr(db, "query_json"):
|
if not hasattr(db, "query_json"):
|
||||||
raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.")
|
raise RuntimeError("Il client DB non espone query_json necessario per eseguire la stored procedure.")
|
||||||
|
# commit=True is required because the stored procedure updates
|
||||||
|
# PyPickingListReservation and Celle.IDStato.
|
||||||
res = await db.query_json(
|
res = await db.query_json(
|
||||||
sql,
|
sql,
|
||||||
{"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione},
|
{"IDOperatore": IDOperatore, "Documento": Documento, "Azione": azione},
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ from gestione_aree import AsyncRunner
|
|||||||
from gestione_scarico import move_pallet_async
|
from gestione_scarico import move_pallet_async
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
from ui_theme import theme_color, theme_font, theme_padding, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import style_treeview, zebra_tag
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
||||||
@@ -275,7 +279,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._theme = theme_section("reset_corsie", {})
|
self._theme = theme_section("reset_corsie", {})
|
||||||
self._locale_catalog = load_locale_catalog()
|
self._locale_catalog = load_locale_catalog()
|
||||||
self.title(loc_text("reset.title", catalog=self._locale_catalog, default="Gestione Corsie - svuotamento celle per corsia"))
|
self.title(versioned_title(loc_text("reset.title", catalog=self._locale_catalog, default="Gestione Corsie - svuotamento celle per corsia"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1000x680")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [880, 560])
|
minsize = theme_value(self._theme, "window_minsize", [880, 560])
|
||||||
self.minsize(int(minsize[0]), int(minsize[1]))
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
@@ -298,35 +302,6 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
|||||||
def _setup_tree_style(self):
|
def _setup_tree_style(self):
|
||||||
"""Apply a denser, spreadsheet-like style to the main result grid."""
|
"""Apply a denser, spreadsheet-like style to the main result grid."""
|
||||||
|
|
||||||
style = ttk.Style(self)
|
|
||||||
style.configure(
|
|
||||||
"ResetCorsie.Treeview.Heading",
|
|
||||||
font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
|
|
||||||
background=theme_value(self._theme, "tree_heading_bg", "#b8c7db"),
|
|
||||||
foreground=theme_value(self._theme, "tree_heading_fg", "#10243e"),
|
|
||||||
relief="flat",
|
|
||||||
padding=theme_padding(self._theme, "tree_heading_padding", (8, 6)),
|
|
||||||
)
|
|
||||||
style.map(
|
|
||||||
"ResetCorsie.Treeview.Heading",
|
|
||||||
background=[("active", theme_value(self._theme, "tree_heading_bg_active", "#aebfd6"))],
|
|
||||||
relief=[("pressed", "groove"), ("!pressed", "flat")],
|
|
||||||
)
|
|
||||||
style.configure(
|
|
||||||
"ResetCorsie.Treeview",
|
|
||||||
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
|
|
||||||
rowheight=int(theme_value(self._theme, "tree_row_height", 30)),
|
|
||||||
background=theme_value(self._theme, "tree_body_bg", "#ffffff"),
|
|
||||||
fieldbackground=theme_value(self._theme, "tree_body_bg", "#ffffff"),
|
|
||||||
foreground=theme_value(self._theme, "tree_body_fg", "#1f1f1f"),
|
|
||||||
borderwidth=0,
|
|
||||||
)
|
|
||||||
style.map(
|
|
||||||
"ResetCorsie.Treeview",
|
|
||||||
background=[("selected", theme_value(self._theme, "tree_selected_bg", "#cfe4ff"))],
|
|
||||||
foreground=[("selected", theme_value(self._theme, "tree_selected_fg", "#10243e"))],
|
|
||||||
)
|
|
||||||
|
|
||||||
@_log_call()
|
@_log_call()
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
"""Create selectors, summary widgets and the occupied-cell grid."""
|
"""Create selectors, summary widgets and the occupied-cell grid."""
|
||||||
@@ -411,8 +386,13 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
|||||||
width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)),
|
width=int(theme_value(self._theme, "tree_col_num_udc_width", 160)),
|
||||||
anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")),
|
anchor=str(theme_value(self._theme, "tree_col_num_udc_anchor", "center")),
|
||||||
)
|
)
|
||||||
self.tree.tag_configure("odd", background=theme_value(self._theme, "tree_row_odd_bg", "#ffffff"))
|
style_treeview(
|
||||||
self.tree.tag_configure("even", background=theme_value(self._theme, "tree_row_even_bg", "#f3f6fb"))
|
self.tree,
|
||||||
|
style_name="ResetCorsie.Treeview",
|
||||||
|
rowheight=int(theme_value(self._theme, "tree_row_height", 30)),
|
||||||
|
font=theme_font(self._theme, "tree_body_font", ("Segoe UI", 10)),
|
||||||
|
heading_font=theme_font(self._theme, "tree_heading_font", ("Segoe UI", 10, "bold")),
|
||||||
|
)
|
||||||
|
|
||||||
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
|
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
|
||||||
sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
|
sx = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
|
||||||
@@ -529,7 +509,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
|
|||||||
for item in self.tree.get_children():
|
for item in self.tree.get_children():
|
||||||
self.tree.delete(item)
|
self.tree.delete(item)
|
||||||
for idx, (_idc, ubi, n) in enumerate(det_rows):
|
for idx, (_idc, ubi, n) in enumerate(det_rows):
|
||||||
tag = "even" if idx % 2 else "odd"
|
tag = zebra_tag(idx)
|
||||||
self.tree.insert("", "end", values=(ubi, n), tags=(tag,))
|
self.tree.insert("", "end", values=(ubi, n), tags=(tag,))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}")
|
_MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}")
|
||||||
|
|||||||
@@ -3,28 +3,47 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
|
FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
|
||||||
|
TEMP_DIR = Path(tempfile.gettempdir())
|
||||||
_STDIO_HANDLES = []
|
_STDIO_HANDLES = []
|
||||||
|
_EXCEPTION_LOGGING_CONFIGURED: set[str] = set()
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def _open_log_file(path: Path):
|
||||||
|
"""Open a log path, falling back to the user temp directory when needed."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path.open("a", encoding="utf-8", buffering=1)
|
||||||
|
except Exception:
|
||||||
|
fallback = TEMP_DIR / path.name
|
||||||
|
fallback.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return fallback.open("a", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
|
||||||
def ensure_stdio(app_name: str) -> None:
|
def ensure_stdio(app_name: str) -> None:
|
||||||
"""Give ``pythonw`` a real stdout/stderr target before loggers are imported."""
|
"""Give ``pythonw`` a real stdout/stderr target before loggers are imported."""
|
||||||
|
|
||||||
stamp = datetime.now().strftime("%Y%m%d")
|
stamp = datetime.now().strftime("%Y%m%d")
|
||||||
if sys.stdout is None:
|
if sys.stdout is None:
|
||||||
handle = (BASE_DIR / f"{app_name}_stdout_{stamp}.log").open("a", encoding="utf-8", buffering=1)
|
handle = _open_log_file(BASE_DIR / f"{app_name}_stdout_{stamp}.log")
|
||||||
_STDIO_HANDLES.append(handle)
|
_STDIO_HANDLES.append(handle)
|
||||||
sys.stdout = handle
|
sys.stdout = handle
|
||||||
if sys.stderr is None:
|
if sys.stderr is None:
|
||||||
handle = (BASE_DIR / f"{app_name}_stderr_{stamp}.log").open("a", encoding="utf-8", buffering=1)
|
handle = _open_log_file(BASE_DIR / f"{app_name}_stderr_{stamp}.log")
|
||||||
_STDIO_HANDLES.append(handle)
|
_STDIO_HANDLES.append(handle)
|
||||||
sys.stderr = handle
|
sys.stderr = handle
|
||||||
|
|
||||||
@@ -32,12 +51,100 @@ def ensure_stdio(app_name: str) -> None:
|
|||||||
def log_fatal(app_name: str, exc: BaseException) -> None:
|
def log_fatal(app_name: str, exc: BaseException) -> None:
|
||||||
"""Write one startup/runtime crash to a persistent log file."""
|
"""Write one startup/runtime crash to a persistent log file."""
|
||||||
|
|
||||||
with FATAL_LOG.open("a", encoding="utf-8") as handle:
|
with _open_log_file(FATAL_LOG) as handle:
|
||||||
handle.write("\n" + "=" * 80 + "\n")
|
handle.write("\n" + "=" * 80 + "\n")
|
||||||
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}\n")
|
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}\n")
|
||||||
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
||||||
|
|
||||||
|
|
||||||
|
def log_exception(app_name: str, exc: BaseException, *, context: str = "") -> None:
|
||||||
|
"""Write a handled or callback exception to the persistent fatal log."""
|
||||||
|
|
||||||
|
with _open_log_file(FATAL_LOG) as handle:
|
||||||
|
handle.write("\n" + "=" * 80 + "\n")
|
||||||
|
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {app_name}")
|
||||||
|
if context:
|
||||||
|
handle.write(f" | {context}")
|
||||||
|
handle.write("\n")
|
||||||
|
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
||||||
|
|
||||||
|
|
||||||
|
def configure_exception_logging(app_name: str, root=None, loop=None) -> None:
|
||||||
|
"""Install process, Tk and asyncio exception hooks for console-less apps."""
|
||||||
|
|
||||||
|
if app_name in _EXCEPTION_LOGGING_CONFIGURED:
|
||||||
|
return
|
||||||
|
_EXCEPTION_LOGGING_CONFIGURED.add(app_name)
|
||||||
|
|
||||||
|
previous_excepthook = sys.excepthook
|
||||||
|
|
||||||
|
def _sys_excepthook(exc_type, exc, tb):
|
||||||
|
log_exception(app_name, exc, context="sys.excepthook")
|
||||||
|
if previous_excepthook:
|
||||||
|
previous_excepthook(exc_type, exc, tb)
|
||||||
|
|
||||||
|
sys.excepthook = _sys_excepthook
|
||||||
|
|
||||||
|
if hasattr(threading, "excepthook"):
|
||||||
|
previous_threading_excepthook = threading.excepthook
|
||||||
|
|
||||||
|
def _threading_excepthook(args):
|
||||||
|
log_exception(app_name, args.exc_value, context=f"thread={getattr(args.thread, 'name', '')}")
|
||||||
|
previous_threading_excepthook(args)
|
||||||
|
|
||||||
|
threading.excepthook = _threading_excepthook
|
||||||
|
|
||||||
|
if root is not None:
|
||||||
|
def _tk_exception(exc_type, exc, tb):
|
||||||
|
log_exception(app_name, exc, context="tkinter callback")
|
||||||
|
try:
|
||||||
|
import tkinter.messagebox as messagebox
|
||||||
|
|
||||||
|
messagebox.showerror(
|
||||||
|
app_name,
|
||||||
|
"Errore applicativo registrato nei log.\n\n"
|
||||||
|
"Se l'operazione era in corso, ripeterla o avvisare il responsabile.",
|
||||||
|
parent=root,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
root.report_callback_exception = _tk_exception
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if loop is not None:
|
||||||
|
previous_loop_handler = None
|
||||||
|
try:
|
||||||
|
previous_loop_handler = loop.get_exception_handler()
|
||||||
|
except Exception:
|
||||||
|
previous_loop_handler = None
|
||||||
|
|
||||||
|
def _loop_exception_handler(active_loop, context):
|
||||||
|
exc = context.get("exception")
|
||||||
|
if exc is not None:
|
||||||
|
log_exception(app_name, exc, context=f"asyncio: {context.get('message', '')}")
|
||||||
|
else:
|
||||||
|
log_runtime_event(app_name, f"ASYNCIO {context!r}")
|
||||||
|
if previous_loop_handler is not None:
|
||||||
|
previous_loop_handler(active_loop, context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.set_exception_handler(_loop_exception_handler)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def log_runtime_event(app_name: str, message: str) -> None:
|
||||||
|
"""Write a lightweight launch/shutdown trace for console-less starts."""
|
||||||
|
|
||||||
|
safe_name = "".join(ch if ch.isalnum() or ch in ("_", "-") else "_" for ch in app_name.lower())
|
||||||
|
path = BASE_DIR / f"{safe_name}_launch.log"
|
||||||
|
with _open_log_file(path) as handle:
|
||||||
|
handle.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {message}\n")
|
||||||
|
|
||||||
|
|
||||||
def show_fatal_message(app_name: str, exc: BaseException) -> None:
|
def show_fatal_message(app_name: str, exc: BaseException) -> None:
|
||||||
"""Show a best-effort message box for console-less launches."""
|
"""Show a best-effort message box for console-less launches."""
|
||||||
|
|
||||||
@@ -49,7 +156,10 @@ def show_fatal_message(app_name: str, exc: BaseException) -> None:
|
|||||||
root.withdraw()
|
root.withdraw()
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
app_name,
|
app_name,
|
||||||
f"Avvio non riuscito.\n\nDettaglio salvato in:\n{FATAL_LOG}\n\n{exc}",
|
"Avvio non riuscito.\n\n"
|
||||||
|
f"Controlla i log in:\n{BASE_DIR}\n\n"
|
||||||
|
f"Se non ci sono, controlla anche:\n{TEMP_DIR}\n\n"
|
||||||
|
f"{exc}",
|
||||||
parent=root,
|
parent=root,
|
||||||
)
|
)
|
||||||
root.destroy()
|
root.destroy()
|
||||||
@@ -61,8 +171,15 @@ def run_with_fatal_log(app_name: str, func: Callable[[], T]) -> T:
|
|||||||
"""Run an app entry point and persist otherwise invisible ``pythonw`` crashes."""
|
"""Run an app entry point and persist otherwise invisible ``pythonw`` crashes."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return func()
|
log_runtime_event(
|
||||||
|
app_name,
|
||||||
|
f"START exe={sys.executable!r} version={sys.version.split()[0]} cwd={Path.cwd()} base={BASE_DIR} argv={sys.argv!r}",
|
||||||
|
)
|
||||||
|
result = func()
|
||||||
|
log_runtime_event(app_name, f"RETURN result={result!r}")
|
||||||
|
return result
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
|
log_runtime_event(app_name, f"EXCEPTION type={type(exc).__name__} value={exc!r}")
|
||||||
log_fatal(app_name, exc)
|
log_fatal(app_name, exc)
|
||||||
show_fatal_message(app_name, exc)
|
show_fatal_message(app_name, exc)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ from busy_overlay import InlineBusyOverlay
|
|||||||
from gestione_aree import AsyncRunner
|
from gestione_aree import AsyncRunner
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import (
|
||||||
|
apply_tksheet_visual_style,
|
||||||
|
apply_tksheet_zebra,
|
||||||
|
merge_tags,
|
||||||
|
style_treeview,
|
||||||
|
zebra_tag,
|
||||||
|
)
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Alignment, Font
|
from openpyxl.styles import Alignment, Font
|
||||||
@@ -85,7 +95,7 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
self._theme = theme_section("search_window", {})
|
self._theme = theme_section("search_window", {})
|
||||||
self._locale_catalog = load_locale_catalog()
|
self._locale_catalog = load_locale_catalog()
|
||||||
self._tooltip_catalog = load_tooltip_catalog()
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
self.title(loc_text("search.title", catalog=self._locale_catalog, default="Ricerca UDC/Lotto/Codice"))
|
self.title(versioned_title(loc_text("search.title", catalog=self._locale_catalog, default="Ricerca UDC/Lotto/Codice"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [900, 560])
|
minsize = theme_value(self._theme, "window_minsize", [900, 560])
|
||||||
self.minsize(int(minsize[0]), int(minsize[1]))
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
@@ -174,17 +184,25 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
self.use_sheet = False
|
self.use_sheet = False
|
||||||
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
|
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
|
||||||
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
|
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
|
||||||
self._style = ttk.Style(self)
|
headings = {
|
||||||
try:
|
"IDCella": ("IDCella", 90, "e"),
|
||||||
self._style.theme_use(self._style.theme_use())
|
"Ubicazione": ("Ubicazione", 150, "w"),
|
||||||
except Exception:
|
"UDC": ("UDC / Barcode", 130, "w"),
|
||||||
pass
|
"Lotto": ("Lotto", 130, "w"),
|
||||||
self._style.configure("Search.Treeview", rowheight=22, font=("", 9))
|
"Codice": ("Codice prodotto", 150, "w"),
|
||||||
self._style.configure("Search.Treeview.Heading", font=("", 9, "bold"), background="#F3F4F6")
|
"Descrizione": ("Descrizione prodotto", 340, "w"),
|
||||||
self._style.map("Search.Treeview", background=[("selected", "#DCEBFF")])
|
}
|
||||||
self.tree.configure(style="Search.Treeview")
|
for col in cols:
|
||||||
self.tree.tag_configure("even", background="#FFFFFF")
|
text, width, anchor = headings[col]
|
||||||
self.tree.tag_configure("odd", background="#F7F9FC")
|
self.tree.heading(col, text=text)
|
||||||
|
self.tree.column(col, width=width, anchor=anchor, stretch=True)
|
||||||
|
self._style = style_treeview(
|
||||||
|
self.tree,
|
||||||
|
style_name="Search.Treeview",
|
||||||
|
rowheight=22,
|
||||||
|
font=("", 9),
|
||||||
|
heading_font=("", 9, "bold"),
|
||||||
|
)
|
||||||
self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020")
|
self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020")
|
||||||
|
|
||||||
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
|
sy = ttk.Scrollbar(wrap, orient="vertical", command=self.tree.yview)
|
||||||
@@ -209,7 +227,7 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
is9999 = int(vals[0]) == 9999
|
is9999 = int(vals[0]) == 9999
|
||||||
except Exception:
|
except Exception:
|
||||||
is9999 = False
|
is9999 = False
|
||||||
tags = ("id9999", zebra) if is9999 else (zebra,)
|
tags = merge_tags(zebra, "id9999" if is9999 else "")
|
||||||
self.tree.item(iid, tags=tags)
|
self.tree.item(iid, tags=tags)
|
||||||
|
|
||||||
def _export_xlsx(self):
|
def _export_xlsx(self):
|
||||||
@@ -414,6 +432,8 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
hdrs = list(headers)
|
hdrs = list(headers)
|
||||||
hdrs[c] = hdrs[c] + arrow
|
hdrs[c] = hdrs[c] + arrow
|
||||||
self.sheet.headers(hdrs)
|
self.sheet.headers(hdrs)
|
||||||
|
apply_tksheet_visual_style(self.sheet)
|
||||||
|
apply_tksheet_zebra(self.sheet, len(data))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -449,7 +469,10 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
|
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
|
||||||
data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v])
|
data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v])
|
||||||
|
self.sheet.headers(["IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione"])
|
||||||
|
apply_tksheet_visual_style(self.sheet)
|
||||||
self.sheet.set_sheet_data(data)
|
self.sheet.set_sheet_data(data)
|
||||||
|
apply_tksheet_zebra(self.sheet, len(data))
|
||||||
self.sheet.set_all_cell_sizes_to_text()
|
self.sheet.set_all_cell_sizes_to_text()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.use_sheet = False
|
self.use_sheet = False
|
||||||
@@ -458,12 +481,11 @@ class SearchWindow(ctk.CTkToplevel):
|
|||||||
self.tree.delete(iid)
|
self.tree.delete(iid)
|
||||||
for idx, row in enumerate(rows):
|
for idx, row in enumerate(rows):
|
||||||
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
|
idc, ubi, udc_v, lot_v, cod_v, desc_v = row
|
||||||
zebra = "even" if idx % 2 == 0 else "odd"
|
|
||||||
try:
|
try:
|
||||||
is9999 = int(idc) == 9999
|
is9999 = int(idc) == 9999
|
||||||
except Exception:
|
except Exception:
|
||||||
is9999 = False
|
is9999 = False
|
||||||
tags = ("id9999", zebra) if is9999 else (zebra,)
|
tags = merge_tags(zebra_tag(idx), "id9999" if is9999 else "")
|
||||||
self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags)
|
self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Read-only picking-list history window."""
|
"""Picking-list history window and controlled residual-shipment workflow.
|
||||||
|
|
||||||
|
The window is mostly read-only: it summarizes historical picking lists and shows
|
||||||
|
their UDC detail rows. The only write operation is the explicit, user-confirmed
|
||||||
|
"Versa residui in 7G.1.1" action for lists classified as closed with residuals.
|
||||||
|
That action delegates each UDC movement to ``move_pallet_async`` so the same
|
||||||
|
MagazziniPallet trace used by the rest of the WMS is preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,55 +18,88 @@ import customtkinter as ctk
|
|||||||
|
|
||||||
from busy_overlay import InlineBusyOverlay
|
from busy_overlay import InlineBusyOverlay
|
||||||
from gestione_aree import AsyncRunner
|
from gestione_aree import AsyncRunner
|
||||||
|
from gestione_scarico import move_pallet_async
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import merge_tags, style_treeview, zebra_tag
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Master query:
|
||||||
|
# - reads the Python-only historical view, not the legacy C# views;
|
||||||
|
# - groups repeated lot rows by distinct UDC/PalletKey;
|
||||||
|
# - derives the operational status shown in the upper grid.
|
||||||
SQL_STORICO_PL = """
|
SQL_STORICO_PL = """
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT *
|
SELECT
|
||||||
|
*,
|
||||||
|
NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '') AS PalletKey
|
||||||
FROM dbo.py_XMag_ViewPackingListStorico
|
FROM dbo.py_XMag_ViewPackingListStorico
|
||||||
),
|
),
|
||||||
agg AS (
|
pallets AS (
|
||||||
|
SELECT
|
||||||
|
Documento,
|
||||||
|
PalletKey,
|
||||||
|
MAX(CASE WHEN Cella <> 9999 OR Cella IS NULL THEN 1 ELSE 0 END) AS HasResiduo,
|
||||||
|
MAX(CASE WHEN Cella = 9999 THEN 1 ELSE 0 END) AS HasSpedito
|
||||||
|
FROM base
|
||||||
|
WHERE PalletKey IS NOT NULL
|
||||||
|
GROUP BY Documento, PalletKey
|
||||||
|
),
|
||||||
|
meta AS (
|
||||||
SELECT
|
SELECT
|
||||||
Documento,
|
Documento,
|
||||||
MAX(DataDocumento) AS DataDocumento,
|
MAX(DataDocumento) AS DataDocumento,
|
||||||
MAX(StatoDocumento) AS StatoDocumento,
|
MAX(StatoDocumento) AS StatoDocumento,
|
||||||
MAX(NAZIONE) AS NAZIONE,
|
MAX(NAZIONE) AS NAZIONE,
|
||||||
MAX(CodNazione) AS CodNazione,
|
MAX(CodNazione) AS CodNazione,
|
||||||
COUNT(DISTINCT Pallet) AS TotUDC,
|
|
||||||
SUM(CASE WHEN Cella = 9999 THEN 1 ELSE 0 END) AS RigheSpedite,
|
|
||||||
SUM(CASE WHEN Cella <> 9999 OR Cella IS NULL THEN 1 ELSE 0 END) AS RigheResidue,
|
|
||||||
COUNT(*) AS RigheTotali,
|
COUNT(*) AS RigheTotali,
|
||||||
MIN(Ordinamento) AS PrimoOrdine,
|
MIN(Ordinamento) AS PrimoOrdine,
|
||||||
MAX(IDStato) AS IDStato
|
MAX(IDStato) AS IDStato
|
||||||
FROM base
|
FROM base
|
||||||
GROUP BY Documento
|
GROUP BY Documento
|
||||||
|
),
|
||||||
|
agg AS (
|
||||||
|
SELECT
|
||||||
|
Documento,
|
||||||
|
COUNT(*) AS TotUDC,
|
||||||
|
SUM(CASE WHEN p.HasResiduo = 0 AND p.HasSpedito = 1 THEN 1 ELSE 0 END) AS RigheSpedite,
|
||||||
|
SUM(CASE WHEN p.HasResiduo = 1 THEN 1 ELSE 0 END) AS RigheResidue
|
||||||
|
FROM pallets p
|
||||||
|
GROUP BY Documento
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
Documento,
|
m.Documento,
|
||||||
DataDocumento,
|
m.DataDocumento,
|
||||||
StatoDocumento,
|
m.StatoDocumento,
|
||||||
NAZIONE,
|
m.NAZIONE,
|
||||||
CodNazione,
|
m.CodNazione,
|
||||||
TotUDC,
|
COALESCE(a.TotUDC, 0) AS TotUDC,
|
||||||
RigheResidue,
|
COALESCE(a.RigheResidue, 0) AS RigheResidue,
|
||||||
RigheSpedite,
|
COALESCE(a.RigheSpedite, 0) AS RigheSpedite,
|
||||||
RigheTotali,
|
m.RigheTotali,
|
||||||
CASE
|
CASE
|
||||||
WHEN StatoDocumento = 'D' THEN 'Chiusa'
|
WHEN m.StatoDocumento = 'D' AND COALESCE(a.RigheResidue, 0) > 0 THEN 'Chiusa ERP con residui'
|
||||||
WHEN RigheResidue = 0 THEN 'Esaurita'
|
WHEN m.StatoDocumento = 'D' THEN 'Chiusa'
|
||||||
WHEN RigheSpedite > 0 THEN 'In corso'
|
WHEN COALESCE(a.RigheResidue, 0) = 0 THEN 'Esaurita'
|
||||||
|
WHEN COALESCE(a.RigheSpedite, 0) > 0 THEN 'In corso'
|
||||||
ELSE 'Da lavorare'
|
ELSE 'Da lavorare'
|
||||||
END AS StatoOperativo,
|
END AS StatoOperativo,
|
||||||
IDStato,
|
m.IDStato,
|
||||||
PrimoOrdine
|
m.PrimoOrdine
|
||||||
FROM agg
|
FROM meta m
|
||||||
WHERE (:documento IS NULL OR CAST(Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
|
LEFT JOIN agg a ON a.Documento = m.Documento
|
||||||
ORDER BY Documento DESC;
|
WHERE (:documento IS NULL OR CAST(m.Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
|
||||||
|
ORDER BY m.Documento DESC;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Detail query:
|
||||||
|
# - returns the ERP picking-list rows enriched with the current WMS cell;
|
||||||
|
# - may contain multiple rows for the same UDC when the pallet contains more lots;
|
||||||
|
# - is intentionally ordered by warehouse extraction order, then pallet.
|
||||||
SQL_STORICO_PL_DETAILS = """
|
SQL_STORICO_PL_DETAILS = """
|
||||||
SELECT
|
SELECT
|
||||||
Documento,
|
Documento,
|
||||||
@@ -135,8 +175,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
self._async = AsyncRunner(self)
|
self._async = AsyncRunner(self)
|
||||||
self._busy = InlineBusyOverlay(self, self._theme)
|
self._busy = InlineBusyOverlay(self, self._theme)
|
||||||
self.var_documento = tk.StringVar()
|
self.var_documento = tk.StringVar()
|
||||||
|
self._selected_documento: str | None = None
|
||||||
|
self._selected_stato_operativo: str = ""
|
||||||
|
self._detail_rows: list[dict[str, Any]] = []
|
||||||
|
|
||||||
self.title(loc_text("history.picking.title", catalog=self._locale_catalog, default="Storico Picking List"))
|
self.title(versioned_title(loc_text("history.picking.title", catalog=self._locale_catalog, default="Storico Picking List"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x720")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1200x720")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [980, 560])
|
minsize = theme_value(self._theme, "window_minsize", [980, 560])
|
||||||
self.minsize(int(minsize[0]), int(minsize[1]))
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
@@ -158,7 +201,7 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
|
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
|
||||||
)
|
)
|
||||||
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
|
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
|
||||||
top.grid_columnconfigure(3, weight=1)
|
top.grid_columnconfigure(4, weight=1)
|
||||||
|
|
||||||
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
|
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
|
||||||
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
|
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
|
||||||
@@ -176,6 +219,18 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
).grid(
|
).grid(
|
||||||
row=0, column=2, sticky="w"
|
row=0, column=2, sticky="w"
|
||||||
)
|
)
|
||||||
|
self.btn_ship_residuals = ctk.CTkButton(
|
||||||
|
top,
|
||||||
|
text=loc_text(
|
||||||
|
"history.picking.button.ship_residuals",
|
||||||
|
catalog=self._locale_catalog,
|
||||||
|
default="Versa residui in 7G.1.1",
|
||||||
|
),
|
||||||
|
command=self._ship_selected_residuals,
|
||||||
|
state="disabled",
|
||||||
|
font=button_font,
|
||||||
|
)
|
||||||
|
self.btn_ship_residuals.grid(row=0, column=3, sticky="w", padx=(12, 0))
|
||||||
|
|
||||||
self.master_tree = self._make_tree(
|
self.master_tree = self._make_tree(
|
||||||
row=1,
|
row=1,
|
||||||
@@ -228,8 +283,10 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
for col in columns:
|
for col in columns:
|
||||||
tree.heading(col, text=col)
|
tree.heading(col, text=col)
|
||||||
tree.column(col, width=widths.get(col, 100), anchor="w")
|
tree.column(col, width=widths.get(col, 100), anchor="w")
|
||||||
|
style_treeview(tree, style_name="HistoryPicking.Treeview", rowheight=24)
|
||||||
tree.tag_configure("done", background="#ECECEC")
|
tree.tag_configure("done", background="#ECECEC")
|
||||||
tree.tag_configure("active", background="#EAF7EA")
|
tree.tag_configure("active", background="#EAF7EA")
|
||||||
|
tree.tag_configure("warning", background="#FFE3B3")
|
||||||
sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview)
|
sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview)
|
||||||
sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
|
sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
|
||||||
tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
|
tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
|
||||||
@@ -240,12 +297,15 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
|
|
||||||
def _load_master(self) -> None:
|
def _load_master(self) -> None:
|
||||||
params = {"documento": str(self.var_documento.get() or "").strip() or None}
|
params = {"documento": str(self.var_documento.get() or "").strip() or None}
|
||||||
|
previous_documento = self._selected_documento
|
||||||
|
|
||||||
async def _job():
|
async def _job():
|
||||||
return await self.db_client.query_json(SQL_STORICO_PL, params)
|
return await self.db_client.query_json(SQL_STORICO_PL, params)
|
||||||
|
|
||||||
def _ok(res):
|
def _ok(res):
|
||||||
self._fill_master(_rows_to_dicts(res))
|
self._fill_master(_rows_to_dicts(res))
|
||||||
|
if previous_documento:
|
||||||
|
self._restore_master_selection(previous_documento)
|
||||||
|
|
||||||
def _err(ex):
|
def _err(ex):
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
@@ -273,9 +333,25 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
def _fill_master(self, rows: list[dict[str, Any]]) -> None:
|
def _fill_master(self, rows: list[dict[str, Any]]) -> None:
|
||||||
self.master_tree.delete(*self.master_tree.get_children(""))
|
self.master_tree.delete(*self.master_tree.get_children(""))
|
||||||
self.detail_tree.delete(*self.detail_tree.get_children(""))
|
self.detail_tree.delete(*self.detail_tree.get_children(""))
|
||||||
|
self._selected_documento = None
|
||||||
|
self._selected_stato_operativo = ""
|
||||||
|
self._detail_rows = []
|
||||||
|
self._update_residual_button()
|
||||||
for index, row in enumerate(rows):
|
for index, row in enumerate(rows):
|
||||||
stato = str(row.get("StatoOperativo") or "")
|
stato = str(row.get("StatoOperativo") or "")
|
||||||
tag = "done" if stato in {"Chiusa", "Esaurita"} else "active" if int(row.get("IDStato") or 0) == 1 else ""
|
is_open_with_shipped = (
|
||||||
|
str(row.get("StatoDocumento") or "") == "P"
|
||||||
|
and int(row.get("RigheSpedite") or 0) > 0
|
||||||
|
)
|
||||||
|
tag = (
|
||||||
|
"warning"
|
||||||
|
if is_open_with_shipped
|
||||||
|
else "done"
|
||||||
|
if stato in {"Chiusa", "Esaurita"}
|
||||||
|
else "active"
|
||||||
|
if int(row.get("IDStato") or 0) == 1
|
||||||
|
else ""
|
||||||
|
)
|
||||||
self.master_tree.insert(
|
self.master_tree.insert(
|
||||||
"",
|
"",
|
||||||
"end",
|
"end",
|
||||||
@@ -291,23 +367,32 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
stato,
|
stato,
|
||||||
row.get("IDStato", ""),
|
row.get("IDStato", ""),
|
||||||
),
|
),
|
||||||
tags=(tag,) if tag else (),
|
tags=merge_tags(zebra_tag(index), tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_master_select(self, _event=None) -> None:
|
def _on_master_select(self, _event=None) -> None:
|
||||||
selected = self.master_tree.selection()
|
selected = self.master_tree.selection()
|
||||||
if not selected:
|
if not selected:
|
||||||
|
self._selected_documento = None
|
||||||
|
self._selected_stato_operativo = ""
|
||||||
|
self._detail_rows = []
|
||||||
|
self._update_residual_button()
|
||||||
return
|
return
|
||||||
values = self.master_tree.item(selected[0], "values")
|
values = self.master_tree.item(selected[0], "values")
|
||||||
if not values:
|
if not values:
|
||||||
return
|
return
|
||||||
documento = values[0]
|
documento = values[0]
|
||||||
|
self._selected_documento = str(documento)
|
||||||
|
self._selected_stato_operativo = str(values[7] if len(values) > 7 else "")
|
||||||
|
self._detail_rows = []
|
||||||
|
self._update_residual_button()
|
||||||
|
|
||||||
async def _job():
|
async def _job():
|
||||||
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
|
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
|
||||||
|
|
||||||
def _ok(res):
|
def _ok(res):
|
||||||
self._fill_detail(_rows_to_dicts(res))
|
self._fill_detail(_rows_to_dicts(res))
|
||||||
|
self._update_residual_button()
|
||||||
|
|
||||||
def _err(ex):
|
def _err(ex):
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
@@ -334,8 +419,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
|
|
||||||
def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
|
def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
|
||||||
self.detail_tree.delete(*self.detail_tree.get_children(""))
|
self.detail_tree.delete(*self.detail_tree.get_children(""))
|
||||||
for row in rows:
|
self._detail_rows = rows
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
is_open_shipped = str(row.get("StatoDocumento") or "") == "P" and int(row.get("Cella") or 0) == 9999
|
||||||
done = str(row.get("StatoDocumento") or "") == "D" or int(row.get("Cella") or 0) == 9999
|
done = str(row.get("StatoDocumento") or "") == "D" or int(row.get("Cella") or 0) == 9999
|
||||||
|
tag = "warning" if is_open_shipped else "done" if done else ""
|
||||||
self.detail_tree.insert(
|
self.detail_tree.insert(
|
||||||
"",
|
"",
|
||||||
"end",
|
"end",
|
||||||
@@ -351,9 +439,130 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
|
|||||||
row.get("Ubicazione", ""),
|
row.get("Ubicazione", ""),
|
||||||
row.get("Ordinamento", ""),
|
row.get("Ordinamento", ""),
|
||||||
),
|
),
|
||||||
tags=("done",) if done else (),
|
tags=merge_tags(zebra_tag(index), tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_residual_button(self) -> None:
|
||||||
|
"""Enable the bulk shipment button only for closed picking lists with residual UDCs."""
|
||||||
|
|
||||||
|
enabled = self._selected_stato_operativo == "Chiusa ERP con residui"
|
||||||
|
try:
|
||||||
|
self.btn_ship_residuals.configure(state="normal" if enabled else "disabled")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _restore_master_selection(self, documento: str) -> None:
|
||||||
|
"""Re-select a document after a reload, when it is still visible."""
|
||||||
|
|
||||||
|
for iid in self.master_tree.get_children(""):
|
||||||
|
values = self.master_tree.item(iid, "values")
|
||||||
|
if values and str(values[0]) == str(documento):
|
||||||
|
self.master_tree.selection_set(iid)
|
||||||
|
self.master_tree.focus(iid)
|
||||||
|
self.master_tree.see(iid)
|
||||||
|
self._on_master_select()
|
||||||
|
return
|
||||||
|
|
||||||
|
def _residual_pallets_from_rows(self, rows: list[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Return distinct residual UDCs that are not already in 7G.1.1."""
|
||||||
|
|
||||||
|
pallets: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
pallet = str(row.get("Pallet") or "").strip()
|
||||||
|
# One UDC can appear on multiple detail rows when it contains
|
||||||
|
# multiple lots; movement must be executed once per physical UDC.
|
||||||
|
if not pallet or pallet in seen:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cella = int(row.get("Cella") or 0)
|
||||||
|
except Exception:
|
||||||
|
cella = 0
|
||||||
|
# IDCella 9999 is the conventional 7G.1.1 shipment cell. Rows
|
||||||
|
# already there are complete and must not receive another movement.
|
||||||
|
if cella == 9999:
|
||||||
|
continue
|
||||||
|
seen.add(pallet)
|
||||||
|
pallets.append(pallet)
|
||||||
|
return pallets
|
||||||
|
|
||||||
|
def _operator_login(self) -> str:
|
||||||
|
"""Return the user recorded on generated warehouse movements."""
|
||||||
|
|
||||||
|
login = str(getattr(self.session, "login", "") or "").strip()
|
||||||
|
return login or "warehouse_ui"
|
||||||
|
|
||||||
|
def _ship_selected_residuals(self) -> None:
|
||||||
|
"""Move all residual UDCs of the selected closed PL to the shipment cell 7G.1.1."""
|
||||||
|
|
||||||
|
if self._selected_stato_operativo != "Chiusa ERP con residui" or not self._selected_documento:
|
||||||
|
return
|
||||||
|
estimated = self._residual_pallets_from_rows(self._detail_rows)
|
||||||
|
count_text = str(len(estimated)) if estimated else "le"
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
|
||||||
|
(
|
||||||
|
f"Documento {self._selected_documento}\n\n"
|
||||||
|
f"Verranno versate in 7G.1.1 {count_text} UDC residue della picking list chiusa.\n"
|
||||||
|
"L'operazione registra i movimenti nello storico UDC.\n\n"
|
||||||
|
"Procedere?"
|
||||||
|
),
|
||||||
|
parent=self,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
documento = self._selected_documento
|
||||||
|
utente = self._operator_login()
|
||||||
|
|
||||||
|
async def _job():
|
||||||
|
# Re-read the detail inside the async job to avoid moving stale UI
|
||||||
|
# rows if another operator or barcode client changed the list after
|
||||||
|
# the user selected it.
|
||||||
|
detail_res = await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
|
||||||
|
detail_rows = _rows_to_dicts(detail_res)
|
||||||
|
pallets = self._residual_pallets_from_rows(detail_rows)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for pallet in pallets:
|
||||||
|
# Reuse the central movement primitive: it closes the current
|
||||||
|
# source V row with a P movement and inserts a new V on 9999,
|
||||||
|
# preserving the historical trail required by Storico UDC.
|
||||||
|
result = await move_pallet_async(
|
||||||
|
self.db_client,
|
||||||
|
barcode_pallet=pallet,
|
||||||
|
target_idcella=9999,
|
||||||
|
target_barcode_cella="9000000",
|
||||||
|
utente=utente,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
return {"pallets": pallets, "results": results}
|
||||||
|
|
||||||
|
def _ok(res):
|
||||||
|
pallets = res.get("pallets", []) if isinstance(res, dict) else []
|
||||||
|
results = res.get("results", []) if isinstance(res, dict) else []
|
||||||
|
moved = sum(1 for row in results if int(row.get("ok") or 0) == 1)
|
||||||
|
messagebox.showinfo(
|
||||||
|
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
|
||||||
|
f"Documento {documento}\nUDC residue trovate: {len(pallets)}\nUDC versate in 7G.1.1: {moved}",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
self._selected_documento = str(documento)
|
||||||
|
self._load_master()
|
||||||
|
|
||||||
|
def _err(ex):
|
||||||
|
messagebox.showerror(
|
||||||
|
loc_text("history.picking.msg.title", catalog=self._locale_catalog, default="Storico Picking List"),
|
||||||
|
f"Versamento residui fallito:\n{ex}",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._async.run(
|
||||||
|
_job(),
|
||||||
|
_ok,
|
||||||
|
_err,
|
||||||
|
busy=self._busy,
|
||||||
|
message=f"Verso residui PL {documento} in 7G.1.1...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
|
def open_storico_pickinglist_window(parent: tk.Misc, db_client, session=None) -> tk.Misc:
|
||||||
"""Open the picking-list history window."""
|
"""Open the picking-list history window."""
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ from busy_overlay import InlineBusyOverlay
|
|||||||
from gestione_aree import AsyncRunner
|
from gestione_aree import AsyncRunner
|
||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import merge_tags, style_treeview, zebra_tag
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
|
|
||||||
SQL_STORICO_UDC = """
|
SQL_STORICO_UDC = """
|
||||||
WITH direct AS (
|
WITH direct AS (
|
||||||
@@ -155,7 +159,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
|
|||||||
self._busy = InlineBusyOverlay(self, self._theme)
|
self._busy = InlineBusyOverlay(self, self._theme)
|
||||||
self.var_udc = tk.StringVar(value=str(initial_udc or ""))
|
self.var_udc = tk.StringVar(value=str(initial_udc or ""))
|
||||||
|
|
||||||
self.title(loc_text("history.udc.title", catalog=self._locale_catalog, default="Storico movimenti UDC"))
|
self.title(versioned_title(loc_text("history.udc.title", catalog=self._locale_catalog, default="Storico movimenti UDC"), __name__))
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x720")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [900, 560])
|
minsize = theme_value(self._theme, "window_minsize", [900, 560])
|
||||||
self.minsize(int(minsize[0]), int(minsize[1]))
|
self.minsize(int(minsize[0]), int(minsize[1]))
|
||||||
@@ -218,6 +222,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
|
|||||||
for col in cols:
|
for col in cols:
|
||||||
self.tree.heading(col, text=col)
|
self.tree.heading(col, text=col)
|
||||||
self.tree.column(col, width=90, anchor="w")
|
self.tree.column(col, width=90, anchor="w")
|
||||||
|
style_treeview(self.tree, style_name="HistoryUDC.Treeview", rowheight=24)
|
||||||
self.tree.column("ID", width=70, anchor="e")
|
self.tree.column("ID", width=70, anchor="e")
|
||||||
self.tree.column("Tipo", width=55, anchor="center")
|
self.tree.column("Tipo", width=55, anchor="center")
|
||||||
self.tree.column("Rif", width=70, anchor="e")
|
self.tree.column("Rif", width=70, anchor="e")
|
||||||
@@ -280,7 +285,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
|
|||||||
value = row.get(name, "")
|
value = row.get(name, "")
|
||||||
return "" if value is None else value
|
return "" if value is None else value
|
||||||
|
|
||||||
for row in rows:
|
for index, row in enumerate(rows):
|
||||||
tipo = str(row.get("Tipo") or "")
|
tipo = str(row.get("Tipo") or "")
|
||||||
tag = "versamento" if tipo == "V" else "prelievo" if tipo == "P" else "spedita" if tipo == "SPED" else ""
|
tag = "versamento" if tipo == "V" else "prelievo" if tipo == "P" else "spedita" if tipo == "SPED" else ""
|
||||||
self.tree.insert(
|
self.tree.insert(
|
||||||
@@ -299,7 +304,7 @@ class StoricoUDCWindow(ctk.CTkToplevel):
|
|||||||
_value(row, "ModUtente"),
|
_value(row, "ModUtente"),
|
||||||
_value(row, "ModDataOra"),
|
_value(row, "ModDataOra"),
|
||||||
),
|
),
|
||||||
tags=(tag,) if tag else (),
|
tags=merge_tags(zebra_tag(index), tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
_TOOLTIP_FILE = Path(__file__).with_name("tooltip.json")
|
_TOOLTIP_FILE = Path(__file__).with_name("tooltip.json")
|
||||||
|
|
||||||
|
|||||||
187
udc_non_scaffalate.py
Normal file
187
udc_non_scaffalate.py
Normal file
@@ -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
|
||||||
108
ui_tables.py
Normal file
108
ui_tables.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Shared visual helpers for data grids."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tkinter import ttk
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
TABLE_ROW_EVEN = "#FFFFFF"
|
||||||
|
TABLE_ROW_ODD = "#F4F6F8"
|
||||||
|
TABLE_HEADER_BG = "#D1D5DB"
|
||||||
|
TABLE_HEADER_FG = "#111827"
|
||||||
|
TABLE_SELECTED_BG = "#DCEBFF"
|
||||||
|
TABLE_SELECTED_FG = "#111827"
|
||||||
|
|
||||||
|
|
||||||
|
def style_treeview(
|
||||||
|
tree: ttk.Treeview,
|
||||||
|
*,
|
||||||
|
style_name: str,
|
||||||
|
rowheight: int = 24,
|
||||||
|
font: Any = ("Segoe UI", 9),
|
||||||
|
heading_font: Any = ("Segoe UI", 9, "bold"),
|
||||||
|
) -> ttk.Style:
|
||||||
|
"""Apply a consistent high-contrast header and zebra-ready style."""
|
||||||
|
|
||||||
|
style = ttk.Style(tree)
|
||||||
|
style.configure(style_name, rowheight=rowheight, font=font)
|
||||||
|
style.configure(
|
||||||
|
f"{style_name}.Heading",
|
||||||
|
font=heading_font,
|
||||||
|
background=TABLE_HEADER_BG,
|
||||||
|
foreground=TABLE_HEADER_FG,
|
||||||
|
relief="flat",
|
||||||
|
)
|
||||||
|
style.map(
|
||||||
|
f"{style_name}.Heading",
|
||||||
|
background=[("active", TABLE_HEADER_BG), ("pressed", TABLE_HEADER_BG)],
|
||||||
|
foreground=[("active", TABLE_HEADER_FG), ("pressed", TABLE_HEADER_FG)],
|
||||||
|
)
|
||||||
|
style.map(
|
||||||
|
style_name,
|
||||||
|
background=[("selected", TABLE_SELECTED_BG)],
|
||||||
|
foreground=[("selected", TABLE_SELECTED_FG)],
|
||||||
|
)
|
||||||
|
tree.configure(style=style_name)
|
||||||
|
configure_treeview_zebra_tags(tree)
|
||||||
|
return style
|
||||||
|
|
||||||
|
|
||||||
|
def configure_treeview_zebra_tags(tree: ttk.Treeview) -> None:
|
||||||
|
"""Register alternating row color tags on a Treeview."""
|
||||||
|
|
||||||
|
tree.tag_configure("even", background=TABLE_ROW_EVEN)
|
||||||
|
tree.tag_configure("odd", background=TABLE_ROW_ODD)
|
||||||
|
|
||||||
|
|
||||||
|
def zebra_tag(index: int) -> str:
|
||||||
|
"""Return the alternating row tag for the given zero-based index."""
|
||||||
|
|
||||||
|
return "even" if index % 2 == 0 else "odd"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tags(*tags: str) -> tuple[str, ...]:
|
||||||
|
"""Return non-empty tags preserving order."""
|
||||||
|
|
||||||
|
return tuple(tag for tag in tags if tag)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tksheet_visual_style(sheet: Any) -> None:
|
||||||
|
"""Apply best-effort header contrast and zebra rows to a tksheet widget."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
sheet.set_options(
|
||||||
|
header_bg=TABLE_HEADER_BG,
|
||||||
|
header_fg=TABLE_HEADER_FG,
|
||||||
|
header_selected_cells_bg=TABLE_HEADER_BG,
|
||||||
|
header_selected_cells_fg=TABLE_HEADER_FG,
|
||||||
|
table_bg=TABLE_ROW_EVEN,
|
||||||
|
table_fg="#111827",
|
||||||
|
selected_rows_bg=TABLE_SELECTED_BG,
|
||||||
|
selected_rows_fg=TABLE_SELECTED_FG,
|
||||||
|
selected_cells_bg=TABLE_SELECTED_BG,
|
||||||
|
selected_cells_fg=TABLE_SELECTED_FG,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tksheet_zebra(sheet: Any, row_count: int) -> None:
|
||||||
|
"""Apply alternating row colors to a tksheet widget when supported."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
sheet.dehighlight_rows(redraw=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for row_index in range(row_count):
|
||||||
|
try:
|
||||||
|
sheet.highlight_rows(
|
||||||
|
rows=[row_index],
|
||||||
|
bg=TABLE_ROW_EVEN if row_index % 2 == 0 else TABLE_ROW_ODD,
|
||||||
|
redraw=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
sheet.redraw()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login_window": {
|
"login_window": {
|
||||||
"window_geometry": "165x155+0+0",
|
"window_geometry": "165x170+0+0",
|
||||||
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
|
"overlay_cover_fg_color": ["#d9d9d9", "#4a4a4a"],
|
||||||
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
|
"overlay_panel_fg_color": ["#f2f2f2", "#353535"],
|
||||||
"overlay_panel_corner_radius": 10,
|
"overlay_panel_corner_radius": 10,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
THEME_PATH = Path(__file__).with_name("ui_theme.json")
|
THEME_PATH = Path(__file__).with_name("ui_theme.json")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import FrozenSet
|
from typing import FrozenSet
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
ALL_ACTIONS: FrozenSet[str] = frozenset(
|
ALL_ACTIONS: FrozenSet[str] = frozenset(
|
||||||
{
|
{
|
||||||
@@ -13,6 +16,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
|
|||||||
"launcher.open_layout",
|
"launcher.open_layout",
|
||||||
"launcher.open_multi_udc",
|
"launcher.open_multi_udc",
|
||||||
"launcher.open_search",
|
"launcher.open_search",
|
||||||
|
"launcher.open_non_shelved",
|
||||||
"launcher.open_history_udc",
|
"launcher.open_history_udc",
|
||||||
"launcher.open_pickinglist",
|
"launcher.open_pickinglist",
|
||||||
"launcher.open_history_pickinglist",
|
"launcher.open_history_pickinglist",
|
||||||
@@ -20,6 +24,7 @@ ALL_ACTIONS: FrozenSet[str] = frozenset(
|
|||||||
"launcher.exit",
|
"launcher.exit",
|
||||||
"reset_corsie.view",
|
"reset_corsie.view",
|
||||||
"search.view",
|
"search.view",
|
||||||
|
"non_shelved.view",
|
||||||
"history_udc.view",
|
"history_udc.view",
|
||||||
"history_pickinglist.view",
|
"history_pickinglist.view",
|
||||||
"multi_udc.view",
|
"multi_udc.view",
|
||||||
|
|||||||
73
version_info.py
Normal file
73
version_info.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Application and module version registry for Warehouse/FlyWMS bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
MODULE_VERSIONS: dict[str, str] = {
|
||||||
|
"app": APP_VERSION,
|
||||||
|
"async_loop_singleton": "1.0.0",
|
||||||
|
"async_msssql_query": "1.0.0",
|
||||||
|
"audit_log": "1.0.0",
|
||||||
|
"main": "1.0.1",
|
||||||
|
"barcode_client": "1.0.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",
|
||||||
|
"gestione_layout": "1.0.0",
|
||||||
|
"gestione_pickinglist": "1.0.2",
|
||||||
|
"gestione_scarico": "1.0.0",
|
||||||
|
"locale_text": "1.0.0",
|
||||||
|
"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",
|
||||||
|
"window_placement": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def module_key(module_name: str) -> str:
|
||||||
|
"""Return the stable registry key for a module name or path."""
|
||||||
|
|
||||||
|
name = str(module_name or "").replace("\\", "/")
|
||||||
|
stem = Path(name).stem if "/" in name or "." not in name else name.rsplit(".", 1)[-1]
|
||||||
|
return stem or "app"
|
||||||
|
|
||||||
|
|
||||||
|
def module_version(module_name: str | None = None) -> str:
|
||||||
|
"""Return the version for a module, falling back to the app version."""
|
||||||
|
|
||||||
|
if not module_name:
|
||||||
|
return APP_VERSION
|
||||||
|
return MODULE_VERSIONS.get(module_key(module_name), APP_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
def version_label(module_name: str | None = None) -> str:
|
||||||
|
"""Return the standard visual label used in window titles."""
|
||||||
|
|
||||||
|
return f"ver. {module_version(module_name)}"
|
||||||
|
|
||||||
|
|
||||||
|
def versioned_title(title: str, module_name: str | None = None) -> str:
|
||||||
|
"""Append the standard version label to a window title."""
|
||||||
|
|
||||||
|
clean_title = str(title or "").strip()
|
||||||
|
label = version_label(module_name)
|
||||||
|
if not clean_title:
|
||||||
|
return label
|
||||||
|
if label.lower() in clean_title.lower():
|
||||||
|
return clean_title
|
||||||
|
return f"{clean_title} - {label}"
|
||||||
@@ -22,8 +22,12 @@ from gestione_scarico import move_pallet_async
|
|||||||
from locale_text import load_locale_catalog, text as loc_text
|
from locale_text import load_locale_catalog, text as loc_text
|
||||||
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
|
||||||
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
from ui_theme import theme_color, theme_font, theme_section, theme_value
|
||||||
|
from ui_tables import merge_tags, style_treeview, zebra_tag
|
||||||
|
from version_info import module_version, versioned_title
|
||||||
from window_placement import place_window_fullsize_below_parent_later
|
from window_placement import place_window_fullsize_below_parent_later
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
except Exception: # pragma: no cover - fallback used only when Loguru is not available
|
||||||
@@ -436,7 +440,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
self._theme = theme_section("multi_udc", {})
|
self._theme = theme_section("multi_udc", {})
|
||||||
self._locale_catalog = load_locale_catalog()
|
self._locale_catalog = load_locale_catalog()
|
||||||
self._tooltip_catalog = load_tooltip_catalog()
|
self._tooltip_catalog = load_tooltip_catalog()
|
||||||
self.title(loc_text("multi.title", catalog=self._locale_catalog, default="Celle con piu' pallet"))
|
self.title(versioned_title(loc_text("multi.title", catalog=self._locale_catalog, default="Celle con piu' pallet"), __name__))
|
||||||
self.session = session
|
self.session = session
|
||||||
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700")))
|
self.geometry(str(theme_value(self._theme, "window_geometry", "1100x700")))
|
||||||
minsize = theme_value(self._theme, "window_minsize", [900, 550])
|
minsize = theme_value(self._theme, "window_minsize", [900, 550])
|
||||||
@@ -507,6 +511,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
self.tree.column("col2", width=250, anchor="w")
|
self.tree.column("col2", width=250, anchor="w")
|
||||||
self.tree.column("col3", width=120, anchor="w")
|
self.tree.column("col3", width=120, anchor="w")
|
||||||
self.tree.column("col4", width=260, anchor="w")
|
self.tree.column("col4", width=260, anchor="w")
|
||||||
|
style_treeview(self.tree, style_name="MultiUDC.Treeview", rowheight=24)
|
||||||
y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
|
y = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
|
||||||
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
|
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
|
||||||
self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
|
self.tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
|
||||||
@@ -542,6 +547,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
):
|
):
|
||||||
self.sum_tbl.heading(key, text=title)
|
self.sum_tbl.heading(key, text=title)
|
||||||
self.sum_tbl.column(key, width=width, anchor=anchor)
|
self.sum_tbl.column(key, width=width, anchor=anchor)
|
||||||
|
style_treeview(self.sum_tbl, style_name="MultiUDCSummary.Treeview", rowheight=24)
|
||||||
y2 = ttk.Scrollbar(inner, orient="vertical", command=self.sum_tbl.yview)
|
y2 = ttk.Scrollbar(inner, orient="vertical", command=self.sum_tbl.yview)
|
||||||
x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview)
|
x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview)
|
||||||
self.sum_tbl.configure(yscrollcommand=y2.set, xscrollcommand=x2.set)
|
self.sum_tbl.configure(yscrollcommand=y2.set, xscrollcommand=x2.set)
|
||||||
@@ -660,7 +666,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
"""Populate root tree nodes after the aisle query completes."""
|
"""Populate root tree nodes after the aisle query completes."""
|
||||||
rows = _json_obj(res).get("rows", [])
|
rows = _json_obj(res).get("rows", [])
|
||||||
_log_dataset("multi_udc_corsie", rows)
|
_log_dataset("multi_udc_corsie", rows)
|
||||||
for row in rows:
|
for index, row in enumerate(rows):
|
||||||
corsia = row.get("Corsia")
|
corsia = row.get("Corsia")
|
||||||
if not corsia:
|
if not corsia:
|
||||||
continue
|
continue
|
||||||
@@ -672,7 +678,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
text=self._format_corsia_text(corsia),
|
text=self._format_corsia_text(corsia),
|
||||||
values=("", "", ""),
|
values=("", "", ""),
|
||||||
open=False,
|
open=False,
|
||||||
tags=("corsia",),
|
tags=merge_tags(zebra_tag(index), "corsia"),
|
||||||
)
|
)
|
||||||
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
|
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
|
||||||
|
|
||||||
@@ -724,7 +730,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
if not rows:
|
if not rows:
|
||||||
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", ""))
|
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", ""))
|
||||||
return
|
return
|
||||||
for row in rows:
|
for index, row in enumerate(rows):
|
||||||
idc = row["IDCella"]
|
idc = row["IDCella"]
|
||||||
ubi = row["Ubicazione"]
|
ubi = row["Ubicazione"]
|
||||||
corsia = row.get("Corsia")
|
corsia = row.get("Corsia")
|
||||||
@@ -741,7 +747,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
text=label,
|
text=label,
|
||||||
values=(f"IDCella {idc}", "", ""),
|
values=(f"IDCella {idc}", "", ""),
|
||||||
open=False,
|
open=False,
|
||||||
tags=("cella", f"corsia:{corsia}"),
|
tags=merge_tags(zebra_tag(index), "cella", f"corsia:{corsia}"),
|
||||||
)
|
)
|
||||||
if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)):
|
if not any(child.endswith("::lazy") for child in self.tree.get_children(node_id)):
|
||||||
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
|
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
|
||||||
@@ -781,7 +787,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
idcella_txt = self.tree.item(parent_iid, "values")[0]
|
idcella_txt = self.tree.item(parent_iid, "values")[0]
|
||||||
idcella_num = int(idcella_txt.split()[-1]) if idcella_txt else None
|
idcella_num = int(idcella_txt.split()[-1]) if idcella_txt else None
|
||||||
|
|
||||||
for row in rows:
|
for index, row in enumerate(rows):
|
||||||
pallet = row.get("Pallet", "")
|
pallet = row.get("Pallet", "")
|
||||||
desc = row.get("Descrizione", "")
|
desc = row.get("Descrizione", "")
|
||||||
lotto = row.get("Lotto", "")
|
lotto = row.get("Lotto", "")
|
||||||
@@ -813,7 +819,13 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
iid=leaf_id,
|
iid=leaf_id,
|
||||||
text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys),
|
text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys),
|
||||||
values=(desc, lotto, causale),
|
values=(desc, lotto, causale),
|
||||||
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
|
tags=merge_tags(
|
||||||
|
zebra_tag(index),
|
||||||
|
"pallet",
|
||||||
|
f"corsia:{corsia_val}",
|
||||||
|
f"ubicazione:{cella_ubi}",
|
||||||
|
f"idcella:{idcella_num}",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@_log_call()
|
@_log_call()
|
||||||
@@ -987,11 +999,12 @@ class CelleMultipleWindow(ctk.CTkToplevel):
|
|||||||
_log_dataset("multi_udc_riepilogo", rows)
|
_log_dataset("multi_udc_riepilogo", rows)
|
||||||
for item in self.sum_tbl.get_children():
|
for item in self.sum_tbl.get_children():
|
||||||
self.sum_tbl.delete(item)
|
self.sum_tbl.delete(item)
|
||||||
for row in rows:
|
for index, row in enumerate(rows):
|
||||||
self.sum_tbl.insert(
|
self.sum_tbl.insert(
|
||||||
"",
|
"",
|
||||||
"end",
|
"end",
|
||||||
values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"),
|
values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"),
|
||||||
|
tags=(zebra_tag(index),),
|
||||||
)
|
)
|
||||||
|
|
||||||
def expand_all(self):
|
def expand_all(self):
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
os.chdir(Path(__file__).resolve().parent)
|
||||||
|
|
||||||
from runtime_support import ensure_stdio, run_with_fatal_log
|
from runtime_support import ensure_stdio, run_with_fatal_log
|
||||||
|
|
||||||
ensure_stdio("warehouse_main")
|
ensure_stdio("warehouse_main")
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import math
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from version_info import module_version
|
||||||
|
|
||||||
|
__version__ = module_version(__name__)
|
||||||
|
|
||||||
MODULE_LOG_NAME = "window_placement"
|
MODULE_LOG_NAME = "window_placement"
|
||||||
MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log")
|
MODULE_LOG_PATH = Path(__file__).with_name("window_placement.log")
|
||||||
|
|||||||
Reference in New Issue
Block a user