Waren: Einkaufspreise, Gebinde und Mindestmengen

Der Warenstamm mit Einkaufspreisen — das Betriebsgeheimnis des Hauses. Lesen, anlegen, ändern, nach Unterdeckung filtern und per Scannercode auflösen.

Für Entwickler

Voraussetzungen

  • Recht `rawMaterials:read` zum Lesen, `rawMaterials:write` zum Anlegen und Ändern
  • Plan-Merkmal „Warenwirtschaft"
  • Bewusstsein darüber, dass dieser Bereich die Einkaufspreise des Betriebs vollständig herausgibt

HIER LIEGT DIE KALKULATION DES BETRIEBS. Ein Schlüssel mit `rawMaterials:read` gibt jeden Einkaufspreis vollständig heraus. Das ist kein Nebeneffekt, sondern der Zweck des Anwendungsfalls „Wareneinsatz an die Buchhaltung" — aber es ist der Grund, warum `rawMaterials` ein eigenes Modul ist und nicht an `products` mit hängt, und warum die Zutat eines Rezepts nur die KENNUNG der Ware trägt und nicht deren Preis. Wer diesen Haken setzt, gibt seine Einkaufskonditionen aus der Hand.

GELD IST IMMER EIN GANZZAHLIGER CENT-BETRAG, und der Feldname sagt es: `packagePriceNetCents`. 84,50 € sind `8450`. Rechnen Sie niemals in Gleitkomma weiter — drei Additionen und eine Division reichen, damit aus 84,50 € ein Wert wird, der auf keiner Rechnung so steht. Die EINZIGE Ausnahme ist `vatRate`: das ist ein Prozentsatz (`10` für 10 %), kein Betrag.

`packagePriceGrossCents` IST ABGELEITET und nur lesbar. Der Brutto folgt aus Netto und Satz. Schickt man ihn mit, ist das 400 `read_only_field` — und nicht etwa ein stilles Ignorieren: sonst glaubte der Aufrufer, er habe den Bruttopreis gesetzt, während zwei Quellen für dieselbe Zahl auseinanderdriften.

DER PREIS GILT FÜR DAS GEBINDE, NICHT FÜR DIE EINHEIT. `packageSize` und `packageUnit` beschreiben, was man einkauft: `5` und `KILOGRAM` heisst „ein Gebinde zu 5 kg", und `packagePriceNetCents` ist der Preis DIESES Gebindes. Der Kilopreis ist eine Division, die Sie selbst machen — die API speichert ihn nicht, weil er sonst beim ersten Gebindewechsel falsch wäre.

`stockTotal` IST DIE SUMME ÜBER ALLE LAGER, in der `packageUnit` der Ware, und ebenfalls nur lesbar. Geschrieben wird Bestand ausschliesslich je Lager über `PUT /api/v1/warehouse-stock`. Eine Summe zu setzen wäre sinnlos: es stünde nicht fest, welches Lager gemeint ist.

`stock=below` IST DER BESTELLVORSCHLAG. Der Filter liefert die Waren, deren Gesamtbestand unter `minStock` liegt — die Abfrage vergleicht dafür eine Summe mit einer Spalte einer anderen Tabelle und ist deshalb auf 5000 Kennungen gedeckelt. Das liegt weit über jedem realen Warenstamm; ein Haus, das dort anstösst, hat ein anderes Problem als diese API.

`sku` FILTERT EXAKT, NICHT ALS TEILTEXT. Das ist die Auflösung eines gescannten Codes und keine Suche: `?sku=4260123456789` trifft genau die eine Ware oder keine. Wer suchen will, nimmt `q` — das geht über Name, `sku` und Marke und braucht mindestens zwei Zeichen.

KEIN LÖSCHEN. An einer Ware hängen ihre Lagerbestände und ihre Verwendungen in Rezepten. Ein Löschen wäre entweder blockiert oder würfe Bestandsdaten weg, ohne dass ein Journal davon erzählte. `PATCH {"isActive": false}` nimmt die Ware aus dem Verkehr und lässt sich zurücknehmen.

Die Routen

GET/api/v1/raw-materials

Den Warenstamm lesen — gesucht, gefiltert, nach Unterdeckung eingegrenzt, seitenweise.

