Auftragsanbindung für Anfrage-Portale (eforms)¶
Externe Anfrage-Portale (z. B. das eforms.cloud-Modul eCalserver) reichen Kalibrier-Anfragen als Aufträge in calServer ein und begleiten sie durch den Angebots-Workflow. Diese Seite beschreibt den verbindlichen Integrationsvertrag gegen die V2-API.
Nur V2
Die Auftrags-API existiert ausschließlich in der V2-API (Laravel). Die V1-REST-API kennt keine Aufträge — die Zielinstanz muss V2 fahren.
Workflow¶
Angebotsanfrage → Angebot → Angebot abgegeben → Auftrag → (Teil-)Lieferung → Abgeschlossen
(Portal) (Labor) (wartet auf Kunde) (Kunde
bestätigt)
| Schritt | Wer | Status (kanonisch) |
|---|---|---|
| Anfrage eingereicht | Portal | Angebotsanfrage (OFFER_REQUEST) |
| Angebot in Erstellung | Labor (calServer) | Angebot (OFFER) |
| Angebot bepreist, wartet auf Kunde | Labor (calServer) | Angebot abgegeben (OFFER_SUBMITTED) |
| Kunde hat bestätigt | Portal | Auftrag (ORDER) |
| Lieferung / Abschluss | Labor (calServer) | Lieferung / Abgeschlossen |
Die Status Angebotsanfrage und Angebot abgegeben werden seit Migration
2026_07_24_200000 kanonisch ausgeliefert. Beide rendern im Auftragsbericht
das Angebots-Dokument (Positionsmodus: Gerätetypen).
Der Konfigurator läuft anonym — calServer sieht erst den Absender
Im eforms-Modul eCalserver stellt der Besucher seine Anfrage ohne
Konto zusammen: Katalog durchsuchen, Geräte und Stückzahlen wählen,
Adresse eintragen. Bis dahin läuft gegen calServer ausschließlich der
lesende Katalog-Proxy (GET /device-types, serverseitig gecacht) — es
entsteht kein Kundensatz und keine Buchung. Ein Portal-Konto
verlangt erst das Absenden; genau dann laufen die Schritte unter
„Anfrage einreichen“ (Kunde anlegen, Nummer reservieren, Auftrag
anlegen). Anonyme Konfiguratoren erzeugen also keine Karteileichen im
Kundenstamm.
Service-Benutzer und Berechtigungen¶
Für das Portal wird ein dedizierter calServer-Benutzer angelegt und ihm die
mitgelieferte Rolle api_integration ("API-Integration (Anfrage-Portal)")
zugewiesen. Die Rolle existiert in jeder Installation und enthält genau die
Operationen, die der Vertrag unten braucht:
using_API, bookings_view, bookings_edit, customers_view, customers_edit,
types_view, price_category_view, dms_view
bookings_view/bookings_edit decken Buchungen, Positionen (Articles),
Typ-Positionen, change-status, generate-number, report-dataset und die
Dokumentliste der Buchung ab; dms_view erlaubt den Download der erzeugten
PDFs. Status-Reads brauchen keine Operation.
Least Privilege
Sanctum-Tokens tragen keine Abilities — die Rolle des Service-Benutzers ist die einzige Berechtigungsgrenze. Keine weiteren Operationen zuweisen. Tokens laufen nicht automatisch ab; Rotation ist Betriebs-Policy des Portal-Betreibers.
api_integration wird nur angelegt, nie überschrieben: eine im Betrieb
angepasste Rolle bleibt bei Updates unverändert.
Authentifizierung wie in Authentifizierung:
POST /api/v2/auth/token mit X-REST-API-KEY-Header und
{"email": …, "password": …}; das Bearer-Token gilt für alle Folge-Requests.
Rate-Limit: 60 Requests/Minute pro Benutzer.
Zugangsweg wählen: API-Token oder Login¶
Für die Anmeldung des Portals gibt es zwei Wege. Empfohlen ist das API-Token, weil dann weder das Passwort des Service-Benutzers noch sein API-Key beim Portalbetreiber liegt — das Passwort ist zugleich ein Oberflächen-Login.
Unter Administration → Benutzer → [Benutzer] → API-Token ein Token
ausstellen und den Wert im Portal hinterlegen. Er wird direkt als
Authorization: Bearer … verwendet, ohne /auth/token-Aufruf.
Der Klartext erscheint genau einmal, bei der Ausgabe — gespeichert wird nur ein SHA-256-Hash. Das ist unkritisch: ein neues Token entwertet bestehende nicht. Geht der Wert verloren, wird ein neues ausgestellt und das alte widerrufen, ohne andere Integrationen zu stören. Optional lässt sich ein Ablaufdatum setzen.
Ein Token trägt die vollen Rechte seines Benutzers; Sanctum-Abilities werden nicht ausgewertet. Die Rolle bleibt also die einzige Berechtigungsgrenze.
Der klassische Weg über POST /auth/token mit X-REST-API-KEY-Header und
{"email": …, "password": …}.
Jeder neu angelegte Benutzer bekommt automatisch einen API-Key. Verwaltet
wird er unter Administration → Benutzer → [Benutzer] → API-Key; der
hinterlegte Key ist dort jederzeit einsehbar, kopierbar, neu generierbar und
entfernbar. Ein Benutzer ohne Key kann sich nicht über /auth/token
anmelden, der Endpunkt antwortet mit 401 Invalid API key. Für Benutzer,
die vor dieser Version angelegt wurden, einmalig „API-Key generieren"
klicken.
Beim Löschen des Benutzers
Wird der Service-Benutzer gelöscht, werden seine Token und sein API-Key
sofort ungültig. Das Portal meldet dann 401.
Status-Auflösung (niemals Titel hartkodieren)¶
Status-Titel sind pro Installation konfigurierbar. bookings.status und
POST /bookings/{id}/change-status arbeiten mit der Status-uID. Das
Portal lädt die Liste einmalig (bzw. für seine Konfigurations-UI) über:
curl "https://example.com/api/v2/statuses?filter[type]=booking&filter[active]=1" \
-H "Authorization: Bearer {token}"
und speichert die gemappten uIDs (Angebotsanfrage, Angebot abgegeben, Auftrag, Lieferung, Abgeschlossen) in seiner eigenen Konfiguration.
Gerätekatalog durchsuchen¶
Der Konfigurator des Portals sucht die anzufragenden Geräte über
GET /api/v2/device-types. Drei Filter stehen zur Verfügung, alle als
case-insensitive Teilstring-Suche (funktioniert unter MySQL, PostgreSQL
und MS SQL Server gleichermaßen):
| Filter | Sucht in |
|---|---|
filter[search] |
Typbezeichnung, Hersteller, Modell, Typ-Code |
filter[manufacturer] |
Hersteller |
filter[model] |
Modell |
filter[manufacturer] und filter[model] lassen sich kombinieren (UND-Verknüpfung).
curl "https://example.com/api/v2/device-types?filter[manufacturer]=fluke&filter[model]=87" \
-H "Authorization: Bearer {token}"
fluke findet also auch Fluke, und 87 findet 87V. Für das Portal ist das
Pflicht — Endkunden tippen Herstellernamen selten in der Schreibweise des
Katalogs.
Wert ohne Operator, sonst die allgemeine Filtersyntax
Die Teilstring-Suche gilt für einen Wert ohne Operator. Trägt der Wert
ein Operator-Token (contains:, startswith:, ne:, …), gilt die
allgemeine Filtersyntax der API — dieselbe, die jedes Grid benutzt, und
verfügbar auf jeder Spalte des Katalogs, nicht nur auf diesen dreien:
curl "https://example.com/api/v2/device-types?filter[description]=startswith:Digitalmultimeter&sort=-type_version" \
-H "Authorization: Bearer {token}"
Keine Wirkung hat sie auf den drei berechneten Spalten („Anzahl Geräte" und die beiden Blaupausen-Vorgaben): sie haben keine Spalte hinter sich, ein Filter darauf wird ignoriert.
Anfrage einreichen¶
-
Kunde anlegen (einmalig pro Portal-Konto), inklusive Preisgruppe — ohne
price_category_idfindet die automatische Bepreisung keine Preiszeile: -
Auftragsnummer reservieren —
POST /api/v2/bookings/generate-numbermit{"status": "{angebotsanfrage-uid}"}. Liefertgenerated: false, wenn am Status kein Nummernkreis konfiguriert ist; das Portal fällt dann auf eine eigene Nummer zurück.Nummernkreis empfohlen
Am Status
Angebotsanfrageeinen eigenen Nummernkreis konfigurieren (z. B.AF-[YYYY]-[STATUS_COUNT]), damit Anfragen calServer-Nummern tragen. -
Auftrag anlegen —
POST /api/v2/bookingsmit StatusAngebotsanfrageund Korrelationsmarker als erster Kommentarzeile:{ "customer_id": "{kunden-uid}", "number": "AF-2026-0042", "status": "{angebotsanfrage-uid}", "comment": "eforms:9f4c9850-0913-4d60-a964-8643d82af652\nAnfrage über das Kundenportal …" }Der Marker
eforms:{uuid}macht Wiederholungsversuche idempotent:GET /api/v2/bookings?filter[search]=eforms:{uuid}findet die Buchung über die Kommentar-Suche wieder, bevor erneut angelegt wird. -
Positionen anlegen — pro angefragtem Gerätetyp
POST /api/v2/bookings/{id}/typesmit{"type_id": …, "quantity": …}. calServer bepreist die Position automatisch aus der Preismatrix (Gerätetyp × Preisgruppe des Kunden). Seriennummer/Bemerkung des Kunden anschließend perPUT /api/v2/articles/{articleId}in dascomment-Feld der Position schreiben.
Adressen: Kundenstammsatz und Kontaktrollen¶
Die Belegadresse des Angebots entsteht nicht aus Feldern der Buchung,
sondern wird pro Rolle aufgelöst: Kontakt der Rolle → Rollenkunde →
Auftragskunde. Gedruckt werden name, customer_number, street, zip,
city, country des Kunden bzw. die Felder des Kontakts.
Ein Portal, das nur name und email am Kunden setzt, erzeugt damit einen
Vorgang ohne druckbare Adresse — das Labor muss sie vor dem Angebot von
Hand nachtragen. Wer Adressen erhebt, überträgt sie so:
-
Anschrift des Kunden direkt am Kundendatensatz (
POST /customersbzw.PUT /customers/{id}).Spaltenbreite 48 Zeichen
Die Kundenspalten (
name,street,zip,city,country,phone,contact_person) fassen 48 Zeichen; die Validierung des Endpunkts erlaubt mehr. Längere Werte laufen in einen DB-Fehler — das Portal muss selbst begrenzen. -
Abweichende Liefer-/Rechnungsadresse als Kontakt am Kunden anlegen:
curl -X POST "https://example.com/api/v2/contacts" \ -H "Authorization: Bearer {token}" \ -d '{"name": "Muster GmbH Werk 2", "street": "Industriestr. 7", "zip": "54321", "location": "Lieferstadt", "type": "Delivery", "customer_id": "{kunden-uid}"}'Erlaubte Typen sind
Delivery(Lieferadresse) undBilling(Rechnungsadresse); ein Ansprechpartner ohne Typ ist ebenfalls möglich. Achtung: der Ort heißt im Kontaktschemalocation, nichtcity. -
Kontakte am Vorgang als Rolle setzen — erst dadurch findet die Belegauflösung sie:
curl -X PUT "https://example.com/api/v2/bookings/{id}" \ -H "Authorization: Bearer {token}" \ -d '{"customer_contact_id": "…", "delivery_contact_id": "…", "invoice_contact_id": "…"}'Der Aufruf ist idempotent und kann bei einem Retry gefahrlos wiederholt werden.
Bestandskunden verknüpfen statt duplizieren
Vor dem Anlegen GET /api/v2/customers?filter[email]=eq:{mail} abfragen
und bei Treffer den bestehenden Kunden verwenden. Der Filter arbeitet mit
LIKE — die Trefferzeile daher nochmal exakt gegen die Mailadresse prüfen.
An einem bestehenden Kunden sollten nur leere Felder ergänzt werden:
die Kundenpflege gehört dem Labor.
Statuswechsel und Kundenbestätigung¶
Das Labor arbeitet vollständig in calServer (bewerten, Positionen ergänzen,
Status auf Angebot abgegeben setzen). Das Portal pollt und reagiert:
curl -X POST "https://example.com/api/v2/bookings/{id}/change-status" \
-H "Authorization: Bearer {token}" \
-d '{"status": "{auftrag-uid}"}'
Antwortet der Wechsel mit Pflichtfeld-Anforderungen (additional-fields),
füllt das Portal diese nicht selbst aus — Pflichtfelder gehören dem
Labor; das Portal meldet dem Kunden, dass das Labor den Vorgang
vervollständigen muss.
Abgewiesene Statuswechsel¶
Seit der Einführung der Ablaufregeln (ADR-027) kann change-status einen
Wechsel auch aus fachlichen Gründen abweisen. Die Antwort ist 422 mit einer
von required_fields_missing unterscheidbaren Nutzlast:
{
"data": {
"success": false,
"workflow_blocked": true,
"reasons": [
{ "code": "order_stage_required", "message": "Lieferung, Rechnung und Abschluss setzen einen erteilten Auftrag voraus. …" }
]
}
}
| Code | Bedeutung |
|---|---|
order_stage_required |
Ziel gehört zur Abwicklung, der Vorgang steht noch im Angebotsteil |
no_positions |
Der Vorgang hat keine Positionen |
customer_required |
Dem Vorgang ist kein Kunde zugeordnet |
no_delivery_part |
Keine Position ist einer Teillieferung zugeordnet |
Die code-Werte sind stabil und für die Anzeige beim Kunden gedacht; die
message ist der deutsche Vorgabetext für Aufrufer ohne eigene Übersetzung.
Für das Portal relevant ist praktisch nur der Wechsel auf Auftrag nach der
Kundenbestätigung — der ist bewusst an keine Vorbedingung geknüpft. Ein
Portal, das darüber hinaus Status setzt, wertet die Codes aus, statt den
Fehler als technischen Ausfall zu behandeln.
Übergänge, die einen Status außerhalb der kanonischen Kette betreffen (mandanteneigene Arbeitsstationen), werden nie abgewiesen.
Vorschau: was ist als Nächstes möglich¶
Liefert den Stand des Vorgangs, den Liefer- und Rechnungsfortschritt sowie jeden
Folgestatus mit allowed und den Begründungen, falls nicht. Das Portal kann
damit den Bestätigen-Knopf sperren, bevor der Kunde in eine Ablehnung läuft, und
dem Kunden einen ehrlichen Fortschritt anzeigen:
{
"data": {
"current": { "status": "{uid}", "title": "Auftrag", "stage": "order" },
"progress": { "positions": 4, "delivered": 2, "billed": 0, "open_delivery": 2, "open_billing": 4 },
"actions": [
{ "status": "{uid}", "title": "Lieferung", "stage": "delivery", "allowed": true, "reasons": [] }
],
"recommended": "{uid}"
}
}
stage ist null, wenn ein Status nicht Teil der kanonischen Kette ist. Der
Endpunkt braucht bookings_view, ist also von der Rolle api_integration
abgedeckt.
Dokumente (Angebot, Auftragsbestätigung, Lieferschein)¶
PDFs werden im DMS abgelegt und vom Portal von dort gestreamt:
| Aktion | Endpunkt |
|---|---|
| Bericht erzeugen und im DMS ablegen | POST /api/v2/bookings/{id}/reports/save-to-dms |
| Kundensichtbare Belege einer Buchung listen | GET /api/v2/bookings/{id}/customer-documents |
| Beleg herunterladen | GET /api/v2/bookings/{id}/customer-documents/{documentId}/download |
Der Kundenkanal ist ein eigener Endpunkt, kein Filter. GET
/bookings/{id}/documents liefert alles, was am Auftrag liegt — interne
Ablagen und nicht freigegebene Entwürfe eingeschlossen. Ein Portal darf ihn
nicht benutzen. customer-documents kann dagegen gar nichts anderes
ausliefern:
- nur Fächer mit
file_setting.customer_visible(Default 0, fail-closed), - nur freigegebene Dokumente,
- nur die jeweils letzte Version.
Der Download hängt an derselben Prüfung. Eine Prüfung nur im Client wäre eine Absichtserklärung, keine Grenze.
Berechtigung: bookings_view genügt, der Service-Benutzer braucht für den
Beleg-Abruf kein dms_view mehr.
Der Kunde reicht eine Datei nach¶
Die Gegenrichtung läuft über denselben Pfad:
curl -X POST "https://example.com/api/v2/bookings/{id}/customer-documents" \
-H "Authorization: Bearer {token}" \
-F "file=@bestellung.pdf"
Die Datei landet ausnahmslos im Beilagenfach booking_attachment. Das
Fach ist nicht wählbar: POST /bookings/{id}/documents nimmt es als type
aus dem Request, und ein Portal könnte damit booking_offer angeben und sich
ein selbst hochgeladenes PDF als freigegebenes Angebot in den Kundenkanal
legen. Ein mitgeschicktes type wird deshalb ignoriert. Berechtigung ist
bookings_edit, also von der Rolle api_integration abgedeckt.
Weil Beilagen customer_visible = 0 sind, taucht die hochgeladene Datei in
GET /customer-documents nicht wieder auf. Das ist Absicht: die Liste ist
der Belegkanal des Labors, nicht die Ablage des Kunden. Ein Portal, das dem
Kunden seine eigenen Uploads zeigen will, führt sie lokal.
| Grenze | Wert |
|---|---|
| Größe je Datei | 20 MB |
| Erlaubte Endungen | pdf, jpg/jpeg/png/webp/heic/gif/tif/tiff, doc/docx/odt, xls/xlsx/ods/csv, txt/rtf, zip |
Anders als der allgemeine DMS-Upload (Blockliste gefährlicher Endungen) arbeitet dieser Endpunkt mit einer Positivliste: hier lädt ein Endkunde hoch, den niemand kennt.
Der Dateiname wird aus dem Muster des Fachs erzeugt ({order-id}{#ANY#}),
trägt also die Auftragsnummer, egal wie der Kunde seine Datei genannt hat.
Ein optionales name überschreibt nur den Namensteil, nicht die Zuordnung.
Antworten: 201 mit dem Dokument, 422 bei Typ/Größe, 409 wenn in der
Installation kein Beilagenfach konfiguriert ist (dann ist nicht die Datei das
Problem, sondern die Ablage), 404 bei unbekanntem Auftrag.
Ablage-Fächer¶
Ausgeliefert werden fünf DMS-Fächer für Aufträge. Vier davon tragen einen Belegtyp und gehen an den Kunden, das Beilagenfach nicht:
| Fach | Belegtyp | Einleseordner | Portal |
|---|---|---|---|
booking_offer |
offer |
auftrag-angebote |
ja |
booking_order_confirmation |
order_confirmation |
auftrag-auftragsbestaetigungen |
ja |
booking_delivery_note |
delivery_note |
auftrag-lieferscheine |
ja |
booking_invoice |
invoice |
auftrag-rechnungen |
ja |
booking_attachment |
— | auftrag-beilagen |
nein |
Das Labor legt Belege über „Bericht → Im DMS ablegen" ab (oder automatisch via
save_to_dms am Report-Setting) — erst dadurch werden sie für das Portal
sichtbar. In welches Fach ein Beleg geht, entscheidet sein Belegtyp, den
calServer aus dem Buchungsstatus ableitet (Angebotsanfrage/Angebot
abgegeben → Angebot, Auftrag → Auftragsbestätigung, Lieferung/
Teillieferung → Lieferschein, Rechnung → Rechnung), nicht der Dateiname.
Alle fünf Fächer tragen dasselbe Muster {order-id}{#ANY#}: es ordnet den
Auftrag zu, der Ordner entscheidet das Fach. Ein eingescannter
unterschriebener Lieferschein in auftrag-lieferscheine landet damit
richtig, ohne dass der Dateiname mehr können muss als die Auftragsnummer zu
tragen.
Rückfragen beantworten¶
Am Auftrag hängt ein Nachrichtenverlauf. Das Labor stellt eine Rückfrage, der Kunde antwortet, und das Labor erfährt davon in der Glocke.
| Aktion | Endpunkt |
|---|---|
| Verlauf für den Kunden lesen | GET /bookings/{id}/customer-messages |
| Kundenantwort einstellen | POST /bookings/{id}/customer-messages |
| Rückfragen als gelesen quittieren | POST /bookings/{id}/customer-messages/mark-read |
curl -X POST "https://example.com/api/v2/bookings/{id}/customer-messages" \
-H "Authorization: Bearer {token}" \
-d '{"body": "Die Seriennummer lautet 4711."}'
Auch hier ein eigener Endpunkt, kein Filter. Im Verlauf stehen interne
Notizen des Labors („Kunde ruft nachmittags an") neben den Nachrichten an den
Kunden. GET /bookings/{id}/messages liefert alles und ist die Laborsicht;
ein Portal darf ihn nicht benutzen. customer-messages kann interne Einträge
gar nicht ausliefern.
Beim Schreiben gilt dasselbe andersherum: eine über diesen Endpunkt
eingestellte Nachricht ist immer vom Kunden und immer sichtbar. Mitgeschickte
author_type- oder visibility-Felder werden ignoriert — das Portal kann
weder eine Labor-Nachricht fälschen noch eine hinterlegen, die der Kunde
selbst nie zu sehen bekäme.
mark-read quittiert die Rückfragen des Labors, sobald der Kunde den Verlauf
geöffnet hat. Damit sieht das Labor, ob die Rückfrage angekommen ist, statt
nur zu wissen, dass sie abgeschickt wurde. Der Aufruf ist idempotent und
lässt interne Notizen unangetastet (der Kunde hat sie nie gesehen).
Berechtigung: bookings_view zum Lesen, bookings_edit zum Schreiben und
Quittieren, beides von der Rolle api_integration abgedeckt.
Eine Kundenantwort erzeugt in calServer eine Benachrichtigung für alle
Benutzer mit bookings_view. Die Gegenrichtung nicht: dass eine Rückfrage
vorliegt, erfährt der Kunde über das Portal, das ohnehin pollt. calServer
verschickt keine Kundenmails.
Polling¶
calServer sendet keine Webhooks. Das Portal pollt
GET /api/v2/bookings/{id} (Status) und
GET /api/v2/bookings/{id}/customer-documents (neue PDFs) — empfohlen alle 15 Minuten, gebündelt und unterhalb des
Rate-Limits (60/min). Die Statushistorie liefert
GET /api/v2/bookings/{id}/audits.