154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
import time
|
||
from datetime import date, datetime, timedelta, timezone
|
||
|
||
from grotti import bedarf as bedarf_mod
|
||
from grotti import jetzt as jetzt_mod
|
||
from grotti import notify
|
||
from grotti import store
|
||
from grotti.db import connect
|
||
|
||
|
||
def _yesterday_stats(conn):
|
||
today = date.today()
|
||
yesterday = today - timedelta(days=1)
|
||
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
|
||
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||
row = conn.execute(
|
||
"""
|
||
SELECT
|
||
COUNT(*) AS runs,
|
||
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||
COALESCE(SUM(weight_used_g), 0) AS grams,
|
||
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
|
||
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
|
||
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
|
||
FROM runs
|
||
WHERE started_at >= %s AND started_at < %s
|
||
""",
|
||
(start, end),
|
||
).fetchone()
|
||
return {
|
||
"date": yesterday.isoformat(),
|
||
"runs": int(row["runs"] or 0),
|
||
"duration_s": float(row["duration_s"] or 0),
|
||
"grams": float(row["grams"] or 0),
|
||
"scrap": int(row["scrap"] or 0),
|
||
"pieces_ok": int(row["pieces_ok"] or 0),
|
||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||
}
|
||
|
||
|
||
def build_text(conn, watcher_latest=None, printers=None):
|
||
stats = _yesterday_stats(conn)
|
||
waiting = len([
|
||
j for j in store.list_print_queue(conn, limit=200)["items"]
|
||
if j.get("status") == "waiting"
|
||
])
|
||
eta = jetzt_mod.farm_eta([
|
||
{"remain": (live or {}).get("remain") or 0}
|
||
for live in (watcher_latest or [])
|
||
if (live or {}).get("state") in ("printing", "paused", "heating")
|
||
])
|
||
lines = [
|
||
"Grotti Tagesbericht %s" % stats["date"],
|
||
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
|
||
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
|
||
),
|
||
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
|
||
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
|
||
),
|
||
"Heute in Warteschlange: %d" % waiting,
|
||
]
|
||
if eta.get("all_done_in_s"):
|
||
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
|
||
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
|
||
overdue = jetzt_mod.overdue_orders(conn, limit=5)
|
||
if overdue:
|
||
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
|
||
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
|
||
if short:
|
||
bits = []
|
||
for item in short:
|
||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
|
||
lines.append("Bedarf: " + ", ".join(bits))
|
||
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
|
||
if parts:
|
||
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def push_ops_alerts(conn, cfg, printers=None):
|
||
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
|
||
|
||
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
|
||
(DailyReportThread tickt alle 60 s).
|
||
"""
|
||
printers = printers or store.list_printers(conn)
|
||
for order in jetzt_mod.overdue_orders(conn, limit=10):
|
||
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
|
||
key = "order:%s" % order["id"]
|
||
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
|
||
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
|
||
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
|
||
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
|
||
key = "maint:%s" % part["id"]
|
||
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
|
||
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
|
||
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
|
||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
|
||
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
|
||
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
|
||
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
|
||
|
||
|
||
class DailyReportThread:
|
||
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
|
||
|
||
def __init__(self, database_url, cfg, watcher=None):
|
||
self.database_url = database_url
|
||
self.cfg = cfg
|
||
self.watcher = watcher
|
||
self._stop = threading.Event()
|
||
self._thread = None
|
||
self._last_report_day = None
|
||
|
||
def start(self):
|
||
if self._thread and self._thread.is_alive():
|
||
return
|
||
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
|
||
self._thread.start()
|
||
|
||
def stop(self):
|
||
self._stop.set()
|
||
|
||
def _loop(self):
|
||
while not self._stop.wait(60):
|
||
try:
|
||
self._tick()
|
||
except Exception:
|
||
pass
|
||
|
||
def _tick(self):
|
||
now = datetime.now().astimezone()
|
||
with connect(self.database_url) as conn:
|
||
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
|
||
conn.commit()
|
||
if now.hour == 7 and now.minute < 2:
|
||
day = now.date().isoformat()
|
||
if self._last_report_day == day:
|
||
return
|
||
latest = (self.watcher.latest if self.watcher else None) or []
|
||
text = build_text(conn, latest, store.list_printers(conn))
|
||
notify.send_telegram(self.cfg, text)
|
||
notify.send_ntfy(self.cfg, text)
|
||
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
|
||
conn.commit()
|
||
self._last_report_day = day
|