Rechte

rawMaterials:read

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

Abfrageparameter

NameTypBedeutung
qstring, 2–200 ZeichenVolltext über `name`, `sku` und `brand`. Mindestens zwei Zeichen — ein einzelnes Zeichen läse über `LIKE %a%` die ganze Tabelle.
skustring, 1–64 ZeichenEXAKTER Treffer, kein Teiltext. Das ist die Auflösung eines gescannten Codes; sie trifft genau eine Ware oder keine.
activetrue | false`true` nur Waren in Verwendung, `false` nur die stillgelegten. Fehlt der Parameter, kommen beide.
supplierIdUUID oder `none`Waren dieses Lieferanten. Der Sonderwert `none` liefert die Waren OHNE Lieferantenzuordnung — die Lücke, die man vor einer Ausschreibung sucht.
packageUnitEinheit (siehe unten)Nur Waren mit dieser Gebindeeinheit, z. B. `KILOGRAM`. Ein unbekannter Wert ist 400, kein leeres Ergebnis.
stockbelow | zero | tracked | untracked`below` = Gesamtbestand unter `minStock`, der Bestellvorschlag. `zero` = nirgends Bestand. `tracked` = wird in mindestens einem Lager geführt. `untracked` = in keinem Lager angelegt — das ist etwas anderes als `zero`: die Ware wird gar nicht bestandsgeführt.
idsKommaliste, höchstens 100 UUIDsGenau diese Waren. Duplikate fallen weg. Der richtige Weg, um zu Zutaten eines Rezepts die Namen nachzuladen — statt 30 Einzelabrufe.
updatedAtFromISO-8601Nur seit diesem Zeitpunkt geänderte Waren. Zusammen mit `sort=updatedAt` der Filter für den inkrementellen Abgleich.
updatedAtToISO-8601Obergrenze des Änderungsfensters.
sortname | updatedAt | createdAtVorgabe: nameSortierfeld. Für einen Abgleich ist `updatedAt` richtig, für eine Anzeige `name`.
dirasc | descVorgabe: ascRichtung. Sie steckt im Zeiger und muss über alle Seiten einer Blätterung gleich bleiben.
limitinteger 1–200Vorgabe: 50Zeilen je Seite. Für einen Abgleich `200` — das sind viermal weniger Aufrufe gegen dieselbe Ratenbremse.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort, unverändert. Er trägt einen Fingerabdruck über Sortierung UND Filter; ein Zeiger aus einer anderen Abfrage ist 400 und keine falsche Seite.
includeTotaltrue | falseVorgabe: falseErgänzt `total` über alle Treffer — nicht über die Seite. Kostet ein `COUNT(*)`, deshalb nicht voreingestellt.

