Rezepte: Zutaten, Schritte und die Nachkalkulation

Rezepte mit Zutatenliste und Zubereitungsschritten — und die gerechnete Zutatensumme in Cent, die die Nachkalkulation trägt.

Für Entwickler

Voraussetzungen

  • Recht `recipes:read` zum Lesen, `recipes:write` zum Anlegen und Ändern
  • Recht `rawMaterials:read` zusätzlich, wenn zu den Zutaten auch Namen oder Preise gebraucht werden
  • Plan-Merkmal „Warenwirtschaft"

DIE LISTE LIEFERT KEINE ZUTATEN. `GET /api/v1/recipes` gibt je Rezept nur die Zählwerte `ingredientCount`, `stepCount` und `productCount`. Eine Seite mit 200 Rezepten zöge sonst mehrere tausend Zeilen mit, die niemand angefordert hat. Zutaten und Schritte stehen im Detail: `GET /api/v1/recipes/{id}`.

DIE ZUTAT TRÄGT NUR DIE KENNUNG DER WARE — nicht deren Namen und schon gar nicht deren Preis. Das ist keine Sparsamkeit, sondern eine Rechteschranke: `recipes:read` und `rawMaterials:read` sind zwei getrennte Haken, und Einkaufspreise sind das Betriebsgeheimnis des Hauses. Hinge die Ware an der Zutat, wäre jeder Rezeptschlüssel mittelbar ein Warenschlüssel. Wer Namen braucht, holt sie mit `rawMaterials:read` über `GET /api/v1/raw-materials?ids=…` — ein Aufruf für bis zu 100 Waren.

`totalCostCents` IST DIE GERECHNETE ZUTATENSUMME in ganzzahligen Cent, zwischengespeichert und nur lesbar. Der Server bildet sie aus Menge, Einheit und Gebindepreis jeder Zutat und rechnet dabei zwischen Einheiten um (200 g aus einem 5-kg-Gebinde). Eine mitgeschickte Zahl wäre eine Lüge, die man erst bei der ersten Abweichung bemerkt — deshalb ist das Feld 400 `read_only_field`.

`steps` UND `ingredients` WERDEN VOLLSTÄNDIG ERSETZT, wenn sie im Rumpf stehen, und bleiben unberührt, wenn sie fehlen. `[]` leert die Liste. Eine Teiländerung an einer sortierten Liste wäre mehrdeutig: welcher Schritt ist „der dritte" — der von vorher oder der von jetzt? Lesen Sie also das Detail, ändern Sie die vollständige Liste und schicken Sie sie ganz zurück.

OBERGRENZEN, DIE NICHT VERHANDELBAR SIND: 100 Schritte und 200 Zutaten je Rezept. Ohne sie wäre ein Rumpf mit 50 000 Zutaten eine Transaktion, die die einzige Datenbankverbindung der Instanz belegt — und damit jeden anderen Betrieb auf derselben Maschine anhält.

KEIN LÖSCHEN. An einem Rezept hängen Produkte und Speisekarteneinträge, deren Bezug ein Löschen still kappen würde. `PATCH {"isActive": false}` leistet dasselbe und ist umkehrbar. `productCount` sagt Ihnen vorher, wie viele Produkte betroffen wären.

Die Routen

GET/api/v1/recipes

Die Rezepte des Betriebs lesen — ohne Zutaten, dafür seitenweise und schnell.

Rechte

recipes:read

Plan-Merkmal warenwirtschaft — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Abfrageparameter

