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
@@ -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()
|
||||
|
||||
|
||||
def _tariff(raw):
|
||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||
if not isinstance(tariff, dict) or not tariff:
|
||||
return None
|
||||
try:
|
||||
ht = float(tariff.get("ht_euro"))
|
||||
nt = float(tariff.get("nt_euro"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"ht_euro": ht,
|
||||
"nt_euro": nt,
|
||||
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
|
||||
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _energy(raw):
|
||||
energy = raw.get("energy") or {}
|
||||
return {
|
||||
out = {
|
||||
"watts": float(energy.get("watts", 40)),
|
||||
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||
@@ -26,6 +43,20 @@ def _energy(raw):
|
||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||
"utilization": float(energy.get("utilization", 50)),
|
||||
}
|
||||
tariff = _tariff(energy)
|
||||
if tariff:
|
||||
out["tariff"] = tariff
|
||||
return out
|
||||
|
||||
|
||||
def _ntfy(raw):
|
||||
ntfy = raw.get("ntfy") or {}
|
||||
return {
|
||||
"enabled": bool(ntfy.get("enabled")),
|
||||
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
|
||||
"topic": str(ntfy.get("topic") or ""),
|
||||
"token": str(ntfy.get("token") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _telegram(raw):
|
||||
@@ -121,6 +152,7 @@ def load():
|
||||
"printers_file": ROOT / "drucker.json",
|
||||
"energy": _energy(raw),
|
||||
"telegram": _telegram(raw),
|
||||
"ntfy": _ntfy(raw),
|
||||
"alerts": _alerts(raw),
|
||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||
"_raw": raw,
|
||||
@@ -187,6 +219,13 @@ def settings_public(cfg):
|
||||
"bot_token_set": bool(tg.get("bot_token")),
|
||||
"chat_id": str(tg.get("chat_id") or ""),
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
|
||||
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
|
||||
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
|
||||
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
|
||||
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
|
||||
},
|
||||
"alerts": dict(cfg.get("alerts") or {}),
|
||||
"oauth": {
|
||||
"enabled": bool(oauth.get("enabled")),
|
||||
@@ -197,13 +236,23 @@ def settings_public(cfg):
|
||||
}
|
||||
|
||||
|
||||
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
|
||||
def save_settings(
|
||||
cfg,
|
||||
energy=None,
|
||||
telegram=None,
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
|
||||
with _SETTINGS_LOCK:
|
||||
return _save_settings_locked(
|
||||
cfg,
|
||||
energy=energy,
|
||||
telegram=telegram,
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
fqdn=fqdn,
|
||||
@@ -211,7 +260,16 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
|
||||
)
|
||||
|
||||
|
||||
def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None, watch_dir=None):
|
||||
def _save_settings_locked(
|
||||
cfg,
|
||||
energy=None,
|
||||
telegram=None,
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
path = cfg.get("_path") or CONFIG_PATH
|
||||
raw = dict(cfg.get("_raw") or {})
|
||||
if path.exists():
|
||||
@@ -231,7 +289,7 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que
|
||||
raw["fqdn"] = normalized
|
||||
cfg["fqdn"] = normalized
|
||||
if energy is not None:
|
||||
raw["energy"] = {
|
||||
raw_energy = {
|
||||
"watts": float(energy.get("watts", 40)),
|
||||
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
|
||||
"bed_watts": float(energy.get("bed_watts", 220)),
|
||||
@@ -241,6 +299,15 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que
|
||||
"waste_percent": float(energy.get("waste_percent", 0)),
|
||||
"utilization": float(energy.get("utilization", 50)),
|
||||
}
|
||||
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
|
||||
if tariff_in is not None:
|
||||
raw_energy["tariff"] = {
|
||||
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
|
||||
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
|
||||
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
|
||||
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
|
||||
}
|
||||
raw["energy"] = raw_energy
|
||||
cfg["energy"] = _energy(raw)
|
||||
if telegram is not None:
|
||||
current = _telegram(raw)
|
||||
@@ -253,6 +320,18 @@ def _save_settings_locked(cfg, energy=None, telegram=None, alerts=None, auto_que
|
||||
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
|
||||
}
|
||||
cfg["telegram"] = _telegram(raw)
|
||||
if ntfy is not None:
|
||||
current = _ntfy(raw)
|
||||
token = ntfy.get("token")
|
||||
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
|
||||
token = current["token"]
|
||||
raw["ntfy"] = {
|
||||
"enabled": bool(ntfy.get("enabled", current["enabled"])),
|
||||
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
|
||||
"topic": str(ntfy.get("topic", current["topic"]) or ""),
|
||||
"token": str(token or ""),
|
||||
}
|
||||
cfg["ntfy"] = _ntfy(raw)
|
||||
if alerts is not None:
|
||||
raw["alerts"] = {
|
||||
"fertig": bool(alerts.get("fertig", True)),
|
||||
|
||||
+92
-5
@@ -1,6 +1,8 @@
|
||||
"""Dieselbe Rechnung wie in der Druckkalkulation."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from grotti.time_window import _in_segment, _parse_hm
|
||||
|
||||
|
||||
def calculate(job, material):
|
||||
@@ -92,13 +94,49 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
return total
|
||||
|
||||
|
||||
def energy_estimate(duration_s, energy, samples=None):
|
||||
def _is_nt_moment(moment, tariff):
|
||||
start_t = _parse_hm(tariff.get("nt_from"))
|
||||
end_t = _parse_hm(tariff.get("nt_to"))
|
||||
if start_t is None or end_t is None:
|
||||
return False
|
||||
now_t = moment.timetz().replace(tzinfo=None)
|
||||
return _in_segment(now_t, start_t, end_t)
|
||||
|
||||
|
||||
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||||
energy = energy or {}
|
||||
tariff = energy.get("tariff")
|
||||
if not tariff or not duration_s or float(duration_s) <= 0:
|
||||
return float(energy.get("euro_per_kwh") or 0)
|
||||
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
start = end - timedelta(seconds=float(duration_s))
|
||||
step = 300.0
|
||||
t = start
|
||||
nt_s = 0.0
|
||||
total = 0.0
|
||||
while t < end:
|
||||
chunk = min(step, (end - t).total_seconds())
|
||||
if _is_nt_moment(t, tariff):
|
||||
nt_s += chunk
|
||||
total += chunk
|
||||
t += timedelta(seconds=chunk)
|
||||
if total <= 0:
|
||||
return float(energy.get("euro_per_kwh") or 0)
|
||||
nt_frac = nt_s / total
|
||||
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
|
||||
nt = float(tariff.get("nt_euro") or ht)
|
||||
return nt_frac * nt + (1.0 - nt_frac) * ht
|
||||
|
||||
|
||||
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||||
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||||
|
||||
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
|
||||
"""
|
||||
energy = energy or {}
|
||||
euro = float(energy.get("euro_per_kwh") or 0)
|
||||
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
|
||||
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
|
||||
base_w = float(energy.get("watts") or 0)
|
||||
nozzle_w = float(energy.get("nozzle_watts") or 0)
|
||||
@@ -126,5 +164,54 @@ def energy_estimate(duration_s, energy, samples=None):
|
||||
}
|
||||
|
||||
|
||||
def energy_cost(duration_s, energy, samples=None):
|
||||
return energy_estimate(duration_s, energy, samples)["cost"]
|
||||
def energy_cost(duration_s, energy, samples=None, end_dt=None):
|
||||
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
|
||||
|
||||
|
||||
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
|
||||
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
|
||||
weight_g = float((file_row or {}).get("weight_g") or 0)
|
||||
est_s = float((file_row or {}).get("estimated_s") or 0)
|
||||
hours = est_s / 3600.0 if est_s > 0 else 0.0
|
||||
mat = float(material_price or 0)
|
||||
rate = float(machine_rate_per_h or 0)
|
||||
setup_h = float(setup_min or 0) / 60.0
|
||||
material_cost = (weight_g / 1000.0) * mat
|
||||
machine_cost = hours * rate + setup_h * rate
|
||||
subtotal = material_cost + machine_cost
|
||||
pct = float(margin or 0)
|
||||
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
|
||||
return {
|
||||
"weight_g": weight_g,
|
||||
"hours": hours,
|
||||
"material_cost": material_cost,
|
||||
"machine_cost": machine_cost,
|
||||
"subtotal": subtotal,
|
||||
"margin_percent": pct,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def order_actuals(conn, order_id):
|
||||
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
|
||||
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||||
FROM order_items oi
|
||||
JOIN runs r ON r.order_item_id = oi.id
|
||||
WHERE oi.order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"cost_total": float(row["cost_total"] or 0),
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
"scrap_g": float(row["scrap_g"] or 0),
|
||||
"scrap_runs": int(row["scrap_runs"] or 0),
|
||||
}
|
||||
@@ -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."""
|
||||
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def auftraege_lesen(limit: int = 30, status: str = "") -> str:
|
||||
"""Aufträge (optional nach Status filtern)."""
|
||||
st = (status or "").strip().lower()
|
||||
q = "?status=" + urllib.parse.quote(st) if st else ""
|
||||
rows = _api("GET", "/auftraege" + q) or []
|
||||
cap = max(1, min(limit, 200))
|
||||
if len(rows) > cap:
|
||||
rows = rows[:cap]
|
||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def bedarf_lesen() -> str:
|
||||
"""Filament-Bedarf (Queue vs. Spulenrest)."""
|
||||
return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def wartung_lesen() -> str:
|
||||
"""Wartungsteile und Zählerstände."""
|
||||
return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_version() -> str:
|
||||
"""Grotti-Version."""
|
||||
|
||||
@@ -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
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -43,11 +44,52 @@ def alert_enabled(cfg, kind):
|
||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||
|
||||
|
||||
def ntfy_configured(cfg):
|
||||
ntfy = (cfg or {}).get("ntfy") or {}
|
||||
return bool(ntfy.get("enabled") and ntfy.get("topic"))
|
||||
|
||||
|
||||
def send_ntfy(cfg, text):
|
||||
if not ntfy_configured(cfg):
|
||||
return False
|
||||
ntfy = cfg["ntfy"]
|
||||
server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/")
|
||||
topic = str(ntfy.get("topic") or "").strip()
|
||||
if not topic:
|
||||
return False
|
||||
url = "%s/%s" % (server, urllib.parse.quote(topic, safe=""))
|
||||
headers = {"Title": "Grotti", "Priority": "default"}
|
||||
token = str(ntfy.get("token") or "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer %s" % token
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=text[:4000].encode("utf-8"),
|
||||
method="POST",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
return 200 <= response.status < 300
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _push_alerts(cfg, message):
|
||||
send_telegram(cfg, message)
|
||||
send_ntfy(cfg, message)
|
||||
|
||||
|
||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
||||
"""Ereignis immer speichern; Telegram/ntfy nur einmal pro Art und Drucker im Zeitfenster."""
|
||||
from grotti import store
|
||||
|
||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||
if not recent and alert_enabled(cfg, kind):
|
||||
send_telegram(cfg, message)
|
||||
threading.Thread(
|
||||
target=_push_alerts,
|
||||
args=(cfg, message),
|
||||
name="grotti-notify",
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -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.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti import time_window
|
||||
|
||||
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||
from grotti.events_bus import publish as publish_live
|
||||
|
||||
|
||||
def _notify_queue():
|
||||
publish_live("queue", {})
|
||||
|
||||
FREE = {"standby", "complete", "cancelled"}
|
||||
BUSY = {"printing", "paused", "heating", "error"}
|
||||
@@ -169,6 +177,7 @@ class PrintQueueDispatcher:
|
||||
except Exception as exc:
|
||||
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
||||
finally:
|
||||
_notify_queue()
|
||||
with self._worker_lock:
|
||||
self._workers.pop(item_id, None)
|
||||
|
||||
@@ -182,6 +191,12 @@ class PrintQueueDispatcher:
|
||||
return True
|
||||
|
||||
def tick(self):
|
||||
try:
|
||||
self._tick_once()
|
||||
finally:
|
||||
_notify_queue()
|
||||
|
||||
def _tick_once(self):
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
@@ -245,6 +260,20 @@ class PrintQueueDispatcher:
|
||||
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
|
||||
if not waiting:
|
||||
continue
|
||||
sw = printer.get("start_window") or {}
|
||||
if sw and not time_window.allowed_now(sw):
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
est_s = 0
|
||||
if waiting.get("file_id"):
|
||||
file_row = store.get_file_row(conn, waiting["file_id"])
|
||||
if file_row:
|
||||
est_s = float(file_row.get("estimated_s") or 0)
|
||||
if sw and not time_window.job_fits_overnight(sw, est_s):
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
||||
if not claimed:
|
||||
continue
|
||||
@@ -303,6 +332,11 @@ class PrintQueueDispatcher:
|
||||
bed_leveling=bed_leveling,
|
||||
)
|
||||
name = (result or {}).get("filename") or ""
|
||||
oid = item.get("order_item_id")
|
||||
if oid:
|
||||
from grotti import orders
|
||||
|
||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
|
||||
@@ -306,3 +306,67 @@ CREATE TABLE IF NOT EXISTS run_labels (
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
label text NOT NULL DEFAULT '',
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
life_hours double precision NOT NULL DEFAULT 0,
|
||||
life_meters double precision NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
printer_host text NOT NULL,
|
||||
part_key text NOT NULL,
|
||||
event text NOT NULL DEFAULT '',
|
||||
hours_at double precision NOT NULL DEFAULT 0,
|
||||
meters_at double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL DEFAULT '',
|
||||
notes text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
due_at date,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
|
||||
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
|
||||
qty int NOT NULL DEFAULT 1,
|
||||
unit_price double precision NOT NULL DEFAULT 0,
|
||||
material_type text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
done_ok int NOT NULL DEFAULT 0,
|
||||
done_bad int NOT NULL DEFAULT 0,
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
|
||||
@@ -11,6 +11,7 @@ from queue import Empty, Queue
|
||||
from grotti import jobs
|
||||
from grotti.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti.events_bus import publish as publish_live
|
||||
|
||||
|
||||
class SendQueue:
|
||||
@@ -161,6 +162,7 @@ class SendQueue:
|
||||
return
|
||||
job.update(fields)
|
||||
job["updated_at"] = time.time()
|
||||
publish_live("senden", {})
|
||||
|
||||
def _worker(self):
|
||||
while not self._stop.is_set():
|
||||
|
||||
+84
-10
@@ -9,8 +9,6 @@ from psycopg.types.json import Json
|
||||
|
||||
from grotti.catalog import MACHINE_LABELS, MATERIALS
|
||||
from grotti.printers import FAMILY_LABELS, family_from_name
|
||||
|
||||
|
||||
def iso(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
@@ -170,6 +168,10 @@ def run_out(row):
|
||||
"deducted": row["deducted"],
|
||||
"needs_spool": row["needs_spool"],
|
||||
"verdict": row["verdict"],
|
||||
"verdict_reason": row.get("verdict_reason") or "",
|
||||
"pieces_ok": int(row.get("pieces_ok") or 0),
|
||||
"pieces_bad": int(row.get("pieces_bad") or 0),
|
||||
"order_item_id": row.get("order_item_id"),
|
||||
"material_type": row["material_type"],
|
||||
"chargen_nr": row.get("chargen_nr") or "",
|
||||
"message": row.get("message") or "",
|
||||
@@ -675,6 +677,8 @@ def upsert_run(conn, item):
|
||||
**item,
|
||||
}
|
||||
payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else [])
|
||||
if "order_item_id" not in payload:
|
||||
payload["order_item_id"] = item.get("order_item_id")
|
||||
if "weight_g" not in payload or payload.get("weight_g") is None:
|
||||
payload["weight_g"] = payload["weight_planned_g"]
|
||||
return conn.execute(
|
||||
@@ -682,14 +686,14 @@ def upsert_run(conn, item):
|
||||
INSERT INTO runs (
|
||||
printer_host, printer_name, filename, file_id, project_id, spool_id,
|
||||
state, started_at, ended_at, duration_s, weight_g, energy_cost,
|
||||
moonraker_job_id, needs_spool, material_type,
|
||||
moonraker_job_id, needs_spool, material_type, order_item_id,
|
||||
weight_planned_g, weight_used_g, weight_source,
|
||||
filament_used_mm, filament_total_mm, progress_last, estimated_s,
|
||||
message, objects
|
||||
) VALUES (
|
||||
%(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s,
|
||||
%(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s,
|
||||
%(moonraker_job_id)s, %(needs_spool)s, %(material_type)s,
|
||||
%(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, %(order_item_id)s,
|
||||
%(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s,
|
||||
%(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s,
|
||||
%(message)s, %(objects)s
|
||||
@@ -700,6 +704,7 @@ def upsert_run(conn, item):
|
||||
file_id = COALESCE(runs.file_id, EXCLUDED.file_id),
|
||||
project_id = COALESCE(runs.project_id, EXCLUDED.project_id),
|
||||
spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id),
|
||||
order_item_id = COALESCE(runs.order_item_id, EXCLUDED.order_item_id),
|
||||
state = EXCLUDED.state,
|
||||
started_at = COALESCE(runs.started_at, EXCLUDED.started_at),
|
||||
ended_at = EXCLUDED.ended_at,
|
||||
@@ -887,14 +892,55 @@ def get_run_by_chargen(conn, code):
|
||||
return get_run_row(conn, label["run_id"])
|
||||
|
||||
|
||||
def set_verdict(conn, run_id, verdict):
|
||||
row = conn.execute(
|
||||
"UPDATE runs SET verdict = %s WHERE id = %s RETURNING *",
|
||||
(verdict, run_id),
|
||||
def set_verdict(conn, run_id, verdict, verdict_reason="", pieces_ok=0, pieces_bad=0):
|
||||
from grotti import orders
|
||||
|
||||
before = conn.execute(
|
||||
"SELECT order_item_id, pieces_ok, pieces_bad FROM runs WHERE id = %s",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE runs
|
||||
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(verdict, verdict_reason or "", int(pieces_ok or 0), int(pieces_bad or 0), run_id),
|
||||
).fetchone()
|
||||
if row and before and before.get("order_item_id"):
|
||||
orders.apply_verdict_delta(
|
||||
conn,
|
||||
before["order_item_id"],
|
||||
before.get("pieces_ok"),
|
||||
before.get("pieces_bad"),
|
||||
pieces_ok,
|
||||
pieces_bad,
|
||||
)
|
||||
return run_out(row) if row else None
|
||||
|
||||
|
||||
def run_for_abraeumen_verdict(conn, host):
|
||||
"""Letzter abgeschlossener oder noch laufender Lauf des Hosts."""
|
||||
host = (host or "").strip()
|
||||
if not host:
|
||||
return None
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT * FROM runs
|
||||
WHERE printer_host = %s
|
||||
AND state IN ('completed', 'in_progress', 'printing', 'paused')
|
||||
ORDER BY
|
||||
CASE WHEN state IN ('in_progress', 'printing', 'paused') THEN 0 ELSE 1 END,
|
||||
ended_at DESC NULLS LAST,
|
||||
started_at DESC NULLS LAST,
|
||||
id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(host,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def assign_run_spool(conn, run_id, spool_id):
|
||||
return conn.execute(
|
||||
"""
|
||||
@@ -1843,6 +1889,18 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
run_values,
|
||||
).fetchone()
|
||||
|
||||
scrap_reasons = conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(NULLIF(r.verdict_reason, ''), 'sonstiges') AS reason,
|
||||
COUNT(*) AS n
|
||||
FROM runs r
|
||||
WHERE %s AND r.verdict = 'ausschuss'
|
||||
GROUP BY 1
|
||||
ORDER BY n DESC, reason
|
||||
""" % run_clause,
|
||||
run_values,
|
||||
).fetchall()
|
||||
|
||||
ov = overview or {}
|
||||
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
|
||||
if spool_join:
|
||||
@@ -1913,6 +1971,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
],
|
||||
"estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None,
|
||||
"estimate_count": int((estimate or {}).get("counted") or 0),
|
||||
"scrap_reasons": [
|
||||
{"reason": row["reason"] or "sonstiges", "count": int(row["n"] or 0)}
|
||||
for row in scrap_reasons
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1973,6 +2035,14 @@ def delete_idea(conn, idea_id):
|
||||
|
||||
def printer_out(row):
|
||||
family = row["family"] or ""
|
||||
sw = row.get("start_window") or {}
|
||||
if isinstance(sw, str):
|
||||
try:
|
||||
sw = json.loads(sw)
|
||||
except json.JSONDecodeError:
|
||||
sw = {}
|
||||
if not isinstance(sw, dict):
|
||||
sw = {}
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
@@ -1980,6 +2050,7 @@ def printer_out(row):
|
||||
"family": family,
|
||||
"family_label": FAMILY_LABELS.get(family, ""),
|
||||
"locked": bool(row.get("locked")),
|
||||
"start_window": sw,
|
||||
}
|
||||
|
||||
|
||||
@@ -2243,6 +2314,7 @@ def print_queue_out(row, file_name=None):
|
||||
"phase": row.get("phase") or "",
|
||||
"phase_started_at": iso(row.get("phase_started_at")),
|
||||
"is_ablauf": bool(steps),
|
||||
"order_item_id": row.get("order_item_id"),
|
||||
"created_at": iso(row["created_at"]),
|
||||
"updated_at": iso(row["updated_at"]),
|
||||
}
|
||||
@@ -2260,6 +2332,7 @@ def enqueue_print(
|
||||
override,
|
||||
steps=None,
|
||||
repeat_total=1,
|
||||
order_item_id=None,
|
||||
):
|
||||
steps = steps if isinstance(steps, list) else []
|
||||
try:
|
||||
@@ -2271,11 +2344,11 @@ def enqueue_print(
|
||||
INSERT INTO print_queue (
|
||||
file_id, host, family, priority, spool_id, ace_map,
|
||||
bed_leveling, override_machine, status, message,
|
||||
steps, step_index, repeat_total, repeat_done, phase
|
||||
steps, step_index, repeat_total, repeat_done, phase, order_item_id
|
||||
)
|
||||
VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s,
|
||||
%s, 0, %s, 0, ''
|
||||
%s, 0, %s, 0, '', %s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
@@ -2291,6 +2364,7 @@ def enqueue_print(
|
||||
"Ablauf wartet…" if steps else "Wartet auf freien Drucker…",
|
||||
Json(steps),
|
||||
repeats,
|
||||
order_item_id,
|
||||
),
|
||||
).fetchone()
|
||||
file_row = get_file_row(conn, file_id)
|
||||
|
||||
@@ -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__ = "2.10.0"
|
||||
__version__ = "3.0.0"
|
||||
+16
-11
@@ -7,6 +7,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import moonraker, notify, store, usage
|
||||
from grotti.events_bus import publish as publish_live
|
||||
from grotti.db import connect
|
||||
|
||||
SAMPLE_EVERY_S = 30
|
||||
@@ -62,18 +63,21 @@ class StateWatcher:
|
||||
self._stop.wait(self.interval)
|
||||
|
||||
def tick(self):
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
try:
|
||||
with connect(self.database_url) as conn:
|
||||
printers = store.list_printers(conn)
|
||||
if not printers:
|
||||
conn.commit()
|
||||
return
|
||||
lives = moonraker.snapshot(printers)
|
||||
with self._lock:
|
||||
self.latest = lives
|
||||
self._updated_at = time.monotonic()
|
||||
for live in lives:
|
||||
self._one(conn, live)
|
||||
conn.commit()
|
||||
return
|
||||
lives = moonraker.snapshot(printers)
|
||||
with self._lock:
|
||||
self.latest = lives
|
||||
self._updated_at = time.monotonic()
|
||||
for live in lives:
|
||||
self._one(conn, live)
|
||||
conn.commit()
|
||||
finally:
|
||||
publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
|
||||
|
||||
def _one(self, conn, live):
|
||||
host = live.get("host") or ""
|
||||
@@ -394,3 +398,4 @@ class StateWatcher:
|
||||
conn, self.cfg, host, printer, kind, message, filename,
|
||||
dedupe_s=60, run_id=run_id,
|
||||
)
|
||||
publish_live("ereignis", {})
|
||||
Reference in new issue
Block a user