Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner

Directus
- Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/
  Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup
  (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange)
  und Empfängerliste mit Vorschau von Schriftstück und Kuvert.
- Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche.
- Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang.
- Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert),
  Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard.
- Absender aller Mails ist Skrift (Projektname + email_settings); die
  Angebotsanfrage nennt jetzt das angefragte Produkt.

Preislogik
- Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt
  (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt.
- Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen.
- Preis pro Stück ohne einmalige Kosten (unit_net_total).
- Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt.
- Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm.
- Briefnummern 1-basiert: Dateiname entspricht der Briefnummer.

app.skrift.de
- Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als
  eigene Seite, Kontaktprodukte direkt im Anfrageformular.
- Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator.
- Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet.
- Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar,
  Standardmenge 100; Checkout-Checkboxen im Kartenstil.

Produktion
- skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter
  und automatischer Übergang in die Kuvertierung.

WordPress
- Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt,
  stündlicher Abgleich der Preisdaten in WordPress und ein sicherer
  Formel-Interpreter, damit Formeländerungen automatisch greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-23 13:24:12 +02:00
parent 2dbbc4611a
commit 06c4078290
62 changed files with 11384 additions and 154 deletions

View File

@@ -0,0 +1,44 @@
# Skrift Produktions-Agent
Schlanker lokaler Agent (Windows) neben dem Plotter. Steuerung läuft über
Directus – der Agent macht nur: **Heartbeat → Job holen → drucken → melden**.
## Einrichtung
1. In Directus einen Nutzer der Rolle **Service** anlegen und einen **statischen
Token** erzeugen.
2. Die Maschine in Directus unter **Einstellungen → Maschinen** anlegen
(`base_url`, ggf. `mid`/Login). Die numerische ID der Maschine ist die
`machine_id` für die Agent-Config.
3. `config.example.json` → `config.json` kopieren und ausfüllen
(`directus_url`, `directus_token`, `machine_id`).
4. Abhängigkeiten installieren und starten:
```bat
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python agent.py
```
## Ablauf
- Der Agent meldet zyklisch `machines.status` + `last_seen` (Heartbeat).
- Er zieht den nächsten `jobs`-Eintrag seiner Maschine mit `status=queued`
(sortiert nach `priority`, dann Alter).
- Er lädt die SVGs des Auftrags passend zum Job-Typ (Schriftstück/Umschlag)
über den Directus-Datei-Endpoint (`/skrift-orders/files/...`) und schickt sie
mit den Template-Werten (Maße/Position/Rotation) an den Plotter.
- Status zurück: `printing → printed` bzw. `failed` (+ Fehlermeldung).
- Bei Maschinenfehler (z. B. Papierende) ruft er `recover_write` auf.
## Jobs entstehen
Über den Admin-Trigger `POST /skrift-orders/enqueue { order, machine }`
(bzw. später ein Button in der Webapp). Pro Auftrag entsteht ein Brief-Job und
– falls Kuvert beschriftet – ein Umschlag-Job, jeweils mit aufgelöstem Template.
## Signaturen / AutoRecover
Der Signatur-Service bleibt vorerst unberührt. Das win32-`AutoRecover`
(klickt das Java-Fenster) kann weiter separat laufen; der Agent nutzt zusätzlich
den API-`recover_write` der Maschine.

View File

@@ -0,0 +1,294 @@
"""
Skrift – schlanker Produktions-Agent
====================================
Läuft lokal (Windows) neben dem Plotter. Er macht bewusst nur drei Dinge:
1. Heartbeat – meldet den Maschinenzustand an Directus (machines.status/last_seen)
2. Poll – holt den nächsten Druck-Job seiner Maschine (jobs, status=queued)
3. Drucken – lädt die SVGs vom Directus-Datei-Endpoint, schickt sie an den
Plotter und meldet den Status zurück (printing → printed/failed)
Alles andere (Aufträge, Warteschlange, Templates, Maschinenpflege) steuerst du in
Directus / der Webapp. Der Agent kennt nur seine eine `machine`-ID.
Konfiguration: config.json (siehe config.example.json).
Start: python agent.py
"""
import io
import json
import os
import sys
import time
import tempfile
import datetime as dt
import requests
HIER = os.path.dirname(os.path.abspath(__file__))
def lade_config():
pfad = os.path.join(HIER, "config.json")
if not os.path.exists(pfad):
print("config.json fehlt – bitte config.example.json kopieren und ausfüllen.")
sys.exit(1)
with open(pfad, "r", encoding="utf-8") as f:
return json.load(f)
class Directus:
"""Minimaler Directus-Client (Service-Token)."""
def __init__(self, base_url, token):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.s.headers["Authorization"] = f"Bearer {token}"
def get(self, path, **params):
r = self.s.get(self.base + path, params=params, timeout=30)
r.raise_for_status()
return r.json().get("data")
def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=30)
r.raise_for_status()
return r.json().get("data")
def download(self, path, params):
r = self.s.get(self.base + path, params=params, timeout=120)
r.raise_for_status()
return r.content
class Plotter:
"""Sendet SVGs an die Maschinen-API (Auszug aus der bisherigen App)."""
def __init__(self, machine):
self.base = str(machine.get("base_url", "")).rstrip("/")
self.mid = machine.get("mid") or ""
self.user = machine.get("username") or ""
self.passwd = machine.get("password") or ""
self.s = requests.Session()
def login(self):
if not self.user:
return True
r = self.s.get(f"{self.base}/login", params={"uin": self.user, "passwd": self.passwd}, timeout=10)
return r.json().get("error", -1) == 0
def status(self):
params = {"mids": self.mid} if self.mid else {}
try:
r = self.s.get(f"{self.base}/machine_status", params=params, timeout=10)
data = r.json()
return data[0] if isinstance(data, list) and data else None
except Exception:
return None
def recover(self):
params = {"mid": self.mid} if self.mid else {}
try:
r = self.s.get(f"{self.base}/recover_write", params=params, timeout=10)
return r.json().get("code", -1) == 0
except Exception:
return False
@staticmethod
def _fmt(v):
v = float(v or 0)
return str(int(v)) if v == int(v) else str(v)
def write(self, dateien, tpl):
"""dateien: [(name, bytes)]; tpl: Template-Dict mit width/height/... (Zoll)."""
basis = {
"width": f"{self._fmt(tpl.get('width'))}in",
"height": f"{self._fmt(tpl.get('height'))}in",
"xpos": f"{self._fmt(tpl.get('xpos'))}in",
"ypos": f"{self._fmt(tpl.get('ypos'))}in",
"rotation": self._fmt(tpl.get("rotation")),
"scale": self._fmt(tpl.get("scale") if tpl.get("scale") is not None else 1),
"repeat": "1",
}
if self.mid:
basis["mid"] = self.mid
letzte = len(dateien) - 1
antwort = {}
for i, (name, inhalt) in enumerate(dateien):
data = {**basis, "clear": "1" if i == 0 else "0", "start": "1" if i == letzte else "0"}
files = [("file", (name, io.BytesIO(inhalt), "image/svg+xml"))]
r = self.s.post(f"{self.base}/write_svg", data=data, files=files, timeout=60)
antwort = r.json()
if antwort.get("error", -1) != 0:
raise RuntimeError(f"Maschine meldet Fehler {antwort.get('error')} bei {name}")
return antwort
def jetzt():
return dt.datetime.now(dt.timezone.utc).isoformat()
def parse_nummern(spec):
"""„1-3,5,7-8" → {1,2,3,5,7,8}. Leer/None → None (= alle drucken)."""
if not spec or not str(spec).strip():
return None
treffer = set()
for teil in str(spec).split(","):
teil = teil.strip()
if not teil:
continue
if "-" in teil:
try:
a, b = teil.split("-", 1)
a, b = int(a), int(b)
treffer.update(range(min(a, b), max(a, b) + 1))
except ValueError:
continue
else:
try:
treffer.add(int(teil))
except ValueError:
continue
return treffer or None
def datei_nummer(name):
"""Zahl aus einem Dateinamen ziehen (letter_003.svg → 3)."""
ziffern = "".join(ch for ch in os.path.splitext(os.path.basename(name))[0] if ch.isdigit())
return int(ziffern) if ziffern else None
def main():
cfg = lade_config()
dx = Directus(cfg["directus_url"], cfg["directus_token"])
machine_id = cfg["machine_id"]
poll = int(cfg.get("poll_interval_seconds", 5))
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
while True:
try:
maschine = dx.get(f"/items/machines/{machine_id}",
fields="base_url,mid,username,password,active")
if not maschine or not maschine.get("active", True):
_heartbeat(dx, machine_id, "offline")
time.sleep(poll)
continue
plotter = Plotter(maschine)
mstatus = plotter.status()
# Bei Fehler (z. B. Papierende) automatisch fortsetzen.
if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"):
plotter.recover()
_heartbeat(dx, machine_id, "bereit")
job = _naechster_job(dx, machine_id)
if not job:
time.sleep(poll)
continue
_druck(dx, plotter, job, KIND)
except requests.HTTPError as e:
print(f"[Agent] HTTP-Fehler: {e}")
time.sleep(poll)
except Exception as e:
print(f"[Agent] Fehler: {e}")
time.sleep(poll)
def _heartbeat(dx, machine_id, status):
try:
dx.patch(f"/items/machines/{machine_id}", {"status": status, "last_seen": jetzt()})
except Exception:
pass
def _naechster_job(dx, machine_id):
jobs = dx.get(
"/items/jobs",
**{
"filter[machine][_eq]": machine_id,
"filter[status][_eq]": "queued",
"sort": "-priority,date_created",
"limit": 1,
"fields": "id,type,output_ref,params,numbers,"
"order.id,order.order_number,order.needs_envelope,"
"template.width,template.height,"
"template.xpos,template.ypos,template.scale,template.rotation",
},
)
return jobs[0] if jobs else None
def _druck(dx, plotter, job, KIND):
job_id = job["id"]
order = job.get("order") or {}
order_id = order.get("id")
# Ordner: entweder Auftragsnummer oder ein output_ref (z. B. Testdruck).
order_nr = job.get("output_ref") or order.get("order_number")
tpl = dict(job.get("template") or {})
# Pro-Druck-Übersteuerung der Maße (aus dem Druck-Popup) hat Vorrang.
params = job.get("params")
if isinstance(params, dict):
for k in ("width", "height", "xpos", "ypos", "scale", "rotation"):
if params.get(k) is not None:
tpl[k] = params[k]
nummern = parse_nummern(job.get("numbers"))
typ = job.get("type") or "brief"
print(f"[Agent] Job {job_id}: {typ} für {order_nr}")
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
if order_id:
try: dx.patch(f"/items/orders/{order_id}", {"production_status": "im_druck"})
except Exception: pass
try:
if not order_nr:
raise RuntimeError("Auftragsnummer fehlt am Job.")
liste = dx.get(f"/skrift-orders/files/{order_nr}") or {}
gewuenscht = KIND.get(typ, "schriftstueck")
passende = sorted(
[f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht],
key=lambda f: f.get("name", ""),
)
# Nur bestimmte Briefnummern drucken, falls im Job angegeben.
if nummern is not None:
passende = [f for f in passende if datei_nummer(f.get("name")) in nummern]
if not passende:
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")
dateien = []
for f in passende:
inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]})
dateien.append((f["name"], inhalt))
plotter.login()
plotter.write(dateien, tpl)
dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
# Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
# bei Vollständigkeit automatisch in die Kuvertierung schieben.
if order_id:
feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt"
try:
dx.patch(f"/items/orders/{order_id}", {feld: True})
stand = dx.get(f"/items/orders/{order_id}",
fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {}
braucht_kuvert = bool(stand.get("needs_envelope"))
fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert)
if fertig and stand.get("production_status") == "im_druck":
dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"})
print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.")
except Exception:
pass
print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).")
except Exception as e:
dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]})
print(f"[Agent] Job {job_id} fehlgeschlagen: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,6 @@
{
"directus_url": "https://admin.skrift.de",
"directus_token": "SERVICE-TOKEN-HIER-EINTRAGEN",
"machine_id": 1,
"poll_interval_seconds": 5
}

View File

@@ -0,0 +1 @@
requests>=2.31