Mögliche Fehler

  • validationUnbekannter Parameter, `q` unter zwei Zeichen, `limit` über 200, mehr als 100 `ids`, eine unbekannte `packageUnit` — oder ein `cursor` aus einer anderen Abfrage.
  • forbiddenDem Schlüssel fehlt `rawMaterials:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • rate_limitedDas Kontingent des Schlüssels oder des Betriebs ist erschöpft.
  • Flacher Umschlag: `data`, `nextCursor`, `hasMore`, `limit`, optional `total`.
  • Einheiten: MILLILITER, CENTILITER, LITER, MILLIGRAM, GRAM, KILOGRAM, FLUID_OUNCE, CUP, PINT, QUART, GALLON, OUNCE, POUND, TEASPOON, TABLESPOON, DASH, PIECE, BUNCH, SLICE, CAN, BOTTLE, PACK.

POST/api/v1/raw-materials

Eine Ware anlegen.

Rechte

rawMaterials: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 dieselbe Ware ein zweites Mal an — und ab dann zählt die Inventur zwei Zeilen für eine Sache.

Felder im Rumpf

NameTypBedeutung
namePflichtstring, 1–200 ZeichenDer Name im Warenstamm. Das einzige Pflichtfeld.
skustring bis 64 Zeichen oder nullArtikelnummer oder Barcode. Wonach der Handscanner auflöst.
brandstring bis 120 Zeichen oder nullMarke, wenn sie den Einkauf bestimmt.
descriptionstring bis 5000 Zeichen oder nullFreitext (Qualität, Herkunftsbetrieb, Verarbeitungshinweis).
originCountryISO 3166-1 alpha-2 oder nullHerkunftsland als ZWEISTELLIGER Code, z. B. `AT`. Wird grossgeschrieben. Anders als beim Lieferanten, wo `country` Freitext ist — hier steht es auf der Kennzeichnung.
packageSizenumber > 0, höchstens 1 000 000Vorgabe: 1Die Menge EINES Gebindes: `5` bei einem 5-kg-Sack.
packageUnitEinheitVorgabe: PIECEDie Einheit des Gebindes. Sie bestimmt auch, in welcher Einheit `stockTotal` und die Lagerbestände gezählt werden.
packagePriceNetCentsinteger, 0 bis 100 000 000Vorgabe: 0Nettopreis des GEBINDES in ganzzahligen Cent. 84,50 € sind `8450`. Niemals Gleitkomma.
vatRatenumber 0–100Vorgabe: 10Umsatzsteuersatz in PROZENT — `10` heisst 10 %, nicht 10 Cent. Der einzige Zahlenwert hier, der kein Cent-Betrag ist.
minStocknumber ≥ 0Vorgabe: 0Mindestbestand in `packageUnit`. Unterschreitet der Gesamtbestand ihn, erscheint die Ware unter `?stock=below`.
supplierIdUUID oder nullDer Lieferant. `null` löst die Zuordnung. Eine Kennung, die zu einem anderen Betrieb gehört, ist 400 `validation` mit `invalid_reference` — nicht 404.
imageUrlhttp(s)-Adresse oder nullBild der Ware. Nur vollständige `http`- oder `https`-Adressen.
isActivebooleanVorgabe: trueOb die Ware in Verwendung ist.

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, ein unbekanntes Feld im Rumpf, eine unbekannte Einheit, ein Land, das nicht zwei Buchstaben hat — oder eine `supplierId`, die nicht zu diesem Betrieb gehört.
  • nothing_to_writeDer Rumpf ist leer oder `{}`.
  • read_only_field`packagePriceGrossCents`, `stockTotal`, `id`, `createdAt` oder `updatedAt` stehen im Rumpf. Alle fünf bestimmt der Server.
  • forbiddenDem Schlüssel fehlt `rawMaterials:write`.
  • idempotency_key_reuseDerselbe Schlüssel wurde schon für eine ANDERE Anfrage benutzt. Die Ware wurde dann NICHT angelegt.
  • idempotency_in_progressEin Aufruf mit demselben Schlüssel läuft gerade noch.
  • Antwortet mit 201 und der Ware OHNE Umschlag.
  • `packagePriceGrossCents` steht in der Antwort, ist aber gerechnet: Netto mal (1 + `vatRate`/100), kaufmännisch auf den ganzen Cent gerundet.

GET/api/v1/raw-materials/{id}

Eine einzelne Ware lesen — mit Gesamtbestand über alle Lager.

Rechte

rawMaterials:read

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung der Ware. Eine Kennung aus einem fremden Betrieb ergibt 404 und nie 403 — ein 403 verriete, dass es die Zeile gibt.

Mögliche Fehler

  • not_foundDiese Ware gibt es für diesen Betrieb nicht — oder nicht mehr.
  • forbiddenDem Schlüssel fehlt `rawMaterials:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält die Warenwirtschaft nicht.
  • Für mehrere Waren auf einmal ist `GET /api/v1/raw-materials?ids=…` der richtige Weg — ein Aufruf statt hundert.

PATCH/api/v1/raw-materials/{id}

Einzelne Felder einer Ware ändern — Preis pflegen oder die Ware stilllegen.

Rechte

rawMaterials:write

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

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung der Ware.

Kopfzeilen

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

Felder im Rumpf

