chore: initial commit

This commit is contained in:
2025-10-27 17:18:09 +01:00
commit 8806d598eb
48 changed files with 10024 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/warehouse.iml" filepath="$PROJECT_DIR$/.idea/warehouse.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

12
.idea/warehouse.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/icons/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

35
async_loop_singleton.py Normal file
View File

@@ -0,0 +1,35 @@
# async_loop_singleton.py
import asyncio, threading
from typing import Callable
class _LoopHolder:
def __init__(self):
self.loop = None
self.thread = None
_GLOBAL = _LoopHolder()
def get_global_loop() -> asyncio.AbstractEventLoop:
"""Start a single asyncio loop in a background thread and return it."""
if _GLOBAL.loop:
return _GLOBAL.loop
ready = threading.Event()
def _run():
_GLOBAL.loop = asyncio.new_event_loop()
asyncio.set_event_loop(_GLOBAL.loop)
ready.set()
_GLOBAL.loop.run_forever()
_GLOBAL.thread = threading.Thread(target=_run, name="asyncio-bg-loop", daemon=True)
_GLOBAL.thread.start()
ready.wait()
return _GLOBAL.loop
def stop_global_loop():
if _GLOBAL.loop and _GLOBAL.loop.is_running():
_GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop)
_GLOBAL.thread.join(timeout=2)
_GLOBAL.loop = None
_GLOBAL.thread = None

93
async_msssql_query.py Normal file
View File

@@ -0,0 +1,93 @@
# async_msssql_query.py — loop-safe, compat rows=list, no pooling
from __future__ import annotations
import asyncio, urllib.parse, time, logging
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from sqlalchemy import text
try:
import orjson as _json
def _dumps(obj: Any) -> str: return _json.dumps(obj, default=str).decode("utf-8")
except Exception:
import json as _json
def _dumps(obj: Any) -> str: return _json.dumps(obj, default=str)
def make_mssql_dsn(
*, server: str, database: str, user: Optional[str]=None, password: Optional[str]=None,
driver: str="ODBC Driver 17 for SQL Server", trust_server_certificate: bool=True,
encrypt: Optional[str]=None, extra_odbc_kv: Optional[Dict[str,str]]=None
) -> str:
kv = {"DRIVER": driver, "SERVER": server, "DATABASE": database,
"TrustServerCertificate": "Yes" if trust_server_certificate else "No"}
if user: kv["UID"] = user
if password: kv["PWD"] = password
if encrypt: kv["Encrypt"] = encrypt
if extra_odbc_kv: kv.update(extra_odbc_kv)
odbc = ";".join(f"{k}={v}" for k,v in kv.items()) + ";"
return f"mssql+aioodbc:///?odbc_connect={urllib.parse.quote_plus(odbc)}"
class AsyncMSSQLClient:
"""
Engine creato pigramente sul loop corrente, senza pool (NullPool).
Evita “Future attached to a different loop” nei reset/close del pool.
"""
def __init__(self, dsn: str, *, echo: bool=False, log: bool=True):
self._dsn = dsn
self._echo = echo
self._engine = None
self._engine_loop: Optional[asyncio.AbstractEventLoop] = None
self._logger = logging.getLogger("AsyncMSSQLClient")
if log and not self._logger.handlers:
h = logging.StreamHandler()
h.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
self._logger.addHandler(h)
self._enable_log = log
async def _ensure_engine(self):
if self._engine is not None:
return
loop = asyncio.get_running_loop()
self._engine = create_async_engine(
self._dsn,
echo=self._echo,
# IMPORTANTI:
poolclass=NullPool, # no pooling → no reset su loop “sbagliati”
connect_args={"loop": loop}, # usa il loop corrente in aioodbc
)
self._engine_loop = loop
if self._enable_log:
self._logger.info("Engine created on loop %s", id(loop))
async def dispose(self):
if self._engine is None:
return
# sempre sullo stesso loop in cui è nato
if asyncio.get_running_loop() is self._engine_loop:
await self._engine.dispose()
else:
fut = asyncio.run_coroutine_threadsafe(self._engine.dispose(), self._engine_loop)
fut.result(timeout=2)
self._engine = None
if self._enable_log:
self._logger.info("Engine disposed")
async def query_json(self, sql: str, params: Optional[Dict[str, Any]]=None, *, as_dict_rows: bool=False) -> Dict[str, Any]:
await self._ensure_engine()
t0 = time.perf_counter()
async with self._engine.connect() as conn:
res = await conn.execute(text(sql), params or {})
rows = res.fetchall()
cols = list(res.keys())
if as_dict_rows:
rows_out = [dict(zip(cols, r)) for r in rows]
else:
rows_out = [list(r) for r in rows]
return {"columns": cols, "rows": rows_out, "elapsed_ms": round((time.perf_counter()-t0)*1000, 3)}
async def exec(self, sql: str, params: Optional[Dict[str, Any]]=None, *, commit: bool=False) -> int:
await self._ensure_engine()
async with (self._engine.begin() if commit else self._engine.connect()) as conn:
res = await conn.execute(text(sql), params or {})
return res.rowcount or 0

25
async_runner.py Normal file
View File

@@ -0,0 +1,25 @@
# async_runner.py
import asyncio
from typing import Callable
class AsyncRunner:
"""Esegue un awaitable sul loop globale e richiama i callback in Tk via .after."""
def __init__(self, tk_root, loop: asyncio.AbstractEventLoop):
self.tk = tk_root
self.loop = loop
def run(self, awaitable, on_ok: Callable, on_err: Callable, busy=None, message: str | None=None):
if busy: busy.show(message or "Lavoro in corso…")
fut = asyncio.run_coroutine_threadsafe(awaitable, self.loop)
self._poll(fut, on_ok, on_err, busy)
def _poll(self, fut, on_ok, on_err, busy):
if fut.done():
if busy: busy.hide()
try:
res = fut.result()
on_ok(res)
except Exception as ex:
on_err(ex)
return
self.tk.after(50, lambda: self._poll(fut, on_ok, on_err, busy))

148
backups/autosave.tcl.bak1 Normal file
View File

@@ -0,0 +1,148 @@
#############################################################################
# Generated by PAGE version 8.0
# in conjunction with Tcl version 8.6
# Sep 15, 2025 05:35:31 PM CEST platform: Windows NT
set vTcl(timestamp) ""
if {![info exists vTcl(borrow)]} {
::vTcl::MessageBox -title Error -message "You must open project files from within PAGE."
exit}
set vTcl(actual_gui_font_dft_desc) TkDefaultFont
set vTcl(actual_gui_font_dft_name) TkDefaultFont
set vTcl(actual_gui_font_text_desc) TkTextFont
set vTcl(actual_gui_font_text_name) TkTextFont
set vTcl(actual_gui_font_fixed_desc) TkFixedFont
set vTcl(actual_gui_font_fixed_name) TkFixedFont
set vTcl(actual_gui_font_menu_desc) TkMenuFont
set vTcl(actual_gui_font_menu_name) TkMenuFont
set vTcl(actual_gui_font_tooltip_desc) TkDefaultFont
set vTcl(actual_gui_font_tooltip_name) TkDefaultFont
set vTcl(actual_gui_font_treeview_desc) TkDefaultFont
set vTcl(actual_gui_font_treeview_name) TkDefaultFont
###########################################
set vTcl(actual_gui_bg) #d9d9d9
set vTcl(actual_gui_fg) #000000
set vTcl(actual_gui_analog) #ececec
set vTcl(actual_gui_menu_analog) #ececec
set vTcl(actual_gui_menu_bg) #d9d9d9
set vTcl(actual_gui_menu_fg) #000000
set vTcl(complement_color) gray40
set vTcl(analog_color_p) #c3c3c3
set vTcl(analog_color_m) beige
set vTcl(tabfg1) black
set vTcl(tabfg2) white
set vTcl(actual_gui_menu_active_bg) #ececec
set vTcl(actual_gui_menu_active_fg) #000000
###########################################
set vTcl(pr,autoalias) 1
set vTcl(pr,relative_placement) 1
set vTcl(mode) Relative
set vTcl(project_theme) default
proc vTclWindow.top1 {base} {
global vTcl
if {$base == ""} {
set base .top1
}
if {[winfo exists $base]} {
wm deiconify $base; return
}
set top $base
set target $base
###################
# CREATING WIDGETS
###################
vTcl::widgets::core::toplevel::createCmd $top -class Toplevel \
-background #d9d9d9 -highlightbackground #d9d9d9 \
-highlightcolor #000000
wm focusmodel $top passive
wm geometry $top 600x450+374+144
update
# set in toplevel.wgt.
global vTcl
global img_list
set vTcl(save,dflt,origin) 0
wm maxsize $top 3364 881
wm minsize $top 120 1
wm overrideredirect $top 0
wm resizable $top 1 1
wm deiconify $top
set toptitle "Toplevel 0"
wm title $top $toptitle
namespace eval ::widgets::${top}::ClassOption {}
set ::widgets::${top}::ClassOption(-toptitle) $toptitle
vTcl:DefineAlias "$top" "Toplevel1" vTcl:Toplevel:WidgetProc "" 1
set vTcl(real_top) {}
button "$top.but47" \
-activebackground #d9d9d9 -activeforeground black -background #d9d9d9 \
-disabledforeground #a3a3a3 -font "-family {Segoe UI} -size 9" \
-foreground #000000 -highlightbackground #d9d9d9 \
-highlightcolor #000000 -text "Button"
vTcl:DefineAlias "$top.but47" "Button1" vTcl:WidgetProc "Toplevel1" 1
frame "$top.fra48" \
-borderwidth 2 -relief groove -background #d9d9d9 -height 355 \
-highlightbackground #d9d9d9 -highlightcolor #000000 -width 565
vTcl:DefineAlias "$top.fra48" "Frame1" vTcl:WidgetProc "Toplevel1" 1
set site_3_0 $top.fra48
vTcl::widgets::ttk::pnotebook::createCmd "$site_3_0.pNo49" \
-width 512 -height 304 -style "PC.TNotebook" -style PC.TNotebook
vTcl:DefineAlias "$site_3_0.pNo49" "PNotebook1" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 configure -style "PC.TNotebook"
bind $site_3_0.pNo49 <Button-1> {
_button_press
}
bind $site_3_0.pNo49 <ButtonRelease-1> {
_button_release
}
bind $site_3_0.pNo49 <Motion> {
_mouse_over
}
ttk::frame "$site_3_0.pNo49.t0"
vTcl:DefineAlias "$site_3_0.pNo49.t0" "PNotebook1_t1" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 add $site_3_0.pNo49.t0 \
-padding 0 -sticky nsew -state normal -text "Page 1" -image image6 \
-compound right -underline -1
set site_5_0 $site_3_0.pNo49.t0
ttk::frame "$site_3_0.pNo49.t1"
vTcl:DefineAlias "$site_3_0.pNo49.t1" "PNotebook1_t2" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 add $site_3_0.pNo49.t1 \
-padding 0 -sticky nsew -state normal -text "Page 2" -image image6 \
-compound right -underline -1
set site_5_1 $site_3_0.pNo49.t1
place $site_3_0.pNo49 \
-in $site_3_0 -x 0 -relx 0.035 -y 0 -rely 0.056 -width 0 \
-relwidth 0.906 -height 0 -relheight 0.856 -anchor nw \
-bordermode ignore
###################
# SETTING GEOMETRY
###################
place $top.but47 \
-in $top -x 0 -relx 0.033 -y 0 -rely 0.022 -width 97 -relwidth 0 \
-height 46 -relheight 0 -anchor nw -bordermode ignore
place $top.fra48 \
-in $top -x 0 -relx 0.033 -y 0 -rely 0.178 -width 0 -relwidth 0.942 \
-height 0 -relheight 0.789 -anchor nw -bordermode ignore
vTcl:FireEvent $base <<Ready>>
}
proc 36 {args} {return 1}
Window show .
set btop1 ""
if {$vTcl(borrow)} {
set btop1 .bor[expr int([expr rand() * 100])]
while {[lsearch $btop1 $vTcl(tops)] != -1} {
set btop1 .bor[expr int([expr rand() * 100])]
}
}
set vTcl(btop) $btop1
Window show .top1 $btop1
if {$vTcl(borrow)} {
$btop1 configure -background plum
}

148
backups/autosave.tcl.bak2 Normal file
View File

@@ -0,0 +1,148 @@
#############################################################################
# Generated by PAGE version 8.0
# in conjunction with Tcl version 8.6
# Sep 15, 2025 05:34:52 PM CEST platform: Windows NT
set vTcl(timestamp) ""
if {![info exists vTcl(borrow)]} {
::vTcl::MessageBox -title Error -message "You must open project files from within PAGE."
exit}
set vTcl(actual_gui_font_dft_desc) TkDefaultFont
set vTcl(actual_gui_font_dft_name) TkDefaultFont
set vTcl(actual_gui_font_text_desc) TkTextFont
set vTcl(actual_gui_font_text_name) TkTextFont
set vTcl(actual_gui_font_fixed_desc) TkFixedFont
set vTcl(actual_gui_font_fixed_name) TkFixedFont
set vTcl(actual_gui_font_menu_desc) TkMenuFont
set vTcl(actual_gui_font_menu_name) TkMenuFont
set vTcl(actual_gui_font_tooltip_desc) TkDefaultFont
set vTcl(actual_gui_font_tooltip_name) TkDefaultFont
set vTcl(actual_gui_font_treeview_desc) TkDefaultFont
set vTcl(actual_gui_font_treeview_name) TkDefaultFont
###########################################
set vTcl(actual_gui_bg) #d9d9d9
set vTcl(actual_gui_fg) #000000
set vTcl(actual_gui_analog) #ececec
set vTcl(actual_gui_menu_analog) #ececec
set vTcl(actual_gui_menu_bg) #d9d9d9
set vTcl(actual_gui_menu_fg) #000000
set vTcl(complement_color) gray40
set vTcl(analog_color_p) #c3c3c3
set vTcl(analog_color_m) beige
set vTcl(tabfg1) black
set vTcl(tabfg2) white
set vTcl(actual_gui_menu_active_bg) #ececec
set vTcl(actual_gui_menu_active_fg) #000000
###########################################
set vTcl(pr,autoalias) 1
set vTcl(pr,relative_placement) 1
set vTcl(mode) Relative
set vTcl(project_theme) default
proc vTclWindow.top1 {base} {
global vTcl
if {$base == ""} {
set base .top1
}
if {[winfo exists $base]} {
wm deiconify $base; return
}
set top $base
set target $base
###################
# CREATING WIDGETS
###################
vTcl::widgets::core::toplevel::createCmd $top -class Toplevel \
-background #d9d9d9 -highlightbackground #d9d9d9 \
-highlightcolor #000000
wm focusmodel $top passive
wm geometry $top 600x450+374+144
update
# set in toplevel.wgt.
global vTcl
global img_list
set vTcl(save,dflt,origin) 0
wm maxsize $top 3364 881
wm minsize $top 120 1
wm overrideredirect $top 0
wm resizable $top 1 1
wm deiconify $top
set toptitle "Toplevel 0"
wm title $top $toptitle
namespace eval ::widgets::${top}::ClassOption {}
set ::widgets::${top}::ClassOption(-toptitle) $toptitle
vTcl:DefineAlias "$top" "Toplevel1" vTcl:Toplevel:WidgetProc "" 1
set vTcl(real_top) {}
button "$top.but47" \
-activebackground #d9d9d9 -activeforeground black -background #d9d9d9 \
-disabledforeground #a3a3a3 -font "-family {Segoe UI} -size 9" \
-foreground #000000 -highlightbackground #d9d9d9 \
-highlightcolor #000000 -text "Button"
vTcl:DefineAlias "$top.but47" "Button1" vTcl:WidgetProc "Toplevel1" 1
frame "$top.fra48" \
-borderwidth 2 -relief groove -background #d9d9d9 -height 355 \
-highlightbackground #d9d9d9 -highlightcolor #000000 -width 565
vTcl:DefineAlias "$top.fra48" "Frame1" vTcl:WidgetProc "Toplevel1" 1
set site_3_0 $top.fra48
vTcl::widgets::ttk::pnotebook::createCmd "$site_3_0.pNo49" \
-width 300 -height 200 -style "PC.TNotebook" -style PC.TNotebook
vTcl:DefineAlias "$site_3_0.pNo49" "PNotebook1" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 configure -style "PC.TNotebook"
bind $site_3_0.pNo49 <Button-1> {
_button_press
}
bind $site_3_0.pNo49 <ButtonRelease-1> {
_button_release
}
bind $site_3_0.pNo49 <Motion> {
_mouse_over
}
ttk::frame "$site_3_0.pNo49.t0"
vTcl:DefineAlias "$site_3_0.pNo49.t0" "PNotebook1_t1" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 add $site_3_0.pNo49.t0 \
-padding 0 -sticky nsew -state normal -text "Page 1" -image image6 \
-compound right -underline -1
set site_5_0 $site_3_0.pNo49.t0
ttk::frame "$site_3_0.pNo49.t1"
vTcl:DefineAlias "$site_3_0.pNo49.t1" "PNotebook1_t2" vTcl:WidgetProc "Toplevel1" 1
$site_3_0.pNo49 add $site_3_0.pNo49.t1 \
-padding 0 -sticky nsew -state normal -text "Page 2" -image image6 \
-compound right -underline -1
set site_5_1 $site_3_0.pNo49.t1
place $site_3_0.pNo49 \
-in $site_3_0 -x 0 -relx 0.035 -y 0 -rely 0.056 -width 0 \
-relwidth 0.535 -height 0 -relheight 0.631 -anchor nw \
-bordermode ignore
###################
# SETTING GEOMETRY
###################
place $top.but47 \
-in $top -x 0 -relx 0.033 -y 0 -rely 0.022 -width 97 -relwidth 0 \
-height 46 -relheight 0 -anchor nw -bordermode ignore
place $top.fra48 \
-in $top -x 0 -relx 0.033 -y 0 -rely 0.178 -width 0 -relwidth 0.942 \
-height 0 -relheight 0.789 -anchor nw -bordermode ignore
vTcl:FireEvent $base <<Ready>>
}
proc 36 {args} {return 1}
Window show .
set btop1 ""
if {$vTcl(borrow)} {
set btop1 .bor[expr int([expr rand() * 100])]
while {[lsearch $btop1 $vTcl(tops)] != -1} {
set btop1 .bor[expr int([expr rand() * 100])]
}
}
set vTcl(btop) $btop1
Window show .top1 $btop1
if {$vTcl(borrow)} {
$btop1 configure -background plum
}

