Checkpoint before ghost pallet cleanup workflow

This commit is contained in:
2026-05-09 12:18:59 +02:00
parent f556b476ff
commit 6ab42a2303
27 changed files with 3947 additions and 973 deletions

698
view_celle_multi_udc.py Normal file
View File

@@ -0,0 +1,698 @@
"""Exploration window for cells containing more than one pallet."""
from __future__ import annotations
import json
import logging
import sys
import tkinter as tk
from datetime import datetime
from functools import wraps
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any
import customtkinter as ctk
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
from gestione_aree import AsyncRunner
try:
from loguru import logger
except Exception: # pragma: no cover - fallback used only when Loguru is not available
class _FallbackLogger:
"""Minimal adapter used only when Loguru is not installed yet."""
def __init__(self):
self._logger = logging.getLogger(MODULE_LOG_NAME if "MODULE_LOG_NAME" in globals() else __name__)
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = False
def bind(self, **_kwargs):
return self
def add(self, sink, level="INFO", format=None, encoding="utf-8", **_kwargs):
handler: logging.Handler
if hasattr(sink, "write"):
handler = logging.StreamHandler(sink)
else:
handler = logging.FileHandler(str(sink), encoding=encoding)
handler.setLevel(getattr(logging, str(level).upper(), logging.INFO))
handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
)
self._logger.addHandler(handler)
return 0
def log(self, level, message):
getattr(self._logger, str(level).lower(), self._logger.info)(message)
def debug(self, message):
self._logger.debug(message)
def info(self, message):
self._logger.info(message)
def exception(self, message):
self._logger.exception(message)
logger = _FallbackLogger()
MULTI_UDC_LOG_MODE = "INFO" # "OFF" | "INFO" | "DEBUG"
MODULE_LOG_NAME = Path(__file__).stem
MODULE_LOG_PATH = Path(__file__).with_suffix(".log")
_MODULE_LOG_ENABLED = MULTI_UDC_LOG_MODE.upper() != "OFF"
_MODULE_LOG_LEVEL = "DEBUG" if MULTI_UDC_LOG_MODE.upper() == "DEBUG" else "INFO"
_MODULE_LOGGER = logger.bind(warehouse_module=MODULE_LOG_NAME)
_MODULE_LOGGING_CONFIGURED = False
def _configure_module_logger():
"""Configure console and file logging for this module."""
global _MODULE_LOGGING_CONFIGURED
if _MODULE_LOGGING_CONFIGURED:
return
if not _MODULE_LOG_ENABLED:
_MODULE_LOGGING_CONFIGURED = True
return
record_filter = lambda record: record["extra"].get("warehouse_module") == MODULE_LOG_NAME
logger.add(
sys.stderr,
level=_MODULE_LOG_LEVEL,
colorize=True,
filter=record_filter,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>" + MODULE_LOG_NAME + "</cyan> | "
"<level>{message}</level>"
),
)
logger.add(
MODULE_LOG_PATH,
level=_MODULE_LOG_LEVEL,
colorize=False,
encoding="utf-8",
filter=record_filter,
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " + MODULE_LOG_NAME + " | {message}",
)
_MODULE_LOGGING_CONFIGURED = True
def _format_payload(payload: Any) -> str:
"""Serialize payloads for human-readable logging."""
try:
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
except Exception:
return repr(payload)
def _log_call(level: str | None = None):
"""Trace entry, exit and failure of selected high-level functions."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
effective_level = level or _MODULE_LOG_LEVEL
_MODULE_LOGGER.log(
effective_level,
f"CALL {func.__qualname__} args={_format_payload(args[1:] if args else ())} kwargs={_format_payload(kwargs)}",
)
try:
result = func(*args, **kwargs)
except Exception:
_MODULE_LOGGER.exception(f"FAIL {func.__qualname__}")
raise
_MODULE_LOGGER.log(effective_level, f"RETURN {func.__qualname__}")
return result
return wrapper
return decorator
def _log_sql(query_name: str, sql: str, params: dict[str, Any] | None = None):
"""Log one SQL statement and its parameters."""
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} params={_format_payload(params or {})}")
_MODULE_LOGGER.debug(f"SQL {query_name} text:\n{sql.strip()}")
def _log_dataset(query_name: str, rows: list[Any]):
"""Log query results at summary or full-debug level depending on the flag."""
_MODULE_LOGGER.log(_MODULE_LOG_LEVEL, f"SQL {query_name} returned {len(rows)} rows")
if MULTI_UDC_LOG_MODE.upper() == "DEBUG":
_MODULE_LOGGER.debug(f"SQL {query_name} dataset:\n{_format_payload(rows)}")
_configure_module_logger()
if _MODULE_LOG_ENABLED:
_MODULE_LOGGER.info(
f"Logging inizializzato su {MODULE_LOG_PATH.name} livello={_MODULE_LOG_LEVEL} mode={MULTI_UDC_LOG_MODE.upper()}"
)
def _json_obj(res):
"""Normalize raw DB responses into a dictionary with a ``rows`` key."""
if isinstance(res, str):
try:
res = json.loads(res)
except Exception as ex:
raise RuntimeError(f"Risposta non JSON: {ex}\nRaw: {res!r}")
if isinstance(res, dict) and "error" in res:
err = res.get("error") or "Errore sconosciuto"
detail = res.get("sql") or ""
raise RuntimeError(f"{err}\n{detail}")
return res if isinstance(res, dict) else {"rows": res}
UBI_B = (
"UPPER("
" CONCAT("
" RTRIM(b.Corsia), '.', RTRIM(CAST(b.Colonna AS varchar(32))), '.', RTRIM(CAST(b.Fila AS varchar(32)))"
" )"
")"
)
BASE_CTE = """
WITH base AS (
SELECT
g.IDCella,
g.BarcodePallet,
RTRIM(c.Corsia) AS Corsia,
c.Colonna,
c.Fila
FROM dbo.XMag_GiacenzaPallet AS g
JOIN dbo.Celle AS c ON c.ID = g.IDCella
WHERE g.IDCella <> 9999 AND RTRIM(c.Corsia) <> '7G'
)
"""
SQL_CORSIE = BASE_CTE + """
, dup_celle AS (
SELECT IDCCella = b.IDCella
FROM base b
GROUP BY b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
)
SELECT DISTINCT b.Corsia
FROM base b
WHERE EXISTS (SELECT 1 FROM dup_celle d WHERE d.IDCCella = b.IDCella)
ORDER BY b.Corsia;
"""
SQL_CELLE_DUP_PER_CORSIA = BASE_CTE + f"""
, dup_celle AS (
SELECT b.IDCella, COUNT(DISTINCT b.BarcodePallet) AS NumUDC
FROM base b
GROUP BY b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
)
SELECT dc.IDCella,
{UBI_B} AS Ubicazione,
b.Colonna, b.Fila, b.Corsia,
dc.NumUDC
FROM dup_celle dc
JOIN base b ON b.IDCella = dc.IDCella
WHERE b.Corsia = RTRIM(:corsia)
GROUP BY dc.IDCella, {UBI_B}, b.Colonna, b.Fila, b.Corsia, dc.NumUDC
ORDER BY b.Colonna, b.Fila;
"""
SQL_PALLET_IN_CELLA = BASE_CTE + """
, cell_pallets AS (
SELECT DISTINCT b.BarcodePallet
FROM base b
WHERE b.IDCella = :idcella
),
latest_any AS (
SELECT
ranked.BarcodePallet,
ranked.IDCella
FROM (
SELECT
mp.Attributo AS BarcodePallet,
mp.IDCella,
ROW_NUMBER() OVER (
PARTITION BY mp.Attributo
ORDER BY mp.ID DESC
) AS rn
FROM dbo.MagazziniPallet mp
JOIN cell_pallets cp
ON cp.BarcodePallet COLLATE Latin1_General_CI_AS =
mp.Attributo COLLATE Latin1_General_CI_AS
WHERE mp.Tipo = 'V'
AND mp.PesoUnitario > 0
) ranked
WHERE ranked.rn = 1
),
shipped AS (
SELECT DISTINCT shipped.BarcodePallet
FROM dbo.XMag_GiacenzaPalletPlistChiuse shipped
JOIN cell_pallets cp
ON cp.BarcodePallet COLLATE Latin1_General_CI_AS =
shipped.BarcodePallet COLLATE Latin1_General_CI_AS
)
SELECT
b.BarcodePallet AS Pallet,
ta.Descrizione,
ta.Lotto,
CASE
WHEN shipped.BarcodePallet IS NOT NULL THEN CAST(1 AS int)
ELSE CAST(0 AS int)
END AS IsShippedGhost,
CASE
WHEN la.IDCella IS NOT NULL
AND la.IDCella <> :idcella
THEN CAST(1 AS int)
ELSE CAST(0 AS int)
END AS IsMovedGhost
FROM base b
OUTER APPLY (
SELECT TOP (1) t.Descrizione, t.Lotto
FROM dbo.vXTracciaProdotti AS t
WHERE t.Pallet = b.BarcodePallet COLLATE Latin1_General_CI_AS
ORDER BY t.Lotto
) AS ta
LEFT JOIN latest_any la
ON la.BarcodePallet COLLATE Latin1_General_CI_AS =
b.BarcodePallet COLLATE Latin1_General_CI_AS
LEFT JOIN shipped
ON shipped.BarcodePallet COLLATE Latin1_General_CI_AS =
b.BarcodePallet COLLATE Latin1_General_CI_AS
WHERE b.IDCella = :idcella
GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto, shipped.BarcodePallet, la.IDCella
ORDER BY b.BarcodePallet;
"""
def _build_diagnostic_note(is_shipped: int | bool, is_moved: int | bool) -> str:
"""Translate anomaly flags into the operator-facing ghost cause."""
notes: list[str] = []
if bool(is_shipped):
notes.append("Mancato scarico: spedita")
if bool(is_moved):
notes.append("Mancato scarico: spostata")
return " | ".join(notes)
SQL_RIEPILOGO_PERCENTUALI = BASE_CTE + """
, tot AS (
SELECT b.Corsia, COUNT(DISTINCT b.IDCella) AS TotCelle
FROM base b GROUP BY b.Corsia
),
dup_celle AS (
SELECT b.Corsia, b.IDCella
FROM base b
GROUP BY b.Corsia, b.IDCella
HAVING COUNT(DISTINCT b.BarcodePallet) > 1
),
per_corsia AS (
SELECT t.Corsia, t.TotCelle, COALESCE(d.CelleMultiple, 0) AS CelleMultiple
FROM tot t
LEFT JOIN (
SELECT Corsia, COUNT(IDCella) AS CelleMultiple
FROM dup_celle GROUP BY Corsia
) d ON d.Corsia = t.Corsia
),
unione AS (
SELECT Corsia, TotCelle, CelleMultiple,
CAST(100.0 * CelleMultiple / NULLIF(TotCelle, 0) AS decimal(5,2)) AS Percentuale,
CAST(0 AS int) AS Ord
FROM per_corsia
UNION ALL
SELECT 'TOTALE' AS Corsia,
SUM(TotCelle), SUM(CelleMultiple),
CAST(100.0 * SUM(CelleMultiple) / NULLIF(SUM(TotCelle), 0) AS decimal(5,2)),
CAST(1 AS int) AS Ord
FROM per_corsia
)
SELECT Corsia, TotCelle, CelleMultiple, Percentuale
FROM unione
ORDER BY Ord, Corsia;
"""
class CelleMultipleWindow(ctk.CTkToplevel):
"""Tree-based explorer for duplicated pallet allocations."""
@_log_call()
def __init__(self, root, db_client, runner: AsyncRunner | None = None, session=None):
"""Bind the shared DB client and immediately load the tree summary."""
super().__init__(root)
self.title("Celle con piu' pallet")
self.session = session
self.geometry("1100x700")
self.minsize(900, 550)
self.resizable(True, True)
self.db = db_client
self.runner = runner or AsyncRunner(self)
self._build_layout()
self._bind_events()
self.refresh_all()
def _build_layout(self):
"""Create the toolbar, lazy-loaded tree and percentage summary table."""
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=70)
self.grid_rowconfigure(2, weight=25, minsize=160)
self.grid_columnconfigure(0, weight=1)
toolbar = ctk.CTkFrame(self)
toolbar.grid(row=0, column=0, sticky="nsew")
ctk.CTkButton(toolbar, text="Aggiorna", command=self.refresh_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Espandi tutto", command=self.expand_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Comprimi tutto", command=self.collapse_all).pack(side="left", padx=6, pady=4)
ctk.CTkButton(toolbar, text="Esporta in XLSX", command=self.export_to_xlsx).pack(side="left", padx=6, pady=4)
frame = ctk.CTkFrame(self)
frame.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0, 6))
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
self.tree = ttk.Treeview(frame, columns=("col2", "col3", "col4"), show="tree headings", selectmode="browse")
self.tree.heading("#0", text="Nodo")
self.tree.heading("col2", text="Descrizione")
self.tree.heading("col3", text="Lotto")
self.tree.heading("col4", text="Causale")
self.tree.column("#0", width=220, anchor="w")
self.tree.column("col2", width=250, anchor="w")
self.tree.column("col3", width=120, anchor="w")
self.tree.column("col4", width=260, anchor="w")
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)
self.tree.grid(row=0, column=0, sticky="nsew")
y.grid(row=0, column=1, sticky="ns")
x.grid(row=1, column=0, sticky="ew")
sumf = ctk.CTkFrame(self)
sumf.grid(row=2, column=0, sticky="nsew", padx=6, pady=(0, 6))
ctk.CTkLabel(sumf, text="Riepilogo % celle multiple per corsia", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8, 0))
inner = ctk.CTkFrame(sumf)
inner.pack(fill="both", expand=True, padx=6, pady=6)
inner.grid_rowconfigure(0, weight=1)
inner.grid_columnconfigure(0, weight=1)
self.sum_tbl = ttk.Treeview(inner, columns=("Corsia", "TotCelle", "CelleMultiple", "Percentuale"), show="headings")
for key, title, width, anchor in (
("Corsia", "Corsia", 100, "center"),
("TotCelle", "Totale celle", 120, "e"),
("CelleMultiple", ">1 UDC", 120, "e"),
("Percentuale", "%", 80, "e"),
):
self.sum_tbl.heading(key, text=title)
self.sum_tbl.column(key, width=width, anchor=anchor)
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)
self.sum_tbl.grid(row=0, column=0, sticky="nsew")
y2.grid(row=0, column=1, sticky="ns")
x2.grid(row=1, column=0, sticky="ew")
def _bind_events(self):
"""Attach lazy-load behavior when nodes are expanded."""
self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
@_log_call()
def refresh_all(self):
"""Reload both the duplication tree and the summary percentage table."""
self._load_corsie()
self._load_riepilogo()
@_log_call()
def _load_corsie(self):
"""Load root nodes representing aisles with duplicated cells."""
self.tree.delete(*self.tree.get_children())
_log_sql("multi_udc_corsie", SQL_CORSIE, {})
async def _q(db):
return await db.query_json(SQL_CORSIE, as_dict_rows=True)
def _err(ex):
_MODULE_LOGGER.exception(f"Errore caricamento corsie UDC fantasma: {ex}")
messagebox.showerror("Errore", str(ex), parent=self)
self.runner.run(_q(self.db), self._fill_corsie, _err)
@_log_call()
def _fill_corsie(self, res):
"""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:
corsia = row.get("Corsia")
if not corsia:
continue
node_id = f"corsia:{corsia}"
self.tree.insert("", "end", iid=node_id, text=f"Corsia {corsia}", values=("", ""), open=False, tags=("corsia",))
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", ""))
def _on_open_node(self, _evt):
"""Lazy-load children when a tree node is expanded."""
sel = self.tree.focus()
if not sel:
return
if sel.startswith("corsia:"):
lazy_id = f"{sel}::lazy"
if lazy_id in self.tree.get_children(sel):
self.tree.delete(lazy_id)
corsia = sel.split(":", 1)[1]
self._load_celle_for_corsia(sel, corsia)
elif sel.startswith("cella:"):
lazy_id = f"{sel}::lazy"
if lazy_id in self.tree.get_children(sel):
self.tree.delete(lazy_id)
idcella = int(sel.split(":", 1)[1])
for child in self.tree.get_children(sel):
self.tree.delete(child)
self._load_pallet_for_cella(sel, idcella)
@_log_call()
def _load_celle_for_corsia(self, parent_iid, corsia):
"""Query duplicated cells for the selected aisle."""
_log_sql("multi_udc_celle_per_corsia", SQL_CELLE_DUP_PER_CORSIA, {"corsia": corsia})
async def _q(db):
return await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True)
def _err(ex):
_MODULE_LOGGER.exception(f"Errore caricamento celle duplicate corsia={corsia}: {ex}")
messagebox.showerror("Errore", str(ex), parent=self)
self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res), _err)
@_log_call()
def _fill_celle(self, parent_iid, res):
"""Populate duplicated-cell nodes under an aisle node."""
rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_celle_per_corsia", rows)
if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", ""))
return
for row in rows:
idc = row["IDCella"]
ubi = row["Ubicazione"]
corsia = row.get("Corsia")
num = row.get("NumUDC", 0)
node_id = f"cella:{idc}"
label = f"{ubi} [x{num}]"
if self.tree.exists(node_id):
self.tree.item(node_id, text=label, values=(f"IDCella {idc}", ""))
else:
self.tree.insert(parent_iid, "end", iid=node_id, text=label, values=(f"IDCella {idc}", ""), open=False, tags=("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=("", ""))
@_log_call()
def _load_pallet_for_cella(self, parent_iid, idcella: int):
"""Query pallet details for a duplicated cell."""
_log_sql("multi_udc_pallet_in_cella", SQL_PALLET_IN_CELLA, {"idcella": idcella})
async def _q(db):
return await db.query_json(SQL_PALLET_IN_CELLA, params={"idcella": idcella}, as_dict_rows=True)
def _err(ex):
_MODULE_LOGGER.exception(f"Errore caricamento pallet cella idcella={idcella}: {ex}")
messagebox.showerror("Errore", str(ex), parent=self)
self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res), _err)
@_log_call()
def _fill_pallet(self, parent_iid, res):
"""Add pallet leaves under the selected cell node."""
rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_pallet_in_cella", rows)
if not rows:
self.tree.insert(parent_iid, "end", text="(nessun pallet)", values=("", "", ""))
return
parent_tags = self.tree.item(parent_iid, "tags") or ()
corsia_tag = next((tag for tag in parent_tags if tag.startswith("corsia:")), None)
corsia_val = corsia_tag.split(":", 1)[1] if corsia_tag else ""
cella_ubi = self.tree.item(parent_iid, "text")
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:
pallet = row.get("Pallet", "")
desc = row.get("Descrizione", "")
lotto = row.get("Lotto", "")
causale = _build_diagnostic_note(row.get("IsShippedGhost", 0), row.get("IsMovedGhost", 0))
leaf_id = f"pallet:{idcella_num}:{pallet}"
if self.tree.exists(leaf_id):
self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto, causale))
continue
self.tree.insert(
parent_iid,
"end",
iid=leaf_id,
text=str(pallet),
values=(desc, lotto, causale),
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"),
)
@_log_call()
def _load_riepilogo(self):
"""Load the percentage summary by aisle."""
_log_sql("multi_udc_riepilogo", SQL_RIEPILOGO_PERCENTUALI, {})
async def _q(db):
return await db.query_json(SQL_RIEPILOGO_PERCENTUALI, as_dict_rows=True)
def _err(ex):
_MODULE_LOGGER.exception(f"Errore caricamento riepilogo UDC fantasma: {ex}")
messagebox.showerror("Errore", str(ex), parent=self)
self.runner.run(_q(self.db), self._fill_riepilogo, _err)
@_log_call()
def _fill_riepilogo(self, res):
"""Refresh the bottom summary table."""
rows = _json_obj(res).get("rows", [])
_log_dataset("multi_udc_riepilogo", rows)
for item in self.sum_tbl.get_children():
self.sum_tbl.delete(item)
for row in rows:
self.sum_tbl.insert(
"",
"end",
values=(row.get("Corsia"), row.get("TotCelle", 0), row.get("CelleMultiple", 0), f"{row.get('Percentuale', 0):.2f}"),
)
def expand_all(self):
"""Expand all aisle roots and trigger lazy loading where needed."""
for iid in self.tree.get_children(""):
self.tree.item(iid, open=True)
if f"{iid}::lazy" in self.tree.get_children(iid):
self.tree.delete(f"{iid}::lazy")
corsia = iid.split(":", 1)[1]
self._load_celle_for_corsia(iid, corsia)
def collapse_all(self):
"""Collapse all root nodes in the duplication tree."""
for iid in self.tree.get_children(""):
self.tree.item(iid, open=False)
@_log_call()
def export_to_xlsx(self):
"""Export both the detailed tree and the summary table to Excel."""
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default_name = f"esportazione_celle_udc_multiple_{ts}.xlsx"
fname = filedialog.asksaveasfilename(
parent=self,
title="Esporta in Excel",
defaultextension=".xlsx",
filetypes=[("Excel Workbook", "*.xlsx")],
initialfile=default_name,
)
if not fname:
return
try:
wb = Workbook()
ws_det = wb.active
ws_det.title = "Dettaglio"
ws_sum = wb.create_sheet("Riepilogo")
det_headers = ["Corsia", "Ubicazione", "IDCella", "Pallet", "Descrizione", "Lotto", "Causale"]
sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"]
def _hdr(ws, headers):
"""Write formatted headers into the given worksheet."""
for j, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=j, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
_hdr(ws_det, det_headers)
_hdr(ws_sum, sum_headers)
row_idx = 2
for corsia_node in self.tree.get_children(""):
for cella_node in self.tree.get_children(corsia_node):
for pallet_node in self.tree.get_children(cella_node):
tags = self.tree.item(pallet_node, "tags") or ()
if "pallet" not in tags:
continue
corsia = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("corsia:")), "")
ubi = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("ubicazione:")), "")
idcella = next((tag.split(":", 1)[1] for tag in tags if tag.startswith("idcella:")), "")
pallet = self.tree.item(pallet_node, "text")
desc, lotto, causale = self.tree.item(pallet_node, "values")
for j, value in enumerate([corsia, ubi, idcella, pallet, desc, lotto, causale], start=1):
ws_det.cell(row=row_idx, column=j, value=value)
row_idx += 1
row_idx = 2
for iid in self.sum_tbl.get_children(""):
vals = self.sum_tbl.item(iid, "values")
for j, value in enumerate(vals, start=1):
ws_sum.cell(row=row_idx, column=j, value=value)
row_idx += 1
def _autosize(ws):
"""Resize worksheet columns based on their longest value."""
widths = {}
for row in ws.iter_rows(values_only=True):
for j, value in enumerate(row, start=1):
value_s = "" if value is None else str(value)
widths[j] = max(widths.get(j, 0), len(value_s))
from openpyxl.utils import get_column_letter
for j, width in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(width + 2, 10), 60)
_autosize(ws_det)
_autosize(ws_sum)
wb.save(fname)
messagebox.showinfo("Esportazione completata", f"File creato:\n{fname}", parent=self)
except Exception as ex:
_MODULE_LOGGER.exception(f"Errore esportazione UDC fantasma: {ex}")
messagebox.showerror("Errore esportazione", str(ex), parent=self)
def open_celle_multiple_window(
root: tk.Tk,
db_client,
runner: AsyncRunner | None = None,
session=None,
):
"""Create, focus and return the duplicated-cells explorer."""
key = "_celle_multiple_window_singleton"
ex = getattr(root, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
win = CelleMultipleWindow(root, db_client, runner=runner, session=session)
setattr(root, key, win)
try:
win.lift()
win.focus_force()
except Exception:
pass
return win