NameTypBedeutung
qstring, 2–200 ZeichenVolltext über `title` und `subtitle`. NICHT über die Zutaten — das wäre ein Umweg zum Warenstamm an den Rechten vorbei.
activetrue | falseNur Rezepte in Verwendung bzw. nur die stillgelegten.
idsKommaliste, höchstens 100 UUIDsGenau diese Rezepte. Duplikate fallen weg.
updatedAtFromISO-8601Nur seit diesem Zeitpunkt geänderte Rezepte — der Filter für den Abgleich.
updatedAtToISO-8601Obergrenze des Änderungsfensters.
sorttitle | updatedAt | createdAtVorgabe: titleSortierfeld. Für einen Abgleich `updatedAt`.
dirasc | descVorgabe: ascRichtung, über alle Seiten einer Blätterung gleich zu halten.
limitinteger 1–200Vorgabe: 50Zeilen je Seite.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort, unverändert.
includeTotaltrue | falseVorgabe: falseErgänzt `total` über alle Treffer.

Mögliche Fehler

  • validationUnbekannter Parameter, `q` unter zwei Zeichen, `limit` über 200 — oder ein Cursor aus einer anderen Abfrage.
  • forbiddenDem Schlüssel fehlt `recipes:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • Flacher Umschlag: `data`, `nextCursor`, `hasMore`, `limit`, optional `total`.
  • Die Zeilen tragen `ingredientCount`, `stepCount` und `productCount` — aber weder `steps` noch `ingredients`.

POST/api/v1/recipes

Ein Rezept anlegen, wahlweise gleich mit Schritten und Zutaten.

Rechte

recipes:write

Plan-Merkmal warenwirtschaft — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Kopfzeilen

NameTypBedeutung
Idempotency-KeyPflichtstring bis 255 ZeichenPFLICHT. Ohne ihn legt ein Netzwiederholversuch dasselbe Rezept zweimal an — samt aller Zutaten.

Felder im Rumpf

NameTypBedeutung
titlePflichtstring, 1–200 ZeichenDer Name des Rezepts. Das einzige Pflichtfeld.
subtitlestring bis 200 Zeichen oder nullUntertitel, etwa die Beilage oder die Garmethode.
descriptionstring bis 10 000 Zeichen oder nullBeschreibung, Hinweise, Herkunft des Gerichts.
servingsinteger 1–10 000Vorgabe: 1Für wie viele Portionen die Zutatenmengen gelten. Der Bezug der ganzen Kalkulation: `totalCostCents` geteilt durch `servings` ist der Wareneinsatz je Portion.
prepTimeMininteger ≥ 0 oder nullVorbereitungszeit in Minuten. `null` heisst „nicht erfasst", `0` heisst „keine".
cookTimeMininteger ≥ 0 oder nullGarzeit in Minuten.
imageUrlhttp(s)-Adresse oder nullBild des fertigen Gerichts.
isActivebooleanVorgabe: trueOb das Rezept in Verwendung ist.
ingredientsArray, höchstens 200 EinträgeDie Zutaten. Je Eintrag: `rawMaterialId` (UUID, Pflicht), `quantity` (> 0, Pflicht), `unit` (Einheit, Pflicht), `notes` (Text oder null), `sortOrder` (integer). Die Reihenfolge im Array ist NICHT die Sortierung — dafür ist `sortOrder` da.
stepsArray, höchstens 100 EinträgeDie Zubereitungsschritte. Je Eintrag: `description` (Text, Pflicht), `title` (Text oder null), `imageUrl` (Adresse oder null), `sortOrder` (integer).

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, ein unbekanntes Feld, mehr als 200 Zutaten oder 100 Schritte, eine `quantity` von 0 oder darunter, eine unbekannte `unit` — oder eine `rawMaterialId`, die zu einem anderen Betrieb gehört (`invalid_reference` im `issues`-Pfad).
  • nothing_to_writeDer Rumpf ist leer oder `{}`.
  • read_only_field`totalCostCents`, `ingredientCount`, `stepCount` oder `productCount` im Rumpf — alle vier rechnet der Server.
  • forbiddenDem Schlüssel fehlt `recipes:write`.
  • idempotency_key_reuseDerselbe Schlüssel wurde schon für eine ANDERE Anfrage benutzt; das Rezept wurde dann nicht angelegt.
  • Antwortet mit 201 und dem Rezept samt `steps` und `ingredients` — ohne Umschlag.
  • `totalCostCents` steht in der Antwort und ist bereits gerechnet; ein zweiter Aufruf zum Nachladen ist nicht nötig.

GET/api/v1/recipes/{id}

Ein Rezept vollständig lesen — mit Schritten und Zutaten.

Rechte

recipes:read

Plan-Merkmal warenwirtschaft — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Rezepts. Eine Kennung aus einem fremden Betrieb ergibt 404, nie 403.

Mögliche Fehler

  • not_foundDieses Rezept gibt es für diesen Betrieb nicht.
  • forbiddenDem Schlüssel fehlt `recipes:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • Die einzige Route, die `steps` und `ingredients` liefert.
  • Zutaten tragen nur `rawMaterialId`. Namen und Preise holen Sie mit `rawMaterials:read` über `GET /api/v1/raw-materials?ids=…`.

