4 Commits

32 changed files with 776 additions and 144 deletions

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,9 @@ from pathlib import Path
from typing import Any
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")
_LOGGER = logging.getLogger("warehouse_audit")

View File

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

View File

@@ -19,6 +19,9 @@ from barcode_repository import BarcodeRepository
from barcode_service import BarcodeActionResult, BarcodeService, BarcodeViewState
from db_config import build_dsn_from_config, ensure_db_config
from login_window import prompt_login_compact
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
class BarcodeClientApp:
@@ -59,9 +62,11 @@ class BarcodeClientApp:
self._apply_state(self.service.state)
self._bind_keys()
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:
self.root.title("WMS")
self.root.title(versioned_title("WMS", __name__))
self.root.configure(bg="#f1f1f1")
self._apply_responsive_geometry()
@@ -254,6 +259,18 @@ class BarcodeClientApp:
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:
self.root.bind("<F1>", lambda _e: self._start_queue(1))
self.root.bind("<F2>", lambda _e: self._start_queue(0))

View File

@@ -9,6 +9,10 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from version_info import module_version
__version__ = module_version(__name__)
SQL_NEXT_PICKING = """
SELECT TOP (1)

View File

@@ -6,7 +6,9 @@ from dataclasses import dataclass
from typing import Literal
from barcode_repository import BarcodeRepository, LegacyMoveResult
from version_info import module_version
__version__ = module_version(__name__)
ModeName = Literal["idle", "priority_high", "priority_low", "manual_load", "manual_unload", "confirm"]

View File

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

View File

@@ -13,6 +13,9 @@ from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
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")
DEFAULT_DB_CONFIG: dict[str, Any] = {
@@ -101,7 +104,7 @@ class DatabaseConfigWindow(tk.Toplevel):
self.result_config: dict[str, Any] | None = None
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.resizable(False, False)
try:

View File

@@ -19,8 +19,9 @@ from typing import Any, Callable, Optional
import customtkinter as ctk
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:
from async_msssql_query import AsyncMSSQLClient # noqa: F401

View File

@@ -26,8 +26,11 @@ from tksheet import Sheet, natural_sort_key
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from user_session import UserSession
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
try:
from loguru import logger
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._locale_catalog = load_locale_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")))
minsize = theme_value(self._theme, "window_minsize", [980, 560])
self.minsize(int(minsize[0]), int(minsize[1]))

View File

@@ -70,9 +70,20 @@ from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from 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 version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
import asyncio
try:
@@ -200,7 +211,7 @@ if _MODULE_LOG_ENABLED:
# -------------------- SQL --------------------
SQL_PL = """
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 Articolo) AS Articolo,
COUNT(DISTINCT Descrizione) AS Descrizione,
@@ -388,13 +399,14 @@ class ScrollTable(ctk.CTkFrame):
self._sort_key: Optional[str] = None
self._sort_reverse = False
self.total_w = sum(c.width for c in self.columns)
self._vbar_visible = False
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
# header
self.h_canvas = tk.Canvas(self, height=ROW_H, highlightthickness=0, bd=0)
self.h_inner = ctk.CTkFrame(self.h_canvas, fg_color="#f3f3f3",
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=TABLE_HEADER_BG,
height=ROW_H, width=self.total_w)
self.h_canvas.create_window((0,0), window=self.h_inner, anchor="nw",
width=self.total_w, height=ROW_H)
@@ -411,7 +423,6 @@ class ScrollTable(ctk.CTkFrame):
# scrollbars
self.vbar = tk.Scrollbar(self, orient="vertical", command=self.b_canvas.yview)
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")
# link scroll
@@ -433,14 +444,14 @@ class ScrollTable(ctk.CTkFrame):
for w in self.h_inner.winfo_children():
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)
row.pack(fill="x", expand=False)
row.pack_propagate(False)
for col in self.columns:
holder = ctk.CTkFrame(
row, fg_color="#f3f3f3",
row, fg_color=TABLE_HEADER_BG,
width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR
)
@@ -451,7 +462,7 @@ class ScrollTable(ctk.CTkFrame):
if col.key == self._sort_key:
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)
if self.on_header_click and col.key != "__check__":
@@ -472,10 +483,27 @@ class ScrollTable(ctk.CTkFrame):
"""Keep the scroll region aligned with the current body content width."""
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
sr = self.b_canvas.bbox("all")
content_height = int(sr[3]) if sr else 0
if sr:
self.b_canvas.configure(scrollregion=(0,0,max(self.total_w, sr[2]), sr[3]))
else:
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):
"""React to body resize events by syncing dimensions and header scroll."""
@@ -510,8 +538,11 @@ class ScrollTable(ctk.CTkFrame):
delta = getattr(event, "delta", 0)
if delta == 0:
return
if not self._vbar_visible:
return "break"
step = -1 if delta > 0 else 1
self.b_canvas.yview_scroll(step, "units")
return "break"
def clear_rows(self):
"""Remove all rendered body rows."""
@@ -527,14 +558,15 @@ class ScrollTable(ctk.CTkFrame):
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
):
"""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)
row.pack(fill="x", expand=False)
row.pack_propagate(False)
for i, col in enumerate(self.columns):
holder = ctk.CTkFrame(
row, fg_color="transparent",
row, fg_color=row_bg,
width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR
)
@@ -546,10 +578,10 @@ class ScrollTable(ctk.CTkFrame):
cb = checkbox_builder(holder)
cb.pack(padx=(self.PADX_L, self.PADX_R), pady=self.PADY, anchor="w")
else:
ctk.CTkLabel(holder, text="").pack(fill="both")
ctk.CTkLabel(holder, text="", fg_color=row_bg).pack(fill="both")
else:
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
)
@@ -693,6 +725,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.detail_sheet.change_theme("light green")
self.detail_sheet.enable_bindings("all")
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.grid(row=0, column=0, sticky="nsew")
@@ -733,8 +766,10 @@ class GestionePickingListFrame(ctk.CTkFrame):
data,
reset_col_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()
def _detail_sort_value(self, row: Dict[str, Any], key: str):
@@ -904,13 +939,25 @@ class GestionePickingListFrame(ctk.CTkFrame):
self.after_idle(_paint)
break
def _reselect_documento_after_reload(self, documento: str):
"""(Opzionale) Dopo un reload DB, riseleziona la PL con lo stesso Documento."""
def _reselect_documento_after_reload(self, documento: str) -> bool:
"""After a reload, reselect the same document and reload its details."""
for m in self.rows_models:
if _s(m.pl.get("Documento")) == _s(documento):
self._detail_cache.pop(documento, None)
m.set_checked(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 -----
@_log_call()
@@ -964,6 +1011,8 @@ class GestionePickingListFrame(ctk.CTkFrame):
@_log_call()
def reload_from_db(self, first: bool = False, reselect_documento: str | None = None):
"""Load or reload the picking list summary table from the database."""
if reselect_documento is None and not first:
reselect_documento = self._selected_documento_for_reload()
self.spinner.start(" Carico…") # spinner ON
async def _job():
_log_sql("SQL_PL", SQL_PL, {})
@@ -973,7 +1022,17 @@ class GestionePickingListFrame(ctk.CTkFrame):
_log_dataset("SQL_PL", rows)
self._refresh_mid_rows(rows)
if reselect_documento:
self.after_idle(lambda doc=reselect_documento: self._reselect_documento_after_reload(doc))
def _reselect_or_clear(doc=reselect_documento):
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
if self._first_loading:
@@ -1240,7 +1299,7 @@ def open_pickinglist_window(parent: tk.Misc, db_client, session: UserSession | N
win = ctk.CTkToplevel(parent)
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", {})
win.geometry(str(theme_value(theme, "window_geometry", "1200x700")))
minsize = theme_value(theme, "window_minsize", [1000, 560])

View File

@@ -20,7 +20,11 @@ from audit_log import log_user_action
from busy_overlay import InlineBusyOverlay
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import style_treeview, zebra_tag
from user_session import UserSession
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
try:
from loguru import logger
@@ -457,7 +461,7 @@ class ScaricoDialog(ctk.CTkToplevel):
self._async = AsyncRunner(self)
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.transient(parent)
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_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(
tree_host,
columns=("sel", "udc", "last", "diag"),
@@ -519,6 +519,13 @@ class ScaricoDialog(ctk.CTkToplevel):
style="Scarico.Treeview",
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("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"))
@@ -572,6 +579,7 @@ class ScaricoDialog(ctk.CTkToplevel):
row.last_event_at,
row.diagnostic_note or "",
),
tags=(zebra_tag(idx),),
)
self.update_idletasks()

View File

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

View File

@@ -11,6 +11,9 @@ from gestione_aree import AsyncRunner
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_section, theme_value
from user_session import UserSession, create_user_session
from version_info import module_version, versioned_title
__version__ = module_version(__name__)
SQL_LOGIN = """
@@ -64,8 +67,8 @@ class LoginWindow(tk.Toplevel):
self._show_ready_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.geometry("170x145+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x155+0+0")))
self.title(versioned_title(loc_text("login.msg.title", catalog=self._locale_catalog, default="Login"), __name__))
self.geometry("170x148+0+0" if self.compact else str(theme_value(self._theme, "window_geometry", "165x170+0+0")))
self.resizable(False, False)
try:
if parent is not None and parent.winfo_viewable():
@@ -88,53 +91,59 @@ class LoginWindow(tk.Toplevel):
def _build_ui(self) -> None:
"""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.columnconfigure(1, weight=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.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.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:
actions = ttk.Frame(body)
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(6, 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))
actions.grid(row=row_offset + 2, column=0, columnspan=2, sticky="ew", pady=(3, 0))
self._login_button = ttk.Button(
actions,
text="OK",
command=self._on_login,
)
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(
actions,
text=loc_text("login.button.cancel", catalog=self._locale_catalog, default="Annulla"),
text="Annulla",
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(
actions,
text=loc_text("login.button.submit", catalog=self._locale_catalog, default="OK"),
command=self._on_login,
)
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("<Escape>", lambda _e: self._on_cancel())

View File

@@ -33,6 +33,7 @@ from storico_udc import open_storico_udc_window
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
from ui_theme import theme_font, theme_section, theme_value
from user_session import UserSession, create_user_session
from version_info import module_version, versioned_title
from view_celle_multi_udc import open_celle_multiple_window
from window_placement import (
cascade_children_below_parent,
@@ -43,6 +44,7 @@ from window_placement import (
# Development shortcut: skip the login dialog and boot directly as MAG1.
# Set to False when you want to restore normal authentication.
BYPASS_LOGIN = False
__version__ = module_version(__name__)
BYPASS_LOGIN_USER = {
"operator_id": 4,
"login": "MAG1",
@@ -176,7 +178,7 @@ class Launcher(ctk.CTk):
color=str(theme_value(self._theme, "exit_icon_color", "#ca3d3d"))
)
self.title(
f"{loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse 1.0.0')} - {self.session.display_name}"
f"{versioned_title(loc_text('launcher.window_title', catalog=self._locale_catalog, default='Warehouse'), __name__)} - {self.session.display_name}"
)
self._apply_dynamic_geometry()

View File

@@ -10,6 +10,10 @@ from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional
from version_info import module_version
__version__ = module_version(__name__)
try:
from loguru import logger
except Exception: # pragma: no cover - safety fallback if dependency is missing locally

View File

@@ -23,9 +23,13 @@ from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_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
__version__ = module_version(__name__)
try:
from loguru import logger
except Exception: # pragma: no cover - fallback used only when Loguru is not available
@@ -275,7 +279,7 @@ class ResetCorsieWindow(ctk.CTkToplevel):
super().__init__(parent)
self._theme = theme_section("reset_corsie", {})
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")))
minsize = theme_value(self._theme, "window_minsize", [880, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
@@ -298,35 +302,6 @@ class ResetCorsieWindow(ctk.CTkToplevel):
def _setup_tree_style(self):
"""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()
def _build_ui(self):
"""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)),
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"))
self.tree.tag_configure("even", background=theme_value(self._theme, "tree_row_even_bg", "#f3f6fb"))
style_treeview(
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)
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():
self.tree.delete(item)
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,))
except Exception as ex:
_MODULE_LOGGER.exception(f"Errore UI refresh reset corsie corsia={corsia}: {ex}")

View File

@@ -6,25 +6,42 @@ import sys
import traceback
from datetime import datetime
from pathlib import Path
import tempfile
from typing import Callable, TypeVar
from version_info import module_version
__version__ = module_version(__name__)
BASE_DIR = Path(__file__).resolve().parent
FATAL_LOG = BASE_DIR / "warehouse_fatal.log"
TEMP_DIR = Path(tempfile.gettempdir())
_STDIO_HANDLES = []
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:
"""Give ``pythonw`` a real stdout/stderr target before loggers are imported."""
stamp = datetime.now().strftime("%Y%m%d")
if sys.stdout is None:
handle = (BASE_DIR / f"{app_name}_stdout_{stamp}.log").open("a", encoding="utf-8", buffering=1)
handle = _open_log_file(BASE_DIR / f"{app_name}_stdout_{stamp}.log")
_STDIO_HANDLES.append(handle)
sys.stdout = handle
if sys.stderr is None:
handle = (BASE_DIR / f"{app_name}_stderr_{stamp}.log").open("a", encoding="utf-8", buffering=1)
handle = _open_log_file(BASE_DIR / f"{app_name}_stderr_{stamp}.log")
_STDIO_HANDLES.append(handle)
sys.stderr = handle
@@ -32,12 +49,21 @@ def ensure_stdio(app_name: str) -> None:
def log_fatal(app_name: str, exc: BaseException) -> None:
"""Write one startup/runtime crash to a persistent log file."""
with FATAL_LOG.open("a", encoding="utf-8") as handle:
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}\n")
handle.write("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
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:
"""Show a best-effort message box for console-less launches."""
@@ -49,7 +75,10 @@ def show_fatal_message(app_name: str, exc: BaseException) -> None:
root.withdraw()
messagebox.showerror(
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,
)
root.destroy()
@@ -61,8 +90,15 @@ def run_with_fatal_log(app_name: str, func: Callable[[], T]) -> T:
"""Run an app entry point and persist otherwise invisible ``pythonw`` crashes."""
try:
return func()
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:
log_runtime_event(app_name, f"EXCEPTION type={type(exc).__name__} value={exc!r}")
log_fatal(app_name, exc)
show_fatal_message(app_name, exc)
raise

View File

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

View File

@@ -11,53 +11,78 @@ import customtkinter as ctk
from busy_overlay import InlineBusyOverlay
from gestione_aree import AsyncRunner
from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text
from ui_theme import theme_color, theme_font, theme_section, theme_value
from ui_tables import merge_tags, style_treeview, zebra_tag
from version_info import module_version, versioned_title
from window_placement import place_window_fullsize_below_parent_later
__version__ = module_version(__name__)
SQL_STORICO_PL = """
WITH base AS (
SELECT *
SELECT
*,
NULLIF(LTRIM(RTRIM(CAST(Pallet AS varchar(32)))), '') AS PalletKey
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
Documento,
MAX(DataDocumento) AS DataDocumento,
MAX(StatoDocumento) AS StatoDocumento,
MAX(NAZIONE) AS NAZIONE,
MAX(CodNazione) AS CodNazione,
COUNT(DISTINCT Pallet) AS TotUDC,
SUM(CASE WHEN Cella = 9999 THEN 1 ELSE 0 END) AS RigheSpedite,
SUM(CASE WHEN Cella <> 9999 OR Cella IS NULL THEN 1 ELSE 0 END) AS RigheResidue,
COUNT(*) AS RigheTotali,
MIN(Ordinamento) AS PrimoOrdine,
MAX(IDStato) AS IDStato
FROM base
GROUP BY Documento
)
),
agg AS (
SELECT
Documento,
DataDocumento,
StatoDocumento,
NAZIONE,
CodNazione,
TotUDC,
RigheResidue,
RigheSpedite,
RigheTotali,
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
m.Documento,
m.DataDocumento,
m.StatoDocumento,
m.NAZIONE,
m.CodNazione,
COALESCE(a.TotUDC, 0) AS TotUDC,
COALESCE(a.RigheResidue, 0) AS RigheResidue,
COALESCE(a.RigheSpedite, 0) AS RigheSpedite,
m.RigheTotali,
CASE
WHEN StatoDocumento = 'D' THEN 'Chiusa'
WHEN RigheResidue = 0 THEN 'Esaurita'
WHEN RigheSpedite > 0 THEN 'In corso'
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 AS StatoOperativo,
IDStato,
PrimoOrdine
FROM agg
WHERE (:documento IS NULL OR CAST(Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
ORDER BY Documento DESC;
m.IDStato,
m.PrimoOrdine
FROM meta m
LEFT JOIN agg a ON a.Documento = m.Documento
WHERE (:documento IS NULL OR CAST(m.Documento AS varchar(32)) LIKE CONCAT('%', :documento, '%'))
ORDER BY m.Documento DESC;
"""
SQL_STORICO_PL_DETAILS = """
@@ -135,8 +160,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
self._async = AsyncRunner(self)
self._busy = InlineBusyOverlay(self, self._theme)
self.var_documento = tk.StringVar()
self._selected_documento: str | None = None
self._selected_stato_operativo: str = ""
self._detail_rows: list[dict[str, Any]] = []
self.title(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")))
minsize = theme_value(self._theme, "window_minsize", [980, 560])
self.minsize(int(minsize[0]), int(minsize[1]))
@@ -158,7 +186,7 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
fg_color=theme_color(self._theme, "toolbar_frame_fg_color", ("#d7d7d7", "#3b3b3b")),
)
top.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
top.grid_columnconfigure(3, weight=1)
top.grid_columnconfigure(4, weight=1)
label_font = theme_font(self._theme, "toolbar_label_font", ("Segoe UI", 10))
entry_font = theme_font(self._theme, "entry_font", ("Segoe UI", 10))
@@ -176,6 +204,18 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
).grid(
row=0, column=2, sticky="w"
)
self.btn_ship_residuals = ctk.CTkButton(
top,
text=loc_text(
"history.picking.button.ship_residuals",
catalog=self._locale_catalog,
default="Versa residui in 7G.1.1",
),
command=self._ship_selected_residuals,
state="disabled",
font=button_font,
)
self.btn_ship_residuals.grid(row=0, column=3, sticky="w", padx=(12, 0))
self.master_tree = self._make_tree(
row=1,
@@ -228,8 +268,10 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
for col in columns:
tree.heading(col, text=col)
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("active", background="#EAF7EA")
tree.tag_configure("warning", background="#FFE3B3")
sy = ttk.Scrollbar(wrap, orient="vertical", command=tree.yview)
sx = ttk.Scrollbar(wrap, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)
@@ -240,12 +282,15 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _load_master(self) -> None:
params = {"documento": str(self.var_documento.get() or "").strip() or None}
previous_documento = self._selected_documento
async def _job():
return await self.db_client.query_json(SQL_STORICO_PL, params)
def _ok(res):
self._fill_master(_rows_to_dicts(res))
if previous_documento:
self._restore_master_selection(previous_documento)
def _err(ex):
messagebox.showerror(
@@ -273,9 +318,25 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_master(self, rows: list[dict[str, Any]]) -> None:
self.master_tree.delete(*self.master_tree.get_children(""))
self.detail_tree.delete(*self.detail_tree.get_children(""))
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
for index, row in enumerate(rows):
stato = str(row.get("StatoOperativo") or "")
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(
"",
"end",
@@ -291,23 +352,32 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
stato,
row.get("IDStato", ""),
),
tags=(tag,) if tag else (),
tags=merge_tags(zebra_tag(index), tag),
)
def _on_master_select(self, _event=None) -> None:
selected = self.master_tree.selection()
if not selected:
self._selected_documento = None
self._selected_stato_operativo = ""
self._detail_rows = []
self._update_residual_button()
return
values = self.master_tree.item(selected[0], "values")
if not values:
return
documento = values[0]
self._selected_documento = str(documento)
self._selected_stato_operativo = str(values[7] if len(values) > 7 else "")
self._detail_rows = []
self._update_residual_button()
async def _job():
return await self.db_client.query_json(SQL_STORICO_PL_DETAILS, {"documento": documento})
def _ok(res):
self._fill_detail(_rows_to_dicts(res))
self._update_residual_button()
def _err(ex):
messagebox.showerror(
@@ -334,8 +404,11 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
def _fill_detail(self, rows: list[dict[str, Any]]) -> None:
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
tag = "warning" if is_open_shipped else "done" if done else ""
self.detail_tree.insert(
"",
"end",
@@ -351,7 +424,118 @@ class StoricoPickingListWindow(ctk.CTkToplevel):
row.get("Ubicazione", ""),
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()
if not pallet or pallet in seen:
continue
try:
cella = int(row.get("Cella") or 0)
except Exception:
cella = 0
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():
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:
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...",
)

View File

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

View File

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

108
ui_tables.py Normal file
View File

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

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import FrozenSet
from version_info import module_version
__version__ = module_version(__name__)
ALL_ACTIONS: FrozenSet[str] = frozenset(
{

71
version_info.py Normal file
View File

@@ -0,0 +1,71 @@
"""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.0",
"barcode_client": "1.0.0",
"barcode_repository": "1.0.0",
"barcode_service": "1.0.0",
"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",
"search_pallets": "1.0.0",
"storico_pickinglist": "1.0.3",
"storico_udc": "1.0.0",
"tooltips": "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}"

View File

@@ -22,8 +22,12 @@ from gestione_scarico import move_pallet_async
from locale_text import load_locale_catalog, text as loc_text
from tooltips import WidgetToolTip, load_tooltip_catalog, tooltip_text
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
__version__ = module_version(__name__)
try:
from loguru import logger
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._locale_catalog = load_locale_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.geometry(str(theme_value(self._theme, "window_geometry", "1100x700")))
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("col3", width=120, 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)
x = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
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.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)
x2 = ttk.Scrollbar(inner, orient="horizontal", command=self.sum_tbl.xview)
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."""
rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_corsie", rows)
for row in rows:
for index, row in enumerate(rows):
corsia = row.get("Corsia")
if not corsia:
continue
@@ -672,7 +678,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
text=self._format_corsia_text(corsia),
values=("", "", ""),
open=False,
tags=("corsia",),
tags=merge_tags(zebra_tag(index), "corsia"),
)
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", "", ""))
@@ -724,7 +730,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "", ""))
return
for row in rows:
for index, row in enumerate(rows):
idc = row["IDCella"]
ubi = row["Ubicazione"]
corsia = row.get("Corsia")
@@ -741,7 +747,7 @@ class CelleMultipleWindow(ctk.CTkToplevel):
text=label,
values=(f"IDCella {idc}", "", ""),
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)):
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_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", "")
desc = row.get("Descrizione", "")
lotto = row.get("Lotto", "")
@@ -813,7 +819,13 @@ class CelleMultipleWindow(ctk.CTkToplevel):
iid=leaf_id,
text=self._format_pallet_text(str(pallet), leaf_id in self.selected_udc_keys),
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()
@@ -987,11 +999,12 @@ class CelleMultipleWindow(ctk.CTkToplevel):
_log_dataset("multi_udc_riepilogo", rows)
for item in self.sum_tbl.get_children():
self.sum_tbl.delete(item)
for row in rows:
for index, row in enumerate(rows):
self.sum_tbl.insert(
"",
"end",
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):

View File

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

View File

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