Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.

Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:54:51 +02:00
1 parent 0236278929
commit 8e88efc094
45 files changed
+3121 -122

No files matched your search

+21 -11
View File
@@ -4,25 +4,35 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [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 ### Neu
- Datenbank: nummerierte Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes für Sends/Läufe/Dateien/Events, Retention für `run_samples` (90 Tage). - **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools.
- Server: waitress statt Flask-Dev-Server; Hintergrund-Threads starten erst in `server.main()`. - **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 ### Geändert
- Spulenabzug aus Moonraker-History: keine Rate mehr ohne Send-Eintrag; Spulen, die nach Druckende angelegt wurden, werden ignoriert. - Server: waitress; Threads starten in `server.main()`.
- Chargen-Vergabe (`S-…` / `D-…`) mit Advisory-Lock; Druck-Charge nach lokaler Tagesgrenze. - Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons.
- MCP-Steuerung: Parameter `temp`/`percent` (Alias `value` bleibt in Moonraker). - Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS.
- Upload nur `.gcode`/`.3mf`/`.gcode.3mf`; Drucker-Host validiert; `?token=` nur für Kamera/Vorschau; ProxyFix; Settings-Lock. - Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder.
- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`.
### Behoben ### Behoben
- Hängende Queue-Jobs (`phase=starting` ohne Worker) nach Neustart werden nach 45 s auf Fehler gesetzt. - Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions.
- Queue-Worker rollen die Transaktion bei Fehlern zurück; Exceptions werden geloggt. - Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE.
- `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.
## [2.10.0] – 2026-10-18 ## [2.10.0] – 2026-10-18
+2
View File
@@ -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. 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 ```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }] [{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
``` ```
+6
View File
@@ -30,6 +30,12 @@
"bot_token": "", "bot_token": "",
"chat_id": "" "chat_id": ""
}, },
"ntfy": {
"enabled": false,
"server": "https://ntfy.sh",
"topic": "",
"token": ""
},
"alerts": { "alerts": {
"fertig": true, "fertig": true,
"fehler": true, "fehler": true,
+19
View File
@@ -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)
+12
View File
@@ -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)
+18 -46
View File
@@ -1,61 +1,33 @@
# Grotti: Alltag und Oberfläche # Grotti: Alltag und Oberfläche
Stand **18.10.2026**, v2.10.0. Stand **18.10.2026**, v3.0.0.
## Design ## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). - 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. - Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`.
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon. - **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
- **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`). - PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache).
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav. - Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
## Seitenleiste ## 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:** Drucker, Dateien, Kameras, Warteschlange, Aufträge.
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`): - **Rückschau:** Protokoll, Statistik, Aktivität.
- **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. - **Verwalten:** Material, Wartung; Einstellungen, Doku.
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden. - Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts.
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick. - Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet).
- **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.
## 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). - Karten + Cockpit wie bisher.
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis. - **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
- **Cockpit** (Hash `/#<host>`): 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=`.
## 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/<id>/verschieben`, `stored_path` per ID). Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen. Orca-Nachbearbeitung: `scripts/orca_senden.py`. - `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. `/projekte` und `/ideen` leiten um.
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf. - `/wartung`: Stunden/Meter und Verschleißteile.
- 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`.
## Material ## Dateien, Material, Statistik
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht). - Unverändert im Kern (Bibliothek, Send-Flow, Spulen/Sorten, KPIs + Ausschuss-Gründe).
- 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/<id>` (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.
+92
View File
@@ -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
+76
View File
@@ -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}
+83 -4
View File
@@ -14,9 +14,26 @@ DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock() _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): def _energy(raw):
energy = raw.get("energy") or {} energy = raw.get("energy") or {}
return { out = {
"watts": float(energy.get("watts", 40)), "watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)), "nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)), "bed_watts": float(energy.get("bed_watts", 220)),
@@ -26,6 +43,20 @@ def _energy(raw):
"waste_percent": float(energy.get("waste_percent", 0)), "waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)), "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): def _telegram(raw):
@@ -121,6 +152,7 @@ def load():
"printers_file": ROOT / "drucker.json", "printers_file": ROOT / "drucker.json",
"energy": _energy(raw), "energy": _energy(raw),
"telegram": _telegram(raw), "telegram": _telegram(raw),
"ntfy": _ntfy(raw),
"alerts": _alerts(raw), "alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)), "auto_queue": bool(raw.get("auto_queue", True)),
"_raw": raw, "_raw": raw,
@@ -187,6 +219,13 @@ def settings_public(cfg):
"bot_token_set": bool(tg.get("bot_token")), "bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""), "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 {}), "alerts": dict(cfg.get("alerts") or {}),
"oauth": { "oauth": {
"enabled": bool(oauth.get("enabled")), "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.""" """Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
with _SETTINGS_LOCK: with _SETTINGS_LOCK:
return _save_settings_locked( return _save_settings_locked(
cfg, cfg,
energy=energy, energy=energy,
telegram=telegram, telegram=telegram,
ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
fqdn=fqdn, 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 path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {}) raw = dict(cfg.get("_raw") or {})
if path.exists(): if path.exists():
@@ -231,7 +289,7 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que
raw["fqdn"] = normalized raw["fqdn"] = normalized
cfg["fqdn"] = normalized cfg["fqdn"] = normalized
if energy is not None: if energy is not None:
raw["energy"] = { raw_energy = {
"watts": float(energy.get("watts", 40)), "watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)), "nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)), "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)), "waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)), "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) cfg["energy"] = _energy(raw)
if telegram is not None: if telegram is not None:
current = _telegram(raw) 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 ""), "chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
} }
cfg["telegram"] = _telegram(raw) 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: if alerts is not None:
raw["alerts"] = { raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)), "fertig": bool(alerts.get("fertig", True)),
+92 -5
View File
@@ -1,6 +1,8 @@
"""Dieselbe Rechnung wie in der Druckkalkulation.""" """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): def calculate(job, material):
@@ -92,13 +94,49 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
return total 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. """Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen. Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
""" """
energy = energy or {} 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) 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) base_w = float(energy.get("watts") or 0)
nozzle_w = float(energy.get("nozzle_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): def energy_cost(duration_s, energy, samples=None, end_dt=None):
return energy_estimate(duration_s, energy, samples)["cost"] 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),
}
+42
View File
@@ -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
+156
View File
@@ -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)
+21
View File
@@ -124,6 +124,27 @@ def main():
"""Spulen / Materialbestand.""" """Spulen / Materialbestand."""
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2) 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() @server.tool()
def get_version() -> str: def get_version() -> str:
"""Grotti-Version.""" """Grotti-Version."""
+3
View File
@@ -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;
+25
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
+63
View File
@@ -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;
+45 -3
View File
@@ -1,8 +1,9 @@
"""Telegram-Benachrichtigungen (Bot API).""" """Telegram- und ntfy-Benachrichtigungen."""
from __future__ import annotations from __future__ import annotations
import json import json
import threading
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -43,11 +44,52 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF)) 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): 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 from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s) 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) store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
if not recent and alert_enabled(cfg, kind): 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()
+460
View File
@@ -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),
)
+34
View File
@@ -13,6 +13,14 @@ from datetime import datetime, timezone
from grotti import eject, jobs, moonraker, notify, store from grotti import eject, jobs, moonraker, notify, store
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError 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"} FREE = {"standby", "complete", "cancelled"}
BUSY = {"printing", "paused", "heating", "error"} BUSY = {"printing", "paused", "heating", "error"}
@@ -169,6 +177,7 @@ class PrintQueueDispatcher:
except Exception as exc: except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True) print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
finally: finally:
_notify_queue()
with self._worker_lock: with self._worker_lock:
self._workers.pop(item_id, None) self._workers.pop(item_id, None)
@@ -182,6 +191,12 @@ class PrintQueueDispatcher:
return True return True
def tick(self): def tick(self):
try:
self._tick_once()
finally:
_notify_queue()
def _tick_once(self):
with connect(self.database_url) as conn: with connect(self.database_url) as conn:
printers = store.list_printers(conn) printers = store.list_printers(conn)
if not printers: if not printers:
@@ -245,6 +260,20 @@ class PrintQueueDispatcher:
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "") waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
if not waiting: if not waiting:
continue 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) claimed = store.claim_print_queue(conn, waiting["id"], host)
if not claimed: if not claimed:
continue continue
@@ -303,6 +332,11 @@ class PrintQueueDispatcher:
bed_leveling=bed_leveling, bed_leveling=bed_leveling,
) )
name = (result or {}).get("filename") or "" 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( store.update_print_queue(
wconn, wconn,
item_id, item_id,
+64
View File
@@ -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); 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;
+2
View File
@@ -11,6 +11,7 @@ from queue import Empty, Queue
from grotti import jobs from grotti import jobs
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti.events_bus import publish as publish_live
class SendQueue: class SendQueue:
@@ -161,6 +162,7 @@ class SendQueue:
return return
job.update(fields) job.update(fields)
job["updated_at"] = time.time() job["updated_at"] = time.time()
publish_live("senden", {})
def _worker(self): def _worker(self):
while not self._stop.is_set(): while not self._stop.is_set():
+84 -10
View File
@@ -9,8 +9,6 @@ from psycopg.types.json import Json
from grotti.catalog import MACHINE_LABELS, MATERIALS from grotti.catalog import MACHINE_LABELS, MATERIALS
from grotti.printers import FAMILY_LABELS, family_from_name from grotti.printers import FAMILY_LABELS, family_from_name
def iso(value): def iso(value):
return value.isoformat() if value else None return value.isoformat() if value else None
@@ -170,6 +168,10 @@ def run_out(row):
"deducted": row["deducted"], "deducted": row["deducted"],
"needs_spool": row["needs_spool"], "needs_spool": row["needs_spool"],
"verdict": row["verdict"], "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"], "material_type": row["material_type"],
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"message": row.get("message") or "", "message": row.get("message") or "",
@@ -675,6 +677,8 @@ def upsert_run(conn, item):
**item, **item,
} }
payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else []) 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: if "weight_g" not in payload or payload.get("weight_g") is None:
payload["weight_g"] = payload["weight_planned_g"] payload["weight_g"] = payload["weight_planned_g"]
return conn.execute( return conn.execute(
@@ -682,14 +686,14 @@ def upsert_run(conn, item):
INSERT INTO runs ( INSERT INTO runs (
printer_host, printer_name, filename, file_id, project_id, spool_id, printer_host, printer_name, filename, file_id, project_id, spool_id,
state, started_at, ended_at, duration_s, weight_g, energy_cost, 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, weight_planned_g, weight_used_g, weight_source,
filament_used_mm, filament_total_mm, progress_last, estimated_s, filament_used_mm, filament_total_mm, progress_last, estimated_s,
message, objects message, objects
) VALUES ( ) VALUES (
%(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s, %(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, %(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, %(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, %(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s,
%(message)s, %(objects)s %(message)s, %(objects)s
@@ -700,6 +704,7 @@ def upsert_run(conn, item):
file_id = COALESCE(runs.file_id, EXCLUDED.file_id), file_id = COALESCE(runs.file_id, EXCLUDED.file_id),
project_id = COALESCE(runs.project_id, EXCLUDED.project_id), project_id = COALESCE(runs.project_id, EXCLUDED.project_id),
spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_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, state = EXCLUDED.state,
started_at = COALESCE(runs.started_at, EXCLUDED.started_at), started_at = COALESCE(runs.started_at, EXCLUDED.started_at),
ended_at = EXCLUDED.ended_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"]) return get_run_row(conn, label["run_id"])
def set_verdict(conn, run_id, verdict): def set_verdict(conn, run_id, verdict, verdict_reason="", pieces_ok=0, pieces_bad=0):
row = conn.execute( from grotti import orders
"UPDATE runs SET verdict = %s WHERE id = %s RETURNING *",
(verdict, run_id), before = conn.execute(
"SELECT order_item_id, pieces_ok, pieces_bad FROM runs WHERE id = %s",
(run_id,),
).fetchone() ).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 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): def assign_run_spool(conn, run_id, spool_id):
return conn.execute( return conn.execute(
""" """
@@ -1843,6 +1889,18 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
run_values, run_values,
).fetchone() ).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 {} ov = overview or {}
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
if spool_join: 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_ratio": float((estimate or {}).get("median_ratio") or 0) or None,
"estimate_count": int((estimate or {}).get("counted") or 0), "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): def printer_out(row):
family = row["family"] or "" 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 { return {
"id": str(row["id"]), "id": str(row["id"]),
"name": row["name"], "name": row["name"],
@@ -1980,6 +2050,7 @@ def printer_out(row):
"family": family, "family": family,
"family_label": FAMILY_LABELS.get(family, ""), "family_label": FAMILY_LABELS.get(family, ""),
"locked": bool(row.get("locked")), "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": row.get("phase") or "",
"phase_started_at": iso(row.get("phase_started_at")), "phase_started_at": iso(row.get("phase_started_at")),
"is_ablauf": bool(steps), "is_ablauf": bool(steps),
"order_item_id": row.get("order_item_id"),
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
"updated_at": iso(row["updated_at"]), "updated_at": iso(row["updated_at"]),
} }
@@ -2260,6 +2332,7 @@ def enqueue_print(
override, override,
steps=None, steps=None,
repeat_total=1, repeat_total=1,
order_item_id=None,
): ):
steps = steps if isinstance(steps, list) else [] steps = steps if isinstance(steps, list) else []
try: try:
@@ -2271,11 +2344,11 @@ def enqueue_print(
INSERT INTO print_queue ( INSERT INTO print_queue (
file_id, host, family, priority, spool_id, ace_map, file_id, host, family, priority, spool_id, ace_map,
bed_leveling, override_machine, status, message, 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 ( VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s, %s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s,
%s, 0, %s, 0, '' %s, 0, %s, 0, '', %s
) )
RETURNING * RETURNING *
""", """,
@@ -2291,6 +2364,7 @@ def enqueue_print(
"Ablauf wartet…" if steps else "Wartet auf freien Drucker…", "Ablauf wartet…" if steps else "Wartet auf freien Drucker…",
Json(steps), Json(steps),
repeats, repeats,
order_item_id,
), ),
).fetchone() ).fetchone()
file_row = get_file_row(conn, file_id) file_row = get_file_row(conn, file_id)
+72
View File
@@ -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
+33
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.10.0" __version__ = "3.0.0"
+16 -11
View File
@@ -7,6 +7,7 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import moonraker, notify, store, usage from grotti import moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live
from grotti.db import connect from grotti.db import connect
SAMPLE_EVERY_S = 30 SAMPLE_EVERY_S = 30
@@ -62,18 +63,21 @@ class StateWatcher:
self._stop.wait(self.interval) self._stop.wait(self.interval)
def tick(self): def tick(self):
with connect(self.database_url) as conn: try:
printers = store.list_printers(conn) with connect(self.database_url) as conn:
if not printers: 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() conn.commit()
return finally:
lives = moonraker.snapshot(printers) publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
with self._lock:
self.latest = lives
self._updated_at = time.monotonic()
for live in lives:
self._one(conn, live)
conn.commit()
def _one(self, conn, live): def _one(self, conn, live):
host = live.get("host") or "" host = live.get("host") or ""
@@ -394,3 +398,4 @@ class StateWatcher:
conn, self.cfg, host, printer, kind, message, filename, conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id, dedupe_s=60, run_id=run_id,
) )
publish_live("ereignis", {})
+27
View File
@@ -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()
+70
View File
@@ -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()
+28
View File
@@ -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()
+48
View File
@@ -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()
+24
View File
@@ -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()
+348 -6
View File
@@ -11,7 +11,7 @@ from urllib.parse import quote
import markdown import markdown
import psycopg 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 markupsafe import Markup
from psycopg.rows import dict_row from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix 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 db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog 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.errors import GrottiError
from grotti.printers import FAMILY_LABELS, family_from_name from grotti.printers import FAMILY_LABELS, family_from_name
from grotti.print_queue import PrintQueueDispatcher from grotti.print_queue import PrintQueueDispatcher
from grotti.send_queue import SendQueue from grotti.send_queue import SendQueue
from grotti.toolpath import TOOLPATH_VERSION, build_toolpath from grotti.toolpath import TOOLPATH_VERSION, build_toolpath
from grotti.version import __version__ from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
@@ -35,6 +37,7 @@ TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs" DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"} PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"} VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"} IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf") UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -96,6 +99,7 @@ def create_app(cfg):
ADMIN_API_PREFIXES = ( ADMIN_API_PREFIXES = (
"/api/v1/users", "/api/v1/users",
"/api/v1/einstellungen", "/api/v1/einstellungen",
"/api/v1/backup",
) )
ADMIN_API_EXACT = { ADMIN_API_EXACT = {
"/api/v1/telegram/test", "/api/v1/telegram/test",
@@ -566,7 +570,9 @@ def create_app(cfg):
"Anleitung-Orca", "Anleitung-Orca",
"Anleitung-ACE", "Anleitung-ACE",
"Anleitung-Warteschlange", "Anleitung-Warteschlange",
"Anleitung-Auftraege",
"Anleitung-Material", "Anleitung-Material",
"Anleitung-Wartung",
"Anleitung-Einstellungen", "Anleitung-Einstellungen",
"Anleitung-Stoerungen", "Anleitung-Stoerungen",
) )
@@ -587,7 +593,9 @@ def create_app(cfg):
"Anleitung-Orca": "Orca an Grotti", "Anleitung-Orca": "Orca an Grotti",
"Anleitung-ACE": "ACE und Spulen", "Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange", "Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Auftraege": "Aufträge",
"Anleitung-Material": "Material und mehr", "Anleitung-Material": "Material und mehr",
"Anleitung-Wartung": "Wartung",
"Anleitung-Einstellungen": "Einstellungen", "Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen", "Anleitung-Stoerungen": "Störungen",
} }
@@ -620,7 +628,23 @@ def create_app(cfg):
@app.get("/projekte") @app.get("/projekte")
def 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/<int:order_id>/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") @app.get("/einstellungen")
def einstellungen_page(): def einstellungen_page():
@@ -666,6 +690,10 @@ def create_app(cfg):
def statistik_page(): def statistik_page():
return page("statistik.html", "Statistik") return page("statistik.html", "Statistik")
@app.get("/wartung")
def wartung_page():
return page("wartung.html", "Wartung")
@app.get("/etikett/lauf/<int:run_id>") @app.get("/etikett/lauf/<int:run_id>")
def etikett_lauf(run_id): def etikett_lauf(run_id):
row = store.get_run_row(conn(), run_id) row = store.get_run_row(conn(), run_id)
@@ -709,7 +737,7 @@ def create_app(cfg):
@app.get("/ideen") @app.get("/ideen")
def ideen(): def ideen():
return page("ideen.html", "Ideen") return redirect("/auftraege", code=301)
@app.get("/aktivitaet") @app.get("/aktivitaet")
def aktivitaet(): def aktivitaet():
@@ -767,6 +795,7 @@ def create_app(cfg):
data = body() data = body()
energy = data.get("energy") if isinstance(data.get("energy"), dict) else None energy = data.get("energy") if isinstance(data.get("energy"), dict) else None
telegram = data.get("telegram") if isinstance(data.get("telegram"), 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 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 auto_queue = data.get("auto_queue") if "auto_queue" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None fqdn = data.get("fqdn") if "fqdn" in data else None
@@ -775,6 +804,7 @@ def create_app(cfg):
cfg, cfg,
energy=energy, energy=energy,
telegram=telegram, telegram=telegram,
ntfy=ntfy,
alerts=alerts, alerts=alerts,
auto_queue=auto_queue, auto_queue=auto_queue,
fqdn=fqdn, fqdn=fqdn,
@@ -1399,8 +1429,31 @@ def create_app(cfg):
if not bool(data.get("confirm")): if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung") raise GrottiError("Bestätigung fehlt.", "bestaetigung")
require_host(host) require_host(host)
store.set_bed_needs_clear(conn(), host, False, "quittiert") c = conn()
return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."}) 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") @app.get("/api/v1/abraeumen-defaults")
def abraeumen_defaults(): def abraeumen_defaults():
@@ -1667,6 +1720,191 @@ def create_app(cfg):
raise GrottiError("Die Idee fehlt.", "idee", 404) raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204 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/<int:customer_id>")
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/<int:customer_id>")
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/<int:order_id>")
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/<int:order_id>")
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/<int:order_id>")
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/<int:order_id>/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/<int:item_id>")
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/<int:item_id>")
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/<int:order_id>/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") @app.get("/api/v1/spulen")
def spool_list(): def spool_list():
return jsonify(store.list_spools(conn())) return jsonify(store.list_spools(conn()))
@@ -1989,6 +2227,75 @@ def create_app(cfg):
row = store.run_out(current) row = store.run_out(current)
return jsonify(row) 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/<int:part_id>/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/<name>")
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") @app.get("/api/v1/statistik")
def statistik_api(): def statistik_api():
since = request.args.get("since") or None since = request.args.get("since") or None
@@ -2056,4 +2363,39 @@ def create_app(cfg):
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"}, 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 return app
+9 -2
View File
@@ -12,7 +12,8 @@ function escapeHtml(value) {
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;"); .replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -277,10 +278,16 @@ function toast(message, kind) {
host.setAttribute("aria-live", "polite"); host.setAttribute("aria-live", "polite");
document.body.appendChild(host); 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"); const node = document.createElement("div");
node.className = "toast " + (kind || "good"); node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>"; node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message; node.querySelector("div").textContent = text;
host.appendChild(node); host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000; const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => { setTimeout(() => {
+95 -1
View File
@@ -5,7 +5,7 @@
--bg: #0a0a0c; --bg: #0a0a0c;
--ink: #f0f0f2; --ink: #f0f0f2;
--muted: #9a9aa3; --muted: #9a9aa3;
--faint: #6b6b75; --faint: #8d8d97;
--line: rgba(255, 255, 255, 0.08); --line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14); --line-strong: rgba(255, 255, 255, 0.14);
--card: #141418; --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 { 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: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.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; } .queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; }
.send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; } .send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
.send-job { display: grid; gap: 0.3rem; } .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:hover { color: var(--ink); filter: none; }
.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); } .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 { .toast-host {
position: fixed; position: fixed;
right: 1rem; right: 1rem;
@@ -2544,3 +2570,71 @@ ul.plain li:last-child { border-bottom: 0; }
@media (min-width: 800px) { @media (min-width: 800px) {
.grid-2 { grid-template-columns: 1fr 1fr; } .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; }
}
+23
View File
@@ -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"
}
]
}
+27
View File
@@ -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("/")))
);
});
+208
View File
@@ -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<void>} 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<object|null>} 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]) => `<option value="${k}">${label}</option>`)
.join("");
dlg.innerHTML = `
<form method="dialog" class="abraeumen-form">
<h2>Platte geräumt</h2>
<p class="tiny">${escapeHtml(hostLabel || "Drucker")}</p>
<div class="field"><label for="ab-verdict">Urteil</label>
<select id="ab-verdict" name="verdict">
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
</div>
<div class="field" id="ab-reason-wrap" hidden>
<label for="ab-reason">Grund</label>
<select id="ab-reason">${reasons}</select>
</div>
<div class="grid-2" id="ab-pieces" hidden>
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
</div>
</form>`;
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;
+320
View File
@@ -0,0 +1,320 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aufträge</h1>
<p class="tiny">Kunden, Angebote, Positionen und Fortschritt.</p>
</div>
<div class="actions">
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
</div>
</div>
<div class="split">
<section class="card">
<div class="field inline">
<label for="filter-status">Status</label>
<select id="filter-status">
<option value="">alle</option>
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
</div>
</section>
<section class="card">
<h2 id="order-title">Neuer Auftrag</h2>
<form id="order-form">
<input id="order-id" type="hidden">
<div class="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
<div class="grid-2">
<div class="field"><label for="order-status">Status</label>
<select id="order-status">
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="field"><label for="order-due">Fällig</label><input id="order-due" type="date"></div>
</div>
<div class="field"><label for="order-customer">Kunde</label>
<select id="order-customer"><option value="">— keiner —</option></select>
</div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="warn" id="order-delete" hidden>Löschen</button>
</div>
</form>
<p id="order-note" class="tiny"></p>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen</summary>
<form id="customer-form">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<button type="submit">Kunde speichern</button>
</form>
</details>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
idee: "Idee", angebot: "Angebot", bestaetigt: "bestätigt", druckt: "druckt",
fertig: "fertig", geliefert: "geliefert", bezahlt: "bezahlt",
};
let orders = [];
let customers = [];
let files = [];
let current = null;
function fillCustomers() {
const sel = document.getElementById("order-customer");
const val = sel.value;
sel.innerHTML = '<option value="">— keiner —</option>';
customers.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
sel.appendChild(o);
});
if (val) sel.value = val;
}
function fillFiles() {
const sel = document.getElementById("item-file");
sel.innerHTML = '<option value="">—</option>';
files.slice(0, 400).forEach((f) => {
const o = document.createElement("option");
o.value = f.id;
o.textContent = f.name;
sel.appendChild(o);
});
}
function paintActuals(order) {
const el = document.getElementById("order-actuals");
const a = order.actuals;
if (!a) { el.textContent = ""; return; }
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk";
}
function fillOrder(order) {
current = order;
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? order.notes : "";
document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag";
document.getElementById("order-delete").hidden = !order;
const link = document.getElementById("lieferschein-link");
if (order) {
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
} else {
link.hidden = true;
}
const prog = order && order.progress;
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
paintActuals(order || {});
renderItems(order && order.items ? order.items : []);
}
function renderItems(items) {
const body = document.getElementById("item-rows");
body.innerHTML = "";
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="empty">Keine Positionen.</td></tr>';
return;
}
items.forEach((item) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id));
tr.children[3].appendChild(btn);
body.appendChild(tr);
});
}
async function einreihen(itemId) {
if (!current) return;
try {
await api("/auftraege/" + current.id + "/einreihen", {
method: "POST",
json: { item_id: itemId, when_free: true, confirm: true },
});
toast("In Warteschlange.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
await loadList();
} catch (error) {
showNote("order-note", error);
}
}
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
return;
}
orders.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = order.title;
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => {
const full = await api("/auftraege/" + order.id);
fillOrder(full);
});
body.appendChild(tr);
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
renderList();
}
async function loadBase() {
[customers, files] = await Promise.all([
api("/kunden").catch(() => []),
api("/dateien").catch(() => []),
]);
fillCustomers();
fillFiles();
await loadList();
fillOrder(null);
}
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
document.getElementById("order-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("order-id").value;
const payload = {
title: document.getElementById("order-title-in").value,
status: document.getElementById("order-status").value,
notes: document.getElementById("order-notes").value,
due_at: document.getElementById("order-due").value || null,
customer_id: document.getElementById("order-customer").value || null,
};
try {
const saved = id
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null);
document.getElementById("order-note").textContent = "Gemerkt.";
await loadList();
fillOrder(saved);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-new").addEventListener("click", () => fillOrder(null));
document.getElementById("order-delete").addEventListener("click", (event) => {
const id = document.getElementById("order-id").value;
if (!id) return;
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
await api("/auftraege/" + id, { method: "DELETE" });
toast("Gelöscht.");
fillOrder(null);
loadList();
});
});
document.getElementById("item-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) {
showNote("order-note", new Error("Zuerst Auftrag speichern."));
return;
}
const payload = {
file_id: document.getElementById("item-file").value || null,
qty: Number(document.getElementById("item-qty").value),
unit_price: Number(document.getElementById("item-price").value),
material_type: document.getElementById("item-material").value,
};
try {
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
const full = await api("/auftraege/" + current.id);
fillOrder(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const row = await api("/kunden", {
method: "POST",
json: {
name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value,
},
});
customers.push(row);
fillCustomers();
document.getElementById("order-customer").value = row.id;
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
toast("Kunde angelegt.");
} catch (error) {
showNote("order-note", error);
}
});
loadBase().catch((error) => showNote("order-note", error));
</script>
{% endblock %}
+84 -1
View File
@@ -126,6 +126,38 @@
<p id="tg-note"></p> <p id="tg-note"></p>
</section> </section>
<section class="card admin-only">
<h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="ntfy-server">Server</label>
<input id="ntfy-server" placeholder="https://ntfy.sh" autocomplete="off">
</div>
<div class="field">
<label for="ntfy-topic">Topic</label>
<input id="ntfy-topic" placeholder="grotti-werkstatt" autocomplete="off">
</div>
<div class="field" style="grid-column:1/-1">
<label for="ntfy-token">Token (optional)</label>
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
</div>
</div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
<div class="actions"><button type="button" id="ntfy-save">Speichern</button></div>
<p id="ntfy-note"></p>
</section>
<section class="card admin-only">
<h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions">
<button type="button" id="backup-now">Backup jetzt</button>
</div>
<ul id="backup-list" class="tiny"></ul>
<p id="backup-note"></p>
</section>
<section class="card admin-only"> <section class="card admin-only">
<h2>Energie</h2> <h2>Energie</h2>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p> <p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
@@ -381,6 +413,14 @@ async function load() {
document.getElementById("al-queue").checked = alerts.queue !== false; document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf; document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start; 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 || {}; const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 40; document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60; document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
@@ -394,7 +434,7 @@ async function load() {
} }
const legacy = document.getElementById("legacy-token-hint"); const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set; 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) => { 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 `<li><a href="/backup/${encodeURIComponent(item.name)}" download>${escapeHtml(item.name)}</a> · ${mb} MB</li>`;
}).join("") || "<li>Noch kein Backup.</li>";
} catch (error) {
list.innerHTML = "<li>Liste nicht verfügbar.</li>";
}
}
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 () => { document.getElementById("tg-test").addEventListener("click", async () => {
try { try {
await api("/telegram/test", { method: "POST", json: {} }); await api("/telegram/test", { method: "POST", json: {} });
+87 -18
View File
@@ -9,7 +9,9 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=52"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=54">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c">
<script> <script>
(function () { (function () {
try { try {
@@ -38,6 +40,7 @@
</script> </script>
</head> </head>
<body class="app-shell"> <body class="app-shell">
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"> <svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol> <symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol> <symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
@@ -96,14 +99,15 @@
</div> </div>
<nav class="nav-main" aria-label="Hauptmenü"> <nav class="nav-main" aria-label="Hauptmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a> <a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a> <a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a> <a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a> <a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a> <a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a>
</nav> </nav>
<div class="live-panel" id="live-panel"> <div class="live-panel" id="live-panel">
<details class="queue-box" data-box="run" id="run-box" open hidden> <details class="queue-box" data-box="run" id="run-box" open hidden>
@@ -131,7 +135,6 @@
</div> </div>
<nav class="nav-secondary" aria-label="Weitere Seiten"> <nav class="nav-secondary" aria-label="Weitere Seiten">
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a> <a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
<a href="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</span></a>
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a> <a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
</nav> </nav>
<div class="side-foot"> <div class="side-foot">
@@ -152,12 +155,26 @@
</div> </div>
<nav class="mobile-nav" aria-label="Schnellmenü"> <nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a> <button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
</nav> </nav>
<script src="{{ url_for('static', filename='api.js') }}?v=26"></script> <dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten">
<a href="/kameras">Kameras</a>
<a href="/auftraege">Aufträge</a>
<a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a>
<a href="/material">Material</a>
<a href="/wartung">Wartung</a>
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
<form method="dialog"><button class="ghost">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
<script> <script>
document.getElementById("logout-btn")?.addEventListener("click", async () => { document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {} try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -476,17 +493,27 @@ async function refreshTasks() {
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""} ${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
</li>`).join(""); </li>`).join("");
list.querySelectorAll("[data-cleared]").forEach((btn) => { list.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = () => armButton(btn, "leer?", async () => { btn.onclick = async () => {
const host = btn.dataset.cleared;
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
if (!payload) return;
btn.disabled = true; btn.disabled = true;
try { try {
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } }); await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
toast("Platte frei."); toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); } } catch (error) { toast(error.message, "bad"); }
refreshTasks(); refreshTasks();
if (typeof window.onQueueChanged === "function") window.onQueueChanged(); if (typeof window.onQueueChanged === "function") window.onQueueChanged();
}); btn.disabled = false;
};
}); });
} }
document.getElementById("mobile-more")?.addEventListener("click", () => {
document.getElementById("mobile-more-sheet")?.showModal();
});
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
}
document.getElementById("events-clear").addEventListener("click", async (event) => { document.getElementById("events-clear").addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
@@ -502,10 +529,52 @@ refreshPrintQueue();
refreshEvents(); refreshEvents();
refreshTasks(); refreshTasks();
window.refreshTasks = refreshTasks; window.refreshTasks = refreshTasks;
setInterval(refreshTasks, 8000);
setInterval(refreshSendQueue, 2000); (function liveUpdates() {
setInterval(refreshPrintQueue, 4000); const sseKinds = {
setInterval(refreshEvents, 8000); queue: () => refreshPrintQueue(),
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
drucker: () => {
refreshPrintQueue();
refreshTasks();
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
},
};
if (typeof EventSource !== "undefined") {
let es;
let retryMs = 2000;
const retryMax = 60000;
let hadStream = false;
function connectStream() {
es = new EventSource("/api/v1/stream", { withCredentials: true });
es.addEventListener("open", () => {
retryMs = 2000;
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
hadStream = true;
});
es.addEventListener("error", () => {
es.close();
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
es.addEventListener(kind, () => sseKinds[kind]());
});
es.addEventListener("ping", () => {});
}
connectStream();
}
createPoller(refreshPrintQueue, 8000).start();
createPoller(refreshSendQueue, 4000).start();
createPoller(refreshEvents, 12000).start();
createPoller(refreshTasks, 12000).start();
})();
</script> </script>
{% block script %}{% endblock %} {% block script %}{% endblock %}
</body> </body>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 "Segoe UI", system-ui, sans-serif; color: #111; background: #f3f3f3; }
.toolbar { display: flex; gap: 0.6rem; padding: 0.8rem 1rem; background: #1a1a1a; color: #eee; }
.toolbar a, .toolbar button {
color: #111; background: #f97316; border: 0; border-radius: 8px;
padding: 0.4rem 0.85rem; font: inherit; font-weight: 650; text-decoration: none; cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
max-width: 210mm; margin: 1.2rem auto; padding: 14mm;
background: #fff; border: 1px solid #ccc; border-radius: 4px;
}
h1 { margin: 0 0 4mm; font-size: 18px; }
.meta { margin-bottom: 8mm; color: #333; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #ddd; padding: 3mm 2mm; text-align: left; }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet { margin: 0; border: 0; border-radius: 0; }
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/auftraege">Zurück</a>
</div>
<article class="sheet">
<h1>Lieferschein — Auftrag #{{ order.id }}</h1>
<div class="meta">
{% if order.customer %}<p><strong>Kunde:</strong> {{ order.customer.name }}{% if order.customer.email %} · {{ order.customer.email }}{% endif %}</p>{% endif %}
<p><strong>Titel:</strong> {{ order.title }}</p>
{% if order.due_at %}<p><strong>Fällig:</strong> {{ order.due_at }}</p>{% endif %}
<p><strong>Status:</strong> {{ order.status }}</p>
{% if order.notes %}<p>{{ order.notes }}</p>{% endif %}
</div>
<table>
<thead>
<tr><th>Position</th><th class="num">Menge</th><th class="num">geliefert</th><th class="num">€/Stk</th></tr>
</thead>
<tbody>
{% for item in order.items %}
<tr>
<td>{{ item.file_name or item.note or ('Datei #' ~ item.file_id) }}</td>
<td class="num">{{ item.qty }}</td>
<td class="num">{{ item.done_ok }}</td>
<td class="num">{{ '%.2f'|format(item.unit_price) }}</td>
</tr>
{% else %}
<tr><td colspan="4">Keine Positionen.</td></tr>
{% endfor %}
</tbody>
</table>
{% if order.progress %}
<p class="meta" style="margin-top:8mm">Fortschritt gesamt: {{ order.progress.label }}</p>
{% endif %}
</article>
</body>
</html>
+13
View File
@@ -130,6 +130,12 @@ function render(data) {
<td class="num">${p.completed}</td> <td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>'; </tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const reasons = (data.scrap_reasons || []).map((r) => `
<tr>
<td>${escapeHtml(r.reason || "—")}</td>
<td class="num">${r.count}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="2">Kein Ausschuss im Zeitraum.</td></tr>';
const materials = (data.by_material || []).map((m) => ` const materials = (data.by_material || []).map((m) => `
<tr> <tr>
<td>${escapeHtml(m.material_type || "—")}</td> <td>${escapeHtml(m.material_type || "—")}</td>
@@ -156,6 +162,13 @@ function render(data) {
<tbody>${printers}</tbody> <tbody>${printers}</tbody>
</table></div> </table></div>
</div> </div>
<div class="card">
<h2>Ausschuss-Gründe</h2>
<div class="table-wrap"><table>
<thead><tr><th>Grund</th><th>Anzahl</th></tr></thead>
<tbody>${reasons}</tbody>
</table></div>
</div>
<div class="card"> <div class="card">
<h2>Nach Material</h2> <h2>Nach Material</h2>
<div class="table-wrap"><table> <div class="table-wrap"><table>
+5 -3
View File
@@ -1562,19 +1562,21 @@ async function handleWallClick(event) {
return; return;
} }
if (button.dataset.cmd === "abraeumen") { if (button.dataset.cmd === "abraeumen") {
armButton(button, "Platte frei?", async () => { (async () => {
const payload = await promptAbraeumen(host);
if (!payload) return;
button.disabled = true; button.disabled = true;
try { try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", {
method: "POST", method: "POST",
json: { confirm: true }, json: payload,
}); });
toast("Platte quittiert — der nächste Job darf starten."); toast("Platte quittiert — der nächste Job darf starten.");
refresh(); refresh();
if (window.refreshTasks) window.refreshTasks(); if (window.refreshTasks) window.refreshTasks();
} catch (error) { toast(error.message, "bad"); } } catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); })();
return; return;
} }
button.disabled = true; button.disabled = true;
+93
View File
@@ -0,0 +1,93 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Wartung</h1>
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
</div>
</div>
<div id="wartung-root"></div>
<p id="wartung-note"></p>
{% endblock %}
{% block script %}
<script>
function partRow(part) {
const overdue = part.overdue_hours || part.overdue_meters;
return `<tr class="${overdue ? "warn" : ""}">
<td>${escapeHtml(part.label || part.part_key)}</td>
<td class="num">${(part.hours_since || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h</td>
<td class="num">${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm</td>
<td class="num">${part.life_hours ? part.life_hours + " h" : "—"}</td>
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</td>
<td><button type="button" class="ghost sm" data-reset="${part.id}">Zurücksetzen</button></td>
</tr>`;
}
function printerCard(block) {
const c = block.counters || {};
const parts = (block.parts || []).map(partRow).join("")
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
return `<section class="card">
<h2>${escapeHtml(block.name || block.host)}</h2>
<p class="hint">${escapeHtml(block.host)} · ${(c.hours || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h gesamt · ${Math.round(c.meters || 0).toLocaleString("de-DE")} mm · ${c.runs || 0} Läufe</p>
<div class="table-wrap"><table>
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Seit Reset</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
<tbody>${parts}</tbody>
</table></div>
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}" style="margin-top:0.75rem">
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
<div class="field"><label>Intervall mm</label><input name="life_meters" type="number" step="1000" min="0" value="0"></div>
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
<div class="actions"><button type="submit">Teil anlegen</button></div>
</form>
</section>`;
}
async function load() {
const data = await api("/wartung");
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|| '<p class="empty">Keine Drucker angelegt.</p>';
}
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
const form = event.target.closest("form.wartung-add");
if (!form) return;
event.preventDefault();
const fd = new FormData(form);
try {
await api("/wartung/teile", {
method: "POST",
json: {
printer_host: form.dataset.host,
part_key: fd.get("part_key"),
label: fd.get("label"),
life_hours: Number(fd.get("life_hours") || 0),
life_meters: Number(fd.get("life_meters") || 0),
note: fd.get("note"),
},
});
form.reset();
showNote("wartung-note", null, "Teil gespeichert.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
document.getElementById("wartung-root").addEventListener("click", async (event) => {
const btn = event.target.closest("[data-reset]");
if (!btn) return;
try {
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
showNote("wartung-note", null, "Teil zurückgesetzt.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
load();
</script>
{% endblock %}