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:
1 parent
0236278929
commit
8e88efc094
45 files changed
+3121
-122
No files matched your search
+21
-11
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" }]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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", {})
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -12,7 +12,8 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
.replaceAll('"', """);
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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("/")))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user