Zum Inhalt

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.

curl "https://example.com/api/v2/bookings" \
  -H "Authorization: Bearer {token}"

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

  1. Kunde anlegen (einmalig pro Portal-Konto), inklusive Preisgruppe — ohne price_category_id findet die automatische Bepreisung keine Preiszeile:

    curl -X POST "https://example.com/api/v2/customers" \
      -H "Authorization: Bearer {token}" \
      -d '{"name": "Muster GmbH", "email": "kunde@example.com", "price_category_id": "{preisgruppen-uid}"}'
    
  2. Auftragsnummer reservieren — POST /api/v2/bookings/generate-number mit {"status": "{angebotsanfrage-uid}"}. Liefert generated: false, wenn am Status kein Nummernkreis konfiguriert ist; das Portal fällt dann auf eine eigene Nummer zurück.

    Nummernkreis empfohlen

    Am Status Angebotsanfrage einen eigenen Nummernkreis konfigurieren (z. B. AF-[YYYY]-[STATUS_COUNT]), damit Anfragen calServer-Nummern tragen.

  3. Auftrag anlegen — POST /api/v2/bookings mit Status Angebotsanfrage und 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.

  4. Positionen anlegen — pro angefragtem Gerätetyp POST /api/v2/bookings/{id}/types mit {"type_id": …, "quantity": …}. calServer bepreist die Position automatisch aus der Preismatrix (Gerätetyp × Preisgruppe des Kunden). Seriennummer/Bemerkung des Kunden anschließend per PUT /api/v2/articles/{articleId} in das comment-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:

  1. Anschrift des Kunden direkt am Kundendatensatz (POST /customers bzw. 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.

  2. 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) und Billing (Rechnungsadresse); ein Ansprechpartner ohne Typ ist ebenfalls möglich. Achtung: der Ort heißt im Kontaktschema location, nicht city.

  3. 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

curl "https://example.com/api/v2/bookings/{id}/next-actions" \
  -H "Authorization: Bearer {token}"

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.