View File

@@ -0,0 +1,4 @@
{
"db": "Mediseawall",
"baseline": {}
}

33
db_async_singleton.py Normal file
View File

@@ -0,0 +1,33 @@
# db_async_singleton.py
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
class AsyncDB:
def __init__(self, engine):
self.engine = engine
async def query_json(self, sql: str, params: dict):
async with self.engine.connect() as conn:
result = await conn.execute(text(sql), params)
rows = [tuple(r) for r in result]
return {"rows": rows}
_ENGINE = None
async def _make_engine_async(conn_str: str):
return create_async_engine(conn_str, pool_pre_ping=True, future=True)
def get_db(loop: asyncio.AbstractEventLoop, conn_str: str) -> AsyncDB:
"""Crea l'engine UNA volta, dentro il loop globale, e restituisce il client."""
global _ENGINE
if _ENGINE is None:
fut = asyncio.run_coroutine_threadsafe(_make_engine_async(conn_str), loop)
_ENGINE = fut.result()
return AsyncDB(_ENGINE)
async def dispose_async():
global _ENGINE
if _ENGINE is not None:
await _ENGINE.dispose()
_ENGINE = None

40
fix_layout_window.py Normal file
View File

@@ -0,0 +1,40 @@
import re
from pathlib import Path
# Path default (modifica se serve)
p = Path("./layout_window.py")
if not p.exists():
raise SystemExit(f"File non trovato: {p}")
src = p.read_text(encoding="utf-8")
# 1) Rimuovi i parametri border_color="transparent" nelle chiamate configure(...).
# Gestiamo i casi ", border_color='transparent'" e "border_color='transparent',"
patterns = [
re.compile(r""",\s*border_color\s*=\s*["']transparent["']"""), # , border_color="transparent"
re.compile(r"""border_color\s*=\s*["']transparent["']\s*,\s*""") # border_color="transparent",
]
for pat in patterns:
src = pat.sub("", src)
# 2) Se sono rimaste virgole prima della parentesi di chiusura: ", )" -> ")"
src = re.sub(r",\s*\)", ")", src)
# 3) (opzionale/robusto) Rimuovi border_color=None se presente in qualche versione
patterns_none = [
re.compile(r""",\s*border_color\s*=\s*None"""),
re.compile(r"""border_color\s*=\s*None\s*,\s*""")
]
for pat in patterns_none:
src = pat.sub("", src)
src = re.sub(r",\s*\)", ")", src)
# 4) NOTE: manteniamo eventuali border_color="blue" per lhighlight
# Scrivi backup e nuovo file
bak = p.with_suffix(".py.bak_fix_bc_transparent")
if not bak.exists():
bak.write_text(Path(p).read_text(encoding="utf-8"), encoding="utf-8")
p.write_text(src, encoding="utf-8")
print(f"Patch applicata a {p}. Backup: {bak}")

87
fix_query.py Normal file
View File

@@ -0,0 +1,87 @@
from pathlib import Path
import re
p = Path("./layout_window.py")
src = p.read_text(encoding="utf-8")
backup = p.with_suffix(".py.bak_perf")
if not backup.exists():
backup.write_text(src, encoding="utf-8")
# 1) Rimuovi il bind su <Configure> che innescava refresh continui.
src = src.replace(
' self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))\n',
' # disabilitato: il refresh ad ogni <Configure> generava molte query/lag\n'
' # self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))\n'
)
# 2) Nel metodo _refresh_stats, elimina il blocco che interroga il DB "globale".
# Riconosciamo il blocco su "sql_tot = ..." e lo neutralizziamo.
src = re.sub(
r"\n\s*# globale dal DB[\s\S]*?self\._async\.run\(.*?\)\)\n",
"\n # [patch] rimosso refresh globale da DB: calcoliamo solo dalla matrice in memoria\n",
src,
flags=re.MULTILINE
)
# 3) Aggiungi un flag di vita finestra e un destroy sicuro
# - settiamo self._alive = True in __init__
# - override destroy() per annullare timer e marcare _alive=False
src = src.replace(
" self._last_req = 0\n",
" self._last_req = 0\n"
" self._alive = True\n"
" self._stats_after_id = None # se mai userai un refresh periodico, potremo cancellarlo qui\n"
)
# aggiungi metodo destroy() se non esiste già
if "def destroy(self):" not in src:
insert_point = src.find("def open_layout_window(")
destroy_method = (
"\n def destroy(self):\n"
" # evita nuovi refresh/async dopo destroy\n"
" self._alive = False\n"
" # cancella eventuali timer\n"
" try:\n"
" if self._stats_after_id is not None:\n"
" self.after_cancel(self._stats_after_id)\n"
" except Exception:\n"
" pass\n"
" # pulizia UI leggera\n"
" try:\n"
" for w in list(self.host.winfo_children()):\n"
" w.destroy()\n"
" except Exception:\n"
" pass\n"
" try:\n"
" super().destroy()\n"
" except Exception:\n"
" pass\n\n"
)
src = src[:insert_point] + destroy_method + src[insert_point:]
# 4) Nei callback _ok/_err delle query, assicurati che non facciano nulla se la finestra è chiusa
# => sostituiamo 'def _ok(res):' con un guard iniziale e idem per _err.
src = re.sub(
r"def _ok\(res\):\n",
"def _ok(res):\n"
" if not getattr(self, '_alive', True) or not self.winfo_exists():\n"
" return\n",
src
)
src = re.sub(
r"def _err\(ex\):\n",
"def _err(ex):\n"
" if not getattr(self, '_alive', True) or not self.winfo_exists():\n"
" return\n",
src
)
# 5) Piccola robustezza: prima di schedulare highlight post-ricarica controlla ancora _alive
src = src.replace(
" if self._pending_focus and self._pending_focus[0] == corsia:\n",
" if getattr(self, '_alive', True) and self._pending_focus and self._pending_focus[0] == corsia:\n"
)
p.write_text(src, encoding="utf-8")
print(f"Patch applicata a {p} (backup in {backup}).")

View File

@@ -0,0 +1,170 @@
# gestione_aree_frame_async.py
from __future__ import annotations
import asyncio
import threading
import tkinter as tk
import customtkinter as ctk
from typing import Any, Callable, Optional
__VERSION__ = "GestioneAreeFrame v3.2.5-singleloop"
#print("[GestioneAreeFrame] loaded", __VERSION__)
try:
from async_msssql_query import AsyncMSSQLClient # noqa: F401
except Exception:
AsyncMSSQLClient = object # type: ignore
# ========================
# Global asyncio loop
# ========================
class _LoopHolder:
def __init__(self):
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.thread: Optional[threading.Thread] = None
self.ready = threading.Event()
_GLOBAL = _LoopHolder()
def _run_loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_GLOBAL.loop = loop
_GLOBAL.ready.set()
loop.run_forever()
def get_global_loop() -> asyncio.AbstractEventLoop:
if _GLOBAL.loop is not None:
return _GLOBAL.loop
_GLOBAL.thread = threading.Thread(target=_run_loop, name="warehouse-asyncio", daemon=True)
_GLOBAL.thread.start()
_GLOBAL.ready.wait(timeout=5.0)
if _GLOBAL.loop is None:
raise RuntimeError("Impossibile avviare l'event loop globale")
return _GLOBAL.loop
def stop_global_loop():
if _GLOBAL.loop and _GLOBAL.loop.is_running():
_GLOBAL.loop.call_soon_threadsafe(_GLOBAL.loop.stop)
if _GLOBAL.thread:
_GLOBAL.thread.join(timeout=2.0)
_GLOBAL.loop = None
_GLOBAL.thread = None
_GLOBAL.ready.clear()
# ========================
# Busy overlay
# ========================
class BusyOverlay:
def __init__(self, parent: tk.Misc):
self.parent = parent
self._top: Optional[ctk.CTkToplevel] = None
self._pb: Optional[ctk.CTkProgressBar] = None
self._lbl: Optional[ctk.CTkLabel] = None
self._bind_id = None
def _reposition(self):
if not self._top:
return
root = self.parent.winfo_toplevel()
root.update_idletasks()
x, y = root.winfo_rootx(), root.winfo_rooty()
w, h = root.winfo_width(), root.winfo_height()
self._top.geometry(f"{w}x{h}+{x}+{y}")
def show(self, message="Attendere…"):
if self._top:
if self._lbl:
self._lbl.configure(text=message)
return
root = self.parent.winfo_toplevel()
top = ctk.CTkToplevel(root)
self._top = top
top.overrideredirect(True)
try:
top.attributes("-alpha", 0.22)
except tk.TclError:
pass
try:
top.configure(fg_color="#000000")
except Exception:
top.configure(bg="#000000")
top.attributes("-topmost", True)
wrap = ctk.CTkFrame(top, corner_radius=8)
wrap.place(relx=0.5, rely=0.5, anchor="center")
self._lbl = ctk.CTkLabel(wrap, text=message, font=("Segoe UI", 11, "bold"))
self._lbl.pack(pady=(0, 10))
self._pb = ctk.CTkProgressBar(wrap, mode="indeterminate", width=260)
self._pb.pack(fill="x")
try:
self._pb.start()
except Exception:
pass
self._reposition()
self._bind_id = root.bind("<Configure>", lambda e: self._reposition(), add="+")
def hide(self):
if self._pb:
try:
self._pb.stop()
except Exception:
pass
self._pb = None
if self._top:
try:
self._top.destroy()
except Exception:
pass
self._top = None
root = self.parent.winfo_toplevel()
if self._bind_id:
try:
root.unbind("<Configure>", self._bind_id)
except Exception:
pass
self._bind_id = None
# ========================
# AsyncRunner (single-loop)
# ========================
class AsyncRunner:
"""Run awaitables on the single global loop and callback on Tk main thread."""
def __init__(self, widget: tk.Misc):
self.widget = widget
self.loop = get_global_loop()
def run(
self,
awaitable,
on_success: Callable[[Any], None],
on_error: Optional[Callable[[BaseException], None]] = None,
busy: Optional[BusyOverlay] = None,
message: str = "Operazione in corso…",
):
if busy:
busy.show(message)
fut = asyncio.run_coroutine_threadsafe(awaitable, self.loop)
def _poll():
if fut.done():
if busy:
busy.hide()
try:
res = fut.result()
except BaseException as ex:
if on_error:
self.widget.after(0, lambda e=ex: on_error(e))
else:
print("[AsyncRunner] Unhandled error:", repr(ex))
else:
self.widget.after(0, lambda r=res: on_success(r))
else:
self.widget.after(60, _poll)
_poll()
def close(self):
# no-op: loop is global
pass

668
gestione_pickinglist.py Normal file
View File