NameTypBedeutung
packagePriceNetCentsinteger, ganzzahlige CentDer neue Gebindepreis. Der häufigste Schreibzugriff dieses Bereichs — Preise ändern sich, Stammdaten nicht.
minStocknumber ≥ 0Neue Mindestmenge. Wirkt sofort auf `?stock=below`.
supplierIdUUID oder nullUmhängen auf einen anderen Lieferanten; `null` löst die Zuordnung. Ein fehlender Schlüssel heisst UNVERÄNDERT — das ist der Unterschied zwischen `undefined` und `null`, und er trägt diesen ganzen Bereich.
isActiveboolean`false` nimmt die Ware aus dem Verkehr — 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, Wert ausserhalb seiner Grenzen, oder eine `supplierId` aus einem fremden Betrieb.
  • read_only_field`packagePriceGrossCents` oder `stockTotal` im Rumpf — der klassische Fall „gelesenen Datensatz unverändert zurückgeschickt".
  • not_foundDiese Ware gibt es für diesen Betrieb nicht.
  • forbiddenDem Schlüssel fehlt `rawMaterials:write`.
  • Alle Felder aus `POST` sind auch hier erlaubt, jedes einzeln und optional.
  • Bestand ändert man NICHT hier, sondern mit `PUT /api/v1/warehouse-stock` — immer je Lager.

Codebeispiele

