Aktionen
Was ist eine Aktion
Eine Aktion ist ein benannter, serverseitig hinterlegter Ablauf aus Kommando-Schritten. Ein Widget einer Ansicht, das Terminal, ein Trigger oder der Assistent ruft sie über ihren Namen auf. Der Aufrufer kennt nur den Namen und die erwarteten Eingaben, nicht die Schritte dahinter.
Die Schritte laufen genau in der Reihenfolge ihrer Definition; ein externes Kommando bleibt dabei an seiner Position zwischen den anderen Kommandos. Alle Datenbankänderungen einer Aktion laufen in einer einzigen Transaktion. Scheitert ein Schritt, wird diese Transaktion zurückgerollt. Ein einzelnes Kommando bucht genau eine Sache; eine Aktion fasst mehrere Kommandos zu einem Vorgang zusammen, den die Datenbank als Ganzes annimmt oder verwirft.
Ein Fehler rollt zuerst die Datenbankänderungen zurück. Danach laufen die Gegen-Schritte bereits ausgeführter externer Aufrufe in umgekehrter Reihenfolge. Diese Kompensationen werden bestmöglich versucht; ihr Fehlschlag ändert nichts daran, dass die ursprüngliche Aktion fehlgeschlagen ist.
flowchart TD
Call["Aktion aufrufen"] --> Begin["Transaktion beginnen"]
Begin --> Next["Nächstes Kommando der Definition"]
Next --> Run["Kommando ausführen, intern oder extern"]
Run --> Success{"Kommando erfolgreich?"}
Success -->|"Ja"| More{"Weitere Kommandos?"}
More -->|"Ja"| Next
More -->|"Nein"| Commit["Transaktion festschreiben"]
Success -->|"Nein"| Rollback["Datenbank zurückrollen"]
Rollback --> Compensate["Kompensationen rückwärts bestmöglich ausführen"]
Compensate --> Error["Fehler zurückgeben"]
Eine Aktion deklariert typisierte Parameter. Jeder Parameter stammt entweder vom Bediener, der ihn eingibt oder scannt, oder aus dem Kontext. Die Station liefert Kontextparameter automatisch mit, zum Beispiel die aktuelle Maschine oder den angemeldeten Benutzer. Kontextparameter erscheinen nicht als Eingabefeld und lassen sich deshalb am Formular nicht verändern.
min_role legt fest, wer eine Aktion aufrufen darf. Ein Admin kann eine Aktion mit min_role: operator anlegen, damit jede Station sie auslösen darf, oder mit min_role: planner, damit nur Planer und Admins Zugriff haben.
Wann einsetzen
- Mehrschrittige Buchungen, die gemeinsam gelingen müssen. Rüsten und Statuswechsel gehören zusammen: Scheitert der zweite Schritt, darf auch der erste nicht bestehen bleiben. Die Transaktion stellt dieses Verhalten sicher. Der Bediener muss weder zwei getrennte Vorgänge auslösen noch deren gemeinsamen Erfolg selbst prüfen.
- Geführte Abläufe. Ein Schritt soll nur laufen, wenn eine Bedingung zutrifft, etwa eine Buchung nur nach bestandener Prüfung. Dafür gibt es
when-Bedingungen. - Integrationen innerhalb des Ablaufs. REST-, SOAP- oder OPC-UA-Aufrufe an Zielsysteme, mit Kompensation für den Fall, dass ein späterer Schritt scheitert. So lässt sich ein externer Zustand ausgleichen, wenn die zugehörige Datenbankbuchung zurückgerollt wird.
Anatomie der Definition
Eine Aktion ist ein flaches JSON-Objekt:
name: stabiler, kurzer Bezeichner. Ansichten, Trigger und der Assistent referenzieren die Aktion ausschließlich über diesen Namen.summary: eine menschenlesbare Zeile, die die Aktion beschreibt; sie erscheint in Auswahllisten und in der Aktionsverwaltung.min_role: die Mindestrolle für den Aufruf:operator,planneroderadmin. Wer diese Rolle nicht hat, kann die Aktion weder über eine Ansicht noch über den Assistenten auslösen.params: die Liste typisierter Eingaben. Jeder Param hat einekind(text,scan,int,number,select,bool), einlabelfür die Anzeige, einesource(contextoderoperator) und einrequired-Flag. Einselect-Param kann seine Auswahlliste fest (options) oder aus einem verwalteten Katalog (options_source, zum Beispiel Grund-Codes) beziehen.steps: die Liste der Kommando-Schritte, in Reihenfolge ausgeführt. Jeder Schritt nennt eincommandund einebind-Zuordnung, die jede Pflichteingabe dieses Kommandos versorgt: mit{"param": "…"}(Wert aus einem Aktions-Param),{"literal": …}(fester Wert),{"step": {"step": …, "field": "…"}}(Ausgabefeld eines früheren Schritts; der referenzierte Schritt-Index muss kleiner sein als der eigene) oder, innerhalb einer Schleife,{"row": "…"}(Spalte der aktuellen Zeile). Ein Param, der in keinem Schritt gebunden wird, bleibt ungenutzt. Ein Kommandofeld ohne Bindung lässt der Autor der Aktion bewusst offen, damit das Kommando selbst einen Wert ableitet (etwa die aktive Kiste an einer Maschine).${...}-Platzhalter inliteral-Werten: Einliteral-Wert darf Platzhalter wie${charge}(ein Param) oder, innerhalb einerfor_each-Schleife,${row.id}(eine Spalte der aktuellen Zeile) enthalten, etwa"/api/storage/${charge}/consume". Diese Expansion greift ausschließlich beiliteral-Werten, die der Autor der Aktion selbst schreibt. Werte, die überparamoderstepgebunden sind, sind Daten und werden niemals expandiert.when: ein optionaler Bedingungsbaum (and/or/not/cmp) vor einem Schritt. Eincmp-Blatt vergleicht einen Wert (paramoderstep) mit einem festen Wert übereq,ne,gt,gte,ltoderlte. Trifft die Bedingung nicht zu, scheitert die Aktion. Innerhalb einerfor_each-Schleife überspringt sie stattdessen nur diese eine Zeile.for_each: verwandelt einen Schritt in eine Schleife über die Zeilen (rows) eines früherenquery.run-Schritts; das Kommando läuft einmal pro Zeile.compensate: ein Gegen-Schritt (command+bind, genau wie ein normaler Schritt), der einen unumkehrbaren Schritt rückgängig macht, falls ein späterer Schritt in derselben Aktion scheitert.no_compensation: ein Satz, der begründet, warum es zu einem unumkehrbaren letzten Schritt keine Rücknahme gibt. Nur dort erlaubt, und nie zusammen mitcompensate. Wo das Zielsystem eine Rücknahme anbietet, gehört sie in eincompensate.
Zur Orientierung eine minimale, handgeschriebene Aktion mit einem einzigen Schritt (kein lauffähiges Beispiel, nur die Form):
{
"name": "beispiel-minimal",
"summary": "Ein Schritt, ein Kommando.",
"min_role": "operator",
"params": [
{"name": "lot", "kind": "scan", "label": "Los", "source": "operator", "required": true}
],
"steps": [
{
"command": "lot.complete",
"bind": {"lot_id": {"param": "lot"}}
}
]
}
Beispiele
Die ersten beiden Beispiele stammen unverändert aus dem Seed-Profil und zeigen den vollständigen Ablauf, wie er auch in der Demo läuft. Das dritte Beispiel ist eine Aktion zum Nachbauen.
{
"name": "fertigmeldung",
"summary": "Fertiges Los an das ERP melden.",
"min_role": "operator",
"params": [
{
"name": "lot",
"kind": "text",
"label": "Los",
"source": "operator",
"required": true
},
{
"name": "order",
"kind": "text",
"label": "Auftrag",
"source": "operator",
"required": true
},
{
"name": "qty",
"kind": "int",
"label": "Menge",
"source": "operator",
"required": true
}
],
"steps": [
{
"command": "rest.call",
"bind": {
"body": {
"literal": "{\"lot\":\"${lot}\",\"order\":\"${order}\",\"qty\":${qty}}"
},
"dest": {
"literal": "erp"
},
"method": {
"literal": "POST"
},
"path": {
"literal": "/api/finished"
}
},
"no_compensation": "the ERP offers no un-report endpoint for finished goods; a single-step action has no composite invariant to restore"
}
]
}Die Aktion hat einen einzigen Schritt: einen rest.call an das Zielsystem "erp", der das fertige Los per POST /api/finished meldet. Alle drei Params (Los, Auftrag, Menge) kommen vom Bediener (source: operator). Einen Kontext-Param gibt es nicht, weil die Meldung an keine bestimmte Maschine gebunden ist. Der rest.call ist der letzte Schritt, deshalb trägt er statt eines compensate ein no_compensation: Das ERP kennt keinen Endpunkt, der eine Fertigmeldung zurücknimmt. Der Satz hält fest, warum hier nichts rückgängig gemacht werden kann.
{
"name": "maschine-beladen",
"summary": "Charge in die Maschine laden (Material direkt aus dem Lager).",
"min_role": "operator",
"params": [
{
"name": "machine",
"kind": "text",
"label": "Maschine",
"source": "context",
"required": true
},
{
"name": "order",
"kind": "scan",
"label": "Auftrag",
"source": "operator",
"required": true
},
{
"name": "charge",
"kind": "scan",
"label": "Charge",
"source": "operator",
"required": true
},
{
"name": "qty_kg",
"kind": "int",
"label": "Menge (kg)",
"source": "operator",
"required": true
}
],
"steps": [
{
"command": "rest.call",
"bind": {
"body": {
"literal": "{\"qty_kg\":${qty_kg},\"order\":\"${order}\",\"machine\":\"${machine}\"}"
},
"dest": {
"literal": "erp"
},
"extract": {
"literal": "{\"unit\":\"$.unit\"}"
},
"method": {
"literal": "POST"
},
"path": {
"literal": "/api/storage/${charge}/consume"
}
},
"compensate": {
"command": "rest.call",
"bind": {
"body": {
"literal": "{\"qty_kg\":${qty_kg}}"
},
"dest": {
"literal": "erp"
},
"method": {
"literal": "POST"
},
"path": {
"literal": "/api/storage/${charge}/unconsume"
}
}
}
},
{
"command": "lot.create",
"bind": {
"charge": {
"param": "charge"
},
"order": {
"param": "order"
},
"qty": {
"param": "qty_kg"
},
"uom": {
"step": {
"step": 0,
"field": "unit"
}
}
}
},
{
"command": "lot.scan-in",
"bind": {
"lot_id": {
"step": {
"step": 1,
"field": "id"
}
},
"machine": {
"param": "machine"
}
}
}
]
}Drei Schritte, die zusammengehören. Zuerst bucht ein rest.call den Materialverbrauch beim Zielsystem "erp" ab, also die Entnahme der Menge aus dem Lager. Dieser Schritt ist nicht der letzte und verändert ein externes System, deshalb trägt er ein compensate, das die Entnahme im Fehlerfall wieder rückbucht. Danach legt lot.create das neue Los an (die Mengeneinheit kommt als step-Bindung aus dem ersten Schritt zurück), und lot.scan-in aktiviert es an der Maschine. Die Maschine ist ein Kontext-Param (source: context): Die Station liefert sie automatisch, der Bediener wählt sie nicht aus. Die Params order und charge liest der Bediener per Scan ein. lot.scan-in prüft beim Aktivieren, ob die Maschine die nötige Fähigkeit für dieses Los besitzt, und schlägt sonst fehl. Diese Prüfung sichert die Aktivierung ab, ohne dass die Definition einen eigenen when-Guard braucht.
{
"name": "chargen-abschliessen",
"summary": "Schließt jede fällige offene Charge und meldet den Abschluss an den Broker.",
"min_role": "operator",
"params": null,
"steps": [
{
"command": "query.run",
"bind": {
"query": {
"literal": "offene_chargen_faellig"
}
}
},
{
"command": "custom.update",
"bind": {
"data": {
"literal": "{\"status\":\"closed\"}"
},
"entity": {
"literal": "batch"
},
"id": {
"row": "id"
}
},
"for_each": {
"step": 0,
"field": "rows"
}
},
{
"command": "amqp.publish",
"bind": {
"body": {
"literal": "{\"charge_id\":\"${row.id}\"}"
},
"dest": {
"literal": "broker"
},
"routing_key": {
"literal": "charge.abgeschlossen"
}
},
"compensate": {
"command": "amqp.publish",
"bind": {
"body": {
"literal": "{\"charge_id\":\"${row.id}\"}"
},
"dest": {
"literal": "broker"
},
"routing_key": {
"literal": "charge.abschluss.storno"
}
}
},
"for_each": {
"step": 0,
"field": "rows"
}
}
]
}Diese Aktion zeigt das query.run/for_each-Muster. Der erste Schritt liest mit query.run alle fälligen offenen Chargen. Seine "rows" versorgen zwei nachfolgende for_each-Schritte: einen custom.update, der jede Charge auf "closed" setzt, und einen amqp.publish, der pro Charge den Abschluss an den Broker meldet. Innerhalb der Schleife greift {"row": "…"} pro Zeile auf die Spalten der jeweiligen Charge zu. Der amqp.publish ist zwar der letzte Schritt der Aktion, läuft aber innerhalb der for_each-Schleife und ist unumkehrbar. Deshalb trägt er ein compensate, das die Meldung über einen eigenen Storno-Routing-Key zurücknimmt, falls eine spätere Iteration scheitert, nachdem frühere bereits veröffentlicht haben.
Mit dem Assistenten arbeiten
Jedes Beispiel oben zeigt den Prompt, mit dem Sie dieselbe Aktion über den Assistenten anlegen lassen können. Sie müssen Parameter, Schritte, Bindungen und Bedingungen dann nicht manuell als JSON schreiben. Besonders bei Aktionen achtet der Assistent auf ein fehlendes compensate bei einem unumkehrbaren Schritt und ergänzt es, bevor er den Vorschlag zeigt. Wie der Assistent grundsätzlich arbeitet, beschreibt das Kapitel Der KI-Assistent.
Fallstricke
-
Unumkehrbare Schritte brauchen ein
compensate.opcua.write,modbus.writesowie verändernderest.call/soap.call-Aufrufe müssen einen Gegen-Schritt deklarieren. Der letzte Schritt einer Aktion darf stattdessenno_compensationsetzen und dort begründen, warum es keine Rücknahme gibt — schweigen darf er nicht. Innerhalb einerfor_each-Schleife hilft beides nicht: Dort braucht auch der letzte Schritt eincompensate, weil jede Iteration für sich scheitern kann, während vorherige schon verbucht sind.Scheitert ein solcher letzter Schritt, obwohl seine Wirkung beim Zielsystem vielleicht schon eingetreten ist, bleibt sie stehen. Genau diesen Rest beschreibt der Satz in
no_compensation. -
query.runsieht nur den bereits verbuchten Stand. Einquery.run-Schritt sieht keine eigenen, noch nicht verbuchten Schreibungen früherer Schritte derselben Aktion. Prüfungen wie „Ist die Kiste danach voll?“ benötigen deshalb das Zählen-plus-eins-Muster: Lesen Sie die aktuelle Anzahl aus der Abfrage und zählen Sie die geplante Buchung hinzu, statt nach der Buchung erneut abzufragen. -
whenin einerfor_each-Schleife überspringt nur die Zeile. Trifft die Bedingung für eine Zeile nicht zu, überspringt die Aktion diese eine Iteration und läuft weiter. Außerhalb einer Schleife bricht ein nicht erfüllter Guard dagegen die ganze Aktion ab. Ein Guard auf jeder Zeile eignet sich deshalb nicht, um die gesamte Schleife zu stoppen. -
Geänderte Kommando-Eingaben brauchen neu veröffentlichte Aktionen. Ändert sich die Eingabe-Form eines Kommandos, weil ein Feld entfällt oder umbenannt wird, verwirft die Ausführung gespeicherte Bindungen auf die entfernten Felder still, ohne Fehlermeldung. Veröffentlichen Sie die Aktion nach einer solchen Änderung neu, sonst gehen Buchungen unbemerkt verloren.