Files
plenty-dojo/DOJO.md

906 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
```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; // 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:**
```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. 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:**
```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 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 |
| `/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, '<br>');
// 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: `<p>${htmlText}</p>`,
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.
---