Lieferanten

Lesen, anlegen und ändern — mit Volltextsuche über Firma, Ansprechpartner, Stadt und UID. Kein Löschen, und der Grund steht daneben.

Für Entwickler

Voraussetzungen

  • Recht `suppliers:read` zum Lesen, `suppliers:write` zum Anlegen und Ändern
  • Plan-Merkmal „Warenwirtschaft"

VIER OPERATIONEN: Liste, Einzelabruf, Anlegen, Teiländerung. KEIN `DELETE` — und das ist begründet: die Zuordnung `RawMaterial → Lieferant` steht auf „beim Löschen leeren". Ein gelöschter Lieferant kappt still die Zuordnung JEDER seiner Waren, und die Kalkulation verliert ihren Bezug, ohne dass jemand etwas merkt. Ausser Dienst stellen geht über `PATCH {"isActive": false}` und ist umkehrbar.

DIE ANTWORT IST DER DATENSATZ SELBST, ohne Umschlag. Anders als im Gästebereich steht der Lieferant direkt im Körper: `{ "id": …, "companyName": … }`. Die LISTE dagegen trägt den flachen Umschlag mit `data`, `nextCursor`, `hasMore`, `limit`.

`q` SUCHT ÜBER FIRMA, ANSPRECHPARTNER, STADT UND UID — mindestens zwei Zeichen. Ein einzelnes Zeichen träfe praktisch jede Zeile und läse dabei die ganze Tabelle; die Textspalten sind von keinem Index gedeckt.

`Idempotency-Key` IST BEIM ANLEGEN PFLICHT und beim Ändern freiwillig. Beim Anlegen, weil ein Netzwiederholversuch sonst denselben Lieferanten zweimal anlegt; beim Ändern nicht, weil eine Wiederholung dieselben Werte setzt und keinen Schaden anrichtet.

ADRESSEN NUR MIT `http` ODER `https`. `website` wird geprüft — ohne diese Prüfung landete ein `javascript:`-Wert in einer Zeile, die später irgendwo als Verweis dargestellt wird. `country` ist Freitext und kein Ländercode: Lieferanten stehen auch ausserhalb der EU, und das Dashboard hält es ebenso.

EIN LEERER TEXT IST EIN `null`. `""` und `null` bedeuten bei jedem Textfeld dasselbe: „nicht gesetzt". Ein fehlendes Feld in einem `PATCH` heisst dagegen „unverändert" — die Unterscheidung zwischen `undefined` und `null` trägt diesen ganzen Bereich.

Die Routen

GET/api/v1/suppliers

Die Lieferanten des Betriebs lesen — gefiltert, gesucht, seitenweise.

Rechte

suppliers:read

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

Abfrageparameter

NameTypBedeutung
qstring, 2–200 ZeichenVolltext über `companyName`, `contactName`, `city` und `uid`.
activetrue | falseNur Lieferanten in Betrieb bzw. nur die stillgelegten.
idsKommaliste, höchstens 100 KennungenGenau diese Lieferanten. Doppelte fallen weg.
updatedAtFromISO-8601Nur seit diesem Zeitpunkt geänderte Zeilen — der Filter für den Abgleich.
updatedAtToISO-8601Obergrenze des Änderungsfensters.
sortcompanyName | updatedAt | createdAtVorgabe: companyNameSortierfeld. Für einen Abgleich ist `updatedAt` richtig.
dirasc | descVorgabe: ascRichtung. Teil des Zeigers und über alle Seiten hinweg 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 — nicht über die Seite.

