Lager und Bestand: die Inventur

Lager lesen, Bestände je Lager lesen und ABSOLUT setzen — warum das ein `PUT` ist, warum der Schlüssel Pflicht ist, und was es hier bewusst nicht gibt.

Für Entwickler

Voraussetzungen

  • Recht `products:read` zum Lesen von Lagern und Beständen
  • Recht `products:write` zum Setzen von Beständen — NICHT `rawMaterials:write`
  • Plan-Merkmal „Warenwirtschaft"

DAS RECHT SITZT AUF `products`, NICHT AUF `rawMaterials` — und das überrascht jeden, der es zum ersten Mal einbaut. Der Grund ist Treue zum Dashboard: dort hängen Lager und Bestand am Modul `products`, und die API trägt dieselben Schranken wie die Oberfläche. Zwei Vokabulare für dasselbe Recht wären schlimmer als eine überraschende Zuordnung — deshalb steht sie hier ausdrücklich.

`PUT` UND NICHT `PATCH`, und das ist die einzige Stelle der ganzen API mit einem `PUT`: der Aufruf SETZT die Menge, er addiert sie nicht. Bei einer Wiederholung durch Netzverlust summierte sich der Bestand sonst auf. Im Kühlhaus mit schlechtem WLAN ist das nicht die Ausnahme, sondern der Normalfall — und ein Bestand, der sich durch Wiederholungen selbst hochzählt, fällt erst bei der nächsten Inventur auf.

`Idempotency-Key` IST HIER PFLICHT. Zusammen mit dem absoluten Setzen ist das die doppelte Absicherung gegen genau diesen Fall: derselbe Aufruf zweimal geschickt setzt denselben Wert, und der zweite wird gar nicht erst ausgeführt, sondern als Wiederholung beantwortet (erkennbar an der Kopfzeile `Idempotency-Replayed`).

ES GIBT KEIN LAGERJOURNAL — weder im Produkt noch hier, und das ist die wichtigste Einschränkung dieses Bereichs. Der Aufruf überschreibt, und niemand kann hinterher sagen, wer wann von welchem Wert auf welchen gesetzt hat. Zwei Geräte, die dieselbe Position zählen, übersehen einander; der letzte Schreiber gewinnt. Eine Bewegungs-API („Zugang 5 kg", „Abgang 2 kg") wäre hier erfunden: es gibt kein Modell, in dem die Buchung stünde. Wer den Vorher-Wert braucht, liest ihn vorher.

LAGER LASSEN SICH NICHT ÜBER DIE API ANLEGEN ODER LÖSCHEN. Lager sind Einrichtung, und Konfigurationsschreibwege sind in dieser Fassung bewusst draussen: sie verdoppeln die Schreibfläche, und kein Anwendungsfall verlangt, dass eine Erweiterung Lager anlegt. Angelegt werden sie im Dashboard.

MENGEN SIND GLEITKOMMA, PREISE NICHT. `quantity` ist eine Menge — 5,25 Liter sind kein Rundungsfehler, sondern eine korrekte Angabe. Geldbeträge dagegen sind überall ganzzahlige Cent. Negative Mengen sind ausgeschlossen: die Spalte bildet einen physischen Bestand ab, und es gibt kein Journal, in dem eine Korrektur als Buchung stünde.

DIE EINHEIT STEHT AN DER WARE, NICHT AM BESTAND. `quantity` zählt immer in der `packageUnit` der Ware. Ein Bestand von `12` bei einer Ware mit `packageUnit: KILOGRAM` sind 12 Kilogramm — nicht 12 Gebinde. Wer die Einheit anzeigen will, holt sie über `GET /api/v1/raw-materials?ids=…`.

Die Routen

GET/api/v1/warehouses

Die Lager des Betriebs lesen.

Rechte

products:read

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

Abfrageparameter

