From 8e88efc09452fea26740961e08ad9103c230b056 Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sun, 18 Oct 2026 00:54:51 +0200 Subject: [PATCH] =?UTF-8?q?Grotti=203.0.0:=20Auftr=C3=A4ge,=20Wartung,=20S?= =?UTF-8?q?SE,=20Ausschuss-Dialog,=20UI-Umbau.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil, ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü. --- CHANGELOG.md | 32 +- README.md | 2 + config.example.json | 6 + docs/Anleitung-Auftraege.md | 19 ++ docs/Anleitung-Wartung.md | 12 + docs/internal/Features-UI.md | 64 +--- grotti/backup.py | 92 ++++++ grotti/bedarf.py | 76 +++++ grotti/config.py | 87 ++++- grotti/cost.py | 97 +++++- grotti/events_bus.py | 42 +++ grotti/maintenance.py | 156 +++++++++ grotti/mcp_server.py | 21 ++ grotti/migrations/003_ausschuss.sql | 3 + grotti/migrations/004_wartung.sql | 25 ++ grotti/migrations/008_zeitfenster.sql | 1 + grotti/migrations/009_auftraege.sql | 63 ++++ grotti/notify.py | 48 ++- grotti/orders.py | 460 ++++++++++++++++++++++++++ grotti/print_queue.py | 34 ++ grotti/schema.sql | 64 ++++ grotti/send_queue.py | 2 + grotti/store.py | 94 +++++- grotti/time_window.py | 72 ++++ grotti/verdict.py | 33 ++ grotti/version.py | 2 +- grotti/watcher.py | 27 +- tests/test_ausschuss.py | 27 ++ tests/test_bedarf.py | 70 ++++ tests/test_maintenance.py | 28 ++ tests/test_orders.py | 48 +++ tests/test_time_window.py | 24 ++ web/app.py | 354 +++++++++++++++++++- web/static/api.js | 11 +- web/static/grotti.css | 96 +++++- web/static/manifest.webmanifest | 23 ++ web/static/sw.js | 27 ++ web/static/ui.js | 208 ++++++++++++ web/templates/auftraege.html | 320 ++++++++++++++++++ web/templates/einstellungen.html | 85 ++++- web/templates/layout.html | 105 +++++- web/templates/lieferschein.html | 69 ++++ web/templates/statistik.html | 13 + web/templates/wand.html | 8 +- web/templates/wartung.html | 93 ++++++ 45 files changed, 3121 insertions(+), 122 deletions(-) create mode 100644 docs/Anleitung-Auftraege.md create mode 100644 docs/Anleitung-Wartung.md create mode 100644 grotti/backup.py create mode 100644 grotti/bedarf.py create mode 100644 grotti/events_bus.py create mode 100644 grotti/maintenance.py create mode 100644 grotti/migrations/003_ausschuss.sql create mode 100644 grotti/migrations/004_wartung.sql create mode 100644 grotti/migrations/008_zeitfenster.sql create mode 100644 grotti/migrations/009_auftraege.sql create mode 100644 grotti/orders.py create mode 100644 grotti/time_window.py create mode 100644 grotti/verdict.py create mode 100644 tests/test_ausschuss.py create mode 100644 tests/test_bedarf.py create mode 100644 tests/test_maintenance.py create mode 100644 tests/test_orders.py create mode 100644 tests/test_time_window.py create mode 100644 web/static/manifest.webmanifest create mode 100644 web/static/sw.js create mode 100644 web/static/ui.js create mode 100644 web/templates/auftraege.html create mode 100644 web/templates/lieferschein.html create mode 100644 web/templates/wartung.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a3c94..1c9a4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,35 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.0.0] – 2026-10-18 + +Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen. + +**Vor dem Update:** `pg_dump` der Datenbank. Danach Grotti starten (Migrationen 003–009 laufen automatisch). + ### Neu -- Datenbank: nummerierte Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes für Sends/Läufe/Dateien/Events, Retention für `run_samples` (90 Tage). -- Server: waitress statt Flask-Dev-Server; Hintergrund-Threads starten erst in `server.main()`. +- **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools. +- **Ausschuss-Pflicht:** Dialog beim „Platte geräumt“ (Urteil, Grund, Stückzahlen). +- **Wartung:** Laufzähler und Verschleißteile, Seite `/wartung`. +- **Bedarf:** Queue vs. Spulenrest (`GET /api/v1/bedarf`). +- **ntfy** neben Telegram; **Backup**-ZIP (pg_dump + data/); **Zeitfenster** je Drucker; optional HT/NT-Tarif. +- **SSE** Live-Stream `/api/v1/stream`, Verbindungsbanner, PWA-Manifest/Service-Worker. +- Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes, Sample-Retention. +- Mobile-Nav mit „Mehr“-Sheet; Anleitungen Aufträge und Wartung. ### Geändert -- Spulenabzug aus Moonraker-History: keine Rate mehr ohne Send-Eintrag; Spulen, die nach Druckende angelegt wurden, werden ignoriert. -- Chargen-Vergabe (`S-…` / `D-…`) mit Advisory-Lock; Druck-Charge nach lokaler Tagesgrenze. -- MCP-Steuerung: Parameter `temp`/`percent` (Alias `value` bleibt in Moonraker). -- Upload nur `.gcode`/`.3mf`/`.gcode.3mf`; Drucker-Host validiert; `?token=` nur für Kamera/Vorschau; ProxyFix; Settings-Lock. +- Server: waitress; Threads starten in `server.main()`. +- Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons. +- Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS. +- Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder. +- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`. ### Behoben -- Hängende Queue-Jobs (`phase=starting` ohne Worker) nach Neustart werden nach 45 s auf Fehler gesetzt. -- Queue-Worker rollen die Transaktion bei Fehlern zurück; Exceptions werden geloggt. -- `snapshot()` fängt alle Verbindungsfehler ab, damit ein kaputter Drucker den Watcher nicht stoppt. -- `prepare_send` erhält die ACE-Map schon beim Einreihen in die Send-Queue. -- Spulenwarnung im Watcher berücksichtigt ACE-Plätze. +- Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions. +- Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE. ## [2.10.0] – 2026-10-18 diff --git a/README.md b/README.md index d483cdf..fe54dc4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ copy config.example.json config.json Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt. +Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`). + ```json [{ "name": "Kobra 3 da26", "host": "10.10.1.60" }] ``` diff --git a/config.example.json b/config.example.json index 551232d..31a7886 100644 --- a/config.example.json +++ b/config.example.json @@ -30,6 +30,12 @@ "bot_token": "", "chat_id": "" }, + "ntfy": { + "enabled": false, + "server": "https://ntfy.sh", + "topic": "", + "token": "" + }, "alerts": { "fertig": true, "fehler": true, diff --git a/docs/Anleitung-Auftraege.md b/docs/Anleitung-Auftraege.md new file mode 100644 index 0000000..1ffb672 --- /dev/null +++ b/docs/Anleitung-Auftraege.md @@ -0,0 +1,19 @@ +# Aufträge + +Stand **18.10.2026**. Kundenaufträge mit Positionen (Datei × Menge), Fortschritt und Lieferschein. + +## Seite Aufträge + +Unter **Aufträge** legst du Kunden und Aufträge an. Status: Idee → Angebot → Bestätigt → Druckt → Fertig → Geliefert → Bezahlt. + +Pro Position: Datei, Stückzahl, optional Preis und Material. **Einreihen** schickt die Position in die Warteschlange (`confirm`). Fertige gute Stücke aus dem Abräumen-Dialog zählen auf den Fortschritt „n von m“. + +## Lieferschein + +Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Danach Status auf **Geliefert** setzen. + +## Weiter + +- [Warteschlange](Anleitung-Warteschlange.md) +- [Material](Anleitung-Material.md) +- [Protokoll / Bericht](Anleitung-Oberflaeche.md) diff --git a/docs/Anleitung-Wartung.md b/docs/Anleitung-Wartung.md new file mode 100644 index 0000000..41aea09 --- /dev/null +++ b/docs/Anleitung-Wartung.md @@ -0,0 +1,12 @@ +# Wartung + +Stand **18.10.2026**. Druckstunden, Filament-Meter und Verschleißteile je Drucker. + +## Seite Wartung + +Unter **Wartung** siehst du je Drucker die Summe aus dem Protokoll (Stunden, Meter, Starts). Teile mit Soll-Lebensdauer (Stunden oder Meter) und Einbaudatum; bei Reset wird der Zählerstand protokolliert. + +## Weiter + +- [Einstellungen](Anleitung-Einstellungen.md) +- [Statistik](Anleitung-Oberflaeche.md) diff --git a/docs/internal/Features-UI.md b/docs/internal/Features-UI.md index 3ee179b..8a7df07 100644 --- a/docs/internal/Features-UI.md +++ b/docs/internal/Features-UI.md @@ -1,61 +1,33 @@ # Grotti: Alltag und Oberfläche -Stand **18.10.2026**, v2.10.0. +Stand **18.10.2026**, v3.0.0. ## Design - Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). -- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen. -- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon. -- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt. Zeitzone für Anzeigezeiten (`grotti-timezone`). -- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav. +- Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`. +- **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar. +- PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache). +- Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner. ## Seitenleiste -- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Statistik, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog). -- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`): - - **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr, Fortschritt in der Zeile); bei Abläufen Schritt `n/m` statt Schrittleiste; Klick → `/warteschlange`, × bricht ab; auch Direktstarts ohne Queue. - - **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden. - - **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick. - - **Senden**: laufende Uploads mit Fortschritt. - - **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität. -- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s. +- **Live:** Drucker, Dateien, Kameras, Warteschlange, Aufträge. +- **Rückschau:** Protokoll, Statistik, Aktivität. +- **Verwalten:** Material, Wartung; Einstellungen, Doku. +- Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts. +- Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet). -## Wand +## Wand und Abräumen -- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei, Vorschaubild. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick). -- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis. -- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst). -- **Cockpit** (Hash `/#`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung (inkl. ACE-Trockner und ACE-Plätze schreiben), **Verlauf** (aktueller/letzter Druck mit Temp/ED, Link Bericht), 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher. API `GET /api/v1/laeufe/aktuell?host=`. +- Karten + Cockpit wie bisher. +- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen. -## Dateien und Senden +## Aufträge und Wartung -- Links Bibliothek: Suche (Name/Material/Projekt), Filter Maschine/Projekt/Material/Ordner, Sortierung inkl. Gruppenköpfe; Filter in `localStorage` `grotti-lib-v1`. Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. API-Feld `folder` (Relativpfad unter `watch_dir`, Uploads → `Upload`). Mit `watch_dir`: Ordner anlegen (`POST /dateien/ordner`), Watch-Datei verschieben (`POST /dateien//verschieben`, `stored_path` per ID). Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen. Orca-Nachbearbeitung: `scripts/orca_senden.py`. -- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf. -- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest. -- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`. +- `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. `/projekte` und `/ideen` leiten um. +- `/wartung`: Stunden/Meter und Verschleißteile. -## Material +## Dateien, Material, Statistik -- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht). -- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in (KPI-Kacheln). -- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter. - -## Protokoll und Statistik - -- Protokoll-Zeile → Druckbericht `/protokoll/` (Soll/Ist, Spulen, Temperatur/ED-Verlauf, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht. -- Statistik `/statistik`: KPIs (Läufe, Zeit, Verbrauch, Kosten, Erfolg, Ausschuss), nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern. - -## Warteschlange - -- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen. -- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand. -- Ablauf-Tasten: Grenze `max_delta_mm` Default 3 mm. - -## Aktivität - -- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert. - -## Kameras und Handy - -- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität. +- Unverändert im Kern (Bibliothek, Send-Flow, Spulen/Sorten, KPIs + Ausschuss-Gründe). diff --git a/grotti/backup.py b/grotti/backup.py new file mode 100644 index 0000000..6b42501 --- /dev/null +++ b/grotti/backup.py @@ -0,0 +1,92 @@ +"""Datenbank- und Datenordner-Backup.""" + +from __future__ import annotations + +import os +import re +import subprocess +import zipfile +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + + +def backup_dir(cfg): + base = Path(cfg.get("data_dir") or "data") + path = base / "backup" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _safe_name(name): + name = re.sub(r"[^\w.\-]+", "_", name or "") + return name[:180] or "backup.zip" + + +def create_backup(cfg): + """ZIP mit pg_dump (stdout) und data/ ohne toolpath/ und backup/.""" + data_dir = Path(cfg.get("data_dir") or "data") + out_dir = backup_dir(cfg) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + filename = _safe_name("grotti-%s.zip" % stamp) + dest = out_dir / filename + + url = cfg.get("database_url") or "" + parsed = urlparse(url) + db_name = (parsed.path or "/grotti").lstrip("/") or "grotti" + env = {} + if parsed.password: + env["PGPASSWORD"] = parsed.password + + cmd = ["pg_dump", "--no-owner", "--no-acl", db_name] + if parsed.hostname: + cmd[1:1] = ["-h", parsed.hostname] + if parsed.port: + cmd[1:1] = ["-p", str(parsed.port)] + if parsed.username: + cmd[1:1] = ["-U", parsed.username] + + proc = subprocess.run(cmd, capture_output=True, env={**os.environ, **env}) + if proc.returncode != 0: + err = (proc.stderr or b"").decode("utf-8", "replace").strip() + raise RuntimeError(err or "pg_dump fehlgeschlagen.") + + skip_dirs = {"toolpath", "backup"} + with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("database.sql", proc.stdout) + if data_dir.is_dir(): + for path in sorted(data_dir.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(data_dir) + if rel.parts and rel.parts[0] in skip_dirs: + continue + zf.write(path, "data/" + rel.as_posix()) + return { + "name": filename, + "path": str(dest), + "size": dest.stat().st_size, + } + + +def list_backups(cfg): + out_dir = backup_dir(cfg) + items = [] + for path in sorted(out_dir.glob("*.zip"), reverse=True): + try: + size = path.stat().st_size + except OSError: + size = 0 + items.append({"name": path.name, "size": size}) + return items + + +def backup_path(cfg, name): + out_dir = backup_dir(cfg) + safe = _safe_name(name) + path = (out_dir / safe).resolve() + if not str(path).startswith(str(out_dir.resolve())): + return None + if not path.is_file(): + return None + return path diff --git a/grotti/bedarf.py b/grotti/bedarf.py new file mode 100644 index 0000000..db97e19 --- /dev/null +++ b/grotti/bedarf.py @@ -0,0 +1,76 @@ +"""Filament-Bedarf aus wartender Queue vs. Spulenrest.""" + +from __future__ import annotations + + +def _group_key(material_type, color): + return ( + (material_type or "").strip().lower(), + (color or "").strip().lower(), + ) + + +def compute(conn): + """Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe.""" + queue_rows = conn.execute( + """ + SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done, + f.weight_g, f.material_type AS file_material, f.color AS file_color, + s.material_type AS spool_material, s.color AS spool_color + FROM print_queue q + JOIN files f ON f.id = q.file_id + LEFT JOIN spools s ON s.id = q.spool_id + WHERE q.status = 'waiting' + """ + ).fetchall() + + needed_map = {} + for row in queue_rows: + mat = (row.get("spool_material") or row.get("file_material") or "").strip() + color = (row.get("spool_color") or row.get("file_color") or "").strip() + key = _group_key(mat, color) + try: + repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0)) + except (TypeError, ValueError): + repeats = 1 + weight = float(row.get("weight_g") or 0) * repeats + if key not in needed_map: + needed_map[key] = { + "material_type": mat, + "color": color, + "weight_g": 0.0, + "jobs": 0, + } + needed_map[key]["weight_g"] += weight + needed_map[key]["jobs"] += 1 + + spool_rows = conn.execute( + """ + SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g + FROM spools + GROUP BY material_type, color + """ + ).fetchall() + + stock_map = {} + for row in spool_rows: + key = _group_key(row.get("material_type"), row.get("color")) + stock_map[key] = stock_map.get(key, 0.0) + float(row.get("remaining_g") or 0) + + needed = sorted( + needed_map.values(), + key=lambda x: (-x["weight_g"], x["material_type"], x["color"]), + ) + shortfall = [] + for item in needed: + key = _group_key(item["material_type"], item["color"]) + have = stock_map.get(key, 0.0) + gap = item["weight_g"] - have + if gap > 0.5: + shortfall.append({ + **item, + "remaining_g": have, + "shortfall_g": gap, + }) + shortfall.sort(key=lambda x: (-x["shortfall_g"], x["material_type"], x["color"])) + return {"needed": needed, "shortfall": shortfall} diff --git a/grotti/config.py b/grotti/config.py index e124e6b..0713166 100644 --- a/grotti/config.py +++ b/grotti/config.py @@ -14,9 +14,26 @@ DEFAULT_WATCH_REL = "slice" _SETTINGS_LOCK = threading.Lock() +def _tariff(raw): + tariff = raw.get("tariff") if isinstance(raw, dict) else None + if not isinstance(tariff, dict) or not tariff: + return None + try: + ht = float(tariff.get("ht_euro")) + nt = float(tariff.get("nt_euro")) + except (TypeError, ValueError): + return None + return { + "ht_euro": ht, + "nt_euro": nt, + "nt_from": str(tariff.get("nt_from") or "22:00").strip(), + "nt_to": str(tariff.get("nt_to") or "06:00").strip(), + } + + def _energy(raw): energy = raw.get("energy") or {} - return { + out = { "watts": float(energy.get("watts", 40)), "nozzle_watts": float(energy.get("nozzle_watts", 60)), "bed_watts": float(energy.get("bed_watts", 220)), @@ -26,6 +43,20 @@ def _energy(raw): "waste_percent": float(energy.get("waste_percent", 0)), "utilization": float(energy.get("utilization", 50)), } + tariff = _tariff(energy) + if tariff: + out["tariff"] = tariff + return out + + +def _ntfy(raw): + ntfy = raw.get("ntfy") or {} + return { + "enabled": bool(ntfy.get("enabled")), + "server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"), + "topic": str(ntfy.get("topic") or ""), + "token": str(ntfy.get("token") or ""), + } def _telegram(raw): @@ -121,6 +152,7 @@ def load(): "printers_file": ROOT / "drucker.json", "energy": _energy(raw), "telegram": _telegram(raw), + "ntfy": _ntfy(raw), "alerts": _alerts(raw), "auto_queue": bool(raw.get("auto_queue", True)), "_raw": raw, @@ -187,6 +219,13 @@ def settings_public(cfg): "bot_token_set": bool(tg.get("bot_token")), "chat_id": str(tg.get("chat_id") or ""), }, + "ntfy": { + "enabled": bool((cfg.get("ntfy") or {}).get("enabled")), + "server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"), + "topic": str((cfg.get("ntfy") or {}).get("topic") or ""), + "token": mask_token((cfg.get("ntfy") or {}).get("token") or ""), + "token_set": bool((cfg.get("ntfy") or {}).get("token")), + }, "alerts": dict(cfg.get("alerts") or {}), "oauth": { "enabled": bool(oauth.get("enabled")), @@ -197,13 +236,23 @@ def settings_public(cfg): } -def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None): +def save_settings( + cfg, + energy=None, + telegram=None, + ntfy=None, + alerts=None, + auto_queue=None, + fqdn=None, + watch_dir=None, +): """Schreibt Einstellungen zurück nach config.json und aktualisiert cfg.""" with _SETTINGS_LOCK: return _save_settings_locked( cfg, energy=energy, telegram=telegram, + ntfy=ntfy, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn, @@ -211,7 +260,16 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, ) -def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None): +def _save_settings_locked( + cfg, + energy=None, + telegram=None, + ntfy=None, + alerts=None, + auto_queue=None, + fqdn=None, + watch_dir=None, +): path = cfg.get("_path") or CONFIG_PATH raw = dict(cfg.get("_raw") or {}) if path.exists(): @@ -231,7 +289,7 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que raw["fqdn"] = normalized cfg["fqdn"] = normalized if energy is not None: - raw["energy"] = { + raw_energy = { "watts": float(energy.get("watts", 40)), "nozzle_watts": float(energy.get("nozzle_watts", 60)), "bed_watts": float(energy.get("bed_watts", 220)), @@ -241,6 +299,15 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que "waste_percent": float(energy.get("waste_percent", 0)), "utilization": float(energy.get("utilization", 50)), } + tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None + if tariff_in is not None: + raw_energy["tariff"] = { + "ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])), + "nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])), + "nt_from": str(tariff_in.get("nt_from") or "22:00"), + "nt_to": str(tariff_in.get("nt_to") or "06:00"), + } + raw["energy"] = raw_energy cfg["energy"] = _energy(raw) if telegram is not None: current = _telegram(raw) @@ -253,6 +320,18 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que "chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""), } cfg["telegram"] = _telegram(raw) + if ntfy is not None: + current = _ntfy(raw) + token = ntfy.get("token") + if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"): + token = current["token"] + raw["ntfy"] = { + "enabled": bool(ntfy.get("enabled", current["enabled"])), + "server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"), + "topic": str(ntfy.get("topic", current["topic"]) or ""), + "token": str(token or ""), + } + cfg["ntfy"] = _ntfy(raw) if alerts is not None: raw["alerts"] = { "fertig": bool(alerts.get("fertig", True)), diff --git a/grotti/cost.py b/grotti/cost.py index d869f00..964384b 100644 --- a/grotti/cost.py +++ b/grotti/cost.py @@ -1,6 +1,8 @@ """Dieselbe Rechnung wie in der Druckkalkulation.""" -from datetime import datetime +from datetime import datetime, timedelta, timezone + +from grotti.time_window import _in_segment, _parse_hm def calculate(job, material): @@ -92,13 +94,49 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w): return total -def energy_estimate(duration_s, energy, samples=None): +def _is_nt_moment(moment, tariff): + start_t = _parse_hm(tariff.get("nt_from")) + end_t = _parse_hm(tariff.get("nt_to")) + if start_t is None or end_t is None: + return False + now_t = moment.timetz().replace(tzinfo=None) + return _in_segment(now_t, start_t, end_t) + + +def effective_euro_per_kwh(duration_s, energy, end_dt=None): + energy = energy or {} + tariff = energy.get("tariff") + if not tariff or not duration_s or float(duration_s) <= 0: + return float(energy.get("euro_per_kwh") or 0) + end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone() + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + start = end - timedelta(seconds=float(duration_s)) + step = 300.0 + t = start + nt_s = 0.0 + total = 0.0 + while t < end: + chunk = min(step, (end - t).total_seconds()) + if _is_nt_moment(t, tariff): + nt_s += chunk + total += chunk + t += timedelta(seconds=chunk) + if total <= 0: + return float(energy.get("euro_per_kwh") or 0) + nt_frac = nt_s / total + ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0) + nt = float(tariff.get("nt_euro") or ht) + return nt_frac * nt + (1.0 - nt_frac) * ht + + +def energy_estimate(duration_s, energy, samples=None, end_dt=None): """Strom aus ED-Proben oder pauschal Dauer × Watt. Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen. """ energy = energy or {} - euro = float(energy.get("euro_per_kwh") or 0) + euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt) dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0) base_w = float(energy.get("watts") or 0) nozzle_w = float(energy.get("nozzle_watts") or 0) @@ -126,5 +164,54 @@ def energy_estimate(duration_s, energy, samples=None): } -def energy_cost(duration_s, energy, samples=None): - return energy_estimate(duration_s, energy, samples)["cost"] +def energy_cost(duration_s, energy, samples=None, end_dt=None): + return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"] + + +def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin): + """Angebots-Schätzung aus Datei-Gewicht und Druckzeit.""" + weight_g = float((file_row or {}).get("weight_g") or 0) + est_s = float((file_row or {}).get("estimated_s") or 0) + hours = est_s / 3600.0 if est_s > 0 else 0.0 + mat = float(material_price or 0) + rate = float(machine_rate_per_h or 0) + setup_h = float(setup_min or 0) / 60.0 + material_cost = (weight_g / 1000.0) * mat + machine_cost = hours * rate + setup_h * rate + subtotal = material_cost + machine_cost + pct = float(margin or 0) + total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0 + return { + "weight_g": weight_g, + "hours": hours, + "material_cost": material_cost, + "machine_cost": machine_cost, + "subtotal": subtotal, + "margin_percent": pct, + "total": total, + } + + +def order_actuals(conn, order_id): + """Summe Kosten, Zeit und Ausschuss der verknüpften Läufe.""" + row = conn.execute( + """ + SELECT + COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total, + COALESCE(SUM(r.duration_s), 0) AS duration_s, + COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad, + COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g, + COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs + FROM order_items oi + JOIN runs r ON r.order_item_id = oi.id + WHERE oi.order_id = %s + """, + (order_id,), + ).fetchone() + return { + "cost_total": float(row["cost_total"] or 0), + "duration_s": float(row["duration_s"] or 0), + "pieces_bad": int(row["pieces_bad"] or 0), + "scrap_g": float(row["scrap_g"] or 0), + "scrap_runs": int(row["scrap_runs"] or 0), + } diff --git a/grotti/events_bus.py b/grotti/events_bus.py new file mode 100644 index 0000000..326581d --- /dev/null +++ b/grotti/events_bus.py @@ -0,0 +1,42 @@ +"""Thread-sicherer Pub/Sub für Server-Sent Events (Live-UI).""" + +from __future__ import annotations + +import threading +from queue import Empty, Queue + +_lock = threading.Lock() +_subscribers: list[Queue] = [] + + +def subscribe() -> Queue: + q: Queue = Queue(maxsize=256) + with _lock: + _subscribers.append(q) + return q + + +def unsubscribe(q: Queue) -> None: + with _lock: + try: + _subscribers.remove(q) + except ValueError: + pass + + +def publish(kind: str, payload: dict | None = None) -> None: + msg = {"kind": kind or "message", "payload": payload if payload is not None else {}} + with _lock: + targets = list(_subscribers) + for q in targets: + try: + q.put_nowait(msg) + except Exception: + pass + + +def wait_event(q: Queue, timeout_s: float) -> dict | None: + try: + return q.get(timeout=timeout_s) + except Empty: + return None diff --git a/grotti/maintenance.py b/grotti/maintenance.py new file mode 100644 index 0000000..1d57507 --- /dev/null +++ b/grotti/maintenance.py @@ -0,0 +1,156 @@ +"""Wartungs-Teile und Laufzähler aus dem Protokoll.""" + +from __future__ import annotations + +def _baseline(conn, host, part_key): + row = conn.execute( + """ + SELECT hours_at, meters_at FROM maintenance_log + WHERE printer_host = %s AND part_key = %s AND event IN ('install', 'reset') + ORDER BY created_at DESC, id DESC + LIMIT 1 + """, + (host, part_key), + ).fetchone() + if not row: + return 0.0, 0.0 + return float(row.get("hours_at") or 0), float(row.get("meters_at") or 0) + + +def counters_for_host(conn, host): + """Summe Druckzeit, Filament und Anzahl Läufe für einen Host.""" + host = (host or "").strip() + if not host: + return {"hours": 0.0, "meters": 0.0, "runs": 0} + row = conn.execute( + """ + SELECT + COALESCE(SUM(duration_s), 0) AS duration_s, + COALESCE(SUM(filament_used_mm), 0) AS meters, + COUNT(*) AS runs + FROM runs + WHERE printer_host = %s AND state = 'completed' + """, + (host,), + ).fetchone() + if not row: + return {"hours": 0.0, "meters": 0.0, "runs": 0} + return { + "hours": float(row.get("duration_s") or 0) / 3600.0, + "meters": float(row.get("meters") or 0), + "runs": int(row.get("runs") or 0), + } + + +def part_out(row, counters=None, conn=None): + counters = counters or {} + life_h = float(row.get("life_hours") or 0) + life_m = float(row.get("life_meters") or 0) + hours_total = float(counters.get("hours") or 0) + meters_total = float(counters.get("meters") or 0) + host = row["printer_host"] + part_key = row["part_key"] + base_h, base_m = (0.0, 0.0) + if conn is not None: + base_h, base_m = _baseline(conn, host, part_key) + hours_since = max(0.0, hours_total - base_h) + meters_since = max(0.0, meters_total - base_m) + installed = row.get("installed_at") + return { + "id": row["id"], + "printer_host": host, + "part_key": part_key, + "label": row.get("label") or "", + "installed_at": installed.isoformat() if hasattr(installed, "isoformat") else str(installed or ""), + "life_hours": life_h, + "life_meters": life_m, + "note": row.get("note") or "", + "hours_since": hours_since, + "meters_since": meters_since, + "overdue_hours": life_h > 0 and hours_since >= life_h, + "overdue_meters": life_m > 0 and meters_since >= life_m, + } + + +def list_for_api(conn, printers): + out = [] + for printer in printers or []: + host = printer.get("host") or "" + counters = counters_for_host(conn, host) + rows = conn.execute( + "SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id", + (host,), + ).fetchall() + out.append({ + "host": host, + "name": printer.get("name") or host, + "counters": counters, + "parts": [part_out(r, counters, conn=conn) for r in rows], + }) + return out + + +def create_part(conn, printer_host, part_key, label="", life_hours=0, life_meters=0, note=""): + printer_host = (printer_host or "").strip() + part_key = (part_key or "").strip() + if not printer_host or not part_key: + raise ValueError("Drucker und part_key nötig.") + row = conn.execute( + """ + INSERT INTO maintenance_parts (printer_host, part_key, label, life_hours, life_meters, note) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + printer_host, + part_key, + (label or part_key).strip(), + float(life_hours or 0), + float(life_meters or 0), + (note or "").strip(), + ), + ).fetchone() + counters = counters_for_host(conn, printer_host) + conn.execute( + """ + INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note) + VALUES (%s, %s, 'install', %s, %s, %s) + """, + (printer_host, part_key, counters["hours"], counters["meters"], (note or "").strip()), + ) + return part_out(row, counters, conn=conn) + + +def reset_part(conn, part_id): + row = conn.execute( + "SELECT * FROM maintenance_parts WHERE id = %s", + (int(part_id),), + ).fetchone() + if not row: + return None + host = row["printer_host"] + counters = counters_for_host(conn, host) + conn.execute( + """ + INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note) + VALUES (%s, %s, 'reset', %s, %s, %s) + """, + ( + host, + row["part_key"], + counters["hours"], + counters["meters"], + row.get("note") or "", + ), + ) + updated = conn.execute( + """ + UPDATE maintenance_parts + SET installed_at = now() + WHERE id = %s + RETURNING * + """, + (int(part_id),), + ).fetchone() + fresh_counters = counters_for_host(conn, host) + return part_out(updated, fresh_counters, conn=conn) diff --git a/grotti/mcp_server.py b/grotti/mcp_server.py index 2f2d2cb..9dd690d 100644 --- a/grotti/mcp_server.py +++ b/grotti/mcp_server.py @@ -124,6 +124,27 @@ def main(): """Spulen / Materialbestand.""" return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2) + @server.tool() + def auftraege_lesen(limit: int = 30, status: str = "") -> str: + """Aufträge (optional nach Status filtern).""" + st = (status or "").strip().lower() + q = "?status=" + urllib.parse.quote(st) if st else "" + rows = _api("GET", "/auftraege" + q) or [] + cap = max(1, min(limit, 200)) + if len(rows) > cap: + rows = rows[:cap] + return json.dumps(rows, ensure_ascii=False, indent=2) + + @server.tool() + def bedarf_lesen() -> str: + """Filament-Bedarf (Queue vs. Spulenrest).""" + return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2) + + @server.tool() + def wartung_lesen() -> str: + """Wartungsteile und Zählerstände.""" + return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2) + @server.tool() def get_version() -> str: """Grotti-Version.""" diff --git a/grotti/migrations/003_ausschuss.sql b/grotti/migrations/003_ausschuss.sql new file mode 100644 index 0000000..a8c9a26 --- /dev/null +++ b/grotti/migrations/003_ausschuss.sql @@ -0,0 +1,3 @@ +ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT ''; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0; diff --git a/grotti/migrations/004_wartung.sql b/grotti/migrations/004_wartung.sql new file mode 100644 index 0000000..4288d97 --- /dev/null +++ b/grotti/migrations/004_wartung.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS maintenance_parts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + part_key text NOT NULL, + label text NOT NULL DEFAULT '', + installed_at timestamptz NOT NULL DEFAULT now(), + life_hours double precision NOT NULL DEFAULT 0, + life_meters double precision NOT NULL DEFAULT 0, + note text NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS maintenance_parts_host ON maintenance_parts (printer_host); + +CREATE TABLE IF NOT EXISTS maintenance_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + part_key text NOT NULL, + event text NOT NULL DEFAULT '', + hours_at double precision NOT NULL DEFAULT 0, + meters_at double precision NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + note text NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS maintenance_log_host ON maintenance_log (printer_host, created_at DESC); diff --git a/grotti/migrations/008_zeitfenster.sql b/grotti/migrations/008_zeitfenster.sql new file mode 100644 index 0000000..696f605 --- /dev/null +++ b/grotti/migrations/008_zeitfenster.sql @@ -0,0 +1 @@ +ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/grotti/migrations/009_auftraege.sql b/grotti/migrations/009_auftraege.sql new file mode 100644 index 0000000..f684bf8 --- /dev/null +++ b/grotti/migrations/009_auftraege.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + email text NOT NULL DEFAULT '', + notes text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint REFERENCES customers (id) ON DELETE SET NULL, + title text NOT NULL, + status text NOT NULL DEFAULT 'idee', + due_at date, + notes text NOT NULL DEFAULT '', + quote jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE, + file_id bigint REFERENCES files (id) ON DELETE SET NULL, + qty int NOT NULL DEFAULT 1, + unit_price double precision NOT NULL DEFAULT 0, + material_type text NOT NULL DEFAULT '', + color text NOT NULL DEFAULT '', + done_ok int NOT NULL DEFAULT 0, + done_bad int NOT NULL DEFAULT 0, + note text NOT NULL DEFAULT '' +); + +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL; + +INSERT INTO orders (title, status, notes, quote, created_at) +SELECT + p.name, + CASE p.status + WHEN 'bereit' THEN 'angebot' + WHEN 'druckt' THEN 'druckt' + WHEN 'fertig' THEN 'fertig' + ELSE 'idee' + END, + p.notes, + p.quote, + p.created_at +FROM projects p +WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = p.name AND o.created_at = p.created_at); + +INSERT INTO orders (title, status, notes, created_at) +SELECT + i.title, + CASE i.status WHEN 'erledigt' THEN 'fertig' ELSE 'idee' END, + i.body, + i.created_at +FROM ideas i +WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = i.title AND o.created_at = i.created_at); + +CREATE INDEX IF NOT EXISTS orders_status ON orders (status, updated_at DESC); +CREATE INDEX IF NOT EXISTS order_items_order ON order_items (order_id); +CREATE INDEX IF NOT EXISTS runs_order_item ON runs (order_item_id) WHERE order_item_id IS NOT NULL; diff --git a/grotti/notify.py b/grotti/notify.py index acbb9b7..f34f082 100644 --- a/grotti/notify.py +++ b/grotti/notify.py @@ -1,8 +1,9 @@ -"""Telegram-Benachrichtigungen (Bot API).""" +"""Telegram- und ntfy-Benachrichtigungen.""" from __future__ import annotations import json +import threading import urllib.error import urllib.parse import urllib.request @@ -43,11 +44,52 @@ def alert_enabled(cfg, kind): return bool(alerts.get(key, key not in DEFAULT_OFF)) +def ntfy_configured(cfg): + ntfy = (cfg or {}).get("ntfy") or {} + return bool(ntfy.get("enabled") and ntfy.get("topic")) + + +def send_ntfy(cfg, text): + if not ntfy_configured(cfg): + return False + ntfy = cfg["ntfy"] + server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/") + topic = str(ntfy.get("topic") or "").strip() + if not topic: + return False + url = "%s/%s" % (server, urllib.parse.quote(topic, safe="")) + headers = {"Title": "Grotti", "Priority": "default"} + token = str(ntfy.get("token") or "").strip() + if token: + headers["Authorization"] = "Bearer %s" % token + request = urllib.request.Request( + url, + data=text[:4000].encode("utf-8"), + method="POST", + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=12) as response: + return 200 <= response.status < 300 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def _push_alerts(cfg, message): + send_telegram(cfg, message) + send_ntfy(cfg, message) + + def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None): - """Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster.""" + """Ereignis immer speichern; Telegram/ntfy nur einmal pro Art und Drucker im Zeitfenster.""" from grotti import store recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s) store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id) if not recent and alert_enabled(cfg, kind): - send_telegram(cfg, message) + threading.Thread( + target=_push_alerts, + args=(cfg, message), + name="grotti-notify", + daemon=True, + ).start() diff --git a/grotti/orders.py b/grotti/orders.py new file mode 100644 index 0000000..f140b6e --- /dev/null +++ b/grotti/orders.py @@ -0,0 +1,460 @@ +"""Aufträge, Kunden und Positionen.""" + +from __future__ import annotations + +from datetime import date + +from psycopg.types.json import Json + +from grotti import store + +ORDER_STATUSES = frozenset({ + "idee", + "angebot", + "bestaetigt", + "druckt", + "fertig", + "geliefert", + "bezahlt", +}) + +PROJECT_TO_ORDER = { + "idee": "idee", + "bereit": "angebot", + "druckt": "druckt", + "fertig": "fertig", +} + +IDEA_TO_ORDER = { + "offen": "idee", + "spaeter": "idee", + "erledigt": "fertig", +} + + +def normalize_status(value): + s = (value or "idee").strip().lower() + if s not in ORDER_STATUSES: + raise ValueError("Unbekannter Auftragsstatus.") + return s + + +def customer_out(row): + return { + "id": row["id"], + "name": row["name"], + "email": row.get("email") or "", + "notes": row.get("notes") or "", + "created_at": store.iso(row["created_at"]), + } + + +def order_item_out(row, file_name=None): + return { + "id": row["id"], + "order_id": row["order_id"], + "file_id": row.get("file_id"), + "file_name": file_name or "", + "qty": int(row.get("qty") or 1), + "unit_price": float(row.get("unit_price") or 0), + "material_type": row.get("material_type") or "", + "color": row.get("color") or "", + "done_ok": int(row.get("done_ok") or 0), + "done_bad": int(row.get("done_bad") or 0), + "note": row.get("note") or "", + } + + +def order_out(row, customer=None, items=None, progress=None): + data = { + "id": row["id"], + "customer_id": row.get("customer_id"), + "title": row["title"], + "status": row["status"], + "due_at": row["due_at"].isoformat() if row.get("due_at") else None, + "notes": row.get("notes") or "", + "quote": row.get("quote") or {}, + "created_at": store.iso(row["created_at"]), + "updated_at": store.iso(row.get("updated_at") or row["created_at"]), + } + if customer is not None: + data["customer"] = customer + if items is not None: + data["items"] = items + if progress is not None: + data["progress"] = progress + return data + + +def order_progress(conn, order_id): + row = conn.execute( + """ + SELECT + COALESCE(SUM(qty), 0) AS target, + COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok, + COALESCE(SUM(done_bad), 0) AS done_bad + FROM order_items + WHERE order_id = %s + """, + (order_id,), + ).fetchone() + target = int(row["target"] or 0) + done = int(row["done_ok"] or 0) + return { + "target": target, + "done": done, + "done_bad": int(row["done_bad"] or 0), + "label": "%d von %d" % (done, target) if target else "0 von 0", + } + + +def list_customers(conn): + rows = conn.execute( + "SELECT * FROM customers ORDER BY name, id" + ).fetchall() + return [customer_out(r) for r in rows] + + +def get_customer(conn, customer_id): + row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone() + return customer_out(row) if row else None + + +def create_customer(conn, name, email="", notes=""): + row = conn.execute( + """ + INSERT INTO customers (name, email, notes) + VALUES (%s, %s, %s) + RETURNING * + """, + (name.strip(), (email or "").strip(), (notes or "").strip()), + ).fetchone() + return customer_out(row) + + +def update_customer(conn, customer_id, fields): + sets = [] + values = [] + for key in ("name", "email", "notes"): + if key in fields: + sets.append("%s = %%s" % key) + values.append(fields[key]) + if not sets: + return get_customer(conn, customer_id) + values.append(customer_id) + row = conn.execute( + "UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), + values, + ).fetchone() + return customer_out(row) if row else None + + +def delete_customer(conn, customer_id): + row = conn.execute( + "DELETE FROM customers WHERE id = %s RETURNING id", + (customer_id,), + ).fetchone() + return bool(row) + + +def list_orders(conn, status=None): + where = "" + params = [] + if status: + where = "WHERE o.status = %s" + params.append(status) + rows = conn.execute( + """ + SELECT o.*, c.name AS customer_name + FROM orders o + LEFT JOIN customers c ON c.id = o.customer_id + %s + ORDER BY o.updated_at DESC, o.id DESC + """ % where, + params, + ).fetchall() + out = [] + for row in rows: + prog = order_progress(conn, row["id"]) + customer = None + if row.get("customer_id"): + customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""} + out.append(order_out(row, customer=customer, progress=prog)) + return out + + +def get_order(conn, order_id): + row = conn.execute( + """ + SELECT o.*, c.name AS customer_name, c.email AS customer_email + FROM orders o + LEFT JOIN customers c ON c.id = o.customer_id + WHERE o.id = %s + """, + (order_id,), + ).fetchone() + if not row: + return None + items_rows = conn.execute( + """ + SELECT oi.*, f.name AS file_name + FROM order_items oi + LEFT JOIN files f ON f.id = oi.file_id + WHERE oi.order_id = %s + ORDER BY oi.id + """, + (order_id,), + ).fetchall() + items = [order_item_out(r, r.get("file_name")) for r in items_rows] + customer = None + if row.get("customer_id"): + customer = { + "id": row["customer_id"], + "name": row.get("customer_name") or "", + "email": row.get("customer_email") or "", + } + return order_out( + row, + customer=customer, + items=items, + progress=order_progress(conn, order_id), + ) + + +def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None): + status = normalize_status(status) + due = None + if due_at: + if isinstance(due_at, date): + due = due_at + else: + due = date.fromisoformat(str(due_at)[:10]) + row = conn.execute( + """ + INSERT INTO orders (customer_id, title, status, due_at, notes, quote) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + customer_id, + title.strip(), + status, + due, + (notes or "").strip(), + Json(quote if isinstance(quote, dict) else {}), + ), + ).fetchone() + return get_order(conn, row["id"]) + + +def update_order(conn, order_id, fields): + sets = ["updated_at = now()"] + values = [] + if "title" in fields: + sets.append("title = %s") + values.append(fields["title"]) + if "status" in fields: + sets.append("status = %s") + values.append(normalize_status(fields["status"])) + if "customer_id" in fields: + sets.append("customer_id = %s") + values.append(fields["customer_id"]) + if "due_at" in fields: + sets.append("due_at = %s") + due = fields["due_at"] + if due in ("", None): + values.append(None) + elif isinstance(due, date): + values.append(due) + else: + values.append(date.fromisoformat(str(due)[:10])) + if "notes" in fields: + sets.append("notes = %s") + values.append(fields["notes"] or "") + if "quote" in fields: + sets.append("quote = %s") + values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {})) + if len(sets) <= 1: + return get_order(conn, order_id) + values.append(order_id) + row = conn.execute( + "UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets), + values, + ).fetchone() + return get_order(conn, row["id"]) if row else None + + +def delete_order(conn, order_id): + row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone() + return bool(row) + + +def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""): + if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone(): + return None + try: + qty_n = max(1, int(qty)) + except (TypeError, ValueError): + qty_n = 1 + row = conn.execute( + """ + INSERT INTO order_items ( + order_id, file_id, qty, unit_price, material_type, color, note + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + order_id, + file_id, + qty_n, + float(unit_price or 0), + (material_type or "").strip(), + (color or "").strip(), + (note or "").strip(), + ), + ).fetchone() + conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,)) + file_name = "" + if file_id: + f = store.get_file_row(conn, file_id) + file_name = (f or {}).get("name") or "" + return order_item_out(row, file_name) + + +def get_order_item(conn, item_id): + row = conn.execute( + """ + SELECT oi.*, f.name AS file_name + FROM order_items oi + LEFT JOIN files f ON f.id = oi.file_id + WHERE oi.id = %s + """, + (item_id,), + ).fetchone() + return order_item_out(row, row.get("file_name")) if row else None + + +def update_order_item(conn, item_id, fields): + sets = [] + values = [] + for key in ("file_id", "material_type", "color", "note"): + if key in fields: + sets.append("%s = %%s" % key) + values.append(fields[key]) + if "qty" in fields: + try: + qty_n = max(1, int(fields["qty"])) + except (TypeError, ValueError): + qty_n = 1 + sets.append("qty = %s") + values.append(qty_n) + if "unit_price" in fields: + sets.append("unit_price = %s") + values.append(float(fields["unit_price"] or 0)) + if not sets: + return get_order_item(conn, item_id) + values.append(item_id) + row = conn.execute( + """ + UPDATE order_items SET %s + WHERE id = %%s + RETURNING order_id + """ % ", ".join(sets), + values, + ).fetchone() + if not row: + return None + conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],)) + return get_order_item(conn, item_id) + + +def delete_order_item(conn, item_id): + row = conn.execute( + "DELETE FROM order_items WHERE id = %s RETURNING order_id", + (item_id,), + ).fetchone() + if row: + conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],)) + return bool(row) + + +def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad): + """Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand.""" + if not order_item_id: + return + d_ok = int(new_ok or 0) - int(old_ok or 0) + d_bad = int(new_bad or 0) - int(old_bad or 0) + if d_ok == 0 and d_bad == 0: + return + conn.execute( + """ + UPDATE order_items + SET done_ok = done_ok + %s, done_bad = done_bad + %s + WHERE id = %s + """, + (d_ok, d_bad, order_item_id), + ) + item = conn.execute( + "SELECT order_id FROM order_items WHERE id = %s", + (order_item_id,), + ).fetchone() + if item: + conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],)) + + +def enqueue_order_item( + conn, + order_id, + item_id, + host="", + family="", + priority=100, + spool_id=None, + ace_map=None, + bed_leveling=False, + override=False, +): + item = conn.execute( + "SELECT * FROM order_items WHERE id = %s AND order_id = %s", + (item_id, order_id), + ).fetchone() + if not item: + raise ValueError("Position fehlt.") + file_id = item.get("file_id") + if not file_id: + raise ValueError("Position ohne Datei.") + if not store.get_file_row(conn, file_id): + raise ValueError("Datei fehlt.") + row = store.enqueue_print( + conn, + file_id, + host or "", + family or "", + priority, + spool_id, + ace_map if isinstance(ace_map, list) else [], + bool(bed_leveling), + bool(override), + order_item_id=item["id"], + ) + conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,)) + return row + + +def attach_order_item_to_live_run(conn, host, filename, order_item_id): + """Verknüpft gestarteten Lauf mit Auftragsposition.""" + if not order_item_id or not host: + return + base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1] + conn.execute( + """ + UPDATE runs + SET order_item_id = COALESCE(order_item_id, %s) + WHERE printer_host = %s + AND (filename = %s OR filename LIKE %s) + AND state IN ('printing', 'paused', 'in_progress', 'completed') + AND ended_at IS NULL + """, + (order_item_id, host, base, "%/" + base), + ) diff --git a/grotti/print_queue.py b/grotti/print_queue.py index 4d32ce8..52afd1e 100644 --- a/grotti/print_queue.py +++ b/grotti/print_queue.py @@ -13,6 +13,14 @@ from datetime import datetime, timezone from grotti import eject, jobs, moonraker, notify, store from grotti.db import connect from grotti.errors import GrottiError +from grotti import time_window + +WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…" +from grotti.events_bus import publish as publish_live + + +def _notify_queue(): + publish_live("queue", {}) FREE = {"standby", "complete", "cancelled"} BUSY = {"printing", "paused", "heating", "error"} @@ -169,6 +177,7 @@ class PrintQueueDispatcher: except Exception as exc: print("Queue-Worker %s: %s" % (item_id, exc), flush=True) finally: + _notify_queue() with self._worker_lock: self._workers.pop(item_id, None) @@ -182,6 +191,12 @@ class PrintQueueDispatcher: return True def tick(self): + try: + self._tick_once() + finally: + _notify_queue() + + def _tick_once(self): with connect(self.database_url) as conn: printers = store.list_printers(conn) if not printers: @@ -245,6 +260,20 @@ class PrintQueueDispatcher: waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "") if not waiting: continue + sw = printer.get("start_window") or {} + if sw and not time_window.allowed_now(sw): + if (waiting.get("message") or "") != WINDOW_WAIT_MSG: + store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG}) + continue + est_s = 0 + if waiting.get("file_id"): + file_row = store.get_file_row(conn, waiting["file_id"]) + if file_row: + est_s = float(file_row.get("estimated_s") or 0) + if sw and not time_window.job_fits_overnight(sw, est_s): + if (waiting.get("message") or "") != WINDOW_WAIT_MSG: + store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG}) + continue claimed = store.claim_print_queue(conn, waiting["id"], host) if not claimed: continue @@ -303,6 +332,11 @@ class PrintQueueDispatcher: bed_leveling=bed_leveling, ) name = (result or {}).get("filename") or "" + oid = item.get("order_item_id") + if oid: + from grotti import orders + + orders.attach_order_item_to_live_run(wconn, host, name, oid) store.update_print_queue( wconn, item_id, diff --git a/grotti/schema.sql b/grotti/schema.sql index 5233c19..6319894 100644 --- a/grotti/schema.sql +++ b/grotti/schema.sql @@ -306,3 +306,67 @@ CREATE TABLE IF NOT EXISTS run_labels ( ); CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr); + +ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT ''; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS maintenance_parts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + part_key text NOT NULL, + label text NOT NULL DEFAULT '', + installed_at timestamptz NOT NULL DEFAULT now(), + life_hours double precision NOT NULL DEFAULT 0, + life_meters double precision NOT NULL DEFAULT 0, + note text NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS maintenance_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + part_key text NOT NULL, + event text NOT NULL DEFAULT '', + hours_at double precision NOT NULL DEFAULT 0, + meters_at double precision NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + note text NOT NULL DEFAULT '' +); + +ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb; + +CREATE TABLE IF NOT EXISTS customers ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + email text NOT NULL DEFAULT '', + notes text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_id bigint REFERENCES customers (id) ON DELETE SET NULL, + title text NOT NULL, + status text NOT NULL DEFAULT 'idee', + due_at date, + notes text NOT NULL DEFAULT '', + quote jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE, + file_id bigint REFERENCES files (id) ON DELETE SET NULL, + qty int NOT NULL DEFAULT 1, + unit_price double precision NOT NULL DEFAULT 0, + material_type text NOT NULL DEFAULT '', + color text NOT NULL DEFAULT '', + done_ok int NOT NULL DEFAULT 0, + done_bad int NOT NULL DEFAULT 0, + note text NOT NULL DEFAULT '' +); + +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL; diff --git a/grotti/send_queue.py b/grotti/send_queue.py index e0b9454..6205e78 100644 --- a/grotti/send_queue.py +++ b/grotti/send_queue.py @@ -11,6 +11,7 @@ from queue import Empty, Queue from grotti import jobs from grotti.db import connect from grotti.errors import GrottiError +from grotti.events_bus import publish as publish_live class SendQueue: @@ -161,6 +162,7 @@ class SendQueue: return job.update(fields) job["updated_at"] = time.time() + publish_live("senden", {}) def _worker(self): while not self._stop.is_set(): diff --git a/grotti/store.py b/grotti/store.py index 84234bd..277ff9f 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -9,8 +9,6 @@ from psycopg.types.json import Json from grotti.catalog import MACHINE_LABELS, MATERIALS from grotti.printers import FAMILY_LABELS, family_from_name - - def iso(value): return value.isoformat() if value else None @@ -170,6 +168,10 @@ def run_out(row): "deducted": row["deducted"], "needs_spool": row["needs_spool"], "verdict": row["verdict"], + "verdict_reason": row.get("verdict_reason") or "", + "pieces_ok": int(row.get("pieces_ok") or 0), + "pieces_bad": int(row.get("pieces_bad") or 0), + "order_item_id": row.get("order_item_id"), "material_type": row["material_type"], "chargen_nr": row.get("chargen_nr") or "", "message": row.get("message") or "", @@ -675,6 +677,8 @@ def upsert_run(conn, item): **item, } payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else []) + if "order_item_id" not in payload: + payload["order_item_id"] = item.get("order_item_id") if "weight_g" not in payload or payload.get("weight_g") is None: payload["weight_g"] = payload["weight_planned_g"] return conn.execute( @@ -682,14 +686,14 @@ def upsert_run(conn, item): INSERT INTO runs ( printer_host, printer_name, filename, file_id, project_id, spool_id, state, started_at, ended_at, duration_s, weight_g, energy_cost, - moonraker_job_id, needs_spool, material_type, + moonraker_job_id, needs_spool, material_type, order_item_id, weight_planned_g, weight_used_g, weight_source, filament_used_mm, filament_total_mm, progress_last, estimated_s, message, objects ) VALUES ( %(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s, %(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s, - %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, + %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, %(order_item_id)s, %(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s, %(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s, %(message)s, %(objects)s @@ -700,6 +704,7 @@ def upsert_run(conn, item): file_id = COALESCE(runs.file_id, EXCLUDED.file_id), project_id = COALESCE(runs.project_id, EXCLUDED.project_id), spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id), + order_item_id = COALESCE(runs.order_item_id, EXCLUDED.order_item_id), state = EXCLUDED.state, started_at = COALESCE(runs.started_at, EXCLUDED.started_at), ended_at = EXCLUDED.ended_at, @@ -887,14 +892,55 @@ def get_run_by_chargen(conn, code): return get_run_row(conn, label["run_id"]) -def set_verdict(conn, run_id, verdict): - row = conn.execute( - "UPDATE runs SET verdict = %s WHERE id = %s RETURNING *", - (verdict, run_id), +def set_verdict(conn, run_id, verdict, verdict_reason="", pieces_ok=0, pieces_bad=0): + from grotti import orders + + before = conn.execute( + "SELECT order_item_id, pieces_ok, pieces_bad FROM runs WHERE id = %s", + (run_id,), ).fetchone() + row = conn.execute( + """ + UPDATE runs + SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s + WHERE id = %s + RETURNING * + """, + (verdict, verdict_reason or "", int(pieces_ok or 0), int(pieces_bad or 0), run_id), + ).fetchone() + if row and before and before.get("order_item_id"): + orders.apply_verdict_delta( + conn, + before["order_item_id"], + before.get("pieces_ok"), + before.get("pieces_bad"), + pieces_ok, + pieces_bad, + ) return run_out(row) if row else None +def run_for_abraeumen_verdict(conn, host): + """Letzter abgeschlossener oder noch laufender Lauf des Hosts.""" + host = (host or "").strip() + if not host: + return None + return conn.execute( + """ + SELECT * FROM runs + WHERE printer_host = %s + AND state IN ('completed', 'in_progress', 'printing', 'paused') + ORDER BY + CASE WHEN state IN ('in_progress', 'printing', 'paused') THEN 0 ELSE 1 END, + ended_at DESC NULLS LAST, + started_at DESC NULLS LAST, + id DESC + LIMIT 1 + """, + (host,), + ).fetchone() + + def assign_run_spool(conn, run_id, spool_id): return conn.execute( """ @@ -1843,6 +1889,18 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro run_values, ).fetchone() + scrap_reasons = conn.execute( + """ + SELECT COALESCE(NULLIF(r.verdict_reason, ''), 'sonstiges') AS reason, + COUNT(*) AS n + FROM runs r + WHERE %s AND r.verdict = 'ausschuss' + GROUP BY 1 + ORDER BY n DESC, reason + """ % run_clause, + run_values, + ).fetchall() + ov = overview or {} # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu if spool_join: @@ -1913,6 +1971,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro ], "estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None, "estimate_count": int((estimate or {}).get("counted") or 0), + "scrap_reasons": [ + {"reason": row["reason"] or "sonstiges", "count": int(row["n"] or 0)} + for row in scrap_reasons + ], } @@ -1973,6 +2035,14 @@ def delete_idea(conn, idea_id): def printer_out(row): family = row["family"] or "" + sw = row.get("start_window") or {} + if isinstance(sw, str): + try: + sw = json.loads(sw) + except json.JSONDecodeError: + sw = {} + if not isinstance(sw, dict): + sw = {} return { "id": str(row["id"]), "name": row["name"], @@ -1980,6 +2050,7 @@ def printer_out(row): "family": family, "family_label": FAMILY_LABELS.get(family, ""), "locked": bool(row.get("locked")), + "start_window": sw, } @@ -2243,6 +2314,7 @@ def print_queue_out(row, file_name=None): "phase": row.get("phase") or "", "phase_started_at": iso(row.get("phase_started_at")), "is_ablauf": bool(steps), + "order_item_id": row.get("order_item_id"), "created_at": iso(row["created_at"]), "updated_at": iso(row["updated_at"]), } @@ -2260,6 +2332,7 @@ def enqueue_print( override, steps=None, repeat_total=1, + order_item_id=None, ): steps = steps if isinstance(steps, list) else [] try: @@ -2271,11 +2344,11 @@ def enqueue_print( INSERT INTO print_queue ( file_id, host, family, priority, spool_id, ace_map, bed_leveling, override_machine, status, message, - steps, step_index, repeat_total, repeat_done, phase + steps, step_index, repeat_total, repeat_done, phase, order_item_id ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s, - %s, 0, %s, 0, '' + %s, 0, %s, 0, '', %s ) RETURNING * """, @@ -2291,6 +2364,7 @@ def enqueue_print( "Ablauf wartet…" if steps else "Wartet auf freien Drucker…", Json(steps), repeats, + order_item_id, ), ).fetchone() file_row = get_file_row(conn, file_id) diff --git a/grotti/time_window.py b/grotti/time_window.py new file mode 100644 index 0000000..f55e81e --- /dev/null +++ b/grotti/time_window.py @@ -0,0 +1,72 @@ +"""Start-Zeitfenster pro Drucker (Nachtdruck).""" + +from __future__ import annotations + +from datetime import datetime, time, timedelta + + +def _parse_hm(text): + text = str(text or "").strip() + if not text or ":" not in text: + return None + parts = text.split(":", 1) + try: + h, m = int(parts[0]), int(parts[1]) + except (TypeError, ValueError): + return None + if not (0 <= h <= 23 and 0 <= m <= 59): + return None + return time(h, m) + + +def _in_segment(now_t, start_t, end_t): + if start_t <= end_t: + return start_t <= now_t <= end_t + return now_t >= start_t or now_t <= end_t + + +def allowed_now(start_window, now=None): + """True wenn kein Fenster gesetzt oder die aktuelle Uhrzeit erlaubt ist.""" + window = start_window or {} + if not isinstance(window, dict): + return True + allowed = window.get("allowed") + if not allowed: + return True + if not isinstance(allowed, list): + return True + now_dt = now if isinstance(now, datetime) else datetime.now().astimezone() + now_t = now_dt.timetz().replace(tzinfo=None) + for entry in allowed: + if not isinstance(entry, dict): + continue + start_t = _parse_hm(entry.get("from")) + end_t = _parse_hm(entry.get("to")) + if start_t is None or end_t is None: + continue + if _in_segment(now_t, start_t, end_t): + return True + return False + + +def overnight_max_hours(start_window): + window = start_window or {} + if not isinstance(window, dict): + return None + try: + val = float(window.get("max_hours_overnight") or 0) + except (TypeError, ValueError): + return None + return val if val > 0 else None + + +def job_fits_overnight(start_window, estimated_s, now=None): + """Optional: Job darf max_hours_overnight nicht überschreiten (nur wenn gesetzt).""" + max_h = overnight_max_hours(start_window) + if max_h is None: + return True + if not estimated_s or float(estimated_s) <= 0: + return True + if allowed_now(start_window, now=now): + return float(estimated_s) <= max_h * 3600 + return True diff --git a/grotti/verdict.py b/grotti/verdict.py new file mode 100644 index 0000000..81e671f --- /dev/null +++ b/grotti/verdict.py @@ -0,0 +1,33 @@ +"""Urteil und Ausschuss-Gründe beim Abräumen.""" + +VERDICT_REASONS = frozenset({ + "haftung", + "warping", + "spaghetti", + "layer_shift", + "verstopfung", + "farbe", + "sonstiges", +}) + + +def normalize_abraeumen_verdict(verdict, verdict_reason, pieces_ok, pieces_bad): + v = (verdict or "").strip().lower() + if not v: + v = "gut" + if v not in ("gut", "ausschuss"): + raise ValueError("Urteil muss gut oder ausschuss sein.") + reason = (verdict_reason or "").strip().lower() + if v == "gut": + reason = "" + pieces_bad = 0 + elif reason and reason not in VERDICT_REASONS: + raise ValueError("Unbekannter Ausschuss-Grund.") + try: + ok_n = max(0, int(pieces_ok or 0)) + bad_n = max(0, int(pieces_bad or 0)) + except (TypeError, ValueError) as exc: + raise ValueError("Stückzahlen müssen ganze Zahlen sein.") from exc + if v == "ausschuss" and bad_n <= 0 and ok_n <= 0: + bad_n = 1 + return v, reason, ok_n, bad_n diff --git a/grotti/version.py b/grotti/version.py index ab2b6aa..d4ee142 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "2.10.0" +__version__ = "3.0.0" diff --git a/grotti/watcher.py b/grotti/watcher.py index 4f1cf42..b05b307 100644 --- a/grotti/watcher.py +++ b/grotti/watcher.py @@ -7,6 +7,7 @@ import time from datetime import datetime, timezone from grotti import moonraker, notify, store, usage +from grotti.events_bus import publish as publish_live from grotti.db import connect SAMPLE_EVERY_S = 30 @@ -62,18 +63,21 @@ class StateWatcher: self._stop.wait(self.interval) def tick(self): - with connect(self.database_url) as conn: - printers = store.list_printers(conn) - if not printers: + try: + with connect(self.database_url) as conn: + printers = store.list_printers(conn) + if not printers: + conn.commit() + return + lives = moonraker.snapshot(printers) + with self._lock: + self.latest = lives + self._updated_at = time.monotonic() + for live in lives: + self._one(conn, live) conn.commit() - return - lives = moonraker.snapshot(printers) - with self._lock: - self.latest = lives - self._updated_at = time.monotonic() - for live in lives: - self._one(conn, live) - conn.commit() + finally: + publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()}) def _one(self, conn, live): host = live.get("host") or "" @@ -394,3 +398,4 @@ class StateWatcher: conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60, run_id=run_id, ) + publish_live("ereignis", {}) diff --git a/tests/test_ausschuss.py b/tests/test_ausschuss.py new file mode 100644 index 0000000..3f09c36 --- /dev/null +++ b/tests/test_ausschuss.py @@ -0,0 +1,27 @@ +"""Urteil beim Abräumen (reine Hilfslogik ohne DB).""" + +import unittest + +from grotti.verdict import normalize_abraeumen_verdict + + +class AbraeumenVerdictTests(unittest.TestCase): + def test_gut_clears_reason(self): + v, reason, ok, bad = normalize_abraeumen_verdict("gut", "warping", 2, 1) + self.assertEqual(v, "gut") + self.assertEqual(reason, "") + self.assertEqual(bad, 0) + + def test_ausschuss_requires_known_reason_if_set(self): + with self.assertRaises(ValueError): + normalize_abraeumen_verdict("ausschuss", "xyz", 0, 0) + + def test_ausschuss_defaults_bad_piece(self): + v, reason, ok, bad = normalize_abraeumen_verdict("ausschuss", "spaghetti", 0, 0) + self.assertEqual(v, "ausschuss") + self.assertEqual(reason, "spaghetti") + self.assertEqual(bad, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bedarf.py b/tests/test_bedarf.py new file mode 100644 index 0000000..634257a --- /dev/null +++ b/tests/test_bedarf.py @@ -0,0 +1,70 @@ +"""Filament-Bedarf aus Queue und Spulen.""" + +import unittest +from unittest.mock import MagicMock + + +from grotti.bedarf import compute + + +class BedarfTests(unittest.TestCase): + def _rows(self, *lists): + mocks = [] + for rows in lists: + m = MagicMock() + m.fetchall.return_value = rows + mocks.append(m) + return mocks + + def test_shortfall_when_queue_exceeds_stock(self): + conn = MagicMock() + conn.execute.side_effect = self._rows( + [ + { + "id": 1, + "spool_id": 10, + "repeat_total": 2, + "repeat_done": 0, + "weight_g": 50.0, + "file_material": "PLA", + "file_color": "Schwarz", + "spool_material": "PLA", + "spool_color": "Schwarz", + }, + ], + [ + {"material_type": "PLA", "color": "Schwarz", "remaining_g": 40.0}, + ], + ) + out = compute(conn) + self.assertEqual(len(out["needed"]), 1) + self.assertAlmostEqual(out["needed"][0]["weight_g"], 100.0) + self.assertEqual(len(out["shortfall"]), 1) + self.assertAlmostEqual(out["shortfall"][0]["shortfall_g"], 60.0) + + def test_no_shortfall_when_enough_filament(self): + conn = MagicMock() + conn.execute.side_effect = self._rows( + [ + { + "id": 1, + "spool_id": None, + "repeat_total": 1, + "repeat_done": 0, + "weight_g": 20.0, + "file_material": "PETG", + "file_color": "", + "spool_material": None, + "spool_color": None, + }, + ], + [ + {"material_type": "PETG", "color": "", "remaining_g": 500.0}, + ], + ) + out = compute(conn) + self.assertEqual(out["shortfall"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py new file mode 100644 index 0000000..cd95d20 --- /dev/null +++ b/tests/test_maintenance.py @@ -0,0 +1,28 @@ +"""Wartungs-Zähler.""" + +import unittest +from unittest.mock import MagicMock + +from grotti.maintenance import counters_for_host + + +class MaintenanceCounterTests(unittest.TestCase): + def test_counters_from_aggregate_row(self): + conn = MagicMock() + conn.execute.return_value.fetchone.return_value = { + "duration_s": 7200.0, + "meters": 150000.0, + "runs": 12, + } + out = counters_for_host(conn, "10.0.0.1") + self.assertAlmostEqual(out["hours"], 2.0) + self.assertAlmostEqual(out["meters"], 150000.0) + self.assertEqual(out["runs"], 12) + + def test_empty_host(self): + out = counters_for_host(MagicMock(), "") + self.assertEqual(out, {"hours": 0.0, "meters": 0.0, "runs": 0}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_orders.py b/tests/test_orders.py new file mode 100644 index 0000000..4f4df67 --- /dev/null +++ b/tests/test_orders.py @@ -0,0 +1,48 @@ +"""Auftrags-Status und Angebotsrechnung.""" + +import unittest +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti.cost import offer_from_file +from grotti.orders import ORDER_STATUSES, IDEA_TO_ORDER, PROJECT_TO_ORDER, normalize_status + + +class OrderStatusTests(unittest.TestCase): + def test_all_status_values(self): + for s in ("idee", "angebot", "bestaetigt", "druckt", "fertig", "geliefert", "bezahlt"): + self.assertIn(s, ORDER_STATUSES) + self.assertEqual(normalize_status(s), s) + + def test_unknown_status(self): + with self.assertRaises(ValueError): + normalize_status("xyz") + + def test_legacy_project_map(self): + self.assertEqual(PROJECT_TO_ORDER["bereit"], "angebot") + self.assertEqual(PROJECT_TO_ORDER["druckt"], "druckt") + + def test_legacy_idea_map(self): + self.assertEqual(IDEA_TO_ORDER["erledigt"], "fertig") + self.assertEqual(IDEA_TO_ORDER["spaeter"], "idee") + + +class OfferFromFileTests(unittest.TestCase): + def test_basic_offer(self): + file_row = {"weight_g": 50, "estimated_s": 3600} + out = offer_from_file(file_row, material_price=20, machine_rate_per_h=10, setup_min=15, margin=20) + self.assertAlmostEqual(out["material_cost"], 1.0) + self.assertAlmostEqual(out["hours"], 1.0) + self.assertAlmostEqual(out["machine_cost"], 10 + 2.5) + self.assertAlmostEqual(out["total"], out["subtotal"] * 1.2) + + def test_empty_file(self): + out = offer_from_file({}, 0, 0, 0, 0) + self.assertEqual(out["total"], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_time_window.py b/tests/test_time_window.py new file mode 100644 index 0000000..b09ff5e --- /dev/null +++ b/tests/test_time_window.py @@ -0,0 +1,24 @@ +"""Start-Zeitfenster.""" + +import unittest +from datetime import datetime +from zoneinfo import ZoneInfo + +from grotti.time_window import allowed_now + + +class TimeWindowTests(unittest.TestCase): + def test_overnight_window(self): + window = {"allowed": [{"from": "22:00", "to": "06:00"}]} + inside = datetime(2026, 10, 18, 23, 0, tzinfo=ZoneInfo("Europe/Berlin")) + outside = datetime(2026, 10, 18, 12, 0, tzinfo=ZoneInfo("Europe/Berlin")) + self.assertTrue(allowed_now(window, now=inside)) + self.assertFalse(allowed_now(window, now=outside)) + + def test_empty_allows_always(self): + self.assertTrue(allowed_now({})) + self.assertTrue(allowed_now(None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.py b/web/app.py index 08cfb96..c9ca7ac 100644 --- a/web/app.py +++ b/web/app.py @@ -11,7 +11,7 @@ from urllib.parse import quote import markdown import psycopg -from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for +from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for from markupsafe import Markup from psycopg.rows import dict_row from werkzeug.middleware.proxy_fix import ProxyFix @@ -21,13 +21,15 @@ from grotti import config as grotti_config from grotti import db as grotti_db from grotti import eject, jobs, library, moonraker, notify, store from grotti.catalog import color_catalog -from grotti.cost import calculate +from grotti.cost import calculate, offer_from_file, order_actuals +from grotti import orders as grotti_orders from grotti.errors import GrottiError from grotti.printers import FAMILY_LABELS, family_from_name from grotti.print_queue import PrintQueueDispatcher from grotti.send_queue import SendQueue from grotti.toolpath import TOOLPATH_VERSION, build_toolpath from grotti.version import __version__ +from grotti import verdict as grotti_verdict from grotti.watcher import StateWatcher ROOT = Path(__file__).resolve().parent @@ -35,6 +37,7 @@ TOOL_ROOT = ROOT.parent DOCS_DIR = TOOL_ROOT / "docs" PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"} VERDICTS = {"", "gut", "ausschuss"} +VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS) IDEA_STATUS = {"offen", "spaeter", "erledigt"} PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf") @@ -96,6 +99,7 @@ def create_app(cfg): ADMIN_API_PREFIXES = ( "/api/v1/users", "/api/v1/einstellungen", + "/api/v1/backup", ) ADMIN_API_EXACT = { "/api/v1/telegram/test", @@ -566,7 +570,9 @@ def create_app(cfg): "Anleitung-Orca", "Anleitung-ACE", "Anleitung-Warteschlange", + "Anleitung-Auftraege", "Anleitung-Material", + "Anleitung-Wartung", "Anleitung-Einstellungen", "Anleitung-Stoerungen", ) @@ -587,7 +593,9 @@ def create_app(cfg): "Anleitung-Orca": "Orca an Grotti", "Anleitung-ACE": "ACE und Spulen", "Anleitung-Warteschlange": "Warteschlange", + "Anleitung-Auftraege": "Aufträge", "Anleitung-Material": "Material und mehr", + "Anleitung-Wartung": "Wartung", "Anleitung-Einstellungen": "Einstellungen", "Anleitung-Stoerungen": "Störungen", } @@ -620,7 +628,23 @@ def create_app(cfg): @app.get("/projekte") def projekte(): - return page("projekte.html", "Projekte") + return redirect("/auftraege", code=301) + + @app.get("/auftraege") + def auftraege_page(): + return page("auftraege.html", "Aufträge") + + @app.get("/auftraege//lieferschein") + def auftrag_lieferschein(order_id): + row = grotti_orders.get_order(conn(), order_id) + if not row: + abort(404) + return render_template( + "lieferschein.html", + page="auftraege", + title="Lieferschein #%s" % order_id, + order=row, + ) @app.get("/einstellungen") def einstellungen_page(): @@ -666,6 +690,10 @@ def create_app(cfg): def statistik_page(): return page("statistik.html", "Statistik") + @app.get("/wartung") + def wartung_page(): + return page("wartung.html", "Wartung") + @app.get("/etikett/lauf/") def etikett_lauf(run_id): row = store.get_run_row(conn(), run_id) @@ -709,7 +737,7 @@ def create_app(cfg): @app.get("/ideen") def ideen(): - return page("ideen.html", "Ideen") + return redirect("/auftraege", code=301) @app.get("/aktivitaet") def aktivitaet(): @@ -767,6 +795,7 @@ def create_app(cfg): data = body() energy = data.get("energy") if isinstance(data.get("energy"), dict) else None telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None + ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None auto_queue = data.get("auto_queue") if "auto_queue" in data else None fqdn = data.get("fqdn") if "fqdn" in data else None @@ -775,6 +804,7 @@ def create_app(cfg): cfg, energy=energy, telegram=telegram, + ntfy=ntfy, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn, @@ -1399,8 +1429,31 @@ def create_app(cfg): if not bool(data.get("confirm")): raise GrottiError("Bestätigung fehlt.", "bestaetigung") require_host(host) - store.set_bed_needs_clear(conn(), host, False, "quittiert") - return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."}) + c = conn() + store.set_bed_needs_clear(c, host, False, "quittiert") + run_json = None + if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")): + run_row = store.run_for_abraeumen_verdict(c, host) + if not run_row: + raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404) + try: + verdict, reason, ok_n, bad_n = grotti_verdict.normalize_abraeumen_verdict( + data.get("verdict"), + data.get("verdict_reason"), + data.get("pieces_ok"), + data.get("pieces_bad"), + ) + except ValueError as exc: + raise GrottiError(str(exc), "urteil") from exc + run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n) + return jsonify({ + "ok": True, + "host": host, + "needs_clear": False, + "message": "Platte quittiert.", + "run": run_json, + "verdict_reasons": VERDICT_REASONS, + }) @app.get("/api/v1/abraeumen-defaults") def abraeumen_defaults(): @@ -1667,6 +1720,191 @@ def create_app(cfg): raise GrottiError("Die Idee fehlt.", "idee", 404) return "", 204 + @app.get("/api/v1/kunden") + def kunden_list(): + return jsonify(grotti_orders.list_customers(conn())) + + @app.post("/api/v1/kunden") + def kunden_create(): + data = body() + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Der Kunde braucht einen Namen.", "name") + return jsonify( + grotti_orders.create_customer( + conn(), + name, + (data.get("email") or "").strip(), + (data.get("notes") or "").strip(), + ) + ), 201 + + @app.patch("/api/v1/kunden/") + def kunden_patch(customer_id): + data = body() + fields = {} + if "name" in data: + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Der Kunde braucht einen Namen.", "name") + fields["name"] = name + if "email" in data: + fields["email"] = (data.get("email") or "").strip() + if "notes" in data: + fields["notes"] = (data.get("notes") or "").strip() + row = grotti_orders.update_customer(conn(), customer_id, fields) + if not row: + raise GrottiError("Der Kunde fehlt.", "kunde", 404) + return jsonify(row) + + @app.delete("/api/v1/kunden/") + def kunden_delete(customer_id): + if not grotti_orders.delete_customer(conn(), customer_id): + raise GrottiError("Der Kunde fehlt.", "kunde", 404) + return "", 204 + + @app.get("/api/v1/auftraege") + def auftraege_list(): + status = (request.args.get("status") or "").strip().lower() + if status and status not in grotti_orders.ORDER_STATUSES: + raise GrottiError("Unbekannter Status.", "status") + return jsonify(grotti_orders.list_orders(conn(), status=status or None)) + + @app.post("/api/v1/auftraege") + def auftraege_create(): + data = body() + title = (data.get("title") or "").strip() + if not title: + raise GrottiError("Der Auftrag braucht einen Titel.", "titel") + try: + status = grotti_orders.normalize_status(data.get("status") or "idee") + except ValueError as exc: + raise GrottiError(str(exc), "status") from exc + cid = optional_id(data.get("customer_id")) + return jsonify( + grotti_orders.create_order( + conn(), + title, + status, + customer_id=cid, + due_at=data.get("due_at"), + notes=(data.get("notes") or "").strip(), + quote=data.get("quote") if isinstance(data.get("quote"), dict) else {}, + ) + ), 201 + + @app.get("/api/v1/auftraege/") + def auftrag_get(order_id): + row = grotti_orders.get_order(conn(), order_id) + if not row: + raise GrottiError("Der Auftrag fehlt.", "auftrag", 404) + row["actuals"] = order_actuals(conn(), order_id) + return jsonify(row) + + @app.patch("/api/v1/auftraege/") + def auftrag_patch(order_id): + data = body() + fields = {} + if "title" in data: + title = (data.get("title") or "").strip() + if not title: + raise GrottiError("Der Auftrag braucht einen Titel.", "titel") + fields["title"] = title + if "status" in data: + try: + fields["status"] = grotti_orders.normalize_status(data.get("status")) + except ValueError as exc: + raise GrottiError(str(exc), "status") from exc + if "customer_id" in data: + fields["customer_id"] = optional_id(data.get("customer_id")) + if "due_at" in data: + fields["due_at"] = data.get("due_at") + if "notes" in data: + fields["notes"] = data.get("notes") or "" + if "quote" in data and isinstance(data.get("quote"), dict): + fields["quote"] = data["quote"] + row = grotti_orders.update_order(conn(), order_id, fields) + if not row: + raise GrottiError("Der Auftrag fehlt.", "auftrag", 404) + row["actuals"] = order_actuals(conn(), order_id) + return jsonify(row) + + @app.delete("/api/v1/auftraege/") + def auftrag_delete(order_id): + if not grotti_orders.delete_order(conn(), order_id): + raise GrottiError("Der Auftrag fehlt.", "auftrag", 404) + return "", 204 + + @app.post("/api/v1/auftraege//positionen") + def auftrag_position_create(order_id): + data = body() + file_id = optional_id(data.get("file_id")) + row = grotti_orders.create_order_item( + conn(), + order_id, + file_id=file_id, + qty=data.get("qty", 1), + unit_price=data.get("unit_price", 0), + material_type=(data.get("material_type") or "").strip(), + color=(data.get("color") or "").strip(), + note=(data.get("note") or "").strip(), + ) + if not row: + raise GrottiError("Der Auftrag fehlt.", "auftrag", 404) + return jsonify(row), 201 + + @app.patch("/api/v1/auftraege/positionen/") + def auftrag_position_patch(item_id): + data = body() + fields = {} + for key in ("file_id", "qty", "unit_price", "material_type", "color", "note"): + if key in data: + fields[key] = data[key] if key != "file_id" else optional_id(data.get("file_id")) + row = grotti_orders.update_order_item(conn(), item_id, fields) + if not row: + raise GrottiError("Die Position fehlt.", "position", 404) + return jsonify(row) + + @app.delete("/api/v1/auftraege/positionen/") + def auftrag_position_delete(item_id): + if not grotti_orders.delete_order_item(conn(), item_id): + raise GrottiError("Die Position fehlt.", "position", 404) + return "", 204 + + @app.post("/api/v1/auftraege//einreihen") + def auftrag_einreihen(order_id): + data = body() + if not bool(data.get("confirm")): + raise GrottiError("Bestätigung fehlt.", "bestaetigung") + try: + item_id = int(data.get("item_id")) + except (TypeError, ValueError) as exc: + raise GrottiError("Position fehlt.", "position") from exc + host = optional_host(data.get("host") or "") + family = (data.get("family") or "").strip() + if not host and not family: + file_row = None + item = grotti_orders.get_order_item(conn(), item_id) + if item and item.get("file_id"): + file_row = store.get_file_row(conn(), item["file_id"]) + family = (file_row or {}).get("printer_family") or "" + try: + row = grotti_orders.enqueue_order_item( + conn(), + order_id, + item_id, + host=host, + family=family, + priority=int(data.get("priority", 100)), + spool_id=optional_id(data.get("spool_id")), + ace_map=data.get("ace_map") if isinstance(data.get("ace_map"), list) else [], + bed_leveling=bool(data.get("bed_leveling")), + override=bool(data.get("override_machine")), + ) + except ValueError as exc: + raise GrottiError(str(exc), "auftrag") from exc + return jsonify(row), 201 + @app.get("/api/v1/spulen") def spool_list(): return jsonify(store.list_spools(conn())) @@ -1989,6 +2227,75 @@ def create_app(cfg): row = store.run_out(current) return jsonify(row) + @app.get("/api/v1/bedarf") + def bedarf_api(): + from grotti import bedarf as bedarf_mod + + return jsonify(bedarf_mod.compute(conn())) + + @app.get("/api/v1/wartung") + def wartung_api(): + from grotti import maintenance + + return jsonify(maintenance.list_for_api(conn(), store.list_printers(conn()))) + + @app.post("/api/v1/wartung/teile") + def wartung_teil_create(): + from grotti import maintenance + + data = body() + host = validate_printer_host(data.get("printer_host") or data.get("host") or "") + require_host(host) + try: + part = maintenance.create_part( + conn(), + host, + data.get("part_key") or "", + label=data.get("label") or "", + life_hours=data.get("life_hours") or 0, + life_meters=data.get("life_meters") or 0, + note=data.get("note") or "", + ) + except ValueError as exc: + raise GrottiError(str(exc), "wartung") from exc + conn().commit() + return jsonify(part) + + @app.post("/api/v1/wartung/teile//reset") + def wartung_teil_reset(part_id): + from grotti import maintenance + + part = maintenance.reset_part(conn(), part_id) + if not part: + raise GrottiError("Teil fehlt.", "wartung", 404) + conn().commit() + return jsonify(part) + + @app.post("/api/v1/backup") + def backup_create(): + from grotti import backup as backup_mod + + try: + meta = backup_mod.create_backup(cfg) + except RuntimeError as exc: + raise GrottiError(str(exc), "backup") from exc + return jsonify(meta) + + @app.get("/api/v1/backup") + def backup_list(): + from grotti import backup as backup_mod + + return jsonify({"items": backup_mod.list_backups(cfg)}) + + @app.get("/api/v1/backup/") + def backup_download(name): + from grotti import backup as backup_mod + + path = backup_mod.backup_path(cfg, name) + if not path: + raise GrottiError("Backup fehlt.", "backup", 404) + return send_file(path, as_attachment=True, download_name=path.name) + @app.get("/api/v1/statistik") def statistik_api(): since = request.args.get("since") or None @@ -2056,4 +2363,39 @@ def create_app(cfg): headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"}, ) + @app.get("/api/v1/stream") + def api_event_stream(): + from flask import Response, stream_with_context + + from grotti import events_bus + + def generate(): + q = events_bus.subscribe() + try: + while True: + item = events_bus.wait_event(q, 15.0) + if item is None: + yield "event: ping\ndata: {}\n\n" + continue + kind = item.get("kind") or "message" + payload = item.get("payload") or {} + yield "event: %s\ndata: %s\n\n" % ( + kind, + json.dumps(payload, ensure_ascii=False), + ) + except GeneratorExit: + pass + finally: + events_bus.unsubscribe(q) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + return app diff --git a/web/static/api.js b/web/static/api.js index d7a7471..54d7400 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -12,7 +12,8 @@ function escapeHtml(value) { .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") - .replaceAll('"', """); + .replaceAll('"', """) + .replaceAll("'", "'"); } async function api(path, options = {}) { @@ -277,10 +278,16 @@ function toast(message, kind) { host.setAttribute("aria-live", "polite"); document.body.appendChild(host); } + const text = String(message ?? ""); + host.querySelectorAll(".toast").forEach((node) => { + const body = node.querySelector("div"); + if (body && body.textContent === text) node.remove(); + }); + while (host.children.length >= 3) host.firstElementChild?.remove(); const node = document.createElement("div"); node.className = "toast " + (kind || "good"); node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "
"; - node.querySelector("div").textContent = message; + node.querySelector("div").textContent = text; host.appendChild(node); const ttl = kind === "bad" ? 7000 : 4000; setTimeout(() => { diff --git a/web/static/grotti.css b/web/static/grotti.css index b7e315d..f124787 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -5,7 +5,7 @@ --bg: #0a0a0c; --ink: #f0f0f2; --muted: #9a9aa3; - --faint: #6b6b75; + --faint: #8d8d97; --line: rgba(255, 255, 255, 0.08); --line-strong: rgba(255, 255, 255, 0.14); --card: #141418; @@ -484,6 +484,7 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; } .icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; } .icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); } .icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); } +.icon-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.7rem; font-weight: 700; } .queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; } .send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; } .send-job { display: grid; gap: 0.3rem; } @@ -1959,6 +1960,31 @@ body.desk-on main { .tabs button:hover { color: var(--ink); filter: none; } .tabs button.on { color: var(--ink); border-bottom-color: var(--accent); } +.conn-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 120; + padding: 0.45rem 1rem; + text-align: center; + font-size: var(--t-sm); + font-weight: 500; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + pointer-events: none; +} +.conn-banner[hidden] { display: none !important; } +.conn-banner.bad { + background: rgba(127, 29, 29, 0.92); + color: #fecaca; + border-bottom: 1px solid rgba(248, 113, 113, 0.45); +} +.conn-banner.ok { + background: rgba(20, 83, 45, 0.92); + color: #bbf7d0; + border-bottom: 1px solid rgba(61, 206, 124, 0.45); +} + .toast-host { position: fixed; right: 1rem; @@ -2544,3 +2570,71 @@ ul.plain li:last-child { border-bottom: 0; } @media (min-width: 800px) { .grid-2 { grid-template-columns: 1fr 1fr; } } + +/* Abräumen-Dialog + Mobile „Mehr“ */ +.abraeumen-dlg, +.mobile-more-sheet { + border: 1px solid var(--line-strong); + border-radius: var(--radius); + padding: 1rem 1.1rem; + background: var(--card); + color: var(--ink); + max-width: min(22rem, calc(100vw - 2rem)); + box-shadow: var(--shadow); +} +.abraeumen-dlg::backdrop, +.mobile-more-sheet::backdrop { + background: rgba(0, 0, 0, 0.55); +} +.abraeumen-form h2 { margin: 0 0 0.35rem; font-size: var(--t-lg); } +.abraeumen-form .actions { margin-top: 0.85rem; display: flex; gap: 0.5rem; flex-wrap: wrap; } +.mobile-more-sheet nav { + display: grid; + gap: 0.35rem; + margin-bottom: 0.75rem; +} +.mobile-more-sheet a { + display: block; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--ink); + text-decoration: none; + background: var(--card-2); + border: 1px solid var(--line); +} +.mobile-nav .mobile-more-btn { + appearance: none; + background: transparent; + border: 0; + color: inherit; + font: inherit; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + padding: 0.35rem 0.5rem; + cursor: pointer; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +@media (pointer: coarse) { + .icon-btn, .tiny-btn, .fchip, button, .mobile-nav a, .mobile-nav button { + min-height: 2.75rem; + min-width: 2.75rem; + } +} + +@media print { + .sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; } + .app-shell { display: block; } + .app-main { margin: 0; } + body { background: #fff; color: #111; } + .card, article { box-shadow: none; border-color: #ccc; } +} diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest new file mode 100644 index 0000000..18fafda --- /dev/null +++ b/web/static/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Grotti", + "short_name": "Grotti", + "description": "3D-Druck-Farm steuern", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0c", + "theme_color": "#0a0a0c", + "lang": "de", + "icons": [ + { + "src": "/static/brand/favicon.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/brand/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..e2ae8af --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,27 @@ +/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */ +const CACHE = "grotti-shell-v1"; +const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.pathname.startsWith("/api/")) return; + event.respondWith( + fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/"))) + ); +}); diff --git a/web/static/ui.js b/web/static/ui.js new file mode 100644 index 0000000..c5a0c4a --- /dev/null +++ b/web/static/ui.js @@ -0,0 +1,208 @@ +/** Live-UI: Verbindungsbanner, Poller, gemeinsame Zustands-Labels. */ + +const RUN_STATES = { + completed: "fertig", + cancelled: "abgebrochen", + error: "Fehler", + in_progress: "druckt", + printing: "druckt", + paused: "pausiert", + interrupted: "unterbrochen", + standby: "bereit", + heating: "heizt", + complete: "fertig", + offline: "offline", + waiting: "wartet", + running: "läuft", +}; + +let connBannerOk = true; +let connBannerTimer = 0; + +function setConnectionBanner(ok, text) { + let el = document.getElementById("conn-banner"); + if (!el) { + el = document.createElement("div"); + el.id = "conn-banner"; + el.className = "conn-banner"; + el.setAttribute("role", "status"); + el.hidden = true; + document.body.prepend(el); + } + clearTimeout(connBannerTimer); + if (ok) { + const wasBad = !connBannerOk; + connBannerOk = true; + if (wasBad && text) { + el.textContent = text; + el.className = "conn-banner ok"; + el.hidden = false; + connBannerTimer = setTimeout(() => { + el.hidden = true; + el.textContent = ""; + }, 2800); + return; + } + el.hidden = true; + el.textContent = ""; + el.className = "conn-banner"; + return; + } + connBannerOk = false; + el.textContent = text || "Keine Verbindung zum Server."; + el.className = "conn-banner bad"; + el.hidden = false; +} + +/** + * @param {() => void | Promise} fn + * @param {number} intervalMs + * @returns {{ start: () => void, stop: () => void }} + */ +function createPoller(fn, intervalMs) { + const base = Math.max(1000, Number(intervalMs) || 5000); + const maxMs = 60000; + let timer = 0; + let busy = false; + let delay = base; + let fails = 0; + let running = false; + + function schedule() { + if (!running) return; + clearTimeout(timer); + timer = setTimeout(tick, delay); + } + + async function tick() { + if (!running || document.hidden || busy) { + schedule(); + return; + } + busy = true; + try { + await fn(); + delay = base; + fails = 0; + if (!connBannerOk) setConnectionBanner(true, "Wieder verbunden."); + else setConnectionBanner(true, ""); + } catch (error) { + fails += 1; + delay = Math.min(maxMs, Math.max(base, delay * 2)); + if (fails >= 2) { + setConnectionBanner(false, (error && error.message) || "Daten konnten nicht geladen werden."); + } + } finally { + busy = false; + schedule(); + } + } + + function onVis() { + if (!document.hidden && running) tick(); + } + + return { + start() { + if (running) return; + running = true; + document.addEventListener("visibilitychange", onVis); + tick(); + }, + stop() { + running = false; + document.removeEventListener("visibilitychange", onVis); + clearTimeout(timer); + timer = 0; + }, + }; +} + +const VERDICT_REASON_LABELS = { + haftung: "Haftung", + warping: "Warping", + spaghetti: "Spaghetti", + layer_shift: "Layer-Shift", + verstopfung: "Verstopfung", + farbe: "Farbe", + sonstiges: "Sonstiges", +}; + +/** + * Dialog vor „Platte geräumt“: Urteil, optional Grund und Stückzahlen. + * @returns {Promise} Payload für /abraeumen oder null bei Abbruch + */ +function promptAbraeumen(hostLabel) { + return new Promise((resolve) => { + const existing = document.getElementById("abraeumen-dlg"); + if (existing) existing.remove(); + const dlg = document.createElement("dialog"); + dlg.id = "abraeumen-dlg"; + dlg.className = "abraeumen-dlg"; + const reasons = Object.entries(VERDICT_REASON_LABELS) + .map(([k, label]) => ``) + .join(""); + dlg.innerHTML = ` +
+

