Files
plenty-dojo/DOJO.md
2026-04-09 22:02:04 +00:00

29 KiB
Raw Blame History

Plenty Dojo — Allgemeine Plentymarkets-Wissensbasis

Gesammelte Lektionen aus der Arbeit mit der Plentymarkets REST API. Diese Datei enthält allgemeingültige Patterns und Erkenntnisse, die für jeden Plentymarkets-Entwickler relevant sind — unabhängig vom konkreten Shop oder Projekt.

Shop-spezifische Lektionen (eigene Stati, Workflows, Konfigurationen) stehen in instances/<PID>.md. Kurzform-Anti-Patterns stehen in ANTI-PATTERNS.md.


I. Authentifizierung & API-Client

1. Token-Refresh: Konservativ vor Ablauf erneuern

Lektion: Der Access-Token läuft nach ca. 60 Minuten ab. Refresh nach 50 Minuten statt bei Ablauf einplanen.

Warum: Wenn ein Token während eines laufenden Requests abläuft, schlägt der Request fehl. Ein 10-Min-Puffer gibt genug Spielraum, auch bei langsamen Paginierungs-Schleifen.

Pattern:

const TOKEN_LIFETIME_MS = 50 * 60 * 1000; // 50 Min (konservativer Puffer)
if (Date.now() >= this.tokenExpiresAt) await this.authenticate();

Entdeckt: Produktiv seit 2025. Mehrere Projekte nutzen 50min-Threshold ohne Probleme.


2. Rate Limiting: Fester Delay zwischen Requests

Lektion: Die Plenty API hat kein dokumentiertes Rate-Limit-Header-System wie z.B. GitHub. Stattdessen muss man einen eigenen festen Delay zwischen Requests einbauen.

Warum: Ohne Delay kommen ab ~20 Requests/Sekunde 429 Too Many Requests-Fehler. Die API gibt kein Retry-After-Header zurück.

Pattern:

const REQUEST_DELAY = 500; // 500600ms zwischen Requests
async _rateLimit() {
  const elapsed = Date.now() - this.lastRequestTime;
  if (elapsed < REQUEST_DELAY) await sleep(REQUEST_DELAY - elapsed);
  this.lastRequestTime = Date.now();
}

Best Practice: 500ms für Read-Heavy-Workloads, 600ms wenn auch Writes dabei sind. Bei 429-Fehlern exponentiell backoff: 2s → 4s → 8s.

ACHTUNG — Long Period Write Limit: Bei zu vielen Writes über einen längeren Zeitraum (z.B. tausende POSTs in Folge) gibt es ein zweites, härteres Rate Limit: "long period write limit reached". Dieses blockiert ALLE Requests inklusive /rest/login — nicht nur 429, sondern die gesamte API ist für den Account gesperrt. Dauer: ~2 Stunden (getestet: nach 60min noch gesperrt, nach 2h wieder frei). Nicht dokumentiert. Kein Retry-After Header. Lösung: Bulk-Endpoints nutzen (z.B. POST /rest/items/variations/variation_properties für bis zu 50 pro Request), damit die Gesamtzahl der Writes drastisch sinkt.

Entdeckt: Produktiv seit 2025. 500ms hat sich als zuverlässig erwiesen. Long Period Limit entdeckt 2026-04-08 nach ~15.000 Einzel-POSTs auf /rest/properties/relations.


3. Retry-Strategie: 401 und 429 unterschiedlich behandeln

Lektion: 401 Unauthorized und 429 Rate Limit erfordern verschiedene Retry-Strategien.

Pattern:

const MAX_RETRIES = 3;

// 401: Token abgelaufen → neu authentifizieren, einmal retrien
if (res.status === 401 && attempt === 0) {
  await this.authenticate();
  continue; // retry
}

// 429: Rate-Limited → exponentieller Backoff
if (res.status === 429) {
  const delay = 2000 * Math.pow(2, attempt);
  await sleep(delay);
  continue; // retry
}

Wichtig: Bei 401 nur einmal retrien (nach Re-Auth). Wenn der zweite Versuch auch 401 gibt, sind die Credentials falsch — nicht endlos loopen.