@@ -0,0 +1,668 @@
# =================== gestione_pickinglist.py (NO-FLICKER + UX TUNING + MICRO-SPINNER) ===================
from __future__ import annotations
import tkinter as tk
import customtkinter as ctk
from tkinter import messagebox
from typing import Optional, Any, Dict, List, Callable
from dataclasses import dataclass
# Usa overlay e runner "collaudati"
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
from async_loop_singleton import get_global_loop
from db_async_singleton import get_db as _get_db_singleton
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
import asyncio
try:
from prenota_sprenota_sql import sp_xExePackingListPallet_async, SPResult
except Exception:
async def sp_xExePackingListPallet_async(*args, **kwargs):
raise RuntimeError("sp_xExePackingListPallet_async non importabile: verifica prenota_sprenota_sql.py")
class SPResult:
def __init__(self, rc=-1, message="Procedura non disponibile", id_result=None):
self.rc = rc; self.message = message; self.id_result = id_result
# -------------------- SQL --------------------
SQL_PL = """
SELECT
COUNT(DISTINCT Pallet) AS Pallet,
COUNT(DISTINCT Lotto) AS Lotto,
COUNT(DISTINCT Articolo) AS Articolo,
COUNT(DISTINCT Descrizione) AS Descrizione,
SUM(Qta) AS Qta,
Documento,
CodNazione,
NAZIONE,
Stato,
MAX(PalletCella) AS PalletCella,
MAX(Magazzino) AS Magazzino,
MAX(Area) AS Area,
MAX(Cella) AS Cella,
MIN(Ordinamento) AS Ordinamento,
MAX(IDStato) AS IDStato
FROM dbo.XMag_ViewPackingList
GROUP BY Documento, CodNazione, NAZIONE, Stato
ORDER BY MIN(Ordinamento), Documento, NAZIONE, Stato;
"""
SQL_PL_DETAILS = """
SELECT *
FROM ViewPackingListRestante
WHERE Documento = :Documento
ORDER BY Ordinamento;
"""
# -------------------- helpers --------------------
def _rows_to_dicts(res: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Converte il payload ritornato da query_json in lista di dict.
Supporta:
- res = [ {..}, {..} ]
- res = { "rows": [..], "columns": [...] }
- res = { "data": [..], "columns": [...] }
- res = { "rows": [tuple,..], "columns": [...] }
"""
if res is None:
return []
if isinstance(res, list):
if not res:
return []
if isinstance(res[0], dict):
return res
return []
if isinstance(res, dict):
for rows_key in ("rows", "data", "result", "records"):
if rows_key in res and isinstance(res[rows_key], list):
rows = res[rows_key]
if not rows:
return []
if isinstance(rows[0], dict):
return rows
cols = res.get("columns") or res.get("cols") or []
out = []
for r in rows:
if cols and isinstance(r, (list, tuple)):
out.append({ (cols[i] if i < len(cols) else f"c{i}") : r[i]
for i in range(min(len(cols), len(r))) })
else:
if isinstance(r, (list, tuple)):
out.append({ f"c{i}": r[i] for i in range(len(r)) })
return out
if res and all(not isinstance(v, (list, tuple, dict)) for v in res.values()):
return [res]
return []
def _s(v) -> str:
"""Stringify safe: None -> '', altrimenti str(v)."""
return "" if v is None else str(v)
def _first(d: Dict[str, Any], keys: List[str], default: str = ""):
for k in keys:
if k in d and d[k] not in (None, ""):
return d[k]
return default
# -------------------- column specs --------------------
@dataclass
class ColSpec:
title: str
key: str
width: int
anchor: str # 'w' | 'e' | 'center'
# Colonne PL (in alto) — include IDStato per la colorazione
PL_COLS: List[ColSpec] = [
ColSpec("", "__check__", 36, "w"),
ColSpec("Documento", "Documento", 120, "w"),
ColSpec("NAZIONE", "NAZIONE", 240, "w"),
ColSpec("Stato", "Stato", 110, "w"),
ColSpec("IDStato", "IDStato", 80, "e"), # nuova colonna
ColSpec("#Pallet", "Pallet", 100, "e"),
ColSpec("#Lotti", "Lotto", 100, "e"),
ColSpec("#Articoli", "Articolo", 110, "e"),
ColSpec("Qta", "Qta", 120, "e"),
]
DET_COLS: List[ColSpec] = [
ColSpec("UDC/Pallet", "Pallet", 150, "w"),
ColSpec("Lotto", "Lotto", 130, "w"),
ColSpec("Articolo", "Articolo", 150, "w"),
ColSpec("Descrizione","Descrizione", 320, "w"),
ColSpec("Qta", "Qta", 110, "e"),
ColSpec("Ubicazione", "Ubicazione", 320, "w"),
]
ROW_H = 28
# -------------------- Micro spinner (toolbar) --------------------
class ToolbarSpinner:
"""
Micro-animazione leggerissima per indicare attività:
mostra una label con frame: ◐ ◓ ◑ ◒ ... finché è attivo.
"""
FRAMES = ("", "", "", "")
def __init__(self, parent: tk.Widget):
self.parent = parent
self.lbl = ctk.CTkLabel(parent, text="", width=28)
self._i = 0
self._active = False
self._job = None
def widget(self) -> ctk.CTkLabel:
return self.lbl
def start(self, text: str = ""):
if self._active:
return
self._active = True
self.lbl.configure(text=f"{self.FRAMES[self._i]} {text}".strip())
self._tick()
def stop(self):
self._active = False
if self._job is not None:
try:
self.parent.after_cancel(self._job)
except Exception:
pass
self._job = None
self.lbl.configure(text="")
def _tick(self):
if not self._active:
return
self._i = (self._i + 1) % len(self.FRAMES)
current = self.lbl.cget("text")
# Mantieni l'eventuale testo dopo il simbolo
txt_suffix = ""
if isinstance(current, str) and len(current) > 2:
txt_suffix = current[2:]
self.lbl.configure(text=f"{self.FRAMES[self._i]}{txt_suffix}")
self._job = self.parent.after(120, self._tick) # 8 fps soft
# -------------------- Scrollable table --------------------
class ScrollTable(ctk.CTkFrame):
GRID_COLOR = "#D0D5DD"
PADX_L = 8
PADX_R = 8
PADY = 2
def __init__(self, master, columns: List[ColSpec]):
super().__init__(master)
self.columns = columns
self.total_w = sum(c.width for c in self.columns)
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",
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)
self.h_canvas.grid(row=0, column=0, sticky="ew")
# body
self.b_canvas = tk.Canvas(self, highlightthickness=0, bd=0)
self.b_inner = ctk.CTkFrame(self.b_canvas, fg_color="transparent",
width=self.total_w)
self.body_window = self.b_canvas.create_window((0,0), window=self.b_inner,
anchor="nw", width=self.total_w)
self.b_canvas.grid(row=1, column=0, sticky="nsew")
# 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
self.b_canvas.configure(yscrollcommand=self.vbar.set, xscrollcommand=self._xscroll_set_both)
self.h_canvas.configure(xscrollcommand=self.xbar.set)
# bind
self.h_inner.bind("<Configure>", lambda e: self._sync_header_width())
self.b_inner.bind("<Configure>", lambda e: self._on_body_configure())
self._build_header()
def _build_header(self):
for w in self.h_inner.winfo_children():
w.destroy()
row = ctk.CTkFrame(self.h_inner, fg_color="#f3f3f3",
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",
width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR
)
holder.pack(side="left", fill="y")
holder.pack_propagate(False)
lbl = ctk.CTkLabel(holder, text=col.title, anchor="w")
lbl.pack(fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY)
self.h_inner.configure(width=self.total_w, height=ROW_H)
self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
def _update_body_width(self):
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
sr = self.b_canvas.bbox("all")
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))
def _on_body_configure(self):
self._update_body_width()
self._sync_header_width()
def _sync_header_width(self):
first, _ = self.b_canvas.xview()
self.h_canvas.xview_moveto(first)
def _xscroll_both(self, *args):
self.h_canvas.xview(*args)
self.b_canvas.xview(*args)
def _xscroll_set_both(self, first, last):
self.h_canvas.xview_moveto(first)
self.xbar.set(first, last)
def clear_rows(self):
for w in self.b_inner.winfo_children():
w.destroy()
self._update_body_width()
def add_row(
self,
values: List[str],
row_index: int,
anchors: Optional[List[str]] = None,
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
):
row = ctk.CTkFrame(self.b_inner, fg_color="transparent",
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",
width=col.width, height=ROW_H,
border_width=1, border_color=self.GRID_COLOR
)
holder.pack(side="left", fill="y")
holder.pack_propagate(False)
if col.key == "__check__":
if checkbox_builder:
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")
else:
anchor = (anchors[i] if anchors else col.anchor)
ctk.CTkLabel(holder, text=values[i], anchor=anchor).pack(
fill="both", padx=(self.PADX_L, self.PADX_R), pady=self.PADY
)
self._update_body_width()
# -------------------- PL row model --------------------
class PLRow:
def __init__(self, pl: Dict[str, Any], on_check):
self.pl = pl
self.var = ctk.BooleanVar(value=False)
self._callback = on_check
def is_checked(self) -> bool: return self.var.get()
def set_checked(self, val: bool): self.var.set(val)
def build_checkbox(self, parent) -> ctk.CTkCheckBox:
return ctk.CTkCheckBox(parent, text="", variable=self.var,
command=lambda: self._callback(self, self.var.get()))
# -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
class GestionePickingListFrame(ctk.CTkFrame):
def __init__(self, master, *, db_client=None, conn_str=None):
super().__init__(master)
self.db_client = db_client or _get_db_singleton(get_global_loop(), conn_str)
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
self.busy = BusyOverlay(self) # overlay collaudato
self.rows_models: list[PLRow] = []
self._detail_cache: Dict[Any, list] = {}
self.detail_doc = None
self._first_loading: bool = False # flag per cursore d'attesa solo al primo load
self._build_layout()
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
self.after_idle(self._first_show)
def _first_show(self):
"""Chiamato a finestra già resa → evitiamo sfarfallio del primo paint e mostriamo wait-cursor."""
self._first_loading = True
try:
self.winfo_toplevel().configure(cursor="watch")
except Exception:
pass
# spinner inizia
self.spinner.start(" Carico…")
self.reload_from_db(first=True)
# ---------- UI ----------
def _build_layout(self):
for r in (1, 3): self.grid_rowconfigure(r, weight=1)
self.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="ew", padx=10, pady=(8,4))
for i, (text, cmd) in enumerate([
("Ricarica", self.reload_from_db),
("Prenota", self.on_prenota),
("S-prenota", self.on_sprenota),
("Esporta XLSX", self.on_export)
]):
ctk.CTkButton(top, text=text, command=cmd).grid(row=0, column=i, padx=6)
# --- micro spinner a destra della toolbar ---
self.spinner = ToolbarSpinner(top)
self.spinner.widget().grid(row=0, column=10, padx=(8,0)) # largo spazio a destra
self.pl_table = ScrollTable(self, PL_COLS)
self.pl_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=(4,8))
self.det_table = ScrollTable(self, DET_COLS)
self.det_table.grid(row=3, column=0, sticky="nsew", padx=10, pady=(4,10))
self._draw_details_hint()
def _draw_details_hint(self):
self.det_table.clear_rows()
self.det_table.add_row(
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
row_index=0,
anchors=["w"]*6
)
def _apply_row_colors(self, rows: List[Dict[str, Any]]):
"""Colorazione differita (after_idle) per evitare micro-jank durante l'inserimento righe."""
try:
for idx, d in enumerate(rows):
row_widget = self.pl_table.b_inner.winfo_children()[idx]
if int(d.get("IDStato") or 0) == 1:
row_widget.configure(fg_color="#ffe6f2") # rosa tenue
else:
row_widget.configure(fg_color="transparent")
except Exception:
pass
def _refresh_mid_rows(self, rows: List[Dict[str, Any]]):
self.pl_table.clear_rows()
self.rows_models.clear()
for r, d in enumerate(rows):
model = PLRow(d, self.on_row_checked)
self.rows_models.append(model)
values = [
"", # checkbox
_s(d.get("Documento")),
_s(d.get("NAZIONE")),
_s(d.get("Stato")),
_s(d.get("IDStato")), # nuova colonna visibile
_s(d.get("Pallet")),
_s(d.get("Lotto")),
_s(d.get("Articolo")),
_s(d.get("Qta")),
]
self.pl_table.add_row(
values=values,
row_index=r,
anchors=[c.anchor for c in PL_COLS],
checkbox_builder=model.build_checkbox
)
# 🎯 Colora dopo che la UI è resa → no balzi visivi
self.after_idle(lambda: self._apply_row_colors(rows))
# ----- helpers -----
def _get_selected_model(self) -> Optional[PLRow]:
for m in self.rows_models:
if m.is_checked():
return m
return None
def _recolor_row_by_documento(self, documento: str, idstato: int):
"""Aggiorna colore riga e cella IDStato per il Documento indicato."""
for idx, m in enumerate(self.rows_models):
if _s(m.pl.get("Documento")) == _s(documento):
m.pl["IDStato"] = idstato
def _paint():
try:
row_widget = self.pl_table.b_inner.winfo_children()[idx]
row_widget.configure(fg_color="#ffe6f2" if idstato == 1 else "transparent")
row_children = row_widget.winfo_children()
if len(row_children) >= 5:
holder = row_children[4]
if holder.winfo_children():
lbl = holder.winfo_children()[0]
if hasattr(lbl, "configure"):
lbl.configure(text=str(idstato))
except Exception:
pass
# differisci la colorazione (smooth)
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."""
for m in self.rows_models:
if _s(m.pl.get("Documento")) == _s(documento):
m.set_checked(True)
self.on_row_checked(m, True)
break
# ----- eventi -----
def on_row_checked(self, model: PLRow, is_checked: bool):
# selezione esclusiva
if is_checked:
for m in self.rows_models:
if m is not model and m.is_checked():
m.set_checked(False)
self.detail_doc = model.pl.get("Documento")
self.spinner.start(" Carico dettagli…") # spinner ON
async def _job():
return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
def _ok(res):
self.spinner.stop() # spinner OFF
self._detail_cache[self.detail_doc] = _rows_to_dicts(res)
# differisci il render dei dettagli (più fluido)
self.after_idle(self._refresh_details)
def _err(ex):
self.spinner.stop()
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
self.runner.run(
_job(),
on_success=_ok,
on_error=_err,
busy=self.busy,
message=f"Carico UDC per Documento {self.detail_doc}"
)
else:
if not any(m.is_checked() for m in self.rows_models):
self.detail_doc = None
self._refresh_details()
# ----- load PL -----
def reload_from_db(self, first: bool = False):
self.spinner.start(" Carico…") # spinner ON
async def _job():
return await self.db_client.query_json(SQL_PL, {})
def _on_success(res):
rows = _rows_to_dicts(res)
self._refresh_mid_rows(rows)
self.spinner.stop() # spinner OFF
# se era il primo load, ripristina il cursore standard
if self._first_loading:
try:
self.winfo_toplevel().configure(cursor="")
except Exception:
pass
self._first_loading = False
def _on_error(ex):
self.spinner.stop()
if self._first_loading:
try:
self.winfo_toplevel().configure(cursor="")
except Exception:
pass
self._first_loading = False
messagebox.showerror("DB", f"Errore nel caricamento:\n{ex}")
self.runner.run(
_job(),
on_success=_on_success,
on_error=_on_error,
busy=self.busy,
message="Caricamento Picking List…" if first else "Aggiornamento…"
)
def _refresh_details(self):
self.det_table.clear_rows()
if not self.detail_doc:
self._draw_details_hint()
return
rows = self._detail_cache.get(self.detail_doc, [])
if not rows:
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
row_index=0, anchors=["w"]*6)
return
for r, d in enumerate(rows):
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
lotto = _s(_first(d, ["Lotto"]))
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
self.det_table.add_row(
values=[pallet, lotto, articolo, descr, qta, loc],
row_index=r,
anchors=[c.anchor for c in DET_COLS]
)
# ----- azioni -----
def on_prenota(self):
model = self._get_selected_model()
if not model:
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
return
documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 0)
desired = 1
if current == desired:
messagebox.showinfo("Prenota", f"La Picking List {documento} è già prenotata.")
return
id_operatore = 1 # TODO: recupera dal contesto reale
self.spinner.start(" Prenoto…")
async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
def _ok(res: SPResult):
self.spinner.stop()
if res and res.rc == 0:
self._recolor_row_by_documento(documento, desired)
else:
msg = (res.message if res else "Errore sconosciuto")
messagebox.showerror("Prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex):
self.spinner.stop()
messagebox.showerror("Prenota", f"Errore:\n{ex}")
self.runner.run(
_job(),
on_success=_ok,
on_error=_err,
busy=self.busy,
message=f"Prenoto la Picking List {documento}"
)
def on_sprenota(self):
model = self._get_selected_model()
if not model:
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
return
documento = _s(model.pl.get("Documento"))
current = int(model.pl.get("IDStato") or 0)
desired = 0
if current == desired:
messagebox.showinfo("S-prenota", f"La Picking List {documento} è già NON prenotata.")
return
id_operatore = 1 # TODO: recupera dal contesto reale
self.spinner.start(" S-prenoto…")
async def _job():
return await sp_xExePackingListPallet_async(self.db_client, id_operatore, documento)
def _ok(res: SPResult):
self.spinner.stop()
if res and res.rc == 0:
self._recolor_row_by_documento(documento, desired)
else:
msg = (res.message if res else "Errore sconosciuto")
messagebox.showerror("S-prenota", f"Operazione non riuscita:\n{msg}")
def _err(ex):
self.spinner.stop()
messagebox.showerror("S-prenota", f"Errore:\n{ex}")
self.runner.run(
_job(),
on_success=_ok,
on_error=_err,
busy=self.busy,
message=f"S-prenoto la Picking List {documento}"
)
def on_export(self):
messagebox.showinfo("Esporta", "Stub esportazione.")
# factory per main
def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame':
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
return GestionePickingListFrame(parent, db_client=db_client, conn_str=conn_str)
# =================== /gestione_pickinglist.py ===================

670
layout_window.py Normal file
View File

@@ -0,0 +1,670 @@
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._alive = True
self._stats_after_id = None # se mai userai un refresh periodico, potremo cancellarlo qui
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
# disabilitato: il refresh ad ogni <Configure> generava molte query/lag
# self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0
)
def _clear_highlight(self):
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0)
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0)
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if getattr(self, '_alive', True) and self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}")
# ---------------- SEARCH ----------------
def _search_udc(self):
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
if not getattr(self, '_alive', True) or not self.winfo_exists():
return
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def destroy(self):
# evita nuovi refresh/async dopo destroy
self._alive = False
# cancella eventuali timer
try:
if self._stats_after_id is not None:
self.after_cancel(self._stats_after_id)
except Exception:
pass
# pulizia UI leggera
try:
for w in list(self.host.winfo_children()):
w.destroy()
except Exception:
pass
try:
super().destroy()
except Exception:
pass
def open_layout_window(parent, db_app):
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w

View File

@@ -0,0 +1,633 @@
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0, border_color="transparent"
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0, border_color="transparent"
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0, border_color="transparent"
)
def _clear_highlight(self):
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0, border_color="transparent")
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0, border_color="transparent")
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6,
)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}…")
# ---------------- SEARCH ----------------
def _search_udc(self):
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def open_layout_window(parent, db_app):
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w

632
layout_window.py.bak_perf Normal file
View File

@@ -0,0 +1,632 @@
from __future__ import annotations
import tkinter as tk
from tkinter import Menu, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---- Color palette ----
COLOR_EMPTY = "#B0B0B0" # grigio (vuota)
COLOR_FULL = "#FFA500" # arancione (una UDC)
COLOR_DOUBLE = "#D62728" # rosso (>=2 UDC)
FG_DARK = "#111111"
FG_LIGHT = "#FFFFFF"
def pct_text(p_full: float, p_double: float | None = None) -> str:
p_full = max(0.0, min(1.0, p_full))
pf = round(p_full * 100, 1)
pe = round(100 - pf, 1)
if p_double and p_double > 0:
pd = round(p_double * 100, 1)
return f"Pieno {pf}% · Vuoto {pe}% (di cui doppie {pd}%)"
return f"Pieno {pf}% · Vuoto {pe}%"
class LayoutWindow(ctk.CTkToplevel):
"""
Visualizzazione layout corsie con matrice di celle.
- Ogni cella è un pulsante colorato (vuota/piena/doppia)
- Etichetta su DUE righe:
1) "Corsia.Colonna.Fila" (una sola riga, senza andare a capo)
2) barcode UDC (primo, se presente)
- Ricerca per barcode UDC con cambio automatico corsia + highlight cella
- Statistiche: globale e corsia selezionata
- Export XLSX
"""
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Layout corsie")
self.geometry("1200x740")
self.minsize(980, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# layout principale 5% / 80% / 15%
self.grid_rowconfigure(0, weight=5)
self.grid_rowconfigure(1, weight=80)
self.grid_rowconfigure(2, weight=15)
self.grid_columnconfigure(0, weight=1)
# stato runtime
self.corsia_selezionata = tk.StringVar()
self.buttons: list[list[ctk.CTkButton]] = []
self.btn_frames: list[list[ctk.CTkFrame]] = []
self.matrix_state: list[list[int]] = [] # <— rinominata: prima era self.state
self.fila_txt: list[list[str]] = []
self.col_txt: list[list[str]] = []
self.desc: list[list[str]] = []
self.udc1: list[list[str]] = [] # primo barcode UDC trovato (o "")
# ricerca → focus differito (corsia, col, fila, barcode)
self._pending_focus: tuple[str, str, str, str] | None = None
self._highlighted: tuple[int, int] | None = None
# anti-race: token per ignorare risposte vecchie
self._req_counter = 0
self._last_req = 0
self._build_top()
self._build_matrix_host()
self._build_stats()
self._load_corsie()
self.bind("<Configure>", lambda e: self.after_idle(self._refresh_stats))
# ---------------- TOP BAR ----------------
def _build_top(self):
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=6)
for i in range(4):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(1, weight=1)
# lista corsie
lf = ctk.CTkFrame(top)
lf.grid(row=0, column=0, sticky="nsw")
lf.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(lf, text="Corsie", font=("", 12, "bold")).grid(row=0, column=0, sticky="w", padx=6, pady=(6, 2))
self.lb = tk.Listbox(lf, height=6, exportselection=False)
self.lb.grid(row=1, column=0, sticky="nsw", padx=6, pady=(0, 6))
self.lb.bind("<<ListboxSelect>>", self._on_select)
# search by barcode
srch = ctk.CTkFrame(top)
srch.grid(row=0, column=1, sticky="nsew", padx=(10, 10))
self.search_var = tk.StringVar()
self.search_entry = ctk.CTkEntry(srch, textvariable=self.search_var, width=260)
self.search_entry.grid(row=0, column=0, sticky="w")
ctk.CTkButton(srch, text="Cerca per barcode UDC", command=self._search_udc).grid(row=0, column=1, padx=(8, 0))
srch.grid_columnconfigure(0, weight=1)
# toolbar
tb = ctk.CTkFrame(top)
tb.grid(row=0, column=3, sticky="ne")
ctk.CTkButton(tb, text="Aggiorna", command=self._refresh_current).grid(row=0, column=0, padx=4)
ctk.CTkButton(tb, text="Export XLSX", command=self._export_xlsx).grid(row=0, column=1, padx=4)
# ---------------- MATRIX HOST ----------------
def _build_matrix_host(self):
center = ctk.CTkFrame(self)
center.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 6))
center.grid_rowconfigure(0, weight=1)
center.grid_columnconfigure(0, weight=1)
self.host = ctk.CTkFrame(center)
self.host.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
def _apply_cell_style(self, btn: ctk.CTkButton, state: int):
if state == 0:
btn.configure(
fg_color=COLOR_EMPTY, hover_color="#9A9A9A",
text_color=FG_DARK, border_width=0
)
elif state == 1:
btn.configure(
fg_color=COLOR_FULL, hover_color="#E69500",
text_color=FG_DARK, border_width=0
)
else:
btn.configure(
fg_color=COLOR_DOUBLE, hover_color="#B22222",
text_color=FG_LIGHT, border_width=0
)
def _clear_highlight(self):
if self._highlighted and self.buttons:
r, c = self._highlighted
try:
if 0 <= r < len(self.buttons) and 0 <= c < len(self.buttons[r]):
btn = self.buttons[r][c]
if getattr(btn, "winfo_exists", None) and btn.winfo_exists():
try:
btn.configure(border_width=0)
except Exception:
pass
# clear blue frame border
try:
fr = self.btn_frames[r][c]
if fr and getattr(fr, "winfo_exists", None) and fr.winfo_exists():
fr.configure(border_width=0)
# in CTkFrame non esiste highlightthickness come in tk; border_* è corretto
except Exception:
pass
except Exception:
pass
self._highlighted = None
def _rebuild_matrix(self, rows: int, cols: int, state, fila_txt, col_txt, desc, udc1, corsia):
# prima rimuovi highlight su vecchi bottoni
self._clear_highlight()
# ripulisci host
for w in self.host.winfo_children():
w.destroy()
self.buttons.clear()
self.btn_frames.clear()
# salva matrici
self.matrix_state, self.fila_txt, self.col_txt, self.desc, self.udc1 = state, fila_txt, col_txt, desc, udc1
# ridistribuisci pesi griglia
for r in range(rows):
self.host.grid_rowconfigure(r, weight=1)
for c in range(cols):
self.host.grid_columnconfigure(c, weight=1)
# crea Frame+Button per cella (righe invertite: fila "a" in basso)
for r in range(rows):
row_btns = []
row_frames = []
for c in range(cols):
st = state[r][c]
code = f"{corsia}.{col_txt[r][c]}.{fila_txt[r][c]}" # PRIMA RIGA (in linea)
udc = udc1[r][c] or "" # SECONDA RIGA: barcode UDC
text = f"{code}\n{udc}"
cell = ctk.CTkFrame(self.host, corner_radius=6, border_width=0)
btn = ctk.CTkButton(
cell,
text=text,
corner_radius=6)
self._apply_cell_style(btn, st)
rr = (rows - 1) - r # capovolgi
cell.grid(row=rr, column=c, padx=1, pady=1, sticky="nsew")
btn.pack(fill="both", expand=True)
btn.configure(command=lambda rr=r, cc=c: self._open_menu(None, rr, cc))
btn.bind("<Button-3>", lambda e, rr=r, cc=c: self._open_menu(e, rr, cc))
row_btns.append(btn)
row_frames.append(cell)
self.buttons.append(row_btns)
self.btn_frames.append(row_frames)
# focus differito post-ricarica
if self._pending_focus and self._pending_focus[0] == corsia:
_, col, fila, _barcode = self._pending_focus
self._pending_focus = None
self._highlight_cell_by_labels(col, fila)
# ---------------- CONTEXT MENU ----------------
def _open_menu(self, event, r, c):
st = self.matrix_state[r][c]
corsia = self.corsia_selezionata.get()
label = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}"
m = Menu(self, tearoff=0)
m.add_command(label="Apri dettaglio", command=lambda: self._toast(f"Dettaglio {label}"))
if st == 0:
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
elif st == 1:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna doppia", command=lambda: self._set_cell(r, c, 2))
else:
m.add_command(label="Segna vuoto", command=lambda: self._set_cell(r, c, 0))
m.add_command(label="Segna pieno", command=lambda: self._set_cell(r, c, 1))
m.add_separator()
m.add_command(label="Copia ubicazione", command=lambda: self._copy(label))
x = self.winfo_pointerx() if event is None else event.x_root
y = self.winfo_pointery() if event is None else event.y_root
m.tk_popup(x, y)
def _set_cell(self, r, c, val):
self.matrix_state[r][c] = val
btn = self.buttons[r][c]
self._apply_cell_style(btn, val)
self._refresh_stats()
# ---------------- STATS ----------------
def _build_stats(self):
bottom = ctk.CTkFrame(self)
bottom.grid(row=2, column=0, sticky="nsew", padx=8, pady=6)
bottom.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(bottom, text="Riempimento globale", font=("", 10, "bold")).grid(row=0, column=0, sticky="w", pady=(0, 2))
self.tot_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.tot_canvas.grid(row=1, column=0, sticky="ew", padx=(0, 260))
self.tot_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.tot_text.grid(row=1, column=0, sticky="e")
ctk.CTkLabel(bottom, text="Riempimento corsia selezionata", font=("", 10, "bold")).grid(row=2, column=0, sticky="w", pady=(10, 2))
self.sel_canvas = tk.Canvas(bottom, height=22, highlightthickness=0)
self.sel_canvas.grid(row=3, column=0, sticky="ew", padx=(0, 260))
self.sel_text = ctk.CTkLabel(bottom, text=pct_text(0.0, 0.0))
self.sel_text.grid(row=3, column=0, sticky="e")
leg = ctk.CTkFrame(bottom)
leg.grid(row=4, column=0, sticky="w", pady=(10, 0))
ctk.CTkLabel(leg, text="Legenda celle:").grid(row=0, column=0, padx=(0, 8))
self._legend(leg, 1, "Vuota", COLOR_EMPTY)
self._legend(leg, 3, "Piena", COLOR_FULL)
self._legend(leg, 5, "Doppia UDC", COLOR_DOUBLE)
def _legend(self, parent, col, text, color):
box = tk.Canvas(parent, width=18, height=12, highlightthickness=0)
box.create_rectangle(0, 0, 18, 12, fill=color, width=1, outline="#444")
box.grid(row=0, column=col)
ctk.CTkLabel(parent, text=text).grid(row=0, column=col + 1, padx=(4, 12))
# ---------------- DATA LOADING ----------------
def _load_corsie(self):
sql = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
self.lb.delete(0, tk.END)
corsie = [r[0] for r in rows]
for c in corsie:
self.lb.insert(tk.END, c)
idx = corsie.index("1A") if "1A" in corsie else (0 if corsie else -1)
if idx >= 0:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(idx)
self.lb.see(idx)
self._on_select(None)
else:
self._toast("Nessuna corsia trovata.")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def _on_select(self, _):
sel = self.lb.curselection()
if not sel:
return
corsia = self.lb.get(sel[0])
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia)
def _select_corsia_in_listbox(self, corsia: str):
for i in range(self.lb.size()):
if self.lb.get(i) == corsia:
self.lb.selection_clear(0, tk.END)
self.lb.selection_set(i)
self.lb.see(i)
break
def _load_matrix(self, corsia: str):
# nuovo token richiesta → evita che risposte vecchie spazzino la UI
self._req_counter += 1
req_id = self._req_counter
self._last_req = req_id
sql = """
WITH C AS (
SELECT
ID,
LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Fila)) AS Fila,
LTRIM(RTRIM(Colonna)) AS Colonna,
Descrizione
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G' AND LTRIM(RTRIM(Corsia)) = :corsia
),
R AS (
SELECT Fila,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Fila) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Fila), Fila
) AS RowN
FROM C GROUP BY Fila
),
K AS (
SELECT Colonna,
DENSE_RANK() OVER (
ORDER BY CASE WHEN TRY_CONVERT(int, Colonna) IS NULL THEN 1 ELSE 0 END,
TRY_CONVERT(int, Colonna), Colonna
) AS ColN
FROM C GROUP BY Colonna
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c
LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
),
U AS (
SELECT c.ID, MIN(g.BarcodePallet) AS FirstUDC
FROM C c
LEFT JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
r.RowN, k.ColN,
CASE WHEN s.n IS NULL OR s.n = 0 THEN 0
WHEN s.n = 1 THEN 1
ELSE 2 END AS Stato,
c.Descrizione,
LTRIM(RTRIM(c.Fila)) AS FilaTxt,
LTRIM(RTRIM(c.Colonna)) AS ColTxt,
U.FirstUDC
FROM C c
JOIN R r ON r.Fila = c.Fila
JOIN K k ON k.Colonna = c.Colonna
LEFT JOIN S s ON s.ID = c.ID
LEFT JOIN U ON U.ID = c.ID
ORDER BY r.RowN, k.ColN;
"""
def _ok(res):
# ignora risposte superate
if req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
# mostra matrice vuota senza rimuovere il frame (evita "schermo bianco")
self._rebuild_matrix(0, 0, [], [], [], [], [], corsia)
self._refresh_stats()
self._busy.hide()
return
max_r = max_c = 0
for row in rows:
rown, coln = row[0], row[1]
if rown and coln:
max_r = max(max_r, int(rown))
max_c = max(max_c, int(coln))
mat = [[0] * max_c for _ in range(max_r)]
fila = [[""] * max_c for _ in range(max_r)]
col = [[""] * max_c for _ in range(max_r)]
desc = [[""] * max_c for _ in range(max_r)]
udc = [[""] * max_c for _ in range(max_r)]
for row in rows:
rown, coln, stato, descr, fila_txt, col_txt, first_udc = row
r = int(rown) - 1
c = int(coln) - 1
mat[r][c] = int(stato)
fila[r][c] = str(fila_txt or "")
col[r][c] = str(col_txt or "")
desc[r][c] = str(descr or f"{corsia}.{col_txt}.{fila_txt}")
udc[r][c] = str(first_udc or "")
self._rebuild_matrix(max_r, max_c, mat, fila, col, desc, udc, corsia)
self._refresh_stats()
self._busy.hide()
def _err(ex):
if req_id < self._last_req:
return
self._busy.hide()
messagebox.showerror("Errore", f"Caricamento matrice {corsia} fallito:\n{ex}")
self._async.run(self.db.query_json(sql, {"corsia": corsia}), _ok, _err, busy=self._busy, message=f"Carico corsia {corsia}…")
# ---------------- SEARCH ----------------
def _search_udc(self):
barcode = (self.search_var.get() or "").strip()
if not barcode:
self._toast("Inserisci un barcode UDC da cercare.")
return
# bump token per impedire che una vecchia _load_matrix cancelli UI
self._req_counter += 1
search_req_id = self._req_counter
self._last_req = search_req_id
sql = """
SELECT TOP (1)
RTRIM(c.Corsia) AS Corsia,
RTRIM(c.Colonna) AS Colonna,
RTRIM(c.Fila) AS Fila,
c.ID AS IDCella
FROM dbo.XMag_GiacenzaPallet g
JOIN dbo.Celle c ON c.ID = g.IDCella
WHERE g.BarcodePallet = :barcode
AND c.ID <> 9999 AND RTRIM(c.Corsia) <> '7G'
"""
def _ok(res):
if search_req_id < self._last_req:
return
rows = res.get("rows", []) if isinstance(res, dict) else []
if not rows:
messagebox.showinfo("Ricerca", f"UDC {barcode} non trovata.", parent=self)
return
corsia, col, fila, _idc = rows[0]
corsia = str(corsia).strip(); col = str(col).strip(); fila = str(fila).strip()
self._pending_focus = (corsia, col, fila, barcode)
# sincronizza listbox e carica SEMPRE la corsia della UDC
self._select_corsia_in_listbox(corsia)
self.corsia_selezionata.set(corsia)
self._load_matrix(corsia) # highlight avverrà in _rebuild_matrix
def _err(ex):
if search_req_id < self._last_req:
return
messagebox.showerror("Ricerca", f"Errore ricerca UDC:\n{ex}", parent=self)
self._async.run(self.db.query_json(sql, {"barcode": barcode}), _ok, _err, busy=self._busy, message="Cerco UDC…")
def _try_highlight(self, col_txt: str, fila_txt: str) -> bool:
for r in range(len(self.col_txt)):
for c in range(len(self.col_txt[r])):
if self.col_txt[r][c] == col_txt and self.fila_txt[r][c] == fila_txt:
self._clear_highlight()
btn = self.buttons[r][c]
btn.configure(border_width=3, border_color="blue")
try:
fr = self.btn_frames[r][c]
fr.configure(border_color="blue", border_width=2)
except Exception:
pass
self._highlighted = (r, c)
return True
return False
def _highlight_cell_by_labels(self, col_txt: str, fila_txt: str):
if not self._try_highlight(col_txt, fila_txt):
self._toast("Cella trovata ma non mappabile a pulsante.")
# ---------------- COMMANDS ----------------
def _refresh_current(self):
if self.corsia_selezionata.get():
self._load_matrix(self.corsia_selezionata.get())
def _export_xlsx(self):
if not self.matrix_state:
messagebox.showinfo("Export", "Nessuna matrice da esportare.")
return
corsia = self.corsia_selezionata.get() or "NA"
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default = f"layout_matrice_{corsia}_{ts}.xlsx"
path = filedialog.asksaveasfilename(
title="Esporta matrice",
defaultextension=".xlsx",
initialfile=default,
filetypes=[("Excel", "*.xlsx")]
)
if not path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import PatternFill, Alignment, Font
except Exception as ex:
messagebox.showerror("Export", f"Manca openpyxl: {ex}\nInstalla con: pip install openpyxl")
return
rows = len(self.matrix_state)
cols = len(self.matrix_state[0]) if self.matrix_state else 0
wb = Workbook()
ws1 = wb.active
ws1.title = f"Dettaglio {corsia}"
ws1.append(["Corsia", "FilaIdx", "ColIdx", "Stato", "Descrizione", "FilaTxt", "ColTxt", "UDC1"])
for r in range(rows):
for c in range(cols):
st = self.matrix_state[r][c]
stato_lbl = "Vuota" if st == 0 else ("Piena" if st == 1 else "Doppia")
ws1.append([corsia, r + 1, c + 1, stato_lbl,
self.desc[r][c], self.fila_txt[r][c], self.col_txt[r][c], self.udc1[r][c]])
for cell in ws1[1]:
cell.font = Font(bold=True)
ws2 = wb.create_sheet(f"Matrice {corsia}")
fills = {
0: PatternFill("solid", fgColor="B0B0B0"),
1: PatternFill("solid", fgColor="FFA500"),
2: PatternFill("solid", fgColor="D62728"),
}
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for r in range(rows):
for c in range(cols):
value = f"{corsia}.{self.col_txt[r][c]}.{self.fila_txt[r][c]}\n{self.udc1[r][c]}"
cell = ws2.cell(row=(rows - r), column=c + 1, value=value) # capovolto per avere 'a' in basso
cell.fill = fills.get(self.matrix_state[r][c], fills[0])
cell.alignment = center
try:
wb.save(path)
self._toast(f"Esportato: {path}")
except Exception as ex:
messagebox.showerror("Export", f"Salvataggio fallito:\n{ex}")
# ---------------- STATS ----------------
def _refresh_stats(self):
# globale dal DB
sql_tot = """
WITH C AS (
SELECT ID
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) <> '7G'
AND LTRIM(RTRIM(Fila)) IS NOT NULL
AND LTRIM(RTRIM(Colonna)) IS NOT NULL
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
CAST(SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercPieno,
CAST(SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS float)/NULLIF(COUNT(*),0) AS PercDoppie
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
p_full = float(rows[0][0] or 0.0) if rows else 0.0
p_dbl = float(rows[0][1] or 0.0) if rows else 0.0
self._draw_bar(self.tot_canvas, p_full)
self.tot_text.configure(text=pct_text(p_full, p_dbl))
self._async.run(self.db.query_json(sql_tot, {}), _ok, lambda e: None, busy=None, message=None)
# selezionata dalla matrice in memoria
if self.matrix_state:
tot = sum(len(r) for r in self.matrix_state)
full = sum(1 for row in self.matrix_state for v in row if v in (1, 2))
doubles = sum(1 for row in self.matrix_state for v in row if v == 2)
p_full = (full / tot) if tot else 0.0
p_dbl = (doubles / tot) if tot else 0.0
else:
p_full = p_dbl = 0.0
self._draw_bar(self.sel_canvas, p_full)
self.sel_text.configure(text=pct_text(p_full, p_dbl))
def _draw_bar(self, cv: tk.Canvas, p_full: float):
cv.delete("all")
w = max(300, cv.winfo_width() or 600)
h = 18
fw = int(w * max(0.0, min(1.0, p_full)))
cv.create_rectangle(2, 2, 2 + fw, 2 + h, fill="#D62728", width=0)
cv.create_rectangle(2 + fw, 2, 2 + w, 2 + h, fill="#2CA02C", width=0)
cv.create_rectangle(2, 2, 2 + w, 2 + h, outline="#555", width=1)
# ---------------- UTIL ----------------
def _toast(self, msg, ms=1400):
if not hasattr(self, "_status"):
self._status = ctk.CTkLabel(self, anchor="w")
self._status.grid(row=3, column=0, sticky="ew")
self._status.configure(text=msg)
self.after(ms, lambda: self._status.configure(text=""))
def _copy(self, txt: str):
self.clipboard_clear()
self.clipboard_append(txt)
self._toast(f"Copiato: {txt}")
def open_layout_window(parent, db_app):
key = "_layout_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift()
ex.focus_force()
return ex
except Exception:
pass
w = LayoutWindow(parent, db_app)
setattr(parent, key, w)
return w

156
main.py Normal file
View File

@@ -0,0 +1,156 @@
import sys
import asyncio
import tkinter as tk
import customtkinter as ctk
from async_msssql_query import AsyncMSSQLClient, make_mssql_dsn
from async_loop_singleton import get_global_loop
from layout_window import open_layout_window
from view_celle_multiple import open_celle_multiple_window
from reset_corsie import open_reset_corsie_window
from search_pallets import open_search_window
# Try factory, else frame, else app (senza passare conn_str all'App)
try:
from gestione_pickinglist import create_frame as create_pickinglist_frame
except Exception:
try:
from gestione_pickinglist import GestionePickingListFrame as _PLFrame
import customtkinter as ctk
def create_pickinglist_frame(parent, db_client=None, conn_str=None):
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
return _PLFrame(parent, db_client=db_client, conn_str=conn_str)
except Exception:
# Ultimo fallback: alcune versioni espongono solo la App e NON accettano conn_str
# Ultimo fallback: alcune versioni espongono solo la App e NON accettano parametri
from gestione_pickinglist import GestionePickingListApp as _PLApp
def create_pickinglist_frame(parent, db_client=None, conn_str=None):
app = _PLApp() # <-- niente parametri qui
app.mainloop()
return tk.Frame(parent)
# ---- Config ----
SERVER = r"mde3\gesterp"
DBNAME = "Mediseawall"
USER = "sa"
PASSWORD = "1Password1"
if sys.platform.startswith("win"):
try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
except Exception:
pass
# Create ONE global loop and make it the default everywhere
_loop = get_global_loop()
asyncio.set_event_loop(_loop)
# --- DPI tracker compatibility ---
def _noop(*args, **kwargs):
return None
if not hasattr(tk.Toplevel, "block_update_dimensions_event"):
tk.Toplevel.block_update_dimensions_event = _noop # type: ignore[attr-defined]
if not hasattr(tk.Toplevel, "unblock_update_dimensions_event"):
tk.Toplevel.unblock_update_dimensions_event = _noop # type: ignore[attr-defined]
dsn_app = make_mssql_dsn(server=SERVER, database=DBNAME, user=USER, password=PASSWORD)
db_app = AsyncMSSQLClient(dsn_app)
def open_pickinglist_window(parent: tk.Misc, db_client: AsyncMSSQLClient):
win = ctk.CTkToplevel(parent)
win.title("Gestione Picking List")
win.geometry("1200x700+0+100")
win.minsize(1000, 560)
# 1) tieni la toplevel fuori scena mentre componi
try:
win.withdraw()
# opzionale: rendila invisibile anche se il WM la “intr intravede”
win.attributes("-alpha", 0.0)
except Exception:
pass
# 2) costruisci tutto il contenuto
frame = create_pickinglist_frame(win, db_client=db_client)
try:
frame.pack(fill="both", expand=True)
except Exception:
pass
# 3) quando è pronta, mostra "a scatto" davanti, senza topmost
try:
win.update_idletasks()
try:
win.transient(parent) # z-order legato alla main
except Exception:
pass
try:
win.deiconify()
except Exception:
pass
win.lift()
try:
win.focus_force()
except Exception:
pass
# ripristina opacità
try:
win.attributes("-alpha", 1.0)
except Exception:
pass
except Exception:
pass
win.bind("<Escape>", lambda e: win.destroy())
win.protocol("WM_DELETE_WINDOW", win.destroy)
return win
class Launcher(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Warehouse 1.0.0")
self.geometry("1200x70+0+0")
wrap = ctk.CTkFrame(self)
wrap.pack(pady=10, fill="x")
ctk.CTkButton(wrap, text="Gestione Corsie",
command=lambda: open_reset_corsie_window(self, db_app)).grid(row=0, column=0, padx=6, pady=6, sticky="ew")
ctk.CTkButton(wrap, text="Gestione Layout",
command=lambda: open_layout_window(self, db_app)).grid(row=0, column=1, padx=6, pady=6, sticky="ew")
ctk.CTkButton(wrap, text="UDC Fantasma",
command=lambda: open_celle_multiple_window(self, db_app)).grid(row=0, column=2, padx=6, pady=6, sticky="ew")
ctk.CTkButton(wrap, text="Ricerca UDC",
command=lambda: open_search_window(self, db_app)).grid(row=0, column=3, padx=6, pady=6, sticky="ew")
ctk.CTkButton(wrap, text="Gestione Picking List",
command=lambda: open_pickinglist_window(self, db_app)).grid(row=0, column=4, padx=6, pady=6, sticky="ew")
for i in range(5):
wrap.grid_columnconfigure(i, weight=1)
def _on_close():
try:
fut = asyncio.run_coroutine_threadsafe(db_app.dispose(), _loop)
try:
fut.result(timeout=2)
except Exception:
pass
finally:
self.destroy()
self.protocol("WM_DELETE_WINDOW", _on_close)
if __name__ == "__main__":
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("green")
Launcher().mainloop()

62
pickinglist.sql Normal file
View File

@@ -0,0 +1,62 @@
SELECT * FROM vViewMappaturaDescrizioneCorsia WHERE ( Area > 0) ORDER BY Area Desc
SELECT * FROM Celle WHERE ( ID > 0) ORDER BY ID Desc
SELECT TOP 1000 [mc_X] ,[minFila] ,[maxFila] ,[minColonna] ,[maxColonna] ,[mc_Numero_Magazzino] ,[mc_Numero_Area] FROM vViewMappaturaPosizCorsia WHERE [mc_Numero_Area] =1
SELECT CASE WHEN Nota = 'ASC' THEN 0 ELSE CASE WHEN Nota = 'DESC' THEN 1 END END AS Nota FROM MagLayout WHERE (IDArea = 1)
SELECT ID, Descrizione FROM Magazzini ORDER BY Descrizione
SELECT ID, Descrizione FROM Celle ORDER BY Descrizione
SELECT [ID],[CorsiaDescrizione] FROM vViewMappaturaDescrizioneCorsia
SELECT COUNT(DISTINCT Pallet) AS Pallet, COUNT(DISTINCT Lotto) AS Lotto, COUNT(DISTINCT Articolo) AS Articolo, COUNT(DISTINCT Descrizione) AS Descrizione, SUM(Qta) AS Qta, Documento, CodNazione, NAZIONE, Stato, MAX(PalletCella) AS PalletCella, MAX(Magazzino) AS Magazzino, MAX(Area) AS Area, MAX(Cella) AS Cella, MIN(Ordinamento) AS Ordinamento, MAX(IDStato) AS IDStato FROM dbo.XMag_ViewPackingList GROUP BY Documento, CodNazione, NAZIONE, Stato
SELECT * FROM vViewPackingListRestante WHERE Documento = 237 ORDER BY Ordinamento
SELECT * FROM ViewPackingListRestante WHERE Documento = 246 ORDER BY Ordinamento
USE master;
GO
-- (facoltativo) terminare connessioni attive
ALTER DATABASE SAMA1 SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
GO
-- Ripristino dal tuo snapshot
RESTORE DATABASE SAMA1
FROM DATABASE_SNAPSHOT = 'SAMA1_SNAP_20251014_112623';
GO
-- Riporta il DB in multi-user
ALTER DATABASE SAMA1 SET MULTI_USER;
GO
SELECT @@SERVERNAME AS server_name, DB_NAME() AS database_name;
SELECT name, create_date
FROM sys.databases
WHERE source_database_id IS NOT NULL;
SELECT COUNT(*) AS righe FROM dbo.XMag_ViewPackingList;
SELECT COUNT(*) AS righe FROM dbo.ViewPackingListRestante;
SELECT Documento, Stato, IDStato, NAZIONE, COUNT(*) AS righe
FROM dbo.ViewPackingListRestante
WHERE Documento IN (240,241)
GROUP BY Documento, Stato, IDStato, NAZIONE;
EXEC sp_refreshview N'dbo.XMag_ViewPackingList';
EXEC sp_refreshview N'dbo.ViewPackingListRestante';

189
prenota_sprenota_sql.py Normal file
View File

@@ -0,0 +1,189 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Any, Dict, List
@dataclass
class SPResult:
rc: int = 0 # equivalente a @RC OUTPUT
message: Optional[str] = "" # eventuale messaggio/errore
id_result: Optional[int] = None # ID del record inserito in LogPackingList
# --- helpers per il client async (senza conoscere l'API esatta forniamo fallback robusti) ---
async def _query_one_value(db, sql: str, params: Dict[str, Any]) -> Optional[Any]:
"""
Ritorna la prima colonna della prima riga, oppure None.
Tenta prima query_json(...), poi altri metodi comuni.
"""
if hasattr(db, "query_json"):
res = await db.query_json(sql, params)
# res può essere una lista di dict o un payload con rows/columns
if isinstance(res, list) and res:
row0 = res[0]
if isinstance(row0, dict):
# prima colonna disponibile
return next(iter(row0.values()), None)
elif isinstance(res, dict):
rows = None
for k in ("rows", "data", "result", "records"):
if k in res and isinstance(res[k], list):
rows = res[k]
break
if rows:
r0 = rows[0]
if isinstance(r0, dict):
return next(iter(r0.values()), None)
if isinstance(r0, (list, tuple)) and r0:
return r0[0]
return None
# fallback: altri metodi (se esistono)
if hasattr(db, "query_value"):
return await db.query_value(sql, params)
if hasattr(db, "scalar"):
return await db.scalar(sql, params)
raise RuntimeError("Il client DB non espone query_json/query_value/scalar")
async def _query_all(db, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Ritorna una lista di dict {col:val}."""
if hasattr(db, "query_json"):
res = await db.query_json(sql, params)
if res is None:
return []
if isinstance(res, list):
return res if res and isinstance(res[0], dict) else []
if isinstance(res, dict):
for k in ("rows", "data", "result", "records"):
if k in res and isinstance(res[k], list):
rows = res[k]
if rows and isinstance(rows[0], dict):
return rows
cols = res.get("columns") or res.get("cols") or []
out = []
for r in rows:
if isinstance(r, (list, tuple)) and cols:
out.append({ (cols[i] if i < len(cols) else f"c{i}") : r[i]
for i in range(min(len(cols), len(r))) })
return out
return []
# fallback
if hasattr(db, "fetch_all"):
return await db.fetch_all(sql, params)
raise RuntimeError("Il client DB non espone query_json/fetch_all")
async def _execute(db, sql: str, params: Dict[str, Any]) -> int:
"""
Esegue DML e ritorna rowcount (se disponibile).
Prova .execute / .exec / .execute_non_query / altrimenti usa query_json.
"""
for name in ("execute", "exec", "execute_non_query"):
if hasattr(db, name):
rc = await getattr(db, name)(sql, params)
# alcuni client ritornano None, altri rowcount, altri payload
if isinstance(rc, int):
return rc
return 0
# fallback rozzo: molti back-end accettano anche DML in query_json
if hasattr(db, "query_json"):
await db.query_json(sql, params)
return 0
raise RuntimeError("Il client DB non espone metodi di esecuzione DML noti")
# --- Procedura portata in async, usando il client DB passato dall'app ---
async def sp_xExePackingListPallet_async(db, IDOperatore: int, Documento: str) -> SPResult:
"""
Porting asincrono di [dbo].[sp_xExePackingListPallet] usando il client DB già aperto dall'app.
Logica:
1) Recupera LOGIN operatore
2) Elenca le celle (DISTINCT Cella da XMag_ViewPackingList per Documento)
3) Per ogni cella: leggi IDStato e toggla 0<->1 + aggiorna ModUtente/ModDataOra
4) Description = TOP 1 NAZIONE per Documento
5) Inserisci LogPackingList(Code=Documento, Description, IDInsUser=IDOperatore, InsDateTime=GETDATE())
"""
try:
# 1) LOGIN operatore (se manca, prosegue come da SP originaria)
nominativo = await _query_one_value(
db,
"SELECT LOGIN FROM Operatori WHERE id = :IDOperatore",
{"IDOperatore": IDOperatore}
) or ""
# 2) Celle da trattare
celle = await _query_all(
db,
"""
SELECT DISTINCT Cella
FROM dbo.XMag_ViewPackingList
WHERE Documento = :Documento
""",
{"Documento": Documento}
)
id_celle = [r.get("Cella") for r in celle if "Cella" in r]
# 3) Toggle stato per ogni cella
for id_cella in id_celle:
if id_cella is None:
continue
stato = await _query_one_value(
db,
"SELECT IDStato FROM Celle WHERE ID = :IDC",
{"IDC": id_cella}
)
if stato == 0:
await _execute(
db,
"""
UPDATE Celle
SET IDStato = 1,
ModUtente = :N,
ModDataOra = GETDATE()
WHERE ID = :IDC
""",
{"N": nominativo, "IDC": id_cella}
)
else:
await _execute(
db,
"""
UPDATE Celle
SET IDStato = 0,
ModUtente = :N,
ModDataOra = GETDATE()
WHERE ID = :IDC
""",
{"N": nominativo, "IDC": id_cella}
)
# 4) Description = NAZIONE (TOP 1)
description = await _query_one_value(
db,
"""
SELECT TOP 1 NAZIONE
FROM dbo.XMag_ViewPackingList
WHERE Documento = :Documento
GROUP BY Documento, NAZIONE
ORDER BY NAZIONE
""",
{"Documento": Documento}
)
# 5) LogPackingList
await _execute(
db,
"""
INSERT INTO dbo.LogPackingList (Code, Description, IDInsUser, InsDateTime)
VALUES (:Code, :Descr, :IDInsUser, GETDATE());
""",
{"Code": Documento, "Descr": description, "IDInsUser": IDOperatore}
)
# Se vuoi proprio l'ID appena inserito:
new_id = await _query_one_value(db, "SELECT SCOPE_IDENTITY() AS ID", {})
return SPResult(rc=0, message="", id_result=int(new_id) if new_id is not None else None)
except Exception as e:
return SPResult(rc=-1, message=str(e), id_result=None)

16
pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[project]
name = "warehouse"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = [
"sqlalchemy[asyncio]>=2.0",
"aioodbc>=0.3.3",
# "orjson>=3.9" # opzionale: il tuo codice fa fallback su json puro
]
[project.optional-dependencies]
dev = ["pytest", "pytest-cov", "mypy", "black", "flake8"]
[tool.pytest.ini_options]
addopts = "-q --maxfail=1"
pythonpath = ["."]

50
readme.txt Normal file
View File

@@ -0,0 +1,50 @@
-- dbo.XMag_DettaglioPallet source
ALTER VIEW [dbo].[XMag_DettaglioPallet]
AS
SELECT dbo.MagazziniPallet.ID, dbo.MagazziniPallet.Tipo, dbo.MagazziniPallet.IDRiferimento, dbo.MagazziniPallet.NumeroPallet,
dbo.MagazziniPallet.IDMagazzino, dbo.MagazziniPallet.IDArea, dbo.MagazziniPallet.IDCella, dbo.MagazziniPallet.DataMagazzino,
dbo.MagazziniPallet.PesoUnitario, dbo.MagazziniPallet.Tara, dbo.MagazziniPallet.Attributo, dbo.Celle.IDStato
FROM dbo.MagazziniPallet INNER JOIN
dbo.Celle ON dbo.MagazziniPallet.IDCella = dbo.Celle.ID
WHERE (dbo.MagazziniPallet.Tipo = 'V')
UNION ALL
SELECT MagazziniPallet_1.ID, MagazziniPallet_1.Tipo, MagazziniPallet_1.IDRiferimento, MagazziniPallet_1.NumeroPallet, MagazziniPallet_1.IDMagazzino,
MagazziniPallet_1.IDArea, MagazziniPallet_1.IDCella, MagazziniPallet_1.DataMagazzino, - (1 * MagazziniPallet_1.PesoUnitario) AS PesoUnitario,
MagazziniPallet_1.Tara, MagazziniPallet_1.Attributo, Celle_1.IDStato
FROM dbo.MagazziniPallet AS MagazziniPallet_1 INNER JOIN
dbo.Celle AS Celle_1 ON MagazziniPallet_1.IDCella = Celle_1.ID
WHERE (MagazziniPallet_1.Tipo = 'P');
:commento
Dalla tabella Celle e MagazziniPallet ottengo l'insieme delle celle con tipo V = vuota e p=piena, le celle in questo caso possono essere di qualunque tipo cioè fare riferimento a pallet ancora in deposito oppure già spediti, o piene (1A.15.0e) o spedite (7G.01.01).
ALTER VIEW dbo.XMag_GiacenzaPallet
AS
SELECT Attributo AS BarcodePallet, NumeroPallet, IDMagazzino, IDArea, IDCella, SUM(PesoUnitario) AS Peso, Attributo AS CodiceProdotto, IDStato
FROM dbo.XMag_DettaglioPallet
GROUP BY NumeroPallet, IDMagazzino, IDArea, IDCella, Attributo, IDStato
HAVING (SUM(PesoUnitario) > 0);
commento:
Ricomprimo la vista ..dettaglio perchè capita che una udc sia stata allocata più volte sulla stessa cella, la riga in questione appare doppia se non si tiene conto della data in cui è avvenuta la lettura .SI potrebbe pensare di prendere sempre la data più alta , cioè l'ultima a parità di unità di carico.
ALTER VIEW [dbo].[XMag_GiacenzaPalletxUbicazioneCella]
AS
SELECT dbo.Celle.ID as IDCella, dbo.vXTracciaProdotti.Pallet, dbo.vXTracciaProdotti.Lotto, dbo.vXTracciaProdotti.Prodotto, dbo.vXTracciaProdotti.Descrizione, UPPER(REPLICATE('0',
3 - DATALENGTH(dbo.Celle.Corsia)) + dbo.Celle.Corsia + '.' + + REPLICATE('0', 2 - DATALENGTH(dbo.Celle.Colonna))
+ dbo.Celle.Colonna + '.' + REPLICATE('0', 2 - DATALENGTH(dbo.Celle.Fila)) + dbo.Celle.Fila) AS Ubicazione
FROM dbo.XMag_GiacenzaPallet
INNER JOIN
dbo.vXTracciaProdotti ON dbo.XMag_GiacenzaPallet.BarcodePallet = dbo.vXTracciaProdotti.Pallet COLLATE Latin1_General_CI_AS
LEFT OUTER JOIN
dbo.Celle ON dbo.XMag_GiacenzaPallet.IDCella = dbo.Celle.ID;
:commento
qui commbino una vista proveniente da sam in cui vado a cercare i dati della unità di carico e li combino con la tabella vista giacenzapallet, in pratica definisco dove in quale ubicazione si trova il pallet
quindi in pratica ho
tabella vista vista
magazzinipallet ---->xmag_Dettagliopallet--->Xmag_giacenzapallet

245
reset_corsie.py Normal file
View File

@@ -0,0 +1,245 @@
# reset_corsie.py
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import customtkinter as ctk
from datetime import datetime
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
# ---------------- SQL ----------------
SQL_CORSIE = """
WITH C AS (
SELECT DISTINCT LTRIM(RTRIM(Corsia)) AS Corsia
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL) AND LTRIM(RTRIM(Corsia)) <> '7G'
)
SELECT Corsia
FROM C
ORDER BY
CASE
WHEN LEFT(Corsia,3)='MAG' AND TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) IS NOT NULL THEN 0
WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN 1
ELSE 2
END,
CASE WHEN LEFT(Corsia,3)='MAG' THEN TRY_CONVERT(int, SUBSTRING(Corsia,4,50)) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN TRY_CONVERT(int, Corsia) END,
CASE WHEN TRY_CONVERT(int, Corsia) IS NOT NULL THEN SUBSTRING(Corsia, LEN(CAST(TRY_CONVERT(int, Corsia) AS varchar(20)))+1, 50) END,
Corsia;
"""
SQL_RIEPILOGO = """
WITH C AS (
SELECT ID, LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Colonna)) AS Colonna,
LTRIM(RTRIM(Fila)) AS Fila
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) = :corsia
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT
COUNT(*) AS TotCelle,
SUM(CASE WHEN s.n>0 THEN 1 ELSE 0 END) AS CelleOccupate,
SUM(CASE WHEN s.n>1 THEN 1 ELSE 0 END) AS CelleDoppie,
SUM(COALESCE(s.n,0)) AS TotPallet
FROM C LEFT JOIN S s ON s.ID = C.ID;
"""
SQL_DETTAGLIO = """
WITH C AS (
SELECT ID, LTRIM(RTRIM(Corsia)) AS Corsia,
LTRIM(RTRIM(Colonna)) AS Colonna,
LTRIM(RTRIM(Fila)) AS Fila
FROM dbo.Celle
WHERE ID <> 9999 AND (DelDataOra IS NULL)
AND LTRIM(RTRIM(Corsia)) = :corsia
),
S AS (
SELECT c.ID, COUNT(DISTINCT g.BarcodePallet) AS n
FROM C AS c LEFT JOIN dbo.XMag_GiacenzaPallet AS g ON g.IDCella = c.ID
GROUP BY c.ID
)
SELECT c.ID AS IDCella,
CONCAT(c.Corsia, '.', c.Colonna, '.', c.Fila) AS Ubicazione,
COALESCE(s.n,0) AS NumUDC
FROM C c LEFT JOIN S s ON s.ID = c.ID
WHERE COALESCE(s.n,0) > 0
ORDER BY TRY_CONVERT(int,c.Colonna), c.Colonna, TRY_CONVERT(int,c.Fila), c.Fila;
"""
SQL_COUNT_DELETE = """
SELECT COUNT(*) AS RowsToDelete
FROM dbo.MagazziniPallet mp
JOIN dbo.Celle c ON c.ID = mp.IDCella
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
"""
SQL_DELETE = """
DELETE mp
FROM dbo.MagazziniPallet mp
JOIN dbo.Celle c ON c.ID = mp.IDCella
WHERE c.ID <> 9999 AND LTRIM(RTRIM(c.Corsia)) = :corsia;
"""
class ResetCorsieWindow(ctk.CTkToplevel):
"""
Finestra per:
- selezionare una corsia
- vedere riepilogo occupazione / doppie / pallet
- vedere l'elenco celle occupate
- svuotare (DELETE MagazziniPallet) tutte le celle della corsia selezionata
"""
def __init__(self, parent, db_client):
super().__init__(parent)
self.title("Reset Corsie — svuotamento celle per corsia")
self.geometry("1000x680")
self.minsize(880, 560)
self.resizable(True, True)
self.db = db_client
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
self._build_ui()
self._load_corsie()
# ---------- UI ----------
def _build_ui(self):
top = ctk.CTkFrame(self); top.pack(fill="x", padx=8, pady=8)
ctk.CTkLabel(top, text="Corsia:").pack(side="left")
self.cmb = ctk.CTkComboBox(top, width=140, values=[])
self.cmb.pack(side="left", padx=(6,10))
ctk.CTkButton(top, text="Carica", command=self.refresh).pack(side="left")
ctk.CTkButton(top, text="Svuota corsia…", command=self._ask_reset).pack(side="right")
mid = ctk.CTkFrame(self); mid.pack(fill="both", expand=True, padx=8, pady=(0,8))
mid.grid_columnconfigure(0, weight=1); mid.grid_rowconfigure(0, weight=1)
self.tree = ttk.Treeview(mid, columns=("Ubicazione","NumUDC"), show="headings", selectmode="browse")
self.tree.heading("Ubicazione", text="Ubicazione")
self.tree.heading("NumUDC", text="UDC in cella")
self.tree.column("Ubicazione", width=240, anchor="w")
self.tree.column("NumUDC", width=120, anchor="e")
sy = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
sx = ttk.Scrollbar(mid, 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")
bottom = ctk.CTkFrame(self)
bottom.pack(fill="x", padx=8, pady=(0,8))
ctk.CTkLabel(bottom, text="Riepilogo", font=("Segoe UI", 12, "bold")).pack(anchor="w", padx=8, pady=(8,0))
g = ctk.CTkFrame(bottom)
g.pack(fill="x", padx=8, pady=8)
self.var_tot_celle = tk.StringVar(value="0")
self.var_occ = tk.StringVar(value="0")
self.var_dbl = tk.StringVar(value="0")
self.var_pallet = tk.StringVar(value="0")
def _kv(parent, label, var, col):
ctk.CTkLabel(parent, text=label, font=("Segoe UI", 9, "bold")).grid(row=0, column=col*2, sticky="w", padx=(0,6))
ctk.CTkLabel(parent, textvariable=var).grid(row=0, column=col*2+1, sticky="w", padx=(0,18))
g.grid_columnconfigure(7, weight=1)
_kv(g, "Tot. celle:", self.var_tot_celle, 0)
_kv(g, "Celle occupate:", self.var_occ, 1)
_kv(g, "Celle doppie:", self.var_dbl, 2)
_kv(g, "Tot. pallet:", self.var_pallet, 3)
# ---------- Data ----------
def _load_corsie(self):
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
items = [r[0] for r in rows]
self.cmb.configure(values=items)
if items:
# auto 1A se presente
sel = "1A" if "1A" in items else items[0]
self.cmb.set(sel)
self.refresh()
else:
messagebox.showinfo("Info", "Nessuna corsia trovata.", parent=self)
def _err(ex):
messagebox.showerror("Errore", f"Caricamento corsie fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_CORSIE, {}), _ok, _err, busy=self._busy, message="Carico corsie…")
def refresh(self):
corsia = self.cmb.get().strip()
if not corsia:
return
# riepilogo
def _ok_sum(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
if rows:
tot, occ, dbl, pallet = rows[0]
self.var_tot_celle.set(str(tot or 0))
self.var_occ.set(str(occ or 0))
self.var_dbl.set(str(dbl or 0))
self.var_pallet.set(str(pallet or 0))
else:
self.var_tot_celle.set("0"); self.var_occ.set("0"); self.var_dbl.set("0"); self.var_pallet.set("0")
def _err_sum(ex):
messagebox.showerror("Errore", f"Riepilogo fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_RIEPILOGO, {"corsia": corsia}), _ok_sum, _err_sum, busy=self._busy, message=f"Riepilogo {corsia}")
# dettaglio
def _ok_det(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
for i in self.tree.get_children(): self.tree.delete(i)
for idc, ubi, n in rows:
self.tree.insert("", "end", values=(ubi, n))
def _err_det(ex):
messagebox.showerror("Errore", f"Dettaglio fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_DETTAGLIO, {"corsia": corsia}), _ok_det, _err_det, busy=None, message=None)
# ---------- Reset ----------
def _ask_reset(self):
corsia = self.cmb.get().strip()
if not corsia:
return
# Primo: quante righe verrebbero cancellate?
def _ok_count(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
n = int(rows[0][0]) if rows else 0
if n <= 0:
messagebox.showinfo("Svuota corsia", f"Nessun pallet da rimuovere per la corsia {corsia}.", parent=self)
return
# doppia conferma
msg = (f"Verranno cancellati {n} record da MagazziniPallet per la corsia {corsia}.",
"Questa operazione è irreversibile.",
"Digitare il nome della corsia per confermare:")
confirm = simpledialog.askstring("Conferma", "\n".join(msg), parent=self)
if confirm is None:
return
if confirm.strip().upper() != corsia.upper():
messagebox.showwarning("Annullato", "Testo di conferma non corrispondente.", parent=self)
return
self._do_reset(corsia)
def _err_count(ex):
messagebox.showerror("Errore", f"Conteggio righe da cancellare fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_COUNT_DELETE, {"corsia": corsia}), _ok_count, _err_count, busy=self._busy, message="Verifico…")
def _do_reset(self, corsia: str):
def _ok_del(_):
messagebox.showinfo("Completato", f"Corsia {corsia}: svuotamento completato.", parent=self)
self.refresh()
def _err_del(ex):
messagebox.showerror("Errore", f"Svuotamento fallito:\n{ex}", parent=self)
self._async.run(self.db.query_json(SQL_DELETE, {"corsia": corsia}), _ok_del, _err_del, busy=self._busy, message=f"Svuoto {corsia}")
def open_reset_corsie_window(parent, db_app):
win = ResetCorsieWindow(parent, db_app)
win.lift(); win.focus_set()
return win

View File

@@ -0,0 +1,68 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 7 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 6 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/PageMode /UseNone /Pages 6 0 R /Type /Catalog
>>
endobj
5 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20251005172731+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20251005172731+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
6 0 obj
<<
/Count 1 /Kids [ 3 0 R ] /Type /Pages
>>
endobj
7 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1709
>>
stream
Gat=*@;jjj&H1Hm0iGadU(Bku9dAJL2C.[\TU.Q9Q]mO#@Lm(Sp9&>cjoW<shQ.0C8O"4d$/jXK1N2-_fb'e64UQ]!SET"'L:8BGNG6,(dDb$qk#8jZ^\[-DW*/raP/cr[K?MI4Qe6iofJ5L^>[1`N*qiBFUbb:t$ZD@Ec$Rrc:#E>K^+uO]`H_<@f%R>]SYAN9dkSI:dJ(cUE,*SE%^0`$he:F5;`W^eb4D@^V>9!LX9O&Hf@H2%Yf@!>R8'^*;WLukGdAr#Q>u-o`@`@"VE16M1<NR-iXIt0qfkAu*32d%pBlhcU%o3.GW0k6^LNUO=`P2EL<3LVJ4d4Yq&_cD<4`"'<Qo'Ym_[p[T;$+H)A*a$+6H>;YuZ(o-:h*+":B/Ybk#e6*##Bcd,69%*OtBbPWf7OqUEkie@Y+BgS`]d.rA7b-0AP&_j<ru8)Ah$ca6]?FWi%oGX:5(AbOgrjBO=_0@!,O7E.?W;O.0EoG`>;*bmRA=L.#s7qe+MJk^jiTMBcL@L"Ri>^8cc#JW(b\&oP2+nLb[hu0fkQ`48tkRO3EIoNR&K@h*B8ViJ@0Gr4K<43)Ekb?5464etE>.40O/bH4]],f/an%VUh8lchsj8W2]X67"->O_#1`--2`?Z]<NWRKTj2kgAj;Ca2RA($05kF16O,GuY*V^^S`<Y23(=O\au6F0ApXl-Ab$P)7`ZI5S`U?K(CKW$0ImjfD/.3XIb-WJnB35Xhbq\V@"93VXKE]VIP!qhXq.4P\c6U%Fg=dL7hQ-M5/)J;,16'`-f'\ZI`<>kl)4Pm;7fUW`MRnbo*+d.RFV474lEba!uPu]6?1o(iK$eRp!Lg?Ck,p;N,]1c)ABNkcc8G":n'(1;aqN]ptL$EM4FLg"DB]oe9TuiHl`DE^R$,08$nE?+HEqVm[1\P-;6bHfD>n1b\Tr0Yg'WM1s8?n'JM8;KiGA\H0lUna6N)B7:k@&]:Yite,/M5d!,b-RLX_NbV^[\U.;:mWF?aP=o%s=ppcCl9E)X?7?5oq_DO?hLgOZGHVN6FWNW[b.&HZ"CaYrR^CM8?#;,`AK2f-^b7/K4n[PK@i'>Q0?.o(2$'Ze=7ko8h9jSPWUHe0>@9.!CYSo[8XE#Y1TX-qnH!XIfSck8%_PGV>)b=3.pqLM^Ubm$:@\%E*mpC).A.o]>sl$KXV]V)!n0m8Q2'@1R@fdm?KEa&pRc7^YEZMMqR!Zq3%&8Y[e&,D$Zoe-G$8LW,u$h1pP;3.dZB6Bh^4n]F$bicJtuA>RX;U3Lhk-7qu1ehU[4iBG7D.W)e'`<j=EGn%lqi$>FR>]a`HY:(/2lN`7fRPUHLgHmjD"ihb'_R_3(bf2OcpSbMY.8godBKC"pn`t5'>J_Sagk`EjYrUPj6Yf1)NJ/WnkE'+]?P`OYUD81gAqBphrVcZT%q/F)GC5d0Fl:Q_WIV/@9N\A+rmqX-An>"Oo1C;X%ng]E;AUL*;rnFt9^FllJqBW&Q'44ZXq<1!>.;%,L^=?Mdp2+\VY1!Woqu34/]bIsdonQ87"Ln:Ou>oUI/Mq^bb\DmJ6,'CW1H$^d>[[CQogFddhjW4K;jdn@#"PA+0>>cl1_#F't5f\4e(k<olLdMSH!UV@BQFCV0&BF\K]u'e^LW*;\A#QYodjSlS'WG<m@=4JnFl"43]QEOBSi1U7.?;!Ru<)Us@!n?rFGe'R/!/Ggi_n!,00'7K~>endstream
endobj
xref
0 8
0000000000 65535 f
0000000073 00000 n
0000000104 00000 n
0000000211 00000 n
0000000414 00000 n
0000000482 00000 n
0000000765 00000 n
0000000824 00000 n
trailer
<<
/ID
[<54fac52a7e33f1aa3f45091c2782a3eb><54fac52a7e33f1aa3f45091c2782a3eb>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 5 0 R
/Root 4 0 R
/Size 8
>>
startxref
2624
%%EOF

BIN
script.sql Normal file

Binary file not shown.

453
search_pallets.py Normal file
View File

@@ -0,0 +1,453 @@
# search_pallets.py
# Finestra di ricerca su UDC / Lotto / Codice Prodotto
# - Tre campi: UDC, Lotto, Codice Prodotto (AND tra i campi valorizzati)
# - Ricerca su TUTTE le celle, incluse IDCella=9999 e corsia 7G
# - Risultati in una griglia: IDCella, Ubicazione, UDC, Lotto, Codice, Descrizione
# - Se la ricerca restituisce >0 righe, i campi input vengono svuotati
from __future__ import annotations
import tkinter as tk
from tkinter import ttk, messagebox
import customtkinter as ctk
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
from tkinter import filedialog
# opzionale export xlsx
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
_HAS_XLSX = True
except Exception:
_HAS_XLSX = False
# opzionale: usare tksheet per avere griglie con bordi di cella
try:
from tksheet import Sheet
except Exception:
Sheet = None
SQL_SEARCH = r"""
WITH BASE AS (
SELECT
g.IDCella,
-- forza stringa per ricerche LIKE
CONCAT(g.BarcodePallet, '') AS UDC,
c.Corsia,
c.Colonna,
c.Fila
FROM dbo.XMag_GiacenzaPallet AS g
LEFT JOIN dbo.Celle AS c ON c.ID = g.IDCella
-- NB: qui NON escludiamo IDCella=9999 né '7G'
),
JOINED AS (
SELECT
b.IDCella,
b.UDC,
b.Corsia,
b.Colonna,
b.Fila,
t.Lotto,
t.Prodotto,
t.Descrizione
FROM BASE b
LEFT JOIN dbo.vXTracciaProdotti AS t
ON t.Pallet COLLATE Latin1_General_CI_AS = LEFT(b.UDC, 6) COLLATE Latin1_General_CI_AS
)
SELECT
j.IDCella,
UPPER(
CONCAT(
COALESCE(LTRIM(RTRIM(j.Corsia)), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(j.Colonna AS varchar(32)))), 'NA'), '.',
COALESCE(LTRIM(RTRIM(CAST(j.Fila AS varchar(32)))), 'NA')
)
) AS Ubicazione,
j.UDC,
j.Lotto,
j.Prodotto,
j.Descrizione
FROM JOINED j
WHERE 1=1
AND ( :udc IS NULL OR j.UDC COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :udc, '%') )
AND ( :lotto IS NULL OR j.Lotto COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :lotto, '%') )
AND ( :codice IS NULL OR j.Prodotto COLLATE Latin1_General_CI_AS LIKE CONCAT('%', :codice, '%') )
ORDER BY
CASE WHEN j.IDCella = 9999 THEN 1 ELSE 0 END,
j.Corsia, j.Colonna, j.Fila, j.UDC, j.Lotto, j.Prodotto;
"""
class SearchWindow(ctk.CTkToplevel):
def __init__(self, parent: tk.Widget, db_app):
super().__init__(parent)
self.title("Warehouse · Ricerca UDC/Lotto/Codice")
self.geometry("1100x720")
self.minsize(900, 560)
self.resizable(True, True)
self.db = db_app
self._busy = BusyOverlay(self)
self._async = AsyncRunner(self)
# stato ordinamento colonne (col -> reverse bool)
self._sort_state: dict[str, bool] = {}
self._build_ui()
def _build_ui(self):
# layout griglia principale
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
# --- barra di ricerca ---
top = ctk.CTkFrame(self)
top.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
for i in range(8):
top.grid_columnconfigure(i, weight=0)
top.grid_columnconfigure(7, weight=1)
ctk.CTkLabel(top, text="UDC:").grid(row=0, column=0, sticky="w")
self.var_udc = tk.StringVar()
e_udc = ctk.CTkEntry(top, textvariable=self.var_udc, width=160)
e_udc.grid(row=0, column=1, sticky="w", padx=(4, 12))
ctk.CTkLabel(top, text="Lotto:").grid(row=0, column=2, sticky="w")
self.var_lotto = tk.StringVar()
e_lotto = ctk.CTkEntry(top, textvariable=self.var_lotto, width=140)
e_lotto.grid(row=0, column=3, sticky="w", padx=(4, 12))
ctk.CTkLabel(top, text="Codice prodotto:").grid(row=0, column=4, sticky="w")
self.var_codice = tk.StringVar()
e_cod = ctk.CTkEntry(top, textvariable=self.var_codice, width=160)
e_cod.grid(row=0, column=5, sticky="w", padx=(4, 12))
btn = ctk.CTkButton(top, text="Cerca", command=self._do_search)
btn.grid(row=0, column=6, sticky="w")
btn_exp = ctk.CTkButton(top, text="Esporta XLSX", command=self._export_xlsx)
btn_exp.grid(row=0, column=7, sticky="e")
# --- griglia risultati ---
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)
# forza modalità Treeview (niente tksheet) per stabilità
self.use_sheet = False
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
self.tree = ttk.Treeview(wrap, columns=cols, show="headings")
# stile: zebra + header leggibile
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")
# tag per righe alternate + id9999 evidenziate
self.tree.tag_configure("even", background="#FFFFFF")
self.tree.tag_configure("odd", background="#F7F9FC")
# evidenzia spediti (IDCella=9999) in rosato tenue
self.tree.tag_configure("id9999", background="#FFECEC", foreground="#B00020")
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")
# doppio click → copia UDC; gestione click header
self.tree.bind("<Double-1>", self._on_dclick)
self.tree.bind("<Button-1>", self._maybe_handle_heading_click, add=True)
self.tree.bind("<Double-1>", self._on_heading_double_click, add=True)
def _apply_zebra(self):
for i, iid in enumerate(self.tree.get_children("")):
vals = self.tree.item(iid, "values")
zebra = "even" if i % 2 == 0 else "odd"
is9999 = False
if vals:
try:
is9999 = int(vals[0]) == 9999
except Exception:
is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,)
self.tree.item(iid, tags=tags)
# ---------------- AZIONI ----------------
def _export_xlsx(self):
# raccogli dati dalla griglia
rows = []
for iid in self.tree.get_children(""):
rows.append(self.tree.item(iid, "values"))
if not rows:
messagebox.showinfo("Esporta", "Non ci sono righe da esportare.", parent=self)
return
if not _HAS_XLSX:
messagebox.showerror("Esporta", "Per l'esportazione serve 'openpyxl' (pip install openpyxl).", parent=self)
return
# dialog salvataggio
from datetime import datetime
ts = datetime.now().strftime("%d_%m_%Y_%H-%M")
default_name = f"esportazione_ricerca_{ts}.xlsx"
fname = filedialog.asksaveasfilename(parent=self, title="Esporta in Excel",
defaultextension=".xlsx",
filetypes=[("Excel Workbook","*.xlsx")],
initialfile=default_name)
if not fname:
return
# crea workbook e scrivi
try:
wb = Workbook()
ws = wb.active
ws.title = "Risultati"
headers = ("IDCella","Ubicazione","UDC","Lotto","Codice","Descrizione")
for j, h in enumerate(headers, start=1):
c = ws.cell(row=1, column=j, value=h)
c.font = Font(bold=True)
c.alignment = Alignment(horizontal="center", vertical="center")
r = 2
for row in rows:
for j, v in enumerate(row, start=1):
ws.cell(row=r, column=j, value=v)
r += 1
# autosize
widths = {}
for row in ws.iter_rows(values_only=True):
for j, val in enumerate(row, start=1):
s = "" if val is None else str(val)
widths[j] = max(widths.get(j, 0), len(s))
from openpyxl.utils import get_column_letter
for j, w in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(w + 2, 10), 60)
wb.save(fname)
messagebox.showinfo("Esporta", f"File creato:\n{fname}", parent=self)
except Exception as ex:
messagebox.showerror("Esporta", f"Errore durante l'esportazione:{ex}", parent=self)
def _on_dclick(self, evt):
# copia UDC solo se il doppio click avviene su una cella, non sull'header
region = self.tree.identify("region", evt.x, evt.y)
if region != "cell":
return
sel = self.tree.focus()
if not sel:
return
vals = self.tree.item(sel, "values")
if len(vals) >= 3 and vals[2]: # UDC
try:
self.clipboard_clear()
self.clipboard_append(vals[2])
except Exception:
pass
# --------------- ORDINAMENTO COLONNE ---------------
def _maybe_handle_heading_click(self, evt):
# evita che il click sulle intestazioni selezioni una riga fantasma
region = self.tree.identify("region", evt.x, evt.y)
if region == "heading":
return "break"
def _on_heading_double_click(self, evt):
# doppio click su intestazione: ordina la colonna corrispondente
region = self.tree.identify("region", evt.x, evt.y)
if region != "heading":
return
col_id = self.tree.identify_column(evt.x) # es. '#1'
try:
idx = int(col_id.replace('#','')) - 1
except Exception:
return "break"
cols = ("IDCella", "Ubicazione", "UDC", "Lotto", "Codice", "Descrizione")
if 0 <= idx < len(cols):
self._sort_by_column(cols[idx])
return "break"
def _sort_key_for_col(self, col: str, val: str):
if val is None:
return (1, "") # None in fondo
s = str(val)
if col in ("IDCella",):
# prova numero
try:
return (0, int(s))
except Exception:
return (0, s.lower())
if col in ("Lotto", "Codice", "UDC"):
return (0, s.lower())
return (0, s.lower())
def _sort_by_column(self, col: str):
try:
# toggle reverse
rev = self._sort_state.get(col, False)
self._sort_state[col] = not rev
# raccogli dati correnti
rows = []
for iid in self.tree.get_children(""):
vals = self.tree.item(iid, "values")
row = {"iid": iid,
"IDCella": vals[0] if len(vals) > 0 else None,
"Ubicazione": vals[1] if len(vals) > 1 else None,
"UDC": vals[2] if len(vals) > 2 else None,
"Lotto": vals[3] if len(vals) > 3 else None,
"Codice": vals[4] if len(vals) > 4 else None,
"Descrizione": vals[5] if len(vals) > 5 else None}
rows.append(row)
rows.sort(key=lambda r: self._sort_key_for_col(col, r.get(col)), reverse=rev)
def _apply_moves():
for index, r in enumerate(rows):
self.tree.move(r["iid"], "", index)
# aggiorna indicatori visuali nelle heading (▲/▼)
for k in ("IDCella","Ubicazione","UDC","Lotto","Codice","Descrizione"):
base = {
"IDCella": "IDCella",
"Ubicazione": "Ubicazione",
"UDC": "UDC / Barcode",
"Lotto": "Lotto",
"Codice": "Codice prodotto",
"Descrizione": "Descrizione prodotto",
}[k]
if k == col:
arrow = "" if not rev else ""
self.tree.heading(k, text=base + arrow)
else:
self.tree.heading(k, text=base)
self._apply_zebra()
# posticipa le move per evitare reentrancy su doppio click
self.after_idle(_apply_moves)
except Exception:
# in caso di problemi non lasciamo la finestra in stato incoerente
pass
# --- ordinamento per tksheet (doppio click sui titoli) ---
def _on_sheet_header_double_click(self, event_dict):
try:
c = event_dict.get("column")
except Exception:
return
if c is None:
return
headers = ["IDCella","Ubicazione","UDC","Lotto","Codice","Descrizione"]
if not (0 <= c < len(headers)):
return
colname = headers[c]
rev = self._sort_state.get(colname, False)
self._sort_state[colname] = not rev
data = self.sheet.get_sheet_data(return_copy=True)
def keyf(row):
val = row[c] if c < len(row) else None
if val is None:
return (1, "")
s = str(val)
if colname == "IDCella":
try:
return (0, int(s))
except Exception:
return (0, s.lower())
return (0, s.lower())
data.sort(key=keyf, reverse=rev)
self.sheet.set_sheet_data(data)
# evidenzia l'header ordinato (semplice: cambia testo temporaneamente)
try:
arrow = "" if not rev else ""
hdrs = list(headers)
hdrs[c] = hdrs[c] + arrow
self.sheet.headers(hdrs)
except Exception:
pass
def _do_search(self):
udc = (self.var_udc.get() or "").strip()
lotto = (self.var_lotto.get() or "").strip()
codice = (self.var_codice.get() or "").strip()
# Se nessun filtro, chiedi conferma (evita estrazione enorme non voluta)
if not (udc or lotto or codice):
if not messagebox.askyesno(
"Conferma",
"Nessun filtro impostato. Vuoi cercare su TUTTO il magazzino?",
parent=self,
):
return
# Parametri: passa NULL se campo vuoto -> i filtri "si spengono"
params = {
"udc": (udc if udc else None),
"lotto": (lotto if lotto else None),
"codice": (codice if codice else None),
}
def _ok(res):
rows = res.get("rows", []) if isinstance(res, dict) else []
# --- popola UI ---
if self.use_sheet:
try:
data = []
for r in rows:
idc, ubi, udc_v, lot_v, cod_v, desc_v = r
data.append([idc, ubi, udc_v, lot_v, cod_v, desc_v])
self.sheet.set_sheet_data(data)
self.sheet.set_all_cell_sizes_to_text()
except Exception as ex:
# fallback di sicurezza su Treeview
self.use_sheet = False
if not self.use_sheet:
# Treeview
for iid in self.tree.get_children():
self.tree.delete(iid)
for idx, r in enumerate(rows):
idc, ubi, udc_v, lot_v, cod_v, desc_v = r
zebra = "even" if idx % 2 == 0 else "odd"
try:
is9999 = int(idc) == 9999
except Exception:
is9999 = False
tags = ("id9999", zebra) if is9999 else (zebra,)
self.tree.insert("", "end", values=(idc, ubi, udc_v, lot_v, cod_v, desc_v), tags=tags)
# --- feedback utente ---
if not rows:
messagebox.showinfo(
"Nessun risultato",
"Nessuna corrispondenza trovata con le chiavi di ricerca inserite.",
parent=self,
)
else:
# reset campi se risultato non vuoto
self.var_udc.set("")
self.var_lotto.set("")
self.var_codice.set("")
self._busy.hide()
def _err(ex):
self._busy.hide()
messagebox.showerror("Errore ricerca", str(ex), parent=self)
self._async.run(self.db.query_json(SQL_SEARCH, params), _ok, _err, busy=self._busy, message="Cerco…")
def open_search_window(parent, db_app):
key = "_search_window_singleton"
ex = getattr(parent, key, None)
if ex and ex.winfo_exists():
try:
ex.lift(); ex.focus_force(); return ex
except Exception:
pass
w = SearchWindow(parent, db_app)
setattr(parent, key, w)
return w

21
sql.txt Normal file
View File

@@ -0,0 +1,21 @@
SELECT * FROM vViewMappaturaDescrizioneCorsia WHERE ( Area > 0) ORDER BY Area Desc
SELECT * FROM Celle WHERE ( ID > 0) ORDER BY ID Desc
SELECT TOP 1000 [mc_X] ,[minFila] ,[maxFila] ,[minColonna] ,[maxColonna] ,[mc_Numero_Magazzino] ,[mc_Numero_Area] FROM vViewMappaturaPosizCorsia WHERE [mc_Numero_Area] =1
SELECT CASE WHEN Nota = 'ASC' THEN 0 ELSE CASE WHEN Nota = 'DESC' THEN 1 END END AS Nota FROM MagLayout WHERE (IDArea = 1)
SELECT ID, Descrizione FROM Magazzini ORDER BY Descrizione
SELECT ID, Descrizione FROM Celle ORDER BY Descrizione
SELECT [ID],[CorsiaDescrizione] FROM vViewMappaturaDescrizioneCorsia
SELECT COUNT(DISTINCT Pallet) AS Pallet, COUNT(DISTINCT Lotto) AS Lotto, COUNT(DISTINCT Articolo) AS Articolo, COUNT(DISTINCT Descrizione) AS Descrizione, SUM(Qta) AS Qta, Documento, CodNazione, NAZIONE, Stato, MAX(PalletCella) AS PalletCella, MAX(Magazzino) AS Magazzino, MAX(Area) AS Area, MAX(Cella) AS Cella, MIN(Ordinamento) AS Ordinamento, MAX(IDStato) AS IDStato FROM dbo.XMag_ViewPackingList GROUP BY Documento, CodNazione, NAZIONE, Stato
SELECT * FROM vViewPackingListRestante WHERE Documento = 237 ORDER BY Ordinamento
SELECT * FROM vViewPackingListRestante WHERE Documento = 240 ORDER BY Ordinamento

346
view_celle_multiple.py Normal file
View File

@@ -0,0 +1,346 @@
# view_celle_multiple.py
import json
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import customtkinter as ctk
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
from gestione_aree_frame_async import AsyncRunner
def _json_obj(res):
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 + """
SELECT
b.BarcodePallet AS Pallet,
ta.Descrizione,
ta.Lotto
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
WHERE b.IDCella = :idcella
GROUP BY b.BarcodePallet, ta.Descrizione, ta.Lotto
ORDER BY b.BarcodePallet;
"""
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):
def __init__(self, root, db_client, runner: AsyncRunner | None = None):
super().__init__(root)
self.title("Celle con più pallet")
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):
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)
f = ctk.CTkFrame(self); f.grid(row=1, column=0, sticky="nsew", padx=6, pady=(0,6))
f.grid_rowconfigure(0, weight=1); f.grid_columnconfigure(0, weight=1)
self.tree = ttk.Treeview(f, columns=("col2","col3"), show="tree headings", selectmode="browse")
self.tree.heading("#0", text="Nodo"); self.tree.heading("col2", text="Descrizione"); self.tree.heading("col3", text="Lotto")
y = ttk.Scrollbar(f, orient="vertical", command=self.tree.yview)
x = ttk.Scrollbar(f, 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 k,t,w,a in (("Corsia","Corsia",100,"center"),
("TotCelle","Totale celle",120,"e"),
("CelleMultiple",">1 UDC",120,"e"),
("Percentuale","%",80,"e")):
self.sum_tbl.heading(k, text=t); self.sum_tbl.column(k, width=w, anchor=a)
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):
self.tree.bind("<<TreeviewOpen>>", self._on_open_node)
def refresh_all(self):
self._load_corsie(); self._load_riepilogo()
def _load_corsie(self):
self.tree.delete(*self.tree.get_children())
async def _q(db): return await db.query_json(SQL_CORSIE, as_dict_rows=True)
self.runner.run(_q(self.db), self._fill_corsie, lambda e: messagebox.showerror("Errore", str(e), parent=self))
def _fill_corsie(self, res):
rows = _json_obj(res).get("rows", [])
for r in rows:
corsia = r.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):
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)
def _load_celle_for_corsia(self, parent_iid, corsia):
async def _q(db): return await db.query_json(SQL_CELLE_DUP_PER_CORSIA, params={"corsia": corsia}, as_dict_rows=True)
self.runner.run(_q(self.db), lambda res: self._fill_celle(parent_iid, res),
lambda e: messagebox.showerror("Errore", str(e), parent=self))
def _fill_celle(self, parent_iid, res):
rows = _json_obj(res).get("rows", [])
if not rows:
self.tree.insert(parent_iid, "end", text="(nessuna cella con >1 UDC)", values=("", "")); return
for r in rows:
idc = r["IDCella"]; ubi = r["Ubicazione"]; corsia = r.get("Corsia"); num = r.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(ch.endswith("::lazy") for ch in self.tree.get_children(node_id)):
self.tree.insert(node_id, "end", iid=f"{node_id}::lazy", text="...", values=("", ""))
def _load_pallet_for_cella(self, parent_iid, idcella: int):
async def _q(db): return await db.query_json(SQL_PALLET_IN_CELLA, params={"idcella": idcella}, as_dict_rows=True)
self.runner.run(_q(self.db), lambda res: self._fill_pallet(parent_iid, res),
lambda e: messagebox.showerror("Errore", str(e), parent=self))
def _fill_pallet(self, parent_iid, res):
rows = _json_obj(res).get("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((t for t in parent_tags if t.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 r in rows:
pallet = r.get("Pallet", ""); desc = r.get("Descrizione", ""); lotto = r.get("Lotto", "")
leaf_id = f"pallet:{idcella_num}:{pallet}"
if self.tree.exists(leaf_id):
self.tree.item(leaf_id, text=str(pallet), values=(desc, lotto)); continue
self.tree.insert(parent_iid, "end", iid=leaf_id, text=str(pallet),
values=(desc, lotto),
tags=("pallet", f"corsia:{corsia_val}", f"ubicazione:{cella_ubi}", f"idcella:{idcella_num}"))
def _load_riepilogo(self):
async def _q(db): return await db.query_json(SQL_RIEPILOGO_PERCENTUALI, as_dict_rows=True)
self.runner.run(_q(self.db), self._fill_riepilogo, lambda e: messagebox.showerror("Errore", str(e), parent=self))
def _fill_riepilogo(self, res):
rows = _json_obj(res).get("rows", [])
for i in self.sum_tbl.get_children(): self.sum_tbl.delete(i)
for r in rows:
self.sum_tbl.insert("", "end", values=(r.get("Corsia"), r.get("TotCelle",0),
r.get("CelleMultiple",0), f"{r.get('Percentuale',0):.2f}"))
def expand_all(self):
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):
for iid in self.tree.get_children(""):
self.tree.item(iid, open=False)
def export_to_xlsx(self):
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"]
sum_headers = ["Corsia", "TotCelle", "CelleMultiple", "Percentuale"]
def _hdr(ws, headers):
for j,h in enumerate(headers, start=1):
cell = ws.cell(row=1, column=j, value=h)
cell.font = Font(bold=True); cell.alignment = Alignment(horizontal="center", vertical="center")
_hdr(ws_det, det_headers); _hdr(ws_sum, sum_headers)
r = 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((t.split(":",1)[1] for t in tags if t.startswith("corsia:")), "")
ubi = next((t.split(":",1)[1] for t in tags if t.startswith("ubicazione:")), "")
idcella = next((t.split(":",1)[1] for t in tags if t.startswith("idcella:")), "")
pallet = self.tree.item(pallet_node, "text")
desc, lotto = self.tree.item(pallet_node, "values")
for j,v in enumerate([corsia, ubi, idcella, pallet, desc, lotto], start=1):
ws_det.cell(row=r, column=j, value=v)
r += 1
r2 = 2
for iid in self.sum_tbl.get_children(""):
vals = self.sum_tbl.item(iid, "values")
for j, v in enumerate(vals, start=1):
ws_sum.cell(row=r2, column=j, value=v)
r2 += 1
def _autosize(ws):
widths = {}
for row in ws.iter_rows(values_only=True):
for j, val in enumerate(row, start=1):
val_s = "" if val is None else str(val)
widths[j] = max(widths.get(j, 0), len(val_s))
from openpyxl.utils import get_column_letter
for j, w in widths.items():
ws.column_dimensions[get_column_letter(j)].width = min(max(w + 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:
messagebox.showerror("Errore esportazione", str(ex), parent=self)
def open_celle_multiple_window(root: tk.Tk, db_client, runner: AsyncRunner | None = None):
win = CelleMultipleWindow(root, db_client, runner=runner); win.lift(); win.focus_set(); return win

1712
warehouse_sp_python.py Normal file

File diff suppressed because it is too large Load Diff

3270
warehouse_sql.log Normal file

File diff suppressed because one or more lines are too long