Platte geräumt

+

${escapeHtml(hostLabel || "Drucker")}

+
+ +
+ + +
+ + +
+
`; + document.body.appendChild(dlg); + const verdict = dlg.querySelector("#ab-verdict"); + const reasonWrap = dlg.querySelector("#ab-reason-wrap"); + const pieces = dlg.querySelector("#ab-pieces"); + function sync() { + const scrap = verdict.value === "ausschuss"; + reasonWrap.hidden = !scrap; + pieces.hidden = !scrap; + } + verdict.addEventListener("change", sync); + sync(); + dlg.querySelector("#ab-cancel").addEventListener("click", () => { + dlg.close(); + dlg.remove(); + resolve(null); + }); + dlg.addEventListener("close", () => { + if (dlg.returnValue !== "ok") { + dlg.remove(); + resolve(null); + return; + } + const payload = { + confirm: true, + verdict: verdict.value, + verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "", + pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0, + pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0, + }; + dlg.remove(); + resolve(payload); + }); + dlg.showModal(); + }); +} + +window.RUN_STATES = RUN_STATES; +window.setConnectionBanner = setConnectionBanner; +window.createPoller = createPoller; +window.promptAbraeumen = promptAbraeumen; +window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS; diff --git a/web/templates/auftraege.html b/web/templates/auftraege.html new file mode 100644 index 0000000..7f4e564 --- /dev/null +++ b/web/templates/auftraege.html @@ -0,0 +1,320 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Aufträge

+

Kunden, Angebote, Positionen und Fortschritt.

+
+ +
+
+
+
+ + +
+
+ + + +
TitelStatusFortschritt
+
+
+
+

Neuer Auftrag

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ + + +
+
+

+

+

+ +

Positionen

+
+ + + +
DateiStkfertig
+
+
+
+
+
+
+
+
+
+
+
+ Kunde anlegen +
+
+
+ +
+
+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index 6af677d..3481a5f 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -126,6 +126,38 @@

+
+

ntfy

+ +
+
+ + +
+
+ + +
+
+ + +
+
+

Gleiche Meldungen wie Telegram — unabhängig abschaltbar.

+
+

+
+ +
+

Backup

+

PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).

+
+ +
+
    +

    +
    +

    Energie

    Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.

    @@ -381,6 +413,14 @@ async function load() { document.getElementById("al-queue").checked = alerts.queue !== false; document.getElementById("al-ablauf").checked = !!alerts.ablauf; document.getElementById("al-start").checked = !!alerts.start; + const ntfy = settings.ntfy || {}; + document.getElementById("ntfy-enabled").checked = !!ntfy.enabled; + document.getElementById("ntfy-server").value = ntfy.server || "https://ntfy.sh"; + document.getElementById("ntfy-topic").value = ntfy.topic || ""; + document.getElementById("ntfy-token").value = ""; + document.getElementById("ntfy-token").placeholder = ntfy.token_set + ? ("gesetzt: " + (ntfy.token || "••••")) + : "optional"; const en = settings.energy || {}; document.getElementById("en-watts").value = en.watts ?? 40; document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60; @@ -394,7 +434,7 @@ async function load() { } const legacy = document.getElementById("legacy-token-hint"); if (legacy) legacy.hidden = !settings.legacy_token_set; - await Promise.all([loadUsers(), loadTokens()]); + await Promise.all([loadUsers(), loadTokens(), loadBackups()]); } document.getElementById("user-add")?.addEventListener("submit", async (event) => { @@ -542,6 +582,49 @@ document.getElementById("tg-save").addEventListener("click", async () => { } }); +document.getElementById("ntfy-save")?.addEventListener("click", async () => { + const token = document.getElementById("ntfy-token").value.trim(); + const payload = { + ntfy: { + enabled: document.getElementById("ntfy-enabled").checked, + server: document.getElementById("ntfy-server").value.trim() || "https://ntfy.sh", + topic: document.getElementById("ntfy-topic").value.trim(), + }, + }; + if (token) payload.ntfy.token = token; + try { + await api("/einstellungen", { method: "PATCH", json: payload }); + showNote("ntfy-note", null, "ntfy gespeichert."); + load(); + } catch (error) { + showNote("ntfy-note", error); + } +}); + +async function loadBackups() { + const list = document.getElementById("backup-list"); + if (!list) return; + try { + const out = await api("/backup"); + list.innerHTML = (out.items || []).slice(0, 8).map((item) => { + const mb = ((item.size || 0) / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 }); + return `
  • ${escapeHtml(item.name)} · ${mb} MB
  • `; + }).join("") || "
  • Noch kein Backup.
  • "; + } catch (error) { + list.innerHTML = "
  • Liste nicht verfügbar.
  • "; + } +} + +document.getElementById("backup-now")?.addEventListener("click", async () => { + try { + const out = await api("/backup", { method: "POST", json: {} }); + showNote("backup-note", null, "Backup erstellt: " + (out.name || "")); + loadBackups(); + } catch (error) { + showNote("backup-note", error); + } +}); + document.getElementById("tg-test").addEventListener("click", async () => { try { await api("/telegram/test", { method: "POST", json: {} }); diff --git a/web/templates/layout.html b/web/templates/layout.html index a456bdf..575e518 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,7 +9,9 @@ - + + + + Drucker - Dateien + Dateien Kameras - Aktivität Warteschlange + Aufträge Protokoll - Statistik + Statistik + Aktivität Material - Projekte + Wartung
    @@ -152,12 +155,26 @@
    - + + +
    +
    + + {% block script %}{% endblock %} diff --git a/web/templates/lieferschein.html b/web/templates/lieferschein.html new file mode 100644 index 0000000..1342f2d --- /dev/null +++ b/web/templates/lieferschein.html @@ -0,0 +1,69 @@ + + + + + +{{ title }} + + + +
    + + Zurück +
    +
    +

    Lieferschein — Auftrag #{{ order.id }}

    +
    + {% if order.customer %}

    Kunde: {{ order.customer.name }}{% if order.customer.email %} · {{ order.customer.email }}{% endif %}

    {% endif %} +

    Titel: {{ order.title }}

    + {% if order.due_at %}

    Fällig: {{ order.due_at }}

    {% endif %} +

    Status: {{ order.status }}

    + {% if order.notes %}

    {{ order.notes }}

    {% endif %} +
    + + + + + + {% for item in order.items %} + + + + + + + {% else %} + + {% endfor %} + +
    PositionMengegeliefert€/Stk
    {{ item.file_name or item.note or ('Datei #' ~ item.file_id) }}{{ item.qty }}{{ item.done_ok }}{{ '%.2f'|format(item.unit_price) }}
    Keine Positionen.
    + {% if order.progress %} +

    Fortschritt gesamt: {{ order.progress.label }}

    + {% endif %} +
    + + diff --git a/web/templates/statistik.html b/web/templates/statistik.html index c4ffa56..5e54430 100644 --- a/web/templates/statistik.html +++ b/web/templates/statistik.html @@ -130,6 +130,12 @@ function render(data) { ${p.completed} `).join("") || 'Keine Daten.'; + const reasons = (data.scrap_reasons || []).map((r) => ` + + ${escapeHtml(r.reason || "—")} + ${r.count} + `).join("") || 'Kein Ausschuss im Zeitraum.'; + const materials = (data.by_material || []).map((m) => ` ${escapeHtml(m.material_type || "—")} @@ -156,6 +162,13 @@ function render(data) { ${printers} +
    +

    Ausschuss-Gründe

    +
    + + ${reasons} +
    GrundAnzahl
    +

    Nach Material

    diff --git a/web/templates/wand.html b/web/templates/wand.html index 16e8935..9d153e6 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -1562,19 +1562,21 @@ async function handleWallClick(event) { return; } if (button.dataset.cmd === "abraeumen") { - armButton(button, "Platte frei?", async () => { + (async () => { + const payload = await promptAbraeumen(host); + if (!payload) return; button.disabled = true; try { await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", - json: { confirm: true }, + json: payload, }); toast("Platte quittiert — der nächste Job darf starten."); refresh(); if (window.refreshTasks) window.refreshTasks(); } catch (error) { toast(error.message, "bad"); } finally { button.disabled = false; } - }); + })(); return; } button.disabled = true; diff --git a/web/templates/wartung.html b/web/templates/wartung.html new file mode 100644 index 0000000..da99cbb --- /dev/null +++ b/web/templates/wartung.html @@ -0,0 +1,93 @@ +{% extends "layout.html" %} +{% block body %} +
    +
    +

    Wartung

    +

    Laufstunden und Filament je Drucker — Verschleißteile im Blick.

    +
    +
    +
    +

    +{% endblock %} +{% block script %} + +{% endblock %}