II. Order-Suche & Sync

4. Delta-Sync: Großzügige Zeitüberlappung (24h) verwenden

Lektion: Beim Delta-Sync (Orders seit updatedAt >= X laden) niemals exakt den letzten Sync-Timestamp verwenden. Mindestens 24 Stunden Overlap einbauen.

Warum:

  • Race Condition: Wenn ein Sync exakt zur gleichen Sekunde läuft, in der eine Order geändert wird, kann die Order durchfallen. Der Sync speichert seinen Timestamp NACH dem API-Call, aber die Order wurde WÄHREND des Calls geändert. Jeder folgende Sync startet nach der Order und übersieht sie permanent.
  • Zeitumstellung (DST): Bei der Umstellung CET → CEST (oder umgekehrt) verschiebt sich die lokale Zeit um 1 Stunde.
  • NTP-Drift: Server-Uhren können um Sekunden bis Minuten voneinander abweichen.
  • Plenty-interne Verzögerungen: Das updatedAt-Feld wird nicht immer sofort aktualisiert.
  • Fehlgeschlagene Syncs / Container-Restarts: Wenn ein Sync fehlschlägt oder der Container neu startet, kann die Lücke Stunden betragen.

Pattern:

const SYNC_OVERLAP_MS = 24 * 60 * 60 * 1000; // 24 Stunden
const syncSince = lastSyncTimestamp
  ? new Date(new Date(lastSyncTimestamp).getTime() - SYNC_OVERLAP_MS)
      .toISOString().replace(/\.\d+Z$/, '+00:00')
  : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
      .toISOString().replace(/\.\d+Z$/, '+00:00');

Voraussetzung: Die Upsert-Logik muss idempotent sein (ON CONFLICT DO UPDATE), damit doppelt geladene Orders keinen Schaden anrichten.

Aufwand: Etwas mehr Orders pro Sync (idempotent, also kein Problem). Bei typischen Sync-Intervallen absolut akzeptabel.

Warum nicht 3h? 3h wurde initial implementiert, erwies sich aber als zu knapp: Eine Order wurde sekundengenau zum Sync-Zeitpunkt geändert. Durch Container-Restarts (Deploys) kann sich der nächste Sync so weit verschieben, dass ein 3h-Overlap die Order nicht mehr erfasst.

Entdeckt: 2026-04-02. Race Condition bei Sync und gleichzeitigem Statuswechsel. 3h-Overlap half nicht, 24h löste das Problem sofort.


5. Order-Search-Endpoint: Korrekte Struktur

Lektion: Die Auftragssuche in Plenty läuft über POST /rest/orders/search, nicht über einen GET-Endpoint.

Pattern:

const data = await client.search('/rest/orders/search', {
  page: 1,
  itemsPerPage: 250,          // Maximum pro Seite
  with: [
    'orderItems',              // Positionen
    'orderItems.variation',    // Variations-Details (Kategorien etc.)
    'comments',                // Auftragskommentare
    'dates',                   // Datumsfelder (Warenausgang etc.)
    'properties',              // Versandart, Zahlart, Zahlstatus
    'relations',               // Kontakt-ID (Fallback)
    'addresses',               // Rechnungs-/Lieferadresse
    'amounts'                  // Rechnungsbetrag, bezahlter Betrag
  ],
  conditionType: 'and',        // Oder 'or' für OR-Verknüpfung
  fields: [
    { field: 'updatedAt', operator: 'gte', value: '2026-01-15T12:00:00+00:00' }
  ]
});

Response-Struktur:

{
  entries: [...],              // Aufträge dieser Seite
  isLastPage: true/false,      // Letzte Seite?
  lastPageNumber: 5            // Gesamtzahl Seiten
}

Pagination: Seiten durchlaufen bis isLastPage === true ODER page >= lastPageNumber.

Operatoren: gte (>=), gt (>), lte (<=), lt (<), eq (=), in (in Array).


6. With-Parameter: Was man wirklich braucht

Lektion: Die with-Parameter bestimmen, welche Related Data mitgeladen wird. Ohne sie fehlen wichtige Felder.