curl — Bestellvorschlag: alles unter der Mindestmenge
curl
curl -sS -G https://tactictable.com/api/v1/raw-materials \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  --data-urlencode "stock=below" \  --data-urlencode "active=true" \  --data-urlencode "sort=name" \  --data-urlencode "limit=200"
`-G` mit `--data-urlencode` statt eines zusammengeklebten Adresstexts: so kodiert curl Umlaute und Leerzeichen selbst. Ein handgebautes `?q=Räucherlachs` ist genau die Stelle, an der eine Anbindung sporadisch 400 bekommt.
Antwort 200 — GET /api/v1/raw-materials?stock=below
JSON
{  "data": [    {      "id": "08c20d5c-8a03-5372-a85b-9d4c87db516f",      "name": "Rinderschulter",      "description": "Weidehaltung, gereift. Für Schmorgerichte.",      "sku": "4260123456789",      "brand": "Hofer Weiderind",      "originCountry": "AT",      "packageSize": 5,      "packageUnit": "KILOGRAM",      "packagePriceNetCents": 8450,      "packagePriceGrossCents": 9295,      "vatRate": 10,      "minStock": 10,      "supplierId": "461481e7-b92b-5e27-b204-e99540d3bf7d",      "imageUrl": null,      "isActive": true,      "stockTotal": 6.5,      "createdAt": "2026-03-19T08:11:40.000Z",      "updatedAt": "2026-09-02T06:24:18.000Z"    },    {      "id": "28d4e72c-9d34-4917-bf1e-31507e5c8677",      "name": "Butter",      "description": null,      "sku": "9001234000117",      "brand": null,      "originCountry": "AT",      "packageSize": 250,      "packageUnit": "GRAM",      "packagePriceNetCents": 219,      "packagePriceGrossCents": 241,      "vatRate": 10,      "minStock": 20,      "supplierId": null,      "imageUrl": null,      "isActive": true,      "stockTotal": 8,      "createdAt": "2026-03-19T08:12:02.000Z",      "updatedAt": "2026-09-11T15:02:55.000Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 200}
`stockTotal` (6,5) liegt unter `minStock` (10) — genau das ist die Bedingung des Filters. Beide Zahlen stehen in `packageUnit`, hier also in Kilogramm: 6,5 kg vorrätig bei 10 kg Mindestmenge. Der Preis `8450` gilt für das GEBINDE zu 5 kg, nicht für das Kilo.
curl — Ware anlegen
curl
curl -sS -X POST https://tactictable.com/api/v1/raw-materials \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 6b1f0c8e-2d4a-4f19-8c73-5e0a9b1d7f42" \  -d '{    "name": "Rinderschulter",    "sku": "4260123456789",    "originCountry": "AT",    "packageSize": 5,    "packageUnit": "KILOGRAM",    "packagePriceNetCents": 8450,    "vatRate": 10,    "minStock": 10,    "supplierId": "461481e7-b92b-5e27-b204-e99540d3bf7d"  }'
`packagePriceGrossCents` steht bewusst NICHT im Rumpf — der Server rechnet ihn. Schickt man ihn mit, ist die Antwort 400 `read_only_field` und die Ware wird nicht angelegt.
Antwort 400 — read_only_field beim Bruttopreis
JSON
{  "error": "read_only_field",  "message": "Diese Felder sind abgeleitet oder gehoeren der Datenbank und koennen nicht geschrieben werden: packagePriceGrossCents.",  "fields": ["packagePriceGrossCents"],  "issues": [    {      "path": "packagePriceGrossCents",      "code": "read_only_field",      "message": "Nur lesbar."    }  ],  "docs": "https://tactictable.com/dokumentation/api/fehler/read_only_field",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Der typische Anlass: eine Ware wurde gelesen, ein Feld geändert und das ganze Objekt zurückgeschickt. Schicken Sie nur, was sich ändert — `fields` nennt genau die Namen, die zu entfernen sind.
PHP — Einkaufspreise aus einer Lieferantenliste pflegen
PHP
<?php declare(strict_types=1); /** * Setzt neue Einkaufspreise. Der Preis kommt als ganzzahliger Cent-Betrag * herein und wird auch so weitergereicht — eine Umrechnung ueber float * waere genau die Stelle, an der aus 84,50 EUR spaeter 84,49 wird. * * @param array<string,int> $preise Kennung der Ware => Nettopreis in Cent * @return array<string,string> Kennung => Fehlermeldung, leer wenn alles lief */function preisePflegen(string $token, array $preise): array{    $fehler = [];     foreach ($preise as $wareId => $centBetrag) {        if (!is_int($centBetrag) || $centBetrag < 0) {            $fehler[$wareId] = 'Preis muss ein ganzzahliger Cent-Betrag sein.';            continue;        }         $ch = curl_init('https://tactictable.com/api/v1/raw-materials/' . rawurlencode($wareId));        curl_setopt_array($ch, [            CURLOPT_CUSTOMREQUEST  => 'PATCH',            CURLOPT_RETURNTRANSFER => true,            CURLOPT_TIMEOUT        => 30,            CURLOPT_HTTPHEADER     => [                'Authorization: Bearer ' . $token,                'Content-Type: application/json',                'Accept: application/json',            ],            // JSON_THROW_ON_ERROR: ein stilles false von json_encode() waere            // ein leerer Rumpf und damit ein verwirrendes nothing_to_write.            CURLOPT_POSTFIELDS => json_encode(                ['packagePriceNetCents' => $centBetrag],                JSON_THROW_ON_ERROR            ),        ]);         $antwort = curl_exec($ch);        $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);        curl_close($ch);         if ($antwort === false) {            $fehler[$wareId] = 'Verbindung fehlgeschlagen.';            continue;        }         if ($status !== 200) {            $rumpf = json_decode((string) $antwort, true, 512, JSON_THROW_ON_ERROR);            // Auf "error" verzweigen, nie auf "message": der Wortlaut darf            // sich jederzeit aendern, die Kennung nicht.            $fehler[$wareId] = $rumpf['error'] . ': ' . $rumpf['message'];        }    }     return $fehler;}
Kein `Idempotency-Key`: bei `PATCH` ist er freiwillig, weil eine Wiederholung dieselben Werte setzt. Beim Anlegen wäre er Pflicht — dort entstünde sonst eine zweite Zeile.
Go — Warenstamm inkrementell abgleichen
Go
package tactictable import (	"encoding/json"	"fmt"	"net/http"	"net/url"	"time") // Ware ist der Ausschnitt des Warenstamms, den der Abgleich braucht.// Preise sind int64-Cent und bleiben es — ein float64 haette hier nichts// verloren.type Ware struct {	ID                   string   `json:"id"`	Name                 string   `json:"name"`	SKU                  *string  `json:"sku"`	PackageSize          float64  `json:"packageSize"`	PackageUnit          string   `json:"packageUnit"`	PackagePriceNetCents int64    `json:"packagePriceNetCents"`	VatRate              float64  `json:"vatRate"`	StockTotal           float64  `json:"stockTotal"`	IsActive             bool     `json:"isActive"`	UpdatedAt            string   `json:"updatedAt"`} type warenSeite struct {	Data       []Ware  `json:"data"`	NextCursor *string `json:"nextCursor"`	HasMore    bool    `json:"hasMore"`} // GeaenderteWaren holt alles, was sich seit "seit" geaendert hat.//// sort=updatedAt und dir=asc gehoeren zusammen mit updatedAtFrom: nur so// wandert das Fenster lueckenlos nach vorn. Ein Abgleich ueber sort=name// wuerde eine Zeile uebersehen, sobald sich waehrend des Blaetterns etwas// aendert.func GeaenderteWaren(klient *http.Client, token string, seit time.Time) ([]Ware, error) {	var alle []Ware	cursor := "" 	for seite := 0; seite < 500; seite++ {		p := url.Values{}		p.Set("updatedAtFrom", seit.UTC().Format(time.RFC3339))		p.Set("sort", "updatedAt")		p.Set("dir", "asc")		p.Set("limit", "200")		if cursor != "" {			p.Set("cursor", cursor)		} 		req, err := http.NewRequest("GET", "https://tactictable.com/api/v1/raw-materials?"+p.Encode(), nil)		if err != nil {			return nil, err		}		req.Header.Set("Authorization", "Bearer "+token)		req.Header.Set("Accept", "application/json") 		res, err := klient.Do(req)		if err != nil {			return nil, err		}		defer res.Body.Close() 		if res.StatusCode != http.StatusOK {			return nil, fmt.Errorf("raw-materials: HTTP %d (Request-Id %s)",				res.StatusCode, res.Header.Get("TT-Request-Id"))		} 		var s warenSeite		if err := json.NewDecoder(res.Body).Decode(&s); err != nil {			return nil, err		}		alle = append(alle, s.Data...) 		if s.NextCursor == nil {			return alle, nil		}		cursor = *s.NextCursor	} 	return nil, fmt.Errorf("zu viele Seiten - Zeitfenster verkleinern")}
`NextCursor` ist ein `*string`, kein `string`: auf der letzten Seite steht dort `null`, und der Zeiger-Vergleich gegen `nil` ist der einzige zuverlässige Abbruch. Ein leerer Text als Abbruchbedingung bricht, sobald ein Zeiger einmal leer kodiert wird.
Ruby — gescannten Barcode auflösen und Bestand zeigen
Ruby
# frozen_string_literal: true require 'json'require 'net/http'require 'uri' BASIS = 'https://tactictable.com/api/v1' # Loest einen gescannten Code auf. sku filtert EXAKT - das ist kein# Suchen, sondern ein Nachschlagen, und es trifft genau eine Ware oder# keine.def ware_zu_barcode(token, barcode)  uri = URI("#{BASIS}/raw-materials")  uri.query = URI.encode_www_form(sku: barcode, limit: 1)   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   unless antwort.code == '200'    rumpf = JSON.parse(antwort.body)    raise "#{rumpf['error']}: #{rumpf['message']} (Request-Id #{rumpf['requestId']})"  end   # Kein Treffer ist kein Fehler: der Scanner hat eine Ware erwischt, die  # im Stamm noch nicht gefuehrt wird. Das ist bei einer Inventur der  # Normalfall und darf das Geraet nicht anhalten.  JSON.parse(antwort.body)['data'].firstend def bestandszeile(ware)  return 'Unbekannter Code - Ware nicht im Stamm.' if ware.nil?   # Preise kommen als ganzzahlige Cent. Die Division durch 100.0 passiert  # ERST bei der Ausgabe und wird nie zurueckgeschrieben.  netto = format('%.2f', ware['packagePriceNetCents'] / 100.0)   format(    '%<name>s - %<bestand>g %<einheit>s vorraetig (Mindestmenge %<min>g), '     'Gebinde %<groesse>g %<einheit>s zu %<netto>s EUR netto',    name: ware['name'],    bestand: ware['stockTotal'],    einheit: ware['packageUnit'],    min: ware['minStock'],    groesse: ware['packageSize'],    netto: netto  )end
Ein unbekannter Barcode gibt `nil` und keine Ausnahme. Bei einer Inventur mit dem Handscanner ist eine noch nicht angelegte Ware der Normalfall — ein Abbruch dort kostet den ganzen Zähldurchgang.