ba08f6894e
POST /rest/orders/search ignoriert den fields-Array komplett wenn
conditionType ('and' oder 'or') fehlt. Kein Fehler, HTTP 200, stille
Verschluckung — die Suche liefert dann den vollen Auftragsbestand.
Entdeckt 2026-06-01 beim Fulfilment-Sync-Umbau (ServerShop24, PID 7843):
sechs verschiedene statusId-Filter-Schreibweisen gaben alle 496.410
Treffer zurück. Nach Ergänzung von conditionType:'and' wirkten alle
korrekt (1-7744 Treffer je nach Bedingung).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1252 lines
51 KiB
Markdown
1252 lines
51 KiB
Markdown
# 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).
|
||
|
||
**⚠️ Achtung — `conditionType` darf nicht fehlen:** Ohne `conditionType: 'and'`
|
||
(oder `'or'`) wird der **gesamte `fields`-Array stumm ignoriert**. Plenty
|
||
antwortet mit HTTP 200, ohne Fehler, ohne Warnung — aber die Suche liefert
|
||
alle Aufträge, als wäre kein Filter gesetzt. Siehe **ANTI-PATTERNS.md §25**.
|
||
|
||
Status-Filter sind besonders davon betroffen, weil das Symptom („Suche
|
||
gibt zu viele Treffer") leicht als „Filter funktioniert nicht" fehlinterpretiert
|
||
wird — der wahre Bug ist eine Zeile höher.
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
> **⚠ Update 2026-05-01 — Plenty Cloud:** Diese klassischen Endpoints sind in Plenty Cloud für REST-User **gesperrt** (Silent-Deny mit 200 + text/html). Tag-Manipulation läuft dort über die PIM-API — siehe **#34**. Die hier dokumentierten Endpoints können in älteren On-Premise-Installationen weiterhin funktionieren, in der Cloud aber nicht.
|
||
|
||
---
|
||
|
||
# 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, '<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.
|
||
|
||
---
|
||
|
||
## 33. Silent-Deny erkennen: Content-Type-Check bei Schreib-Operationen
|
||
|
||
**Lektion:** Plenty antwortet bei nicht-erlaubten Routen mit `200 OK + Content-Type: text/html + Header x-location: /index.php` (statt 401/403). Naive Clients, die nur den HTTP-Status prüfen, melden Erfolg, obwohl Plenty nichts geschrieben hat.
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
const resp = await client.delete(url);
|
||
const ct = String(resp.headers?.['content-type'] || '').toLowerCase();
|
||
if (!ct.includes('application/json')) {
|
||
throw new Error(
|
||
'Plenty Silent-Deny: Antwort ohne JSON — vermutlich Permission-Problem ' +
|
||
'oder Endpoint nicht für den API-User freigegeben.',
|
||
);
|
||
}
|
||
```
|
||
|
||
**Anti-Pattern:** Nur `resp.status === 200` prüfen — Plenty lügt freundlich:
|
||
|
||
```javascript
|
||
// FALSCH: 200 OK reicht nicht
|
||
await client.delete(url);
|
||
return true; // ← lügt: Plenty hat HTML geliefert, nichts geändert
|
||
```
|
||
|
||
**Diagnose-Tipps:**
|
||
- `x-location: /index.php` als zusätzliches Indiz, aber **nicht verlässlich** — manche legitime Endpoints senden den Header auch
|
||
- Body ist bei Silent-Deny meist leer
|
||
- Verify-Read nach jeder Schreib-Operation ist die robusteste Variante
|
||
|
||
**Entdeckt:** 2026-05-01 bei Tag-DELETE-Operationen, die monatelang Erfolg meldeten ohne Tags zu entfernen. Erst Content-Type-Check + Verify-Read deckte den Lügenmodus auf.
|
||
|
||
---
|
||
|
||
## 34. Variation-Tag-Manipulation in Plenty Cloud: PIM-API + Bulk-DELETE-Endpoint
|
||
|
||
**Lektion:** Die klassischen Tag-Endpoints unter `/rest/items/{id}/variations/{vid}/variation_tags*` sind in Plenty Cloud für REST-User **komplett gesperrt** (Silent-Deny via #33). Der einzige funktionierende Schreib-Pfad führt über die PIM-API.
|
||
|
||
**Lese-Pfade** (beide funktionieren):
|
||
```
|
||
GET /rest/pim/variations?ids=in:{vid}&with=tags.tag
|
||
GET /rest/items/{itemId}/variations/{vid}?with=tags
|
||
```
|
||
|
||
Tag-Beziehungen kommen je nach Endpoint in unterschiedlichen Shapes — robuster Predicate:
|
||
```javascript
|
||
const matchesTag = (row, tagId) => {
|
||
const candidates = [row.tagId, row.id, row.tag?.id, row.tag?.tagId];
|
||
return candidates.some(v => Number(v) === tagId);
|
||
};
|
||
```
|
||
|
||
**Tag-Hinzufügen** — additive PUT auf der Variation (Plenty's eigenes Backend nutzt das):
|
||
```
|
||
PUT /rest/pim/variations?with=...&returnAffectedVariations=false
|
||
Body: [{ id, base: {...}, tags: [...altList, {tagId: neuId}] }]
|
||
```
|
||
Wichtig: das `tags`-Array beim PUT ist **add-only**. Tags entfernen via Weglassen oder leeres Array funktioniert NICHT — `_destroy: true`-Marker auch nicht.
|
||
|
||
**Tag-Entfernen** — separater Bulk-Endpoint:
|
||
```
|
||
DELETE /rest/pim/variations/tags
|
||
Content-Type: application/json
|
||
Body: [{ "variationId": <vid>, "tagId": <tid> }, ...]
|
||
← { "affectedRows": N }
|
||
```
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
const resp = await client.delete('/rest/pim/variations/tags', {
|
||
data: items.map(i => ({ variationId: i.variationId, tagId: i.tagId })),
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
const ct = String(resp.headers?.['content-type'] || '').toLowerCase();
|
||
if (!ct.includes('application/json')) throw new Error('Plenty Silent-Deny');
|
||
// `affectedRows` kann sporadisch 0 sein, obwohl Tag wirklich weg ist
|
||
// → Verify-Read über GET ist verbindlich, nicht der affectedRows-Wert.
|
||
```
|
||
|
||
**Permission-Hintergrund:**
|
||
- Plenty's Rollen-System hat **keine Tag-Edit-Permission** (`tagShow.SHOW` ist die einzige Tag-Permission im ganzen Pool, kein `.EDIT/.DELETE`)
|
||
- Der PIM-Bulk-Endpoint ist über die generische API-Rolle freigeschaltet, nicht route-spezifisch konfigurierbar
|
||
- Wer eine Permission-UI sucht, sucht vergeblich — der Endpoint ist eine Plenty-interne Whitelist
|
||
|
||
**Entdeckt:** 2026-05-01 nach mehrstündiger Recherche. Korrekter Endpoint gefunden via Browser-Network-Capture des Plenty-Backends; Plenty's eigenes UI nutzt genau diesen Bulk-Pfad.
|
||
|
||
---
|
||
|
||
## 35. Variation-Client (Mandanten-Sichtbarkeit) Manipulation in Plenty Cloud
|
||
|
||
**Lektion:** Mandanten-Sichtbarkeit pro Variation wird in Plenty Cloud über Add/Remove aus der `variationClients`-Liste gesteuert — es gibt **kein `isActive`-Flag pro Mandant**, anders als die UI suggeriert. Endpoints folgen dem PIM-Bulk-Pattern wie bei Tags (`/rest/pim/variations/<resource>` mit `[{variationId, plentyId}]`-Body), aber im Gegensatz zu Tags funktioniert hier auch PUT für Add (idempotent / upsert) — kein zweiter Pfad nötig.
|
||
|
||
**Lese-Pfad** (sub-include auf Item-GET):
|
||
```
|
||
GET /rest/items/{itemId}?with=variations.variationClients
|
||
→ main.variationClients = [{variationId, plentyId, isActive: true}, ...]
|
||
Inaktive Mandanten existieren in der Liste NICHT — fehlt = nicht aktiv.
|
||
```
|
||
|
||
**Mandant entfernen / Webshop deaktivieren:**
|
||
```
|
||
DELETE /rest/pim/variations/clients
|
||
Body: [{variationId, plentyId}, ...]
|
||
← { affectedRows: N } (JSON, nicht text/html — Silent-Deny-Check!)
|
||
```
|
||
|
||
**Mandant hinzufügen / Webshop aktivieren:**
|
||
```
|
||
PUT /rest/pim/variations/clients
|
||
Body: [{variationId, plentyId}, ...]
|
||
← [{variationId, plentyId}, ...] (Echo, JSON)
|
||
```
|
||
|
||
**Verworfene Kandidaten** (alle gemessen via Live-Probe):
|
||
- `PUT /rest/items/{id}/variations/{vid}` mit `clients`-Property → http 200 + JSON, aber `clients` wird **ignoriert** (silent no-op, gefährlich).
|
||
- `PUT /rest/pim/variations/{vid}` mit `clients`-Property → Silent-Deny (text/html empty).
|
||
- `PUT /rest/items/{id}/variations/{vid}/clients/{cid}` → Silent-Deny.
|
||
- `POST /rest/pim/variations/clients` → Silent-Deny (text/html empty).
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
const path = '/rest/pim/variations/clients';
|
||
const body = [{ variationId, plentyId: clientPlentyId }];
|
||
const resp = await client.request({
|
||
method: present ? 'PUT' : 'DELETE',
|
||
url: path, data: body, validateStatus: () => true,
|
||
});
|
||
const ct = String(resp.headers['content-type'] || '');
|
||
if (!ct.includes('application/json')) throw new Error('Silent-Deny');
|
||
// Verify per Re-Read von variationClients (Lese-Lag ≤ 2s).
|
||
```
|
||
|
||
**Entdeckt:** 2026-05-01 — Browser-Network-Capture des Plenty-Backends bestätigt, dass Plenty's eigene UI beim Mandanten-Häkchen-Toggle exakt diesen DELETE-Aufruf macht.
|
||
|
||
---
|
||
|
||
## 36. `variationBundleComponents`-Sub-Include liefert kein `stockLimitation`
|
||
|
||
**Lektion:** Beim Lesen von Bundle-Komponenten via `with=variations.variationBundleComponents,variations.stock` liefert Plenty pro Komponente nur `{rowId, componentVariationId, componentItemId, quantity, netStock, physicalStock}` — das `stockLimitation` der Komponenten-Variation ist **nicht** im Sub-Include. Wer es braucht (z.B. um „bestandsabhängig vs. unbegrenzt" pro Komponente zu erkennen), muss entweder pro Komponente einen separaten Item/Variation-Read machen oder eine domänenspezifische Annahme treffen.
|
||
|
||
**Pattern (Annahme = limitiert):**
|
||
```javascript
|
||
// Wenn Use-Case Hardware-Bundles sind (echter Bestand erwartbar),
|
||
// reicht oft die Annahme stockLimitation=1:
|
||
const componentLimit = c => Math.floor((c.netStock ?? 0) / Math.max(1, c.quantity ?? 1));
|
||
const paketBestand = Math.min(...components.map(componentLimit));
|
||
```
|
||
|
||
**Wenn echtes `stockLimitation` zwingend ist:** zusätzlicher GET pro Komponenten-Variation (`/rest/items/{itemId}/variations/{variationId}`) — Vorsicht mit Roundtrip-Anzahl bei vielen Bundles.
|
||
|
||
**Entdeckt:** 2026-05-01 — beim Aufbau einer Paket-Bestand-Berechnung über alle Bundles eines Bereichs. Initial mit `stockLimitation: 0`-Default getestet → Funktion lieferte immer `null`, weil ein Default 0 (=unbegrenzt) jeden Komponenten als "∞" einstuft.
|
||
|
||
---
|
||
|
||
## 37. Catalog-API: Endpoint-Pfad, UUIDs, indirekter Zugriff via `/url/public`
|
||
|
||
**Lektion:** Plenty's Catalog-Feature (für Bulk-Exports z.B. Google Shopping, OTTO) hat seinen eigenen Namespace `/rest/catalogs/catalogs/{id}/...` (doppeltes „catalogs" = Modul + Resource), die `{id}` ist eine **UUID** (nicht Integer), und die echten Daten kommen NICHT direkt vom Catalog-Endpoint, sondern über eine indirekte Public-Download-URL.
|
||
|
||
**Warum:** Drei Endpoints unter dem Catalog-Pfad sehen ähnlich aus, liefern aber komplett verschiedene Dinge:
|
||
- `/content` → Filter-Definitionen (Catalog-Konfiguration), KEINE Daten
|
||
- `/export` → Catalog-Metadata (active-Flag, settings, name), KEINE Daten
|
||
- `/url/public` → eine externe Download-URL (z.B. `https://shop.example.com/rest/catalogs/export/{uuid}/download/public?hash=…`), die separat gefetcht werden muss → erst DAS sind die Daten
|
||
|
||
Plus: ohne aktivierten Catalog + manuell getriggerten Export-Run liefert die Public-URL HTTP 500 (Datei existiert nicht). Bei Catalog-Änderungen erneut Export-Run nötig.
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
// 1. Public-URL holen
|
||
const urlResp = await client.get(`/rest/catalogs/catalogs/${encodeURIComponent(uuid)}/url/public`);
|
||
const downloadUrl = urlResp.data?.data ?? urlResp.data?.url; // Wrapper-Format variiert
|
||
// 2. Externe URL fetchen
|
||
const dataResp = await client.get(downloadUrl);
|
||
// 3. Response ist direkt Array oder Wrapper-Object — NICHT auf .entries verlassen
|
||
const rows = Array.isArray(dataResp.data) ? dataResp.data : findFirstArray(dataResp.data);
|
||
```
|
||
|
||
**Anti-Pattern:**
|
||
```javascript
|
||
// FALSCH: /content oder /export bringt keine Daten
|
||
const data = await client.get(`/rest/catalogs/${id}/content`); // Catalog-Filter-Konfiguration
|
||
|
||
// FALSCH: Plenty-Spec-fern geratene Pfade
|
||
const data = await client.get(`/rest/plenty/export/${id}/export`); // 404
|
||
const data = await client.get(`/rest/exports/${id}`); // 404
|
||
|
||
// FALSCH: UUID als Number behandeln (DB-Spalte INT, Frontend-Input type="number")
|
||
// → Browser frisst Buchstaben still, Save schickt null
|
||
```
|
||
|
||
**Verifikations-Quelle:** `https://github.com/plentymarkets/api-doc → plentymarkets/openApiV2/openApiV2.min.json` listet die echten Catalog-Pfade.
|
||
|
||
**Entdeckt:** 2026-05-03 beim Bau eines Catalog-basierten Bulk-Imports.
|
||
|
||
---
|
||
|
||
## 38. Catalog-Output: Field-Naming-Quirks + Setup-Anforderungen an den User
|
||
|
||
**Lektion:** Plenty's Catalog-Output verwendet ein eigenes flaches Field-Naming, das in mehreren Punkten von der klassischen REST-API abweicht. Manche Felder (z.B. die Variation-ID) sind nur drin, wenn der User sie im Plenty-UI explizit zum Catalog hinzufügt.
|
||
|
||
**Warum:** Catalog-Outputs sind „flach" und Item-zentriert (NICHT Variation-zentriert wie der Default-Bereich der REST-API):
|
||
- `id` → ITEM-ID (NICHT Variation-ID — überraschend)
|
||
- `variationId` (oder `mainVariationId`) → Variation-ID, MUSS vom User explizit ausgewählt werden, sonst nicht in der Response
|
||
- `stockPhysical` (NICHT `physicalStock` wie an `/rest/items/.../variations/{id}` — Plenty-Naming-Inkonsistenz hier wie an `/rest/stockmanagement/stock`)
|
||
- `categoryId` singular als Array von Number, NICHT `categories: [{id, ...}]`
|
||
- `purchasePrice` häufig als deutscher Komma-Decimal-String („1,01") — `parseFloat` schneidet am Komma ab → 1.0; vor parseFloat `replace(',', '.')`
|
||
- `name` direkt (NICHT `texts.name1` wie sonst)
|
||
- `isActive` direkt (NICHT `variation.isActive`)
|
||
- Response-Wrapper ist KEIN `{entries: [...]}`, sondern direkt Array oder anderer Wrapper
|
||
|
||
**Setup-Wizard-Pflicht** für jeden neuen Catalog (User muss):
|
||
1. Catalog mit Format **JSON** anlegen (CSV macht Decimal-/Encoding-Probleme)
|
||
2. Pre-Filter `variation.isActive = true` setzen
|
||
3. Felder explizit auswählen (Liste s.o.) — fehlende Felder = `undefined` in Response
|
||
4. Catalog aktivieren + manuellen Export-Run triggern (sonst 500 auf Public-URL)
|
||
5. Public-URL-Generierung in Catalog-Settings einschalten
|
||
6. Catalog-UUID an die App übergeben
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
// Robuste Field-Extraktion mit Fallbacks für versch. Plenty-Versionen
|
||
const variationId = pickNumber(r, ['mainVariationId', 'variationId', 'variation.id']);
|
||
const itemId = pickNumber(r, ['itemId', 'item.id']) || pickNumber(r, ['id']);
|
||
const stock = pickFloat(r, ['stockPhysical', 'stock.stockPhysical']);
|
||
const price = pickFloat(r, ['purchasePrice']); // muss Komma-Decimal handlen
|
||
const cats = extractCategoryIds(r); // sucht categoryId/categories/parentCategoryId
|
||
|
||
// pickFloat mit Komma-Handling:
|
||
const n = typeof v === 'string' ? parseFloat(v.replace(',', '.')) : Number(v);
|
||
```
|
||
|
||
**Anti-Pattern:**
|
||
```javascript
|
||
// FALSCH: id als Variation-ID behandeln → /stock/movements liefert 200 OK mit
|
||
// leerer Liste (nicht 404!), alle Artikel werden Lager-Lieger
|
||
const variationId = r.id;
|
||
|
||
// FALSCH: physicalStock annehmen → undefined → Default 0 → alle Bestände 0
|
||
const stock = r.physicalStock;
|
||
|
||
// FALSCH: parseFloat ohne Komma-Replace bei deutschen Beträgen
|
||
const price = parseFloat(r.purchasePrice); // "1,01" → 1.0
|
||
|
||
// FALSCH: data.entries als Array annehmen wenn data direkt Array ist
|
||
const rows = data.entries ?? data ?? []; // .entries ist Array.prototype-Method!
|
||
for (const r of rows) { ... } // crashed mit "entries is not iterable"
|
||
```
|
||
|
||
**Entdeckt:** 2026-05-03 — ohne `variationId`-Feld waren 0 echte Movements auflösbar; alle Artikel landeten als Lager-Lieger in der Snapshot-Tabelle.
|
||
|
||
---
|
||
|
||
## 39. Stock-Movements: `processRowType`-Filter für „echte Verkäufe"
|
||
|
||
**Lektion:** Plenty's `/rest/items/0/variations/{id}/stock/movements`-Endpoint liefert ALLE Bestandsänderungen — nicht nur Verkäufe. Wer Verkaufs-Outflows messen will (Reichweite, Lagerumschlag, Lager-Lieger-Erkennung), muss zwingend auf `processRowType === 2` filtern.
|
||
|
||
**Warum:** Plenty unterscheidet vier Typen von Stock-Movements:
|
||
- `1` = incoming item data set (= Lager-Buchung, Korrektur, Umbuchung)
|
||
- `2` = order (= **echter Verkauf**)
|
||
- `3` = deleted stock movement
|
||
- `4` = new stock intake
|
||
|
||
Reason-Codes wie „Warenkorrektur (interne Verschiebung)" oder „Warenumbuchung über plentyWarehouse" haben alle `processRowType=1`. Wenn sie als Outflow gezählt werden:
|
||
- Lager-Lieger-Status fälschlich „aktiv" (intern verschoben = scheinbar verkauft)
|
||
- Reichweite künstlich kürzer (Σ-Outflow überschätzt)
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
for (const e of entries) {
|
||
const qty = Number(e.quantity ?? 0);
|
||
const at = e.createdAt ? new Date(e.createdAt) : null;
|
||
if (!at) continue;
|
||
|
||
if (qty > 0) {
|
||
// Inflows ungefiltert: lastIncoming/firstIncoming sollen jede Lager-Aufnahme
|
||
// berücksichtigen (Wareneingang, Umbuchung, Korrektur sind alle „im Lager")
|
||
if (!lastIn || at > lastIn) lastIn = at;
|
||
} else if (qty < 0) {
|
||
// Outflows: NUR echte Aufträge (processRowType=2) zählen
|
||
if (Number(e.processRowType) !== 2) continue;
|
||
if (!lastOut || at > lastOut) lastOut = at;
|
||
totalOutflow += Math.abs(qty);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Anti-Pattern:**
|
||
```javascript
|
||
// FALSCH: alle qty<0 als Outflow zählen → Lager-Umbuchungen verfälschen Werte
|
||
if (qty < 0) {
|
||
if (!lastOut || at > lastOut) lastOut = at;
|
||
totalOutflow += Math.abs(qty);
|
||
}
|
||
```
|
||
|
||
**Entdeckt:** 2026-05-03 — Item mit zwei „Outflows" (interne Korrektur + Umbuchung, beide processRowType=1) wurde fälschlich als aktiv-verkaufend statt Lager-Lieger erkannt.
|
||
|
||
---
|
||
|
||
## 40. Stock-Movements: Item-ID vs. Variation-ID — silent failure
|
||
|
||
**Lektion:** Der Pfad `/rest/items/0/variations/{id}/stock/movements` braucht eine echte Variation-ID. Wenn man fälschlich eine Item-ID übergibt, kommt **HTTP 200 OK mit leerem `entries`-Array** zurück — KEINE 404 oder Fehlermeldung. Bug ist silent.
|
||
|
||
**Warum:** Plenty's Stock-Movements werden pro Variation indiziert. Eine Item-ID matcht nichts → leere Liste → der Code interpretiert das als „keine Bewegungen" → Artikel wird fälschlich als Lager-Lieger markiert. Tritt häufig auf, wenn man Catalog-Daten verarbeitet, deren `id`-Feld in Wirklichkeit die Item-ID ist (siehe DOJO #38).
|
||
|
||
**Pattern:**
|
||
```javascript
|
||
// Vor Stock-Movement-Calls die ID-Type sicherstellen
|
||
async function resolveToVariationId(plenty, candidateId) {
|
||
try {
|
||
await plenty.get(`/rest/items/variations/${candidateId}`);
|
||
return candidateId; // ist bereits Variation-ID
|
||
} catch {
|
||
// Falls 404 → ist evtl. Item-ID → Hauptvariation auflösen
|
||
const vars = await plenty.get(`/rest/items/${candidateId}/variations`, {
|
||
params: { itemsPerPage: 50 }
|
||
});
|
||
const main = vars.data?.entries?.find(v => v.isMain) ?? vars.data?.entries?.[0];
|
||
if (!main) throw new Error(`Keine Variation für Item ${candidateId}`);
|
||
return main.id;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Anti-Pattern:**
|
||
```javascript
|
||
// FALSCH: ID aus Catalog/Item-API direkt für Movements nutzen, ohne Type-Check
|
||
const movements = await plenty.get(`/rest/items/0/variations/${itemId}/stock/movements`);
|
||
// → 200 OK mit entries:[] → silent failure
|
||
```
|
||
|
||
**Diagnostik bei seltsamen Lager-Lieger-Werten:** zuerst per `/rest/items/variations/{id}` prüfen ob die ID eine Variation ist (404 = ist Item-ID, 200 = Variation).
|
||
|
||
**Entdeckt:** 2026-05-03 — Catalog-Field `id` ist ItemID, falsche Verwendung an Stock-Movements führte zu 100% Lager-Lieger-False-Positives.
|
||
|
||
---
|