Mögliche Fehler

  • validationUnbekannter Parameter, `q` kürzer als zwei Zeichen, mehr als 100 `ids`, oder ein Cursor aus einer anderen Abfrage.
  • forbiddenDem Schlüssel fehlt `suppliers:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • Flacher Umschlag: `data`, `nextCursor`, `hasMore`, `limit`, optional `total`.

POST/api/v1/suppliers

Einen Lieferanten anlegen.

Rechte

suppliers: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 denselben Lieferanten zweimal an.

Felder im Rumpf

NameTypBedeutung
companyNamePflichtstring, 1–200 ZeichenFirmenname. Das einzige Pflichtfeld.
uidstring bis 64 Zeichen oder nullUmsatzsteuer-Identifikationsnummer.
contactNamestring bis 120 Zeichen oder nullAnsprechpartner.
emailE-Mail oder nullBestelladresse. Wird kleingeschrieben gespeichert.
phonestring bis 40 Zeichen oder nullTelefonnummer, Freitext.
addressstring bis 200 Zeichen oder nullStrasse und Hausnummer.
citystring bis 120 Zeichen oder nullOrt.
zipCodestring bis 20 Zeichen oder nullPostleitzahl.
countrystring bis 64 Zeichen oder nullLand als FREITEXT, nicht als Ländercode — Lieferanten stehen auch ausserhalb der EU.
websitehttp(s)-Adresse oder nullNur vollständige `http`- oder `https`-Adressen. Alles andere ist 400.
notesstring bis 5000 Zeichen oder nullFreier Vermerk (Lieferrhythmus, Mindestbestellwert).
isActivebooleanVorgabe: trueOb der Lieferant in Betrieb ist.

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, Schema verletzt oder ein unbekanntes Feld im Rumpf.
  • nothing_to_writeDer Rumpf ist leer oder enthält kein einziges Feld.
  • read_only_fieldDer Rumpf enthält `id`, `createdAt` oder `updatedAt` — die gehören der Datenbank.
  • forbiddenDem Schlüssel fehlt `suppliers:write`.
  • idempotency_key_reuseDerselbe Schlüssel wurde bereits für eine andere Anfrage benutzt.
  • Antwortet mit 201 und dem Datensatz OHNE Umschlag.

GET/api/v1/suppliers/{id}

Einen Lieferanten lesen.

Rechte

suppliers:read

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Lieferanten. Eine fremde Kennung ergibt 404, nie 403.

Mögliche Fehler

  • not_foundDiesen Lieferanten gibt es für diesen Betrieb nicht.
  • forbiddenDem Schlüssel fehlt `suppliers:read`.

PATCH/api/v1/suppliers/{id}

Einzelne Felder eines Lieferanten ändern — oder ihn ausser Dienst stellen.

Rechte

suppliers:write

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung des Lieferanten.

Kopfzeilen

NameTypBedeutung
Idempotency-Keystring bis 255 ZeichenFreiwillig: eine Wiederholung setzt dieselben Werte und richtet keinen Schaden an.

Felder im Rumpf

NameTypBedeutung
companyNamestring, 1–200 ZeichenNeuer Firmenname.
isActiveboolean`false` stellt den Lieferanten ausser Dienst — der umkehrbare Ersatz für ein Löschen.

Mögliche Fehler

  • Alle Felder aus `POST` sind auch hier erlaubt; jedes einzeln und optional.

Codebeispiele

curl — Lieferanten suchen
curl
curl -sS -G https://tactictable.com/api/v1/suppliers \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  --data-urlencode "q=Metzger" \  --data-urlencode "active=true" \  --data-urlencode "sort=companyName" \  --data-urlencode "limit=50"
`q` braucht mindestens zwei Zeichen. Ein einzelnes Zeichen läse über `LIKE %a%` praktisch die ganze Tabelle — bezahlt von jedem anderen Betrieb auf derselben Instanz.
Antwort 200 — GET /api/v1/suppliers
JSON
{  "data": [    {      "id": "461481e7-b92b-5e27-b204-e99540d3bf7d",      "companyName": "Metzgerei Hofer KG",      "uid": "ATU87654321",      "contactName": "Josef Hofer",      "email": "bestellung@metzgerei-hofer.at",      "phone": "+4366298765",      "address": "Gewerbestraße 12",      "city": "Hallein",      "zipCode": "5400",      "country": "Österreich",      "website": "https://metzgerei-hofer.at",      "notes": "Liefert Dienstag und Freitag, Mindestbestellwert 150 €.",      "isActive": true,      "createdAt": "2026-03-19T07:42:10.000Z",      "updatedAt": "2026-08-06T13:55:02.000Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 50}
`country` ist Freitext („Österreich", nicht „AT"). Das spiegelt das Dashboard — Lieferanten stehen auch ausserhalb der EU, und ein zweistelliger Code wäre dort oft falsch.
curl — Lieferanten anlegen
curl
curl -sS -X POST https://tactictable.com/api/v1/suppliers \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 1a4e9f27-6c3b-4d80-9e51-2b7f0d6a3c18" \  -d '{    "companyName": "Metzgerei Hofer KG",    "uid": "ATU87654321",    "contactName": "Josef Hofer",    "email": "bestellung@metzgerei-hofer.at",    "city": "Hallein",    "country": "Österreich",    "website": "https://metzgerei-hofer.at"  }'
Der `Idempotency-Key` ist hier PFLICHT. Ohne ihn ist die Antwort 400 mit genau diesem Hinweis — nicht ein zweiter Lieferant.
Antwort 201 — angelegt (ohne Umschlag)
JSON
{  "id": "461481e7-b92b-5e27-b204-e99540d3bf7d",  "companyName": "Metzgerei Hofer KG",  "uid": "ATU87654321",  "contactName": "Josef Hofer",  "email": "bestellung@metzgerei-hofer.at",  "phone": null,  "address": null,  "city": "Hallein",  "zipCode": null,  "country": "Österreich",  "website": "https://metzgerei-hofer.at",  "notes": null,  "isActive": true,  "createdAt": "2026-09-14T12:18:44.902Z",  "updatedAt": "2026-09-14T12:18:44.902Z"}
Kein `data`-Umschlag: der Datensatz steht direkt im Körper. Die LISTE dagegen hat einen — das ist die eine Stelle, an der die beiden Formen in diesem Bereich auseinandergehen.
TypeScript — Lieferanten abgleichen
TypeScript
import { randomUUID } from 'node:crypto' interface Lieferant {    id: string    companyName: string    uid: string | null    email: string | null    city: string | null    isActive: boolean    updatedAt: string} export async function geaenderteLieferanten(token: string, seit: string): Promise<Lieferant[]> {    const gesammelt: Lieferant[] = []    let cursor: string | null = null     for (let seite = 0; seite < 500; seite++) {        const p = new URLSearchParams({            updatedAtFrom: seit,            sort: 'updatedAt',            dir: 'asc',            limit: '200',        })        if (cursor) p.set('cursor', cursor)         const antwort = await fetch('https://tactictable.com/api/v1/suppliers?' + p, {            headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },        })        if (!antwort.ok) throw new Error('HTTP ' + antwort.status)         const s = (await antwort.json()) as { data: Lieferant[]; nextCursor: string | null }        gesammelt.push(...s.data)         if (!s.nextCursor) return gesammelt        cursor = s.nextCursor    }     throw new Error('Zu viele Seiten — Zeitfenster verkleinern.')} /** Ausser Dienst stellen. Es gibt kein Loeschen — mit Absicht. */export async function stillegen(token: string, id: string): Promise<Lieferant> {    const antwort = await fetch('https://tactictable.com/api/v1/suppliers/' + encodeURIComponent(id), {        method: 'PATCH',        headers: {            Authorization: 'Bearer ' + token,            'Content-Type': 'application/json',            'Idempotency-Key': randomUUID(),        },        body: JSON.stringify({ isActive: false }),    })    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)    return (await antwort.json()) as Lieferant}
Ein gelöschter Lieferant kappt die Zuordnung jeder seiner Waren, und niemand merkt es. `isActive: false` ist umkehrbar und lässt die Kalkulation stehen.
Python — Lieferant anlegen oder finden
Python
import jsonimport uuid import requests  def lieferant_sichern(sitzung: requests.Session, firma: str, felder: dict) -> dict:    """Legt an — oder gibt den bestehenden zurueck, falls es ihn schon gibt."""    gefunden = sitzung.get(        "https://tactictable.com/api/v1/suppliers",        params={"q": firma, "limit": 50},        timeout=20,    )    gefunden.raise_for_status()     for zeile in gefunden.json()["data"]:        if zeile["companyName"].strip().lower() == firma.strip().lower():            return zeile     antwort = sitzung.post(        "https://tactictable.com/api/v1/suppliers",        data=json.dumps({"companyName": firma, **felder}),        headers={            "Content-Type": "application/json",            # PFLICHT bei dieser Route.            "Idempotency-Key": str(uuid.uuid4()),        },        timeout=30,    )    if antwort.status_code != 201:        rumpf = antwort.json()        raise RuntimeError("{}: {}".format(rumpf["error"], rumpf["message"]))     return antwort.json()
`q` sucht auch über Ansprechpartner und Stadt — der Vergleich auf den Firmennamen danach ist deshalb kein Luxus, sondern nötig.
C# — Lieferantenliste in ein Warenwirtschaftssystem holen
C#
using System;using System.Collections.Generic;using System.Net.Http;using System.Text.Json;using System.Threading.Tasks; public sealed record Lieferant(    string Id,    string CompanyName,    string? Uid,    string? Email,    bool IsActive); public sealed class Lieferantenabruf{    private readonly HttpClient _klient;     public Lieferantenabruf(HttpClient klient) => _klient = klient;     public async Task<List<Lieferant>> AlleAsync()    {        var alle = new List<Lieferant>();        string? cursor = null;         for (var seite = 0; seite < 500; seite++)        {            var adresse = "https://tactictable.com/api/v1/suppliers?active=true&limit=200"                + (cursor is null ? "" : "&cursor=" + Uri.EscapeDataString(cursor));             using var antwort = await _klient.GetAsync(adresse);            antwort.EnsureSuccessStatusCode();             using var json = JsonDocument.Parse(await antwort.Content.ReadAsStringAsync());            var wurzel = json.RootElement;             foreach (var zeile in wurzel.GetProperty("data").EnumerateArray())            {                alle.Add(new Lieferant(                    zeile.GetProperty("id").GetString()!,                    zeile.GetProperty("companyName").GetString()!,                    zeile.GetProperty("uid").GetString(),                    zeile.GetProperty("email").GetString(),                    zeile.GetProperty("isActive").GetBoolean()));            }             var weiter = wurzel.GetProperty("nextCursor");            if (weiter.ValueKind == JsonValueKind.Null) return alle;            cursor = weiter.GetString();        }         throw new InvalidOperationException("Zu viele Seiten.");    }}
`JsonValueKind.Null` prüfen und nicht auf einen leeren Text hoffen: `nextCursor` ist `null` auf der letzten Seite, und `GetString()` gäbe dort ebenfalls `null` zurück — der ausdrückliche Vergleich macht die Absicht sichtbar.