Parameter Was es liefert Wann nötig
orderItems Positionen mit Name, Menge, VariationID Fast immer
orderItems.variation Variations-Kategorien Für Klassifizierung nach Warengruppe
comments Auftragskommentare Wenn Kommentare angezeigt/ausgewertet werden
dates Warenausgang, Enddatum etc. Für Versanderkennung
properties Versandprofil, Zahlart, Zahlstatus Für Versandart-/Zahlungslogik
relations Kontakt-Zuordnung Für Contact-ID (Fallback!)
addresses Rechnungs-/Lieferadresse Für Kundennamen, Firma
amounts Rechnungsbetrag, bezahlt Für Zahlstatus-Anzeige

Wichtig: relations muss immer dabei sein, weil contactId manchmal nur über relations.find(r => r.referenceType === 'contact').referenceId erreichbar ist — nicht direkt am Order-Objekt.


7. Verschwundene Aufträge erkennen (Orphan Detection)

Lektion: Aufträge können in der lokalen DB existieren, aber in Plenty nicht mehr im erwarteten Status-Bereich sein. Das passiert z.B. bei manuellen Stornierungen oder Status-Korrekturen.

Pattern: Regelmäßig (z.B. täglich per Active Refresh) alle lokalen Aufträge mit Plenty abgleichen:

  1. Alle Orders im erwarteten Status-Bereich aus Plenty laden (Full Sync)
  2. Vergleichen mit lokaler DB
  3. Für fehlende Orders: Statushistorie einzeln abfragen
  4. Status in lokaler DB korrigieren oder als "verschwunden" markieren

Tipp: Wenn die Plenty API einen 404 für einen Auftrag zurückgibt, mit einem internen Marker (z.B. status_id = -1) versehen, damit er nicht bei jedem Sync erneut abgefragt wird.

Entdeckt: Plenty-KPI-Projekt. Orders, die manuell in Status 8+ verschoben wurden, blieben in der lokalen DB als "aktiv" stehen.


III. Auftrags-Datenmodell

8. Order-Properties: Versandart, Zahlart, Zahlstatus

Lektion: Versandart, Zahlungsmethode und Zahlungsstatus sind nicht direkte Felder am Auftrag, sondern stecken im properties-Array mit verschiedenen typeId-Werten.

Mapping:

typeId Inhalt Datentyp
2 Shipping Profile ID Integer als String
3 Payment Method ID Integer als String
4 Payment Status String-Enum

Payment Status Werte: 'unpaid', 'partlyPaid', 'fullyPaid', 'overpaid'

Pattern:

const shippingProfileId = parseInt(
  order.properties.find(p => p.typeId === 2)?.value, 10
);
const paymentMethodId = parseInt(
  order.properties.find(p => p.typeId === 3)?.value, 10
);
const paymentStatus = order.properties.find(p => p.typeId === 4)?.value;
// → 'unpaid', 'partlyPaid', 'fullyPaid', 'overpaid'

9. Order-Dates: Warenausgang und andere Datumsfelder

Lektion: Datumsfelder wie der Warenausgang stecken im dates-Array mit verschiedenen typeId-Werten.

Bekannte Datums-TypeIDs:

typeId Bedeutung
5 Warenausgang (Goods Issue)
10 Enddatum

Pattern:

const goodsIssueDate = order.dates?.find(d => d.typeId === 5)?.date;
if (goodsIssueDate) {
  // Auftrag hat Warenausgang — wurde versendet
}

10. Adressen: Billing vs. Shipping über pivot.typeId

Lektion: Adressen haben keinen eigenen type-String, sondern ein pivot.typeId das bestimmt, ob es die Rechnungs- oder Lieferadresse ist.

pivot.typeId Adresstyp
1 Rechnungsadresse
2 Lieferadresse

Namensfelder:

Feld Inhalt
name1 Firmenname
name2 Vorname
name3 Nachname

Pattern:

const billing = order.addresses?.find(a => a.pivot?.typeId === 1);
const contactName = billing
  ? [billing.name2, billing.name3].filter(Boolean).join(' ')
  : null;
const company = billing?.name1 || null;

