Initial release: Plenty Dojo Wissensbasis
Geteilte Plentymarkets API Learnings (DOJO.md, ANTI-PATTERNS.md), Claude Code Skill (/plenty-dojo), und Install-Script für neue User.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# PID-spezifische Dateien (shop-interne Lektionen)
|
||||||
|
# Für Kollegen-Sharing: diesen Block entfernen oder eigenes Repo nutzen
|
||||||
|
instances/
|
||||||
231
ANTI-PATTERNS.md
Normal file
231
ANTI-PATTERNS.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Plenty Dojo — Anti-Patterns
|
||||||
|
|
||||||
|
> Dinge, die man mit der Plentymarkets API **nicht** tun sollte.
|
||||||
|
> Diese Datei ist allgemeingültig und kann an jeden Plenty-Entwickler weitergegeben werden.
|
||||||
|
>
|
||||||
|
> Shop-spezifische Anti-Patterns stehen in `instances/<PID>.md`.
|
||||||
|
> Ausführliche Erklärungen stehen in `DOJO.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Delta-Sync mit exaktem oder zu knappem Timestamp
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FALSCH: Exakter Timestamp
|
||||||
|
const syncSince = lastSyncTimestamp;
|
||||||
|
|
||||||
|
// AUCH FALSCH: 3h Overlap reicht nicht
|
||||||
|
const OVERLAP = 3 * 60 * 60 * 1000;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Race Condition — wenn ein Sync exakt zur gleichen Sekunde läuft, in der eine Order geändert wird, fällt die Order permanent durch alle folgenden Syncs. 3h Overlap half nicht, weil zwischen Container-Restarts (Deploys) und der Order-Änderung mehr als 3h vergehen können.
|
||||||
|
|
||||||
|
**Fix:** 24h Overlap. Kostet ~200 extra Orders pro Sync, eliminiert aber die Race Condition. **Siehe DOJO.md #4**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Statushistorie nach Timestamp sortieren
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ORDER BY createdAt DESC -- oder ORDER BY status_at DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Mehrere Statuswechsel in derselben Sekunde → zufällige Reihenfolge → falscher "letzter" Status.
|
||||||
|
|
||||||
|
**Fix:** `ORDER BY plenty_id ASC/DESC` — eindeutig, aufsteigend, chronologisch. **Siehe DOJO.md #13**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Aufträge im Schnellfeuer-Modus verschieben
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
for (const order of orders) {
|
||||||
|
await api.updateStatus(order.id, 6); // kein Warten, keine Prüfung
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Event-Procedures reagieren sofort auf Statuswechsel. Race Conditions zwischen eigenem Write und Plenty-Automationen sind häufig.
|
||||||
|
|
||||||
|
**Fix:** Pause + Verifizieren nach jedem Write. **Siehe DOJO.md #15**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Timestamps ohne Timezone-Offset senden
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
"2026-03-30T02:30:00" // kein Offset — Interpretation unklar
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Plenty's Verhalten bei fehlenden Offsets ist undokumentiert und endpoint-abhängig. Bei DST-Wechseln kritisch.
|
||||||
|
|
||||||
|
**Fix:** Immer `+00:00` für UTC. **Siehe DOJO.md #19**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nur Zielstatus prüfen, nicht die Transition
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (order.statusId === 6) { /* OK */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Auftrag kann im Zielstatus sein, aber zwischendurch durch andere Status gelaufen sein. Oder er war kurz dort und wurde weitergeschoben.
|
||||||
|
|
||||||
|
**Fix:** Statushistorie abrufen und Transition als Sequenz prüfen. **Siehe DOJO.md #15**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. contactId ohne Relations-Fallback lesen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const contactId = order.contactId; // kann undefined sein!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** `contactId` ist nicht immer direkt am Order-Objekt vorhanden. Ohne `relations` in den `with`-Parametern fehlt er komplett.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```javascript
|
||||||
|
const contactId = order.contactId
|
||||||
|
|| order.relations?.find(r => r.referenceType === 'contact')?.referenceId;
|
||||||
|
```
|
||||||
|
**Siehe DOJO.md #6, Quirk #1**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Status-IDs als Integer behandeln
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (parseInt(order.statusId) === 5) { /* FALSCH */ }
|
||||||
|
if (order.statusId === 5) { /* FALSCH */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Plenty verwendet fraktionale Status-IDs: 5.1, 5.12, 5.32, 6.01 etc. `parseInt(5.12)` ergibt `5`, `5.12 === 5` ist `false`.
|
||||||
|
|
||||||
|
**Fix:** `parseFloat()` und Range-Vergleiche:
|
||||||
|
```javascript
|
||||||
|
const s = parseFloat(order.statusId);
|
||||||
|
if (s >= 5 && s < 6) { /* Pool */ }
|
||||||
|
```
|
||||||
|
**Siehe DOJO.md #16**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Status-History-Response als Array erwarten
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const entries = history; // Manchmal ist es aber { entries: [] }!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Der Endpoint gibt manchmal ein Array, manchmal ein Objekt mit `entries`-Feld zurück.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```javascript
|
||||||
|
const entries = Array.isArray(history) ? history : (history.entries || []);
|
||||||
|
```
|
||||||
|
**Siehe DOJO.md #14**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dokument sofort nach Generierung fetchen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await api.post(`/rest/orders/${id}/documents/invoice/generate`);
|
||||||
|
const docs = await api.get(`/rest/orders/${id}/documents`);
|
||||||
|
// → Dokument hat status: 'pending', nicht 'done'!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Dokument-Generierung ist asynchron. Das Dokument ist nicht sofort verfügbar.
|
||||||
|
|
||||||
|
**Fix:** Retry-Loop mit steigenden Delays (3s, 5s, 8s, 10s, 15s), immer auf `status === 'done'` filtern. **Siehe DOJO.md #17**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Dokument-Download über Order-Endpoint
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FALSCH:
|
||||||
|
await api.get(`/rest/orders/${id}/documents/${docId}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Der Download-Endpoint ist `/rest/documents/{docId}` — OHNE Order-Prefix!
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```javascript
|
||||||
|
const res = await api.getRaw(`/rest/documents/${docId}`);
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
```
|
||||||
|
**Siehe DOJO.md #18**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. amounts-Array ohne Null-Check lesen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const total = order.amounts[0].invoiceTotal; // Can crash!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** `amounts` ist ein Array das leer sein kann. Kein Array-Zugriff ohne Check.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```javascript
|
||||||
|
const total = order.amounts?.[0]?.invoiceTotal ?? null;
|
||||||
|
```
|
||||||
|
**Siehe DOJO.md #11, Quirk #7**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Bundle-Komponenten als eigene Artikel zählen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const serverCount = items.filter(i => isServer(i)).reduce((sum, i) => sum + i.quantity, 0);
|
||||||
|
// → Zählt Bundle-Komponenten (typeId 3) doppelt!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Bundle-Header (`typeId 2`) enthält die Gesamtmenge. Bundle-Komponenten (`typeId 3`) sind die Einzelteile des Bundles. Beides zählen = Doppelzählung.
|
||||||
|
|
||||||
|
**Fix:** Nur `typeId 1` (Variation), `typeId 2` (Bundle-Header), `typeId 11` (Variations-Bundle) zählen. `typeId 3` (Komponenten) ignorieren. **Siehe DOJO.md #12**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Tag-Operationen als Fehler behandeln wenn idempotent
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FALSCH: 409 beim Hinzufügen und 404 beim Löschen als Fehler werfen
|
||||||
|
const res = await api.post(`.../variation_tags`, { tagId: 123 });
|
||||||
|
if (res.status !== 200) throw new Error('Tag konnte nicht gesetzt werden');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** `409 Conflict` beim Hinzufügen = Tag existiert bereits (gewünschter Zustand!). `404 Not Found` beim Löschen = Tag existiert nicht (auch gewünscht!).
|
||||||
|
|
||||||
|
**Fix:** Diese HTTP-Codes als Erfolg behandeln, nicht als Fehler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Neue Eigenschaften über den Merkmale-Endpoint setzen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// FALSCH: variation_properties ist für das ALTE Merkmal-System
|
||||||
|
await api.post(`/rest/items/${itemId}/variations/${varId}/variation_properties`, {
|
||||||
|
propertyId: 5, selectionRelationId: 48
|
||||||
|
});
|
||||||
|
// → 500 Foreign Key Constraint (plenty_character_item)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** `variation_properties` arbeitet mit dem Legacy-Merkmal-System (`plenty_character_*`-Tabellen). Neue Eigenschaften (Property-System) werden über `/rest/properties/relations` verwaltet — komplett andere API.
|
||||||
|
|
||||||
|
**Fix:** `/rest/properties/relations` mit `relationTypeIdentifier: 'item'` verwenden. **Siehe DOJO.md #25**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Mehrere Bulk-Scripts parallel gegen die API laufen lassen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Script A: Care Packs setzen (600ms delay)
|
||||||
|
// Script B: Lizenzen setzen (600ms delay)
|
||||||
|
// → Effektiv ~3,3 Requests/s → 429 Rate Limit!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Jedes Script hält seinen eigenen Delay ein, aber die API sieht die Summe aller Requests. Andere Crons/Services teilen sich dasselbe Rate-Limit-Budget.
|
||||||
|
|
||||||
|
**Fix:** Bulk-Operationen immer sequenziell (eins nach dem anderen). Delay ≥ 1.500ms wenn andere Services parallel laufen. **Siehe DOJO.md #2**
|
||||||
|
|
||||||
|
---
|
||||||
725
DOJO.md
Normal file
725
DOJO.md
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
# 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; // 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 (Selection-Eigenschaft)
|
||||||
|
|
||||||
|
**Lektion:** Um eine Auswahl-Eigenschaft an einer Variation zu setzen, einen POST auf `/rest/properties/relations` senden.
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
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=properties` → `properties.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:**
|
||||||
|
```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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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()`.
|
||||||
|
|
||||||
|
---
|
||||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Plenty Dojo
|
||||||
|
|
||||||
|
Gesammelte Lektionen aus der Arbeit mit der **Plentymarkets REST API** — von AI-Agenten für AI-Agenten.
|
||||||
|
|
||||||
|
Dieses Repo ist eine geteilte Wissensbasis, die Claude Code (oder andere AI-Coding-Agenten) beim Arbeiten mit Plentymarkets nutzen und erweitern.
|
||||||
|
|
||||||
|
## Was ist drin?
|
||||||
|
|
||||||
|
| Datei | Inhalt | Geteilt? |
|
||||||
|
|-------|--------|----------|
|
||||||
|
| `DOJO.md` | Allgemeine API-Patterns & Lektionen | Ja |
|
||||||
|
| `ANTI-PATTERNS.md` | Was man NICHT tun soll | Ja |
|
||||||
|
| `instances/<PID>.md` | Shop-spezifische Lektionen (Status, IDs, Workflows) | Nein (gitignored) |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Repo klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <REPO-URL> ~/.claude/plenty-dojo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install-Script ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/plenty-dojo/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script:
|
||||||
|
- Symlinkt den Skill nach `~/.claude/commands/plenty-dojo.md`
|
||||||
|
- Erstellt `instances/` mit einer Vorlage für deine Plenty-ID
|
||||||
|
|
||||||
|
### 3. Plenty-ID eintragen
|
||||||
|
|
||||||
|
Öffne `~/.claude/plenty-dojo/instances/<DEINE-PID>.md` und trage deine shop-spezifischen Learnings ein. Diese Datei wird nicht ins geteilte Repo gepusht.
|
||||||
|
|
||||||
|
### 4. CLAUDE.md ergänzen
|
||||||
|
|
||||||
|
Füge in die `CLAUDE.md` deines Projekts (oder global in `~/.claude/CLAUDE.md`) ein:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Plenty Dojo
|
||||||
|
- Vor jeder Plenty-Integration die Dojo-Dateien lesen:
|
||||||
|
- `~/.claude/plenty-dojo/DOJO.md`
|
||||||
|
- `~/.claude/plenty-dojo/ANTI-PATTERNS.md`
|
||||||
|
- `~/.claude/plenty-dojo/instances/<DEINE-PID>.md` (falls vorhanden)
|
||||||
|
- Nach jedem Bugfix prüfen, ob ein neues Learning ins Dojo gehört (`/plenty-dojo lernen`).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
Der Skill wird über Claude Code aufgerufen:
|
||||||
|
|
||||||
|
| Befehl | Was passiert |
|
||||||
|
|--------|-------------|
|
||||||
|
| `/plenty-dojo` oder `/plenty-dojo lernen` | Analysiert die aktuelle Konversation und schlägt neue Einträge vor |
|
||||||
|
| `/plenty-dojo zeigen` | Zeigt alle Einträge mit Statistik |
|
||||||
|
| `/plenty-dojo suchen <begriff>` | Durchsucht alle Dojo-Dateien |
|
||||||
|
| `/plenty-dojo sync` | Holt neue Einträge von anderen und pusht eigene |
|
||||||
|
|
||||||
|
## Beitragen
|
||||||
|
|
||||||
|
Direkt in `DOJO.md` oder `ANTI-PATTERNS.md` schreiben und pushen. Jeder Eintrag hat:
|
||||||
|
|
||||||
|
- **Überschrift** mit fortlaufender Nummer
|
||||||
|
- **Lektion** — ein Satz
|
||||||
|
- **Warum** — was genau passiert ist
|
||||||
|
- **Pattern** — Codebeispiel (richtig)
|
||||||
|
- **Anti-Pattern** — Codebeispiel (falsch)
|
||||||
|
- **Entdeckt** — Datum + Kontext
|
||||||
|
|
||||||
|
Der `/plenty-dojo lernen` Befehl hilft beim Formulieren und Einordnen.
|
||||||
70
commands/plenty-dojo.md
Normal file
70
commands/plenty-dojo.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
Plenty Dojo — Lerne aus dem aktuellen Kontext und pflege die Plentymarkets-Wissensbasis.
|
||||||
|
|
||||||
|
Das Dojo liegt im Verzeichnis, in dem diese Skill-Datei verlinkt ist: `~/.claude/plenty-dojo/`.
|
||||||
|
Ermittle den tatsächlichen Pfad dynamisch über den Symlink dieser Datei:
|
||||||
|
|
||||||
|
```
|
||||||
|
DOJO_DIR = Verzeichnis in dem DOJO.md liegt (Eltern-Verzeichnis von commands/)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Struktur:
|
||||||
|
- **DOJO.md** — Allgemeine Plentymarkets-Lektionen (geteilt via Git)
|
||||||
|
- **ANTI-PATTERNS.md** — Was man NICHT tun soll (geteilt via Git)
|
||||||
|
- **instances/*.md** — Shop-spezifische Lektionen pro Plenty-ID, z.B. `instances/7843.md` (lokal)
|
||||||
|
|
||||||
|
## Wenn $ARGUMENTS "lernen" enthält oder leer ist:
|
||||||
|
|
||||||
|
1. **Kontext analysieren:** Durchsuche die aktuelle Konversation nach Erkenntnissen über die Plentymarkets API. Suche nach:
|
||||||
|
- Bugs die durch API-Verhalten verursacht wurden
|
||||||
|
- Workarounds die nötig waren
|
||||||
|
- Timing-/Race-Condition-Probleme
|
||||||
|
- Datenformat-Überraschungen
|
||||||
|
- Statuswechsel-Eigenheiten
|
||||||
|
- Sync-Probleme
|
||||||
|
|
||||||
|
2. **Bestehende Einträge lesen:** Lies DOJO.md, ANTI-PATTERNS.md und alle vorhandenen `instances/*.md` Dateien. Prüfe, ob das Learning schon dokumentiert ist.
|
||||||
|
|
||||||
|
3. **Klassifizieren:** Entscheide für jedes neue Learning:
|
||||||
|
- **Allgemein** (DOJO.md + ANTI-PATTERNS.md) — betrifft jeden Plenty-Entwickler
|
||||||
|
- **Shop-spezifisch** (instances/<PID>.md) — betrifft nur die eigene Konfiguration, Stati, IDs
|
||||||
|
|
||||||
|
4. **Eintrag formulieren:** Jeder Eintrag MUSS enthalten:
|
||||||
|
- **Überschrift** mit fortlaufender Nummer
|
||||||
|
- **Lektion** — ein Satz, der das Learning zusammenfasst
|
||||||
|
- **Warum** — was genau passiert ist und warum es ein Problem war
|
||||||
|
- **Pattern** — Codebeispiel wie man es richtig macht
|
||||||
|
- **Anti-Pattern** — Codebeispiel wie man es NICHT macht (wenn sinnvoll)
|
||||||
|
- **Entdeckt** — Datum und kurzer Kontext
|
||||||
|
|
||||||
|
**WICHTIG:** In geteilten Dateien (DOJO.md, ANTI-PATTERNS.md) niemals konkrete Order-IDs, Kundendaten, Auftragszahlen, Projektnamen oder shop-spezifische Mengen nennen. Stattdessen generische Beschreibungen verwenden.
|
||||||
|
|
||||||
|
5. **Dem User zeigen:** Zeige den formulierten Eintrag und frage:
|
||||||
|
- "Soll ich das ins Dojo aufnehmen?"
|
||||||
|
- "Ist das allgemein (DOJO.md) oder shop-spezifisch (instances/<PID>.md)?"
|
||||||
|
|
||||||
|
6. **Eintragen:** Nach Bestätigung den Eintrag in die richtige Datei schreiben. Bei allgemeinen Lektionen auch einen Kurzeintrag in ANTI-PATTERNS.md anlegen.
|
||||||
|
|
||||||
|
## Wenn $ARGUMENTS "zeigen" oder "status" enthält:
|
||||||
|
|
||||||
|
Lies und zeige alle Dojo-Dateien mit einer Zusammenfassung:
|
||||||
|
- Anzahl Einträge pro Datei
|
||||||
|
- Letzte Ergänzung
|
||||||
|
- Kategorien der Einträge
|
||||||
|
|
||||||
|
## Wenn $ARGUMENTS "suchen <begriff>" enthält:
|
||||||
|
|
||||||
|
Durchsuche alle Dojo-Dateien nach dem Begriff und zeige relevante Einträge.
|
||||||
|
|
||||||
|
## Wenn $ARGUMENTS "sync" enthält:
|
||||||
|
|
||||||
|
Synchronisiere das Dojo-Repo mit dem Remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <DOJO_DIR>
|
||||||
|
git pull --rebase
|
||||||
|
git add DOJO.md ANTI-PATTERNS.md
|
||||||
|
git diff --cached --quiet || git commit -m "dojo: neue Einträge"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeige danach eine kurze Zusammenfassung: neue Einträge von anderen, eigene gepushte Änderungen.
|
||||||
62
install.sh
Normal file
62
install.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Plenty Dojo — Install Script
|
||||||
|
# Symlinkt den Skill und richtet die lokale Instanz ein.
|
||||||
|
|
||||||
|
DOJO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
COMMANDS_DIR="${HOME}/.claude/commands"
|
||||||
|
|
||||||
|
echo "Plenty Dojo installieren..."
|
||||||
|
echo " Dojo-Verzeichnis: ${DOJO_DIR}"
|
||||||
|
|
||||||
|
# 1. Commands-Verzeichnis sicherstellen
|
||||||
|
mkdir -p "${COMMANDS_DIR}"
|
||||||
|
|
||||||
|
# 2. Skill symlinken
|
||||||
|
if [ -L "${COMMANDS_DIR}/plenty-dojo.md" ]; then
|
||||||
|
echo " Symlink existiert bereits, wird aktualisiert..."
|
||||||
|
rm "${COMMANDS_DIR}/plenty-dojo.md"
|
||||||
|
elif [ -f "${COMMANDS_DIR}/plenty-dojo.md" ]; then
|
||||||
|
echo " Bestehende Skill-Datei wird gesichert nach plenty-dojo.md.bak"
|
||||||
|
mv "${COMMANDS_DIR}/plenty-dojo.md" "${COMMANDS_DIR}/plenty-dojo.md.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "${DOJO_DIR}/commands/plenty-dojo.md" "${COMMANDS_DIR}/plenty-dojo.md"
|
||||||
|
echo " Skill verlinkt: ${COMMANDS_DIR}/plenty-dojo.md -> commands/plenty-dojo.md"
|
||||||
|
|
||||||
|
# 3. Instances-Verzeichnis erstellen
|
||||||
|
mkdir -p "${DOJO_DIR}/instances"
|
||||||
|
|
||||||
|
# 4. PID abfragen und Vorlage erstellen
|
||||||
|
if [ -z "$(ls -A "${DOJO_DIR}/instances/" 2>/dev/null)" ]; then
|
||||||
|
echo ""
|
||||||
|
read -rp " Deine Plentymarkets-ID (PID), z.B. 7843: " PID
|
||||||
|
if [ -n "${PID}" ]; then
|
||||||
|
cat > "${DOJO_DIR}/instances/${PID}.md" << TEMPLATE
|
||||||
|
# Plenty Dojo — PID ${PID}
|
||||||
|
|
||||||
|
> Shop-spezifische Lektionen und Patterns für **Plenty-ID ${PID}**.
|
||||||
|
> Diese Datei enthält interne Workflows, eigene Statuswerte, Konfigurationen und Geschäftslogik.
|
||||||
|
>
|
||||||
|
> Allgemeingültige Plentymarkets-Lektionen stehen in \`DOJO.md\` (im Repo-Root).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Trage hier deine shop-spezifischen Learnings ein. -->
|
||||||
|
TEMPLATE
|
||||||
|
echo " Instanz erstellt: instances/${PID}.md"
|
||||||
|
else
|
||||||
|
echo " Übersprungen — du kannst später manuell eine instances/<PID>.md anlegen."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Instanzen bereits vorhanden: $(ls "${DOJO_DIR}/instances/")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Fertig! Nutze '/plenty-dojo' in Claude Code."
|
||||||
|
echo ""
|
||||||
|
echo "Vergiss nicht, in deiner CLAUDE.md auf das Dojo zu verweisen:"
|
||||||
|
echo " ~/.claude/plenty-dojo/DOJO.md"
|
||||||
|
echo " ~/.claude/plenty-dojo/ANTI-PATTERNS.md"
|
||||||
|
echo " ~/.claude/plenty-dojo/instances/<PID>.md"
|
||||||
Reference in New Issue
Block a user