PATCH/api/v1/recipes/{id}

Ein Rezept ändern — einzelne Felder, oder die Zutaten- und Schrittliste als Ganzes.

Rechte

recipes:write

Plan-Merkmal warenwirtschaft — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Rezepts.

Kopfzeilen

NameTypBedeutung
Idempotency-Keystring bis 255 ZeichenFreiwillig — eine Wiederholung setzt dieselben Werte.

Felder im Rumpf

NameTypBedeutung
ingredientsArray, höchstens 200 EinträgeERSETZT die gesamte Zutatenliste. Fehlt der Schlüssel, bleibt sie unberührt; `[]` leert sie. Es gibt bewusst keine Teiländerung an einer sortierten Liste.
stepsArray, höchstens 100 EinträgeERSETZT die gesamte Schrittliste. Dieselbe Regel: fehlt = unverändert, `[]` = leeren.
servingsinteger 1–10 000Neue Portionszahl. Ändert NICHT die Zutatenmengen — die stehen absolut und müssten mitgeschickt werden.
isActiveboolean`false` stellt das Rezept still — der umkehrbare Ersatz für ein Löschen, das es hier nicht gibt.

Mögliche Fehler

  • nothing_to_writeDer Rumpf enthält kein einziges Feld.
  • validationUnbekanntes Feld, zu viele Zutaten oder Schritte, oder eine `rawMaterialId` aus einem fremden Betrieb.
  • read_only_field`totalCostCents`, `ingredientCount`, `stepCount` oder `productCount` im Rumpf.
  • not_foundDieses Rezept gibt es für diesen Betrieb nicht.
  • forbiddenDem Schlüssel fehlt `recipes:write`.
  • `totalCostCents` wird nach jeder Zutatenänderung neu gerechnet und steht frisch in der Antwort.
  • Alle Felder aus `POST` sind auch hier erlaubt, jedes einzeln und optional.

Codebeispiele

curl — ein Rezept mit Zutaten lesen
curl
curl -sS https://tactictable.com/api/v1/recipes/afc83dd8-e49b-585b-a86e-cb87fd92042a \
  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \
  -H "Accept: application/json"