11. Amounts: Rechnungsbetrag und Zahlungsbetrag

Lektion: Das amounts-Array enthält Beträge, aber das Array kann leer sein und hat meistens nur einen Eintrag.

Pattern:

const amount = order.amounts?.[0];
const invoiceTotal = amount?.invoiceTotal ?? null;
const paidAmount = amount?.paidAmount ?? null;

Wichtig: Immer mit Null-Checks arbeiten — amounts kann [] oder undefined sein.


12. OrderItem TypeIDs: Variationen, Bundles, Komponenten

Lektion: Nicht jede Position in einem Auftrag ist ein "echtes" Produkt. Das typeId-Feld der OrderItems unterscheidet:

typeId Bedeutung Zählen?
1 Einzelne Variation Ja
2 Bundle-Header Ja (als 1 Stück)
3 Bundle-Komponente Nein (gehört zum Bundle)
11 Variations-Bundle Ja

Wichtig: Beim Zählen von Artikeln (z.B. "wie viele Server im Auftrag?") darf man Bundle-Komponenten (typeId 3) nicht mitzählen — sonst wird jeder Server doppelt gezählt.


IV. Status-System

13. Statushistorie: Nach ID sortieren, nicht nach Timestamp

Lektion: Die Statushistorie eines Auftrags darf nicht nach createdAt/Timestamp sortiert werden. Es gibt regelmäßig mehrere Statusbewegungen in derselben Sekunde.

Warum: Plenty erzeugt bei Event-Procedures, Batch-Statuswechseln oder automatisierten Prozessen mehrere Einträge mit exakt demselben Timestamp. ORDER BY createdAt liefert bei Gleichstand eine zufällige Reihenfolge.

Pattern:

-- Richtig: nach ID sortieren (aufsteigend, eindeutig, chronologisch)
ORDER BY plenty_id ASC;

-- Neuesten Status ermitteln:
ORDER BY plenty_id DESC LIMIT 1;

Entdeckt: 2026-04-01. Statushistorie-Ansicht zeigte Einträge in falscher Reihenfolge.


14. Status-History-Endpoint: Response-Format ist inkonsistent

Lektion: GET /rest/orders/{id}/status-history gibt manchmal ein Array, manchmal ein Objekt mit entries-Feld zurück.

Pattern:

const history = await client.get(`/rest/orders/${orderId}/status-history`);
const entries = Array.isArray(history) ? history : (history.entries || []);

Immer beide Formate behandeln — sich auf eines zu verlassen führt zu sporadischen Fehlern.


15. Schreib-Operationen: Immer verifizieren, nie blind vertrauen

Lektion: Nach jedem Statuswechsel (PUT /rest/orders/{id} mit { statusId: X }) den tatsächlichen Zustand prüfen.

Warum:

  • Plenty hat interne Event-Procedures, die sofort auf Statuswechsel reagieren und den Auftrag weiter verschieben können.
  • HTTP 200 bedeutet "Request angenommen", nicht "Status dauerhaft gesetzt".
  • Race Conditions zwischen eigenen Writes und Plenty-Automatisierungen sind häufig.

Pattern:

  1. Aktuellen Status prüfen (Pre-Check, damit wir nicht rückwärts schieben)
  2. PUT /rest/orders/{id} mit { statusId: targetStatus }
  3. 12 Sekunden warten
  4. Status erneut abfragen
  5. Bei Abweichung: Statushistorie holen und analysieren
  6. Aussagekräftige Meldung ausgeben

16. Status-Werte sind Floats, nicht Integers

Lektion: Plentymarkets verwendet dezimale Statuswerte wie 5.1, 5.12, 5.32, 6.01. Diese müssen als Floats behandelt werden.

Pattern:

const statusId = parseFloat(order.statusId || 0);
if (statusId >= 5 && statusId < 6) { /* im Pool */ }

Anti-Pattern:

if (order.statusId === 5) { /* FALSCH: 5.1 !== 5 */ }
if (parseInt(order.statusId) === 5) { /* FALSCH: verliert Dezimalstellen */ }

V. Dokumente

17. Dokument-Generierung ist asynchron