NameTypBedeutung
qstring, 2–200 ZeichenVolltext über `name` und `location`.
activetrue | falseNur Lager in Betrieb bzw. nur die stillgelegten.
idsKommaliste, höchstens 100 UUIDsGenau diese Lager.
updatedAtFromISO-8601Nur seit diesem Zeitpunkt geänderte Lager.
updatedAtToISO-8601Obergrenze des Änderungsfensters.
sortname | updatedAt | createdAtVorgabe: nameSortierfeld.
dirasc | descVorgabe: ascRichtung, über alle Seiten gleich zu halten.
limitinteger 1–200Vorgabe: 50Zeilen je Seite. Für Lager reicht die Vorgabe fast immer — kein Haus hat 50 davon.
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, oder ein Cursor aus einer anderen Abfrage.
  • forbiddenDem Schlüssel fehlt `products:read` — nicht `rawMaterials:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • NUR LESEND. Lager anlegen, umbenennen oder löschen geht ausschliesslich im Dashboard.
  • `positionCount` sagt, wie viele Waren in diesem Lager überhaupt geführt werden — nicht, wie viel davon da ist.

GET/api/v1/warehouse-stock

Bestände lesen — je Lager, je Ware oder als Ganzes.

Rechte

products:read

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

Abfrageparameter

NameTypBedeutung
warehouseIdUUIDNur Positionen dieses Lagers. Der übliche Einstieg bei einer Inventur: ein Lager, ein Zähldurchgang.
rawMaterialIdUUIDNur Positionen dieser Ware — über alle Lager hinweg. So findet man, wo die fehlenden Kilo liegen.
inStocktrue | false`true` nur Positionen mit Menge grösser null, `false` nur die leeren. Ohne den Parameter kommen beide — und eine Position mit Menge 0 ist etwas anderes als eine, die es nicht gibt.
idsKommaliste, höchstens 100 UUIDsGenau diese Bestandspositionen (nicht Waren, nicht Lager).
updatedAtFromISO-8601Nur seither geänderte Positionen. Der Filter, mit dem ein Abgleich erkennt, was seit dem letzten Lauf gezählt wurde.
updatedAtToISO-8601Obergrenze des Änderungsfensters.
sortupdatedAt | createdAtVorgabe: updatedAtSortierfeld. Nur diese beiden — eine Bestandszeile hat keinen Namen, nach dem sich sortieren liesse.
dirasc | descVorgabe: ascRichtung, über alle Seiten 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, unzulässiges `sort`-Feld, oder ein Cursor aus einer anderen Abfrage.
  • forbiddenDem Schlüssel fehlt `products:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • `quantity` zählt in der `packageUnit` der WARE — die Bestandszeile trägt die Einheit nicht selbst.
  • Eine Ware ohne Zeile in einem Lager wird dort gar nicht geführt. Das ist etwas anderes als eine Zeile mit `quantity: 0`, die bedeutet: hier gezählt, nichts gefunden.

PUT/api/v1/warehouse-stock

Den Bestand einer Ware in einem Lager ABSOLUT setzen — das Ergebnis eines Zählvorgangs.

Rechte

products:write

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

Kopfzeilen

NameTypBedeutung
Idempotency-KeyPflichtstring bis 255 ZeichenPFLICHT. Zusammen mit dem absoluten Setzen die doppelte Absicherung gegen den Wiederholversuch im Funkloch. Je Zählvorgang eine frische UUIDv4.

Felder im Rumpf

