# 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/.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:** ```javascript 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:** ```javascript const REQUEST_DELAY = 500; // 500–600ms 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:** ```javascript 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:** ```javascript 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:** ```javascript 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:** ```javascript { 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:** ```javascript 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:** ```javascript 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:** ```javascript 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:** ```javascript 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:** ```sql -- 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:** ```javascript 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. 1–2 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:** ```javascript const statusId = parseFloat(order.statusId || 0); if (statusId >= 5 && statusId < 6) { /* im Pool */ } ``` **Anti-Pattern:** ```javascript 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):** ```javascript // 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:** ```javascript { 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:** ```javascript new Date().toISOString().replace(/\.\d+Z$/, '+00:00') // → "2026-04-02T10:15:00+00:00" ``` **Anti-Pattern:** ```javascript 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:** ```javascript // 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:** ```javascript { 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:** ```sql -- 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: ```javascript 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 (Item und Kontakt) **Lektion:** `/rest/properties/relations` funktioniert für Items **und** Kontakte — nur `relationTypeIdentifier` ändert sich. **Pattern (Item-Eigenschaft, Selection):** ```javascript await client.post('/rest/properties/relations', { propertyId: 5, relationTypeIdentifier: 'item', // für Variationen relationTargetId: variationId, // mainVariationId selectionRelationId: 48 // Auswahlwert-ID }); ``` **Pattern (Kontakt-Eigenschaft, Textwert):** ```javascript await client.post('/rest/properties/relations', { propertyId: 52, relationTypeIdentifier: 'contact', // für Kontakte relationTargetId: contactId, relationValues: [{ value: 'Ja', lang: 'de' }], // 'lang' ist Pflichtfeld! }); ``` **Lesen (Kontakt):** ```javascript const resp = await client.get('/rest/properties/relations', { params: { propertyId, relationTypeIdentifier: 'contact', relationTargetId: contactId }, }); const entries = Array.isArray(resp.data) ? resp.data : (resp.data.entries || []); const existing = entries.find(r => r.relationTargetId === contactId); ``` **Besonderheiten:** - `selectionRelationId` bei Items wird als `relationValues[0].value` gespeichert, nicht im gleichnamigen Feld. - **Unique Constraint** auf `propertyId + targetId + type`: Duplikat-POST → HTTP 500. - `lang` ist bei `relationValues` ein Pflichtfeld — ohne `lang` wird der Wert nicht gespeichert. - Response-Format inkonsistent: mal Array, mal `{ entries: [] }` — immer beide Fälle behandeln. **Entdeckt:** 2026-04-08 (Items), 2026-04-10 (Kontakte). --- ## 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:** ```javascript 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: ```javascript // 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:** ```javascript // 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:** ```javascript 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:** ```javascript 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 400–500ms 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 | | `/rest/user` | GET | Eigene User-ID und Name des API-Users abrufen | | `/rest/messages` | POST | Kontaktnotiz erstellen (categoryId: 3 = Kontaktnotizen-Tab) | | `/rest/accounts/contacts` | GET | Kontakte laden (filter: classId, with: options) | | `/rest/accounts/contacts/{id}` | PUT | Kontakt aktualisieren (z.B. classId ändern) | | `/rest/accounts/contacts/classes` | GET | Alle Kundenklassen laden (flaches Objekt, kein Array!) | --- # 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()`. 8. **Property-Relation PUT ohne valueId wird silent ignoriert:** Wenn `relationValues[0].id` fehlt, akzeptiert Plenty das PUT mit HTTP 200, speichert aber nichts. Immer DELETE + POST als Fallback. **Siehe #32** --- # XII. Kontakt-API ## 31. Kontaktnotizen via Messenger-API erstellen **Lektion:** Kontaktnotizen im "Kontaktnotizen"-Tab erscheinen **nicht** über einen dedizierten Notiz-Endpoint, sondern über `POST /rest/messages` mit `categoryId: 3`. **Warum:** Der alte Notiz-Endpoint (`/rest/contacts/{id}/notes` o.ä.) erzeugt Einträge, die im falschen Tab landen oder nicht sichtbar sind. Die Messenger-API mit `categoryId: 3` ist der korrekte Weg. **Pattern:** ```javascript // 1. Erst API-User-ID ermitteln (Pflichtfeld für 'from'!) const userResp = await client.get('/rest/user'); const userId = userResp.data?.id; const userName = userResp.data?.real_name || userResp.data?.user || 'System'; // 2. Text HTML-escapen und Zeilenumbrüche konvertieren const htmlText = escapeHtml(text).replace(/\n/g, '
'); // 3. Notiz erstellen await client.post('/rest/messages', { from: { type: 'user', value: userId, name: userName }, to: { user: [], role: [], allUsers: false }, whispered: true, title: 'Customer notes', message: `

${htmlText}

`, referrer: { type: 'backend', value: userId, name: userName }, linkedTo: [{ type: 'contact', value: contactId }], categoryId: 3, // 3 = Kontaktnotizen-Tab }); ``` **Wichtig:** - `from.value` muss eine gültige User-ID sein — nicht weglassen oder erfinden - `message` muss HTML sein (kein Plaintext), Text vorher escapen - `linkedTo` verknüpft die Notiz mit dem Kontakt - `whispered: true` macht die Notiz intern (nicht an Kunden sichtbar) **Entdeckt:** 2026-04-10. Atradius-LimitMgmt-Projekt, Kontaktnotizen nach Limit-Schließung. --- ## 32. Property-Relation aktualisieren: PUT ohne valueId ist ein silent Noop **Lektion:** Wenn eine bestehende Property-Relation in `relationValues[0]` keine `id` hat, wird ein `PUT` auf die Relation mit HTTP 200 beantwortet, aber der Wert **nicht** gespeichert. **Warum:** Plenty erwartet beim PUT die interne Value-ID aus `relationValues[0].id`. Fehlt sie, akzeptiert die API den Request, tut aber nichts. **Pattern (sicher):** ```javascript const existing = entries.find(r => r.relationTargetId === contactId); if (existing) { const valueId = existing.relationValues?.[0]?.id; if (valueId) { // Value-ID bekannt → normaler PUT await client.put(`/rest/properties/relations/${existing.id}`, { relationValues: [{ id: valueId, value: newValue, lang: 'de' }], }); } else { // Keine Value-ID → DELETE + POST (PUT würde silent ignoriert) await client.delete(`/rest/properties/relations/${existing.id}`); await client.post('/rest/properties/relations', { propertyId, relationTypeIdentifier: 'contact', relationTargetId: contactId, relationValues: [{ value: newValue, lang: 'de' }], }); } } else { // Noch keine Relation → POST await client.post('/rest/properties/relations', { propertyId, relationTypeIdentifier: 'contact', relationTargetId: contactId, relationValues: [{ value: newValue, lang: 'de' }], }); } ``` **Anti-Pattern:** ```javascript // FALSCH: PUT ohne id in relationValues → HTTP 200, aber kein Update! await client.put(`/rest/properties/relations/${existing.id}`, { relationValues: [{ value: newValue, lang: 'de' }], // id fehlt → noop }); ``` **Entdeckt:** 2026-04-10. Atradius-LimitMgmt. Wert nach PUT immer noch der alte. ---