Lektion: POST /rest/orders/{id}/documents/{type}/generate generiert das Dokument asynchron. Die Response bedeutet nicht, dass das Dokument sofort verfügbar ist.

Wichtige Dokumenttypen:

Typ-String Dokument
order_custom Kommissionierschein (Picking List)
invoice Rechnung

Pattern (Invoice mit Retry):

// 1. Generierung anstoßen
await client.post(`/rest/orders/${orderId}/documents/invoice/generate`);

// 2. Mit Retry warten und abholen
const delays = [3000, 5000, 8000, 10000, 15000];
for (const delay of delays) {
  await sleep(delay);
  const docs = await client.get(`/rest/orders/${orderId}/documents`);
  const invoice = docs.entries
    ?.filter(d => d.type === 'invoice' && d.status === 'done')
    ?.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
  if (invoice) {
    const res = await client.getRaw(`/rest/documents/${invoice.id}`);
    const buffer = Buffer.from(await res.arrayBuffer());
    return buffer; // PDF-Binary
  }
}

Wichtig:

  • Dokument-Download geht über /rest/documents/{docId} (nicht über den Order-Endpoint!)
  • Immer status === 'done' prüfen — pending Dokumente sind noch nicht fertig
  • Nach createdAt absteigend sortieren, um das neueste zu bekommen

18. Dokument-Listing: Endpoint und Filterung

Lektion: Alle Dokumente eines Auftrags auflisten über GET /rest/orders/{id}/documents.

Response:

{
  entries: [
    {
      id: 12345,                // Dokument-ID (für Download)
      type: 'order_custom',     // Dokumenttyp
      status: 'done',           // 'done' oder 'pending'
      createdAt: '2026-...'     // Erstellungszeitpunkt
    }
  ]
}

Download: Binärdaten über GET /rest/documents/{docId} — liefert den PDF-Inhalt direkt. Methode getRaw() verwenden, dann res.arrayBuffer()Buffer.from().


VI. Timestamps & Zeitzonen

19. Timestamps: Immer explizit UTC mit Offset

Lektion: Alle Timestamps an die Plenty API immer mit explizitem Timezone-Offset senden (+00:00), nie ohne.

Warum: Plenty's Interpretation von Timestamps ohne Offset ist nicht dokumentiert und kann sich je nach Endpoint unterscheiden. Bei DST-Wechseln besonders kritisch.

Pattern:

new Date().toISOString().replace(/\.\d+Z$/, '+00:00')
// → "2026-04-02T10:15:00+00:00"

Anti-Pattern:

new Date().toISOString().replace('Z', '')
// → "2026-04-02T10:15:00.000" — Interpretation unklar

VII. Kategorie-System

20. Kategorie-Baum: Flache Liste mit Parent-Referenzen

Lektion: Kategorien in Plenty sind eine flache Liste mit parentCategoryId-Referenzen. Es gibt keinen Tree-Endpoint — den Baum muss man selbst aufbauen.

Pattern:

// 1. Alle Kategorien laden (paginiert)
const allCategories = [];
let page = 1, hasMore = true;
while (hasMore) {
  const data = await client.get(`/rest/categories?itemsPerPage=250&page=${page}`);
  allCategories.push(...data.entries);
  hasMore = !data.isLastPage;
  page++;
}

// 2. Nachfahren einer Root-Kategorie per BFS finden
function findDescendants(rootId, categories) {
  const ids = new Set([rootId]);
  const queue = [rootId];
  while (queue.length > 0) {
    const current = queue.shift();
    for (const cat of categories) {
      if (cat.parentCategoryId === current && !ids.has(cat.id)) {
        ids.add(cat.id);
        queue.push(cat.id);
      }
    }
  }
  return ids;
}

21. Item-Kategorien: Über die Variation abfragen

Lektion: Die Kategorien eines Items bekommt man nicht direkt am Item, sondern über die Variation.

Endpoint: GET /rest/items/{itemId}/variations/{variationId}?with=variationCategories

Response:

{
  variationCategories: [
    { categoryId: 93 },   // Kategorie-ID
    { categoryId: 142 }
  ]
}