NameTypBedeutung
warehouseIdPflichtUUIDDas Lager. Eine Kennung aus einem fremden Betrieb ist 400 `validation` mit `invalid_reference` — nicht 404.
rawMaterialIdPflichtUUIDDie Ware. Gibt es für dieses Paar noch keine Bestandszeile, wird sie angelegt; sonst überschrieben.
quantityPflichtnumber ≥ 0, höchstens 1 000 000 000Die GEZÄHLTE Menge, nicht die Differenz. `12.5` setzt den Bestand auf 12,5 — in der `packageUnit` der Ware. Negative Werte sind ausgeschlossen: es gibt kein Journal, in dem eine Korrektur als Buchung stünde.

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, ein unbekanntes Feld im Rumpf, eine negative `quantity` — oder `warehouseId`/`rawMaterialId` gehören nicht zu diesem Betrieb.
  • nothing_to_writeDer Rumpf ist leer oder `{}`.
  • read_only_field`id`, `createdAt` oder `updatedAt` stehen im Rumpf.
  • forbiddenDem Schlüssel fehlt `products:write`. Ein Schlüssel mit `rawMaterials:write` reicht hier NICHT.
  • idempotency_key_reuseDerselbe Schlüssel wurde schon für eine ANDERE Anfrage benutzt. Der Bestand wurde dann NICHT gesetzt.
  • idempotency_in_progressEin Aufruf mit demselben Schlüssel läuft gerade noch — in einigen Sekunden erneut versuchen, mit demselben Schlüssel.
  • Antwortet mit 200 und der Bestandszeile OHNE Umschlag — auch dann, wenn die Zeile neu angelegt wurde.
  • Es gibt kein Lagerjournal: der vorherige Wert ist nach dem Aufruf nirgends mehr abrufbar. Wer ihn braucht, liest vorher.
  • Eine Wiederholung mit demselben Schlüssel gibt die erste Antwort zurück, erkennbar an der Kopfzeile `Idempotency-Replayed`.

Codebeispiele