Nur die Detailroute liefert `steps` und `ingredients`. Die Liste gibt stattdessen `ingredientCount` — eine Seite mit 200 Rezepten samt allen Zutaten wären mehrere tausend Zeilen, die niemand bestellt hat.
Antwort 200 — GET /api/v1/recipes/{id}
JSON
{  "id": "afc83dd8-e49b-585b-a86e-cb87fd92042a",  "title": "Geschmorte Rinderschulter",  "subtitle": "Wurzelgemüse, Rotwein",  "description": "Vier Stunden bei 140 °C. Am Vortag ansetzen.",  "imageUrl": null,  "servings": 10,  "prepTimeMin": 45,  "cookTimeMin": 240,  "totalCostCents": 4732,  "isActive": true,  "ingredientCount": 3,  "stepCount": 2,  "productCount": 1,  "createdAt": "2026-04-02T09:15:00.000Z",  "updatedAt": "2026-09-08T11:41:27.000Z",  "steps": [    {      "id": "8f2a1c04-5d6b-4e19-9a37-0c4b8e2d7f51",      "sortOrder": 0,      "title": "Anbraten",      "description": "Fleisch portionsweise scharf anbraten, herausnehmen.",      "imageUrl": null    },    {      "id": "3d9e6b17-2a48-4c05-8f61-7b3d0a9c5e24",      "sortOrder": 1,      "title": "Schmoren",      "description": "Mit Rotwein ablöschen, zugedeckt vier Stunden bei 140 °C.",      "imageUrl": null    }  ],  "ingredients": [    {      "id": "c1b70d83-4e29-4a56-b0f8-16d5c93a7e40",      "rawMaterialId": "08c20d5c-8a03-5372-a85b-9d4c87db516f",      "quantity": 2.5,      "unit": "KILOGRAM",      "notes": "In groben Würfeln.",      "sortOrder": 0    },    {      "id": "a54f2e91-8c30-4d7b-9163-e05a8b42df67",      "rawMaterialId": "28d4e72c-9d34-4917-bf1e-31507e5c8677",      "quantity": 120,      "unit": "GRAM",      "notes": null,      "sortOrder": 1    },    {      "id": "7e30c852-9b14-4f6a-a2d9-58c0e7134b96",      "rawMaterialId": "b79f086d-d89e-53c5-81dc-888d3d93ce3b",      "quantity": 750,      "unit": "MILLILITER",      "notes": "Kräftiger Roter.",      "sortOrder": 2    }  ]}
`totalCostCents` sind 47,32 € Wareneinsatz für 10 Portionen — also 4,73 € je Portion. Die Zutaten tragen NUR `rawMaterialId`: `recipes:read` ist bewusst kein Umweg zu den Einkaufspreisen. Beachten Sie die Einheiten je Zutat: 2,5 kg, 120 g und 750 ml — der Server rechnet aus dem jeweiligen Gebindepreis um.
curl — Zutatenliste vollständig ersetzen
curl
curl -sS -X PATCH https://tactictable.com/api/v1/recipes/afc83dd8-e49b-585b-a86e-cb87fd92042a \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -d '{    "ingredients": [      {        "rawMaterialId": "08c20d5c-8a03-5372-a85b-9d4c87db516f",        "quantity": 3,        "unit": "KILOGRAM",        "sortOrder": 0      },      {        "rawMaterialId": "28d4e72c-9d34-4917-bf1e-31507e5c8677",        "quantity": 150,        "unit": "GRAM",        "sortOrder": 1      }    ]  }'
Die Liste wird GANZ ersetzt — die dritte Zutat aus dem Beispiel oben ist danach weg. Wer nur eine Menge ändern will, liest das Detail, ändert den einen Eintrag im Array und schickt das vollständige Array zurück.
TypeScript — Wareneinsatz je Portion, mit Namen
TypeScript
interface Zutat {    rawMaterialId: string    quantity: number    unit: string    notes: string | null    sortOrder: number} interface RezeptDetail {    id: string    title: string    servings: number    /** Gerechnete Zutatensumme in ganzzahligen Cent. Nur lesbar. */    totalCostCents: number    ingredients: Zutat[]} interface Ware {    id: string    name: string} const BASIS = 'https://tactictable.com/api/v1' async function hole<T>(pfad: string, token: string): Promise<T> {    const antwort = await fetch(BASIS + pfad, {        headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },    })    if (!antwort.ok) {        const rumpf = (await antwort.json()) as { error: string; message: string }        // Auf die Kennung verzweigen, nie auf den Wortlaut.        throw new Error(rumpf.error + ': ' + rumpf.message)    }    return (await antwort.json()) as T} export interface Kalkulation {    titel: string    portionen: number    /** Ganzzahlige Cent. Die Division fuer die Anzeige passiert erst im UI. */    einsatzGesamtCents: number    einsatzJePortionCents: number    zutaten: { name: string; menge: number; einheit: string }[]} /** * Holt ein Rezept und loest die Zutaten auf EINEN Warenabruf auf. * * Warum nicht je Zutat ein Aufruf: 30 Zutaten waeren 30 Anfragen gegen * dieselbe Ratenbremse, fuer Daten, die ein einziges ?ids= liefert. * Braucht zusaetzlich rawMaterials:read - ohne dieses Recht ist der * zweite Aufruf 403, und das ist Absicht. */export async function kalkulation(token: string, rezeptId: string): Promise<Kalkulation> {    const rezept = await hole<RezeptDetail>('/recipes/' + encodeURIComponent(rezeptId), token)     const ids = [...new Set(rezept.ingredients.map((z) => z.rawMaterialId))]    const waren =        ids.length === 0            ? { data: [] as Ware[] }            : await hole<{ data: Ware[] }>('/raw-materials?ids=' + ids.join(','), token)     const namen = new Map(waren.data.map((w) => [w.id, w.name]))     return {        titel: rezept.title,        portionen: rezept.servings,        einsatzGesamtCents: rezept.totalCostCents,        // Ganzzahlig teilen: ein Bruchteil eines Cents steht auf keiner        // Rechnung, und Math.round haelt die Summe stabil.        einsatzJePortionCents: Math.round(rezept.totalCostCents / rezept.servings),        zutaten: rezept.ingredients            .sort((a, b) => a.sortOrder - b.sortOrder)            .map((z) => ({                // Eine Ware, die der zweite Aufruf nicht kennt, ist kein                // Absturz: sie kann zwischen beiden Aufrufen stillgelegt                // worden sein.                name: namen.get(z.rawMaterialId) ?? '(unbekannte Ware)',                menge: z.quantity,                einheit: z.unit,            })),    }}
Zwei Aufrufe statt einunddreissig: `?ids=` löst bis zu 100 Waren auf einmal auf. Und `totalCostCents` wird NICHT selbst nachgerechnet — der Server kennt die Gebindegrössen und rechnet zwischen Einheiten um.
Java — Rezepte in ein Kalkulationssystem übernehmen
Java
package com.example.tactictable; import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.time.Duration;import java.util.ArrayList;import java.util.List; import com.fasterxml.jackson.databind.JsonNode;import com.fasterxml.jackson.databind.ObjectMapper; /** * Holt alle aktiven Rezepte seitenweise. * * Der Wareneinsatz bleibt durchgehend ein long in Cent. Ein double waere * hier der klassische Fehler: nach dem dritten Summieren steht auf der * Auswertung ein Betrag, den keine Rechnung bestaetigt. */public final class Rezeptabruf {     private static final String BASIS = "https://tactictable.com/api/v1";     private final HttpClient klient = HttpClient.newBuilder()            .connectTimeout(Duration.ofSeconds(10))            .build();     private final ObjectMapper mapper = new ObjectMapper();    private final String token;     public Rezeptabruf(String token) {        this.token = token;    }     public record Rezept(String id, String titel, int portionen, long einsatzCents, int zutaten) {         /** Wareneinsatz je Portion, kaufmaennisch gerundet. */        public long einsatzJePortionCents() {            if (portionen <= 0) {                return einsatzCents;            }            return Math.round((double) einsatzCents / portionen);        }    }     public List<Rezept> alleAktiven() throws Exception {        List<Rezept> alle = new ArrayList<>();        String cursor = null;         for (int seite = 0; seite < 500; seite++) {            StringBuilder adresse = new StringBuilder(BASIS)                    .append("/recipes?active=true&sort=title&dir=asc&limit=200");            if (cursor != null) {                adresse.append("&cursor=").append(URI.create("x").resolve(".").toString().isEmpty()                        ? cursor                        : java.net.URLEncoder.encode(cursor, java.nio.charset.StandardCharsets.UTF_8));            }             HttpRequest anfrage = HttpRequest.newBuilder()                    .uri(URI.create(adresse.toString()))                    .header("Authorization", "Bearer " + token)                    .header("Accept", "application/json")                    .timeout(Duration.ofSeconds(30))                    .GET()                    .build();             HttpResponse<String> antwort =                    klient.send(anfrage, HttpResponse.BodyHandlers.ofString());             if (antwort.statusCode() != 200) {                JsonNode fehler = mapper.readTree(antwort.body());                throw new IllegalStateException(                        fehler.get("error").asText() + ": " + fehler.get("message").asText()                                + " (Request-Id " + fehler.get("requestId").asText() + ")");            }             JsonNode wurzel = mapper.readTree(antwort.body());            for (JsonNode zeile : wurzel.get("data")) {                alle.add(new Rezept(                        zeile.get("id").asText(),                        zeile.get("title").asText(),                        zeile.get("servings").asInt(),                        zeile.get("totalCostCents").asLong(),                        zeile.get("ingredientCount").asInt()));            }             JsonNode weiter = wurzel.get("nextCursor");            // isNull() pruefen und nicht auf einen leeren Text hoffen:            // auf der letzten Seite steht dort JSON-null.            if (weiter == null || weiter.isNull()) {                return alle;            }            cursor = weiter.asText();        }         throw new IllegalStateException("Zu viele Seiten.");    }}
`asLong()` für `totalCostCents` und nicht `asDouble()`: der Wert ist ein ganzzahliger Cent-Betrag und bleibt es durch die ganze Auswertung. Die Liste liefert `ingredientCount`, aber keine Zutaten — für die braucht es je Rezept die Detailroute.
Python — Rezept mit Zutaten anlegen
Python
import jsonimport uuid import requests BASIS = "https://tactictable.com/api/v1"  def rezept_anlegen(    sitzung: requests.Session,    titel: str,    portionen: int,    zutaten: list[dict],) -> dict:    """Legt ein Rezept samt Zutaten in EINEM Aufruf an.     zutaten: [{"rawMaterialId": ..., "quantity": 2.5, "unit": "KILOGRAM"}, ...]     totalCostCents wird NICHT mitgeschickt - der Server rechnet die Summe    aus Menge, Einheit und Gebindepreis. Eine eigene Zahl waere ein    read_only_field und ausserdem falsch, sobald sich ein Preis aendert.    """    rumpf = {        "title": titel,        "servings": portionen,        "ingredients": [            {                "rawMaterialId": z["rawMaterialId"],                "quantity": z["quantity"],                "unit": z["unit"],                "sortOrder": i,            }            for i, z in enumerate(zutaten)        ],    }     antwort = sitzung.post(        f"{BASIS}/recipes",        data=json.dumps(rumpf),        headers={            "Content-Type": "application/json",            # PFLICHT bei dieser Route: ohne ihn legt ein Wiederholversuch            # das Rezept samt aller Zutaten ein zweites Mal an.            "Idempotency-Key": str(uuid.uuid4()),        },        timeout=30,    )     if antwort.status_code != 201:        koerper = antwort.json()        # issues nennt den genauen Pfad, auch in Listen:        # "ingredients[2].quantity" oder "ingredients[0].rawMaterialId".        stellen = "; ".join(            f"{i['path']}: {i['message']}" for i in koerper.get("issues", [])        )        raise RuntimeError(            f"{koerper['error']}: {koerper['message']} {stellen}".strip()        )     return antwort.json()
Der `issues`-Pfad zeigt bis in die Zutatenliste hinein: `ingredients[2].quantity` sagt genau, welcher der 200 Einträge klemmt. Ohne diese Ausgabe sucht man bei einem 400 von Hand.