Tipp: Ergebnisse lokal cachen (z.B. SQLite-Tabelle item_category_cache), um die API nicht bei jedem Sync zu belasten. Kategorien ändern sich selten.


VIII. Tags & Item-Management

22. Tags sind Variations-bezogen, nicht Item-bezogen

Lektion: Tags werden pro Variation gesetzt, nicht pro Item.

Endpoints:

POST   /rest/items/{itemId}/variations/{variationId}/variation_tags
       Body: { tagId: 123 }

DELETE /rest/items/{itemId}/variations/{variationId}/variation_tags/{tagId}

Besonderheiten:

  • 409 Conflict beim Hinzufügen = Tag existiert bereits → ignorieren
  • 404 Not Found beim Löschen = Tag existiert nicht → ignorieren
  • Beides sind erwartete Zustände, keine Fehler

IX. Cycle Times & KPIs

23. Durchlaufzeiten berechnen: Statushistorie statt Auftragsdaten

Lektion: Um Durchlaufzeiten (z.B. "wie lange dauert es von Auftragseingang bis Versand?") zu berechnen, die Statushistorie verwenden — nicht die Auftrags-Timestamps.

Pattern:

-- Erste Ankunft in Status-Range ermitteln
SELECT MIN(status_at) as first_5
FROM status_history
WHERE order_id = ? AND status_id >= 5 AND status_id < 6;

-- Cycle Times berechnen
SELECT
  MIN(CASE WHEN status_id >= 5 AND status_id < 6 THEN status_at END) as first_5,
  MIN(CASE WHEN status_id >= 6 AND status_id < 7 THEN status_at END) as first_6,
  MIN(CASE WHEN status_id >= 7 THEN status_at END) as first_7
FROM status_history
WHERE order_id = ?;

-- Dann: 5→6 = first_6 - first_5, 6→7 = first_7 - first_6, 5→7 = first_7 - first_5

Tipp: Für Business-Hours-Berechnung Wochenenden ausschließen. SQLite braucht dafür eine Custom Function:

db.function('business_hours', (startIso, endIso) => {
  // Montag-Freitag zählen, Samstag/Sonntag ignorieren
});

X. Properties (neue Eigenschaften, nicht Merkmale!)

24. Properties vs. Merkmale: Zwei verschiedene Systeme

Lektion: Plentymarkets hat zwei Eigenschaftssysteme, die nicht kompatibel sind:

System API-Felder Endpunkte
Merkmale (alt) itemProperties, variationProperties /rest/items/{id}/variations/{varId}/variation_properties
Eigenschaften (neu) properties (via with=properties) /rest/properties/relations

Wichtig: Der variation_properties-Endpoint ist für das alte Merkmal-System. Neue Eigenschaften (wie Auswahl-Properties) laufen über /rest/properties/relations.


25. Property-Relation erstellen (Selection-Eigenschaft)

Lektion: Um eine Auswahl-Eigenschaft an einer Variation zu setzen, einen POST auf /rest/properties/relations senden.

Pattern:

const res = await fetch(`${BASE}/rest/properties/relations`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    propertyId: 5,                    // ID der Eigenschaft
    relationTypeIdentifier: 'item',   // Verknüpfungstyp
    relationTargetId: variationId,    // mainVariationId des Artikels
    selectionRelationId: 48           // ID des Auswahlwerts
  })
});

Besonderheiten:

  • selectionRelationId wird NICHT im gleichnamigen Response-Feld gespeichert (bleibt null), sondern als relationValues[0].value mit lang: "0". Das Plenty-UI interpretiert es trotzdem korrekt.
  • Unique Constraint auf propertyId + targetId + type: Duplikat-POST → HTTP 500 (Integrity Constraint). Sicher als "bereits gesetzt" behandeln.
  • Auslesen: GET /rest/items/{itemId}/variations/{varId}?with=propertiesproperties.find(p => p.propertyId === X)

Entdeckt: 2026-04-08. Artikeltyp-Eigenschaft (Property 5) auf Variationen setzen.


26. Property-Selections abrufen

