Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
from grotti import bedarf as bedarf_mod
|
|
from grotti import maintenance
|
|
from grotti import store
|
|
|
|
|
|
def _filename_label(filename):
|
|
name = (filename or "").strip()
|
|
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
|
|
if name.lower().endswith(suffix):
|
|
name = name[: -len(suffix)]
|
|
break
|
|
while name and name[0].isdigit():
|
|
parts = name.split("-", 1)
|
|
if len(parts) == 2 and parts[0].isdigit():
|
|
name = parts[1]
|
|
else:
|
|
break
|
|
return name or filename or ""
|
|
|
|
|
|
def _iso_remain(remain_s):
|
|
remain = float(remain_s or 0)
|
|
if remain <= 0:
|
|
return None
|
|
at = datetime.now(timezone.utc).timestamp() + remain
|
|
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
|
|
|
|
|
|
def farm_eta(running_items):
|
|
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
|
|
remains = [
|
|
float(item.get("remain") or 0)
|
|
for item in (running_items or [])
|
|
if float(item.get("remain") or 0) > 0
|
|
]
|
|
if not remains:
|
|
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
|
|
return {
|
|
"all_done_at": _iso_remain(max(remains)),
|
|
"all_done_in_s": max(remains),
|
|
"next_free_in_s": min(remains),
|
|
}
|
|
|
|
|
|
def overdue_orders(conn, limit=8):
|
|
today = date.today()
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT o.*, c.name AS customer_name
|
|
FROM orders o
|
|
LEFT JOIN customers c ON c.id = o.customer_id
|
|
WHERE o.due_at IS NOT NULL
|
|
AND o.due_at < %s
|
|
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
|
|
ORDER BY o.due_at ASC, o.id ASC
|
|
LIMIT %s
|
|
""",
|
|
(today, int(limit)),
|
|
).fetchall()
|
|
out = []
|
|
for row in rows:
|
|
out.append({
|
|
"id": row["id"],
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
|
"customer": row.get("customer_name") or "",
|
|
})
|
|
return out
|
|
|
|
|
|
def overdue_maintenance(conn, printers, limit=8):
|
|
items = []
|
|
for printer in printers or []:
|
|
host = printer.get("host") or ""
|
|
counters = maintenance.counters_for_host(conn, host)
|
|
rows = conn.execute(
|
|
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
|
(host,),
|
|
).fetchall()
|
|
for row in rows:
|
|
part = maintenance.part_out(row, counters, conn=conn)
|
|
if not (part.get("overdue_hours") or part.get("overdue_meters")):
|
|
continue
|
|
items.append({
|
|
"id": part["id"],
|
|
"host": host,
|
|
"printer": printer.get("name") or host,
|
|
"label": part.get("label") or part.get("part_key") or "Teil",
|
|
"overdue_hours": bool(part.get("overdue_hours")),
|
|
"overdue_meters": bool(part.get("overdue_meters")),
|
|
})
|
|
if len(items) >= limit:
|
|
return items
|
|
return items
|
|
|
|
|
|
def build(conn, watcher_latest, printers=None, send_jobs=None):
|
|
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
|
|
printers = printers if printers is not None else store.list_printers(conn)
|
|
clears = store.bed_clear_map(conn)
|
|
busy_hosts = {
|
|
row["host"]
|
|
for row in conn.execute(
|
|
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
|
).fetchall()
|
|
}
|
|
queue = store.list_print_queue(conn, limit=80)
|
|
waiting = [j for j in queue if j.get("status") == "waiting"]
|
|
errors = [j for j in queue if j.get("status") == "error"]
|
|
running_q = [j for j in queue if j.get("status") == "running"]
|
|
|
|
actions = []
|
|
|
|
for live in watcher_latest or []:
|
|
host = live.get("host") or ""
|
|
state = (live.get("state") or "").lower()
|
|
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
|
|
actions.append({
|
|
"kind": "filament_pause",
|
|
"priority": 12,
|
|
"host": host,
|
|
"printer": live.get("name") or host,
|
|
"title": "Material nachlegen",
|
|
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
|
|
"filename": live.get("filename") or "",
|
|
"href": "/#" + host,
|
|
})
|
|
|
|
for live in watcher_latest or []:
|
|
host = live.get("host") or ""
|
|
state = (live.get("state") or "").lower()
|
|
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
|
|
continue
|
|
actions.append({
|
|
"kind": "clear",
|
|
"priority": 10,
|
|
"host": host,
|
|
"printer": live.get("name") or host,
|
|
"title": "Platte räumen",
|
|
"detail": _filename_label(live.get("filename") or ""),
|
|
"filename": live.get("filename") or "",
|
|
"href": "/#" + host,
|
|
})
|
|
|
|
for job in errors[:6]:
|
|
actions.append({
|
|
"kind": "queue_error",
|
|
"priority": 20,
|
|
"id": job.get("id"),
|
|
"host": job.get("host") or "",
|
|
"printer": job.get("printer_name") or job.get("host") or "",
|
|
"title": "Druck fehlgeschlagen",
|
|
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
|
|
"filename": job.get("filename") or "",
|
|
"is_ablauf": bool(job.get("is_ablauf")),
|
|
"step_index": job.get("step_index"),
|
|
"step_label_current": job.get("step_label_current") or "",
|
|
"href": "/warteschlange",
|
|
})
|
|
|
|
bedarf = bedarf_mod.compute(conn)
|
|
for item in (bedarf.get("shortfall") or [])[:5]:
|
|
label = " · ".join(
|
|
x for x in [item.get("material_type"), item.get("color")] if x
|
|
) or "Filament"
|
|
actions.append({
|
|
"kind": "bedarf",
|
|
"priority": 30,
|
|
"title": "Spule fehlt",
|
|
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
|
|
"material_type": item.get("material_type") or "",
|
|
"color": item.get("color") or "",
|
|
"shortfall_g": item.get("shortfall_g") or 0,
|
|
"href": "/material#bedarf",
|
|
})
|
|
|
|
for part in overdue_maintenance(conn, printers):
|
|
actions.append({
|
|
"kind": "wartung",
|
|
"priority": 40,
|
|
"id": part["id"],
|
|
"host": part["host"],
|
|
"printer": part["printer"],
|
|
"title": "Wartung fällig",
|
|
"detail": "%s · %s" % (part["printer"], part["label"]),
|
|
"href": "/wartung",
|
|
})
|
|
|
|
for order in overdue_orders(conn):
|
|
actions.append({
|
|
"kind": "auftrag",
|
|
"priority": 50,
|
|
"id": order["id"],
|
|
"title": "Auftrag überfällig",
|
|
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
|
|
"due_at": order.get("due_at"),
|
|
"href": "/auftraege",
|
|
})
|
|
|
|
shortfall = bedarf.get("shortfall") or []
|
|
for job in waiting[:8]:
|
|
tip = (job.get("wait_reason") or "").strip()
|
|
if not tip and shortfall:
|
|
# Fallback ohne Routing-Hinweis
|
|
tip = ""
|
|
if not tip:
|
|
continue
|
|
actions.append({
|
|
"kind": "waiting",
|
|
"priority": 60,
|
|
"id": job.get("id"),
|
|
"host": job.get("host") or "",
|
|
"printer": job.get("printer_name") or job.get("host") or "",
|
|
"title": "Wartet auf Material",
|
|
"detail": tip,
|
|
"filename": job.get("filename") or "",
|
|
"reason": tip,
|
|
"href": "/warteschlange",
|
|
})
|
|
|
|
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
|
|
|
|
live_by_host = {}
|
|
running = []
|
|
for live in watcher_latest or []:
|
|
state = (live.get("state") or "").lower()
|
|
if state not in ("printing", "paused", "heating"):
|
|
continue
|
|
item = {
|
|
"host": live.get("host") or "",
|
|
"name": live.get("name") or "",
|
|
"state": state,
|
|
"progress": live.get("progress") or 0,
|
|
"remain": live.get("remain") or 0,
|
|
"filename": live.get("filename") or "",
|
|
"filament_empty": bool(live.get("filament_empty")),
|
|
"pause_reason": (live.get("pause_reason") or "").strip(),
|
|
"queue_id": None,
|
|
}
|
|
live_by_host[item["host"]] = item
|
|
running.append(item)
|
|
for job in running_q:
|
|
host = job.get("host") or ""
|
|
if host in live_by_host:
|
|
live_by_host[host]["queue_id"] = job.get("id")
|
|
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
|
|
live_by_host[host]["step_labels"] = job.get("step_labels")
|
|
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
|
live_by_host[host]["step_index"] = job.get("step_index")
|
|
else:
|
|
running.append({
|
|
"host": host,
|
|
"name": job.get("printer_name") or host,
|
|
"state": "running",
|
|
"progress": job.get("progress") or 0,
|
|
"remain": job.get("remain") or 0,
|
|
"filename": job.get("filename") or "",
|
|
"queue_id": job.get("id"),
|
|
"is_ablauf": bool(job.get("is_ablauf")),
|
|
})
|
|
|
|
sends = []
|
|
for job in send_jobs or []:
|
|
if job.get("status") in ("pending", "running"):
|
|
sends.append(job)
|
|
elif job.get("status") == "error" and len(sends) < 2:
|
|
sends.append(job)
|
|
|
|
alert_kinds = list(store.ALERT_EVENT_KINDS)
|
|
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
|
|
unread = store.unread_event_count(conn, kinds=alert_kinds)
|
|
|
|
return {
|
|
"actions": actions,
|
|
"running": running,
|
|
"waiting_count": len(waiting),
|
|
"sends": sends[:5],
|
|
"alerts": alert_items,
|
|
"unread_alerts": unread,
|
|
"eta": farm_eta(running),
|
|
"bedarf_shortfall": len(shortfall),
|
|
}
|