curl — Bestände eines Lagers lesen
curl
curl -sS -G https://tactictable.com/api/v1/warehouse-stock \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  --data-urlencode "warehouseId=fa4459da-c3f1-56d9-ae8c-e7c8f0191616" \  --data-urlencode "inStock=true" \  --data-urlencode "limit=200"
`inStock=true` lässt die leeren Positionen weg. Ohne den Parameter kommen auch Zeilen mit `quantity: 0` — und die bedeuten etwas anderes als „nicht vorhanden": hier wurde gezählt und nichts gefunden.
Antwort 200 — GET /api/v1/warehouse-stock
JSON
{  "data": [    {      "id": "0df57cc6-a98f-5288-8a12-087cb5824183",      "warehouseId": "fa4459da-c3f1-56d9-ae8c-e7c8f0191616",      "rawMaterialId": "08c20d5c-8a03-5372-a85b-9d4c87db516f",      "quantity": 6.5,      "createdAt": "2026-03-20T07:00:00.000Z",      "updatedAt": "2026-09-12T05:48:31.000Z"    },    {      "id": "8d141776-c99e-4442-8c96-a5d293f286e7",      "warehouseId": "fa4459da-c3f1-56d9-ae8c-e7c8f0191616",      "rawMaterialId": "28d4e72c-9d34-4917-bf1e-31507e5c8677",      "quantity": 8,      "createdAt": "2026-03-20T07:00:00.000Z",      "updatedAt": "2026-09-11T15:02:55.000Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 200}
Die Zeile trägt KEINE Einheit. `6.5` sind 6,5 Kilogramm, weil die Ware `packageUnit: KILOGRAM` hat — nicht 6,5 Gebinde. Die Einheit holen Sie über `GET /api/v1/raw-materials?ids=…`.
curl — gezählten Bestand setzen
curl
curl -sS -X PUT https://tactictable.com/api/v1/warehouse-stock \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 2f8c5a10-7b93-4e26-b0d4-9c13ea75f608" \  -d '{    "warehouseId": "fa4459da-c3f1-56d9-ae8c-e7c8f0191616",    "rawMaterialId": "08c20d5c-8a03-5372-a85b-9d4c87db516f",    "quantity": 12.5  }'
SETZT auf 12,5 — es werden nicht 12,5 addiert. Genau darum ist es ein `PUT`: derselbe Aufruf zweimal geschickt lässt den Bestand bei 12,5, statt ihn auf 25 zu treiben.
Antwort 200 — Bestand gesetzt
JSON
{  "id": "0df57cc6-a98f-5288-8a12-087cb5824183",  "warehouseId": "fa4459da-c3f1-56d9-ae8c-e7c8f0191616",  "rawMaterialId": "08c20d5c-8a03-5372-a85b-9d4c87db516f",  "quantity": 12.5,  "createdAt": "2026-03-20T07:00:00.000Z",  "updatedAt": "2026-09-14T09:30:12.418Z"}
200 und nicht 201, auch wenn die Zeile neu entstanden ist: `PUT` beschreibt einen Zustand, und der ist danach hergestellt — ob vorher schon eine Zeile da war, ist für den Aufrufer ohne Belang. `createdAt` verrät es trotzdem.
C# — Inventur eines Lagers übertragen
C#
using System;using System.Collections.Generic;using System.Net;using System.Net.Http;using System.Text;using System.Text.Json;using System.Threading.Tasks; namespace TacticTable; /// <summary>Eine gezaehlte Position: Ware und gefundene Menge.</summary>public sealed record Zaehlung(string RawMaterialId, double Menge); public sealed class Inventurabgleich{    private const string Basis = "https://tactictable.com/api/v1";     private readonly HttpClient _klient;     public Inventurabgleich(HttpClient klient) => _klient = klient;     /// <summary>    /// Uebertraegt einen Zaehldurchgang. Jede Position bekommt EINEN    /// Idempotency-Key, der ueber alle Wiederholversuche derselbe bleibt —    /// sonst legte ein Wiederholversuch nach Zeitueberschreitung eine    /// zweite, widersprechende Buchung an.    ///    /// Es gibt kein Lagerjournal: nach dem Aufruf ist der vorherige Wert    /// nirgends mehr abrufbar. Wer ihn protokollieren will, liest ihn    /// vorher.    /// </summary>    public async Task<IReadOnlyList<string>> UebertrageAsync(        string lagerId,        IEnumerable<Zaehlung> zaehlungen)    {        var fehler = new List<string>();         foreach (var z in zaehlungen)        {            // Ein Schluessel je Position, VOR der Schleife der Versuche            // erzeugt und ueber alle Versuche hinweg derselbe.            var schluessel = Guid.NewGuid().ToString();             for (var versuch = 1; versuch <= 3; versuch++)            {                using var anfrage = new HttpRequestMessage(HttpMethod.Put, Basis + "/warehouse-stock");                anfrage.Headers.Add("Idempotency-Key", schluessel);                 var rumpf = JsonSerializer.Serialize(new                {                    warehouseId = lagerId,                    rawMaterialId = z.RawMaterialId,                    quantity = z.Menge,                });                anfrage.Content = new StringContent(rumpf, Encoding.UTF8, "application/json");                 using var antwort = await _klient.SendAsync(anfrage);                 if (antwort.IsSuccessStatusCode)                {                    break;                }                 // 409 idempotency_in_progress heisst: der erste Versuch                // arbeitet noch. Warten und MIT DEMSELBEN Schluessel erneut.                if (antwort.StatusCode == HttpStatusCode.Conflict && versuch < 3)                {                    await Task.Delay(TimeSpan.FromSeconds(2 * versuch));                    continue;                }                 var text = await antwort.Content.ReadAsStringAsync();                using var doc = JsonDocument.Parse(text);                fehler.Add($"{z.RawMaterialId}: {doc.RootElement.GetProperty("error").GetString()}");                break;            }        }         return fehler;    }}
Der `Idempotency-Key` entsteht VOR der Wiederholschleife, nicht in ihr. Ein frischer Schlüssel je Versuch hebt den ganzen Schutz auf — dann ist jeder Wiederholversuch für den Server eine neue Anfrage.
Python — Bestand lesen, dann setzen (mit Protokoll)
Python
import jsonimport uuid import requests BASIS = "https://tactictable.com/api/v1"  def bestand_setzen_mit_protokoll(    sitzung: requests.Session,    lager_id: str,    ware_id: str,    gezaehlt: float,) -> dict:    """Setzt den Bestand und haelt die Differenz selbst fest.     Die API fuehrt KEIN Lagerjournal. Nach dem PUT ist der vorherige Wert    nirgends mehr abrufbar - wer eine Nachvollziehbarkeit braucht, muss    vorher lesen und selbst protokollieren. Das ist kein Workaround,    sondern die dokumentierte Grenze dieses Bereichs.    """    vorher = sitzung.get(        f"{BASIS}/warehouse-stock",        params={"warehouseId": lager_id, "rawMaterialId": ware_id, "limit": 1},        timeout=20,    )    vorher.raise_for_status()     zeilen = vorher.json()["data"]    # Keine Zeile heisst: die Ware wird in diesem Lager noch nicht gefuehrt.    # Das PUT legt sie an.    alt = zeilen[0]["quantity"] if zeilen else None     antwort = sitzung.put(        f"{BASIS}/warehouse-stock",        data=json.dumps(            {                "warehouseId": lager_id,                "rawMaterialId": ware_id,                # ABSOLUT, nicht die Differenz.                "quantity": gezaehlt,            }        ),        headers={            "Content-Type": "application/json",            "Idempotency-Key": str(uuid.uuid4()),        },        timeout=30,    )     if antwort.status_code != 200:        koerper = antwort.json()        raise RuntimeError(f"{koerper['error']}: {koerper['message']}")     neu = antwort.json()    return {        "warehouseId": lager_id,        "rawMaterialId": ware_id,        "vorher": alt,        "nachher": neu["quantity"],        "gebucht_um": neu["updatedAt"],        "war_neu": alt is None,    }
Zwei Aufrufe, und zwischen ihnen kann ein anderes Gerät schreiben — der Vorher-Wert ist also eine Beobachtung, keine Garantie. Mehr gibt der Bereich nicht her, solange es kein Lagerjournal gibt; das ehrlich hinzuschreiben ist besser, als es zu verschweigen.
Ruby — alle Lager mit ihren Positionszahlen
Ruby
# frozen_string_literal: true require 'json'require 'net/http'require 'uri' BASIS = 'https://tactictable.com/api/v1' # Liest alle Lager. Das Recht sitzt auf products:read - NICHT auf# rawMaterials:read. Wer hier 403 bekommt und den Warenhaken prueft,# sucht an der falschen Stelle.def lager_uebersicht(token)  uri = URI("#{BASIS}/warehouses")  uri.query = URI.encode_www_form(active: 'true', sort: 'name', limit: 200)   anfrage = Net::HTTP::Get.new(uri)  anfrage['Authorization'] = "Bearer #{token}"  anfrage['Accept'] = 'application/json'   antwort = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|    http.request(anfrage)  end   if antwort.code == '403'    rumpf = JSON.parse(antwort.body)    raise "Fehlendes Recht #{rumpf['module']}:#{rumpf['op']} - Lager haengen "           'am Modul products, nicht an rawMaterials.'  end   raise "HTTP #{antwort.code}" unless antwort.code == '200'   JSON.parse(antwort.body)['data'].map do |lager|    {      id: lager['id'],      name: lager['name'],      ort: lager['location'],      # positionCount sagt, wie viele Waren hier GEFUEHRT werden -      # nicht, wie viel davon da ist.      gefuehrte_waren: lager['positionCount']    }  endend
Der Körper eines 403 nennt `module` und `op` — hier also `products` und `read`. Das ist die schnellste Art, die überraschende Rechtezuordnung dieses Bereichs zu erkennen, ohne die Dokumentation nochmals zu lesen.