Lektion: Die Auswahlwerte einer Eigenschaft bekommt man nicht über einen dedizierten Selection-Endpoint, sondern über die Property selbst mit with=selections.

Pattern:

const prop = await get(`/rest/properties/${propertyId}?with=selections`);
for (const sel of prop.selections) {
  const name = sel.relation?.relationValues?.find(v => v.lang === 'de')?.value;
  console.log(`ID: ${sel.id}${name}`);
}

Nicht funktionierend:

  • GET /rest/properties/{id}/selections → 404
  • GET /rest/properties/selections?propertyId={id} → 403 (fehlende Berechtigung)

Entdeckt: 2026-04-08.


27. Kategorie-Suche: Kein rekursiver Filter

Lektion: Die Plenty-API hat keinen rekursiven Kategorie-Filter. categoryId=X liefert nur Artikel, die direkt in Kategorie X liegen — nicht in Unterkategorien.

Pattern: Kategorie-Baum selbst aufbauen per BFS:

// 1. Alle Kategorien laden
const allCats = []; let page = 1;
while (true) {
  const data = await get(`/rest/categories?itemsPerPage=250&page=${page}&type=item&with=details`);
  if (!data.entries?.length) break;
  allCats.push(...data.entries);
  if (data.isLastPage) break;
  page++;
}

// 2. BFS: Alle Nachfahren einer Root-Kategorie finden
function getDescendants(rootId) {
  const ids = new Set([rootId]);
  const queue = [rootId];
  while (queue.length) {
    const current = queue.shift();
    for (const cat of allCats) {
      if (cat.parentCategoryId === current && !ids.has(cat.id)) {
        ids.add(cat.id); queue.push(cat.id);
      }
    }
  }
  return ids;
}

// 3. Artikel pro Kategorie laden und deduplizieren
const allItems = new Map();
for (const catId of getDescendants(rootId)) {
  const data = await get(`/rest/items/variations?categoryId=${catId}&isMain=true&itemsPerPage=250`);
  for (const v of data.entries) allItems.set(v.itemId, v);
}

Entdeckt: 2026-04-08. includeChildren=true und recursive=true → 422 Invalid Filter.


28. Item-Texte: Nur am Item, nicht an der Variation

Lektion: Artikelnamen (name1, name2, name3) stehen im texts-Array des Items, nicht an der Variation.

Pattern:

// Richtig:
const item = await get(`/rest/items/${itemId}`);
const name = item.texts?.[0]?.name1;

// Falsch — variation.name ist oft leer:
const variation = await get(`/rest/items/${itemId}/variations/${varId}`);
variation.name; // → '' oder undefined

Entdeckt: 2026-04-08. Alle 1612 Care Pack Artikel zeigten "(kein Name)" weil variation.name abgefragt wurde statt item.texts.


29. /rest/batch-Endpoint: Mehrere API-Calls in einem Request

Lektion: Der /rest/batch-Endpoint ermöglicht bis zu 20 API-Calls in einem HTTP-Request. Das reduziert HTTP-Overhead, aber das Write-Budget wird trotzdem pro enthaltener Operation gezählt, nicht pro Batch-Request.

Pattern:

const res = await fetch(`${BASE}/rest/batch`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    payloads: items.map(item => ({
      resource: '/rest/properties/relations',  // NICHT "uri"!
      method: 'POST',
      body: {
        propertyId: 5,
        relationTypeIdentifier: 'item',
        relationTargetId: item.varId,
        selectionRelationId: 48
      }
    }))
  })
});
// Response: Array von { resource, method, content (JSON-String!), statusCode }

Wichtig:

  • Feld heißt resource, nicht uri — sonst 422 Validation Error
  • Payloads müssen Objekte sein, nicht JSON-Strings
  • content in der Response ist ein JSON-String, muss separat geparst werden
  • Jede Operation im Batch zählt einzeln fürs Rate Limit / Write-Budget
  • Kein Vorteil gegenüber Einzel-Requests beim Write-Limit, nur weniger HTTP-Roundtrips

Entdeckt: 2026-04-09. Getestet mit Property-Relations — funktioniert, aber kein Write-Limit-Vorteil.


30. AIMD Rate Limiting für Bulk-Operationen

Lektion: Für lang laufende Bulk-Operationen ist ein adaptiver AIMD-Ansatz (Additive Increase / Multiplicative Decrease) effektiver als ein fester Delay.

Warum: Fester Delay ist entweder zu langsam (verschenkt Kapazität nachts) oder zu schnell (provoziert 429/Write-Limit tagsüber). AIMD konvergiert automatisch zum Optimum und passt sich an wechselnde Bedingungen an (z.B. andere Services die parallel laufen).

Pattern:

let delay = 2000;                    // Start konservativ
const MIN_DELAY = 400;               // Minimum für Writes
const MAX_DELAY = 10000;
let successCount = 0;

// Nach jedem erfolgreichen Request:
successCount++;
if (successCount % 20 === 0) {
  delay = Math.max(MIN_DELAY, delay - 50);  // Additive Increase
}

// Bei 429:
delay = Math.min(MAX_DELAY, delay * 2);     // Multiplicative Decrease
await sleep(delay * 2);                      // Extra-Pause vor Retry

Ergebnisse:

  • Reads: Von 500ms auf 200ms runtergeregelt, 0 Rate-Limit-Fehler
  • Writes: Von 2000ms auf 400500ms runtergeregelt, gelegentliche 429er werden automatisch abgefangen

Entdeckt: 2026-04-09. Inspiriert von TCP Congestion Control.


XI. API-Endpunkt-Referenz

Endpoint Methode Zweck
/rest/login POST Authentifizierung (Username + Password)
/rest/orders/search POST Aufträge suchen (mit Filtern & Pagination)
/rest/orders/{id} GET Einzelauftrag laden
/rest/orders/{id} PUT Auftrag aktualisieren (z.B. Status ändern)
/rest/orders/{id}/status-history GET Statushistorie abrufen
/rest/orders/{id}/documents GET Dokumente eines Auftrags auflisten
/rest/orders/{id}/documents/{type}/generate POST Dokument generieren (async)
/rest/documents/{docId} GET Dokument-Binary herunterladen
/rest/categories GET Alle Kategorien (paginiert)
/rest/items/{itemId}/variations/{varId} GET Variation mit Kategorien laden
/rest/items/{itemId}/variations/{varId}/variation_tags POST Tag hinzufügen
/rest/items/{itemId}/variations/{varId}/variation_tags/{tagId} DELETE Tag entfernen
/rest/properties/{id}?with=selections GET Eigenschaft mit Auswahlwerten laden
/rest/properties/relations POST Eigenschafts-Verknüpfung erstellen (neue Properties)
/rest/properties/relations/{relId} PUT Eigenschafts-Verknüpfung aktualisieren
/rest/properties/relations/{relId} DELETE Eigenschafts-Verknüpfung löschen
/rest/batch POST Bis zu 20 API-Calls in einem Request bündeln

XI. Bekannte API-Eigenheiten (Quirks)

  1. contactId fehlt manchmal: Nicht direkt am Auftrag, nur über relations.find(r => r.referenceType === 'contact') erreichbar. Deshalb relations immer mit-laden.

  2. PUT-Response kann leer sein: PUT /rest/orders/{id} gibt manchmal einen leeren Body zurück. Immer prüfen ob Text vorhanden bevor JSON-Parse.

  3. Amounts-Array kann leer sein: order.amounts ist ein Array, meistens 1 Eintrag, kann aber [] sein. Immer amounts?.[0]?.invoiceTotal mit Null-Check.

  4. Status-History-Format variiert: Manchmal Array, manchmal { entries: [] }. Immer Array.isArray(x) ? x : (x.entries || []).

  5. Dokument-Download-URL ist anders: Nicht /rest/orders/{id}/documents/{docId}, sondern /rest/documents/{docId} — ohne Order-Prefix!

  6. Generierte Dokumente haben erstmal status=pending: Nach generate sofort fetchen liefert ein unfertiges Dokument. Immer auf status === 'done' filtern.

  7. Fraktionale Status-IDs: 5.1, 5.12, 5.32, 6.01 etc. — parseFloat() verwenden, nie parseInt().