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>
426 lines
16 KiB
Markdown
426 lines
16 KiB
Markdown
# 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.
|
|
|
|
> **⚠ Update 2026-05-01 — Plenty Cloud:** In Plenty Cloud kommen 409/404 für Tag-Operationen auf `/variation_tags` gar nicht erst — der Endpoint ist gesperrt und antwortet mit Silent-Deny (siehe #19). Das hier dokumentierte Idempotenz-Pattern bleibt für On-Premise-Installationen gültig.
|
|
|
|
---
|
|
|
|
## 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**
|
|
|
|
---
|
|
|
|
## 16. `/rest/batch` als Write-Limit-Umgehung erwarten
|
|
|
|
```javascript
|
|
// FALSCH: "20 Writes in einem Request = nur 1 Write fürs Budget"
|
|
const payloads = items.slice(0, 20).map(i => ({ resource: '...', method: 'POST', body: {...} }));
|
|
await fetch('/rest/batch', { body: JSON.stringify({ payloads }) });
|
|
// → Zählt intern als 20 einzelne Writes!
|
|
```
|
|
|
|
**Problem:** `/rest/batch` reduziert HTTP-Roundtrips, aber das Write-Budget von Plenty zählt jede Operation einzeln. 20 Writes pro Batch = 20 Writes fürs Rate Limit. Kein Vorteil beim "long period write limit".
|
|
|
|
**Fix:** Batch nur für HTTP-Overhead-Reduktion nutzen, nicht als Rate-Limit-Umgehung. Für Writes AIMD-basiertes Rate Limiting verwenden. **Siehe DOJO.md #29, #30**
|
|
|
|
---
|
|
|
|
## 18. Property-Relation aktualisieren ohne valueId
|
|
|
|
```javascript
|
|
// FALSCH: PUT ohne id in relationValues → HTTP 200, aber kein Update!
|
|
await client.put(`/rest/properties/relations/${existing.id}`, {
|
|
relationValues: [{ value: 'Ja', lang: 'de' }], // id fehlt → silent noop
|
|
});
|
|
```
|
|
|
|
**Problem:** Plenty akzeptiert den PUT mit HTTP 200, aber der Wert wird nicht gespeichert, wenn `relationValues[0].id` fehlt. Kein Fehler, kein Hinweis — es passiert einfach nichts.
|
|
|
|
**Fix:** Prüfen ob `valueId` vorhanden. Wenn nicht: Relation löschen und neu erstellen (DELETE + POST). **Siehe DOJO.md #32**
|
|
|
|
---
|
|
|
|
## 17. Fester Delay für lang laufende Bulk-Operationen
|
|
|
|
```javascript
|
|
// FALSCH: Fester Delay für tausende Requests
|
|
const DELAY = 1000; // Tagsüber zu schnell, nachts zu langsam
|
|
for (const item of items) {
|
|
await sleep(DELAY);
|
|
await api.post(...);
|
|
}
|
|
```
|
|
|
|
**Problem:** Tagsüber konkurrieren andere Services um dasselbe Rate-Limit-Budget → 429er. Nachts ist die API frei → unnötig langsam. Fester Delay kann beides nicht optimal bedienen.
|
|
|
|
**Fix:** AIMD Rate Limiting: bei Erfolg schneller werden, bei 429 verdoppeln. Konvergiert automatisch zum Optimum. **Siehe DOJO.md #30**
|
|
|
|
---
|
|
|
|
## 19. Klassische `/variation_tags`-Endpoints in Plenty Cloud nutzen
|
|
|
|
```javascript
|
|
// FALSCH: in Plenty Cloud Silent-Deny — schweigend ignoriert
|
|
await client.delete(
|
|
`/rest/items/${itemId}/variations/${vid}/variation_tags/${tagId}`,
|
|
);
|
|
// → 200 OK + Content-Type: text/html
|
|
// → Tag bleibt aber an der Variation hängen
|
|
```
|
|
|
|
**Problem:** Plenty Cloud hat die klassischen Tag-Manipulations-Endpoints für REST-User komplett gesperrt. Plenty antwortet mit der HTML-Login-Page (`x-location: /index.php`), nicht mit 401/403. Naive Clients erkennen das als Erfolg und melden falsch — der Tag bleibt in der Realität aber dran.
|
|
|
|
Plenty's Rollen-System bietet **keine eigene Tag-Edit-Permission** — der Endpoint ist nicht über User-Rollen freischaltbar. Stundenlange Permission-Recherche endet im Sackgassen-Modus.
|
|
|
|
**Fix:** PIM-Bulk-Endpoint nutzen + Content-Type prüfen. **Siehe DOJO.md #34**
|
|
|
|
```javascript
|
|
// RICHTIG
|
|
const resp = await client.delete('/rest/pim/variations/tags', {
|
|
data: [{ variationId, tagId }],
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const ct = resp.headers?.['content-type']?.toLowerCase() ?? '';
|
|
if (!ct.includes('application/json')) throw new Error('Silent-Deny');
|
|
// + Verify-Read via /rest/pim/variations?with=tags.tag
|
|
```
|
|
|
|
---
|
|
|
|
## 20. POST auf `/rest/pim/variations/clients`
|
|
|
|
**Was:** Auf dem PIM-Bulk-Pfad für Variations-Clients gibt es nur `DELETE` (entfernen) und `PUT` (hinzufügen/upsert). `POST` antwortet mit http 200 + `text/html` empty — klassisches Silent-Deny. Plenty's eigenes UI nutzt POST hier nicht.
|
|
|
|
**Stattdessen:** PUT für Add (ist idempotent), DELETE für Remove. Siehe DOJO #35.
|
|
|
|
---
|
|
|
|
## 21. `clients`-Property im PUT auf `/rest/items/{id}/variations/{vid}` setzen
|
|
|
|
**Was:** Naheliegend wirkender Versuch, die Mandanten-Liste der Variation per Standard-PUT zu manipulieren: `PUT /rest/items/{id}/variations/{vid}` mit Body `{clients: [{plentyId, ...}]}`. Plenty antwortet **http 200 + JSON** mit der unveränderten Variation — der `clients`-Schlüssel im Body wird stillschweigend ignoriert. Besonders heimtückisch, weil Status + Content-Type alle Silent-Deny-Checks bestehen — der Verify-Read deckt es erst auf.
|
|
|
|
**Stattdessen:** Den dedizierten Bulk-Pfad `/rest/pim/variations/clients` nutzen (DOJO #35).
|
|
|
|
---
|
|
|
|
## 22. Catalog-`id`-Feld als Variation-ID interpretieren
|
|
|
|
```javascript
|
|
// FALSCH: Plenty-Catalog liefert id = ITEM-ID, nicht Variation-ID
|
|
const variationId = catalogRow.id;
|
|
const movements = await plenty.get(`/rest/items/0/variations/${variationId}/stock/movements`);
|
|
// → 200 OK mit entries:[] → alle Artikel werden als Lager-Lieger fehlinterpretiert
|
|
```
|
|
|
|
**Problem:** Catalog-Output ist Item-zentriert, `id` ist die Item-ID. Stock-Movements brauchen die echte Variation-ID. Plenty gibt aber kein 404, sondern eine leere Liste zurück — silent failure.
|
|
|
|
**Fix:** Im Plenty-UI explizit `variationId` (oder `mainVariationId`) als zusätzliches Feld zum Catalog hinzufügen, ODER vor jedem Movement-Call per `/rest/items/variations/{id}` (404=Item) zu Variation auflösen. **Siehe DOJO #38, #40**
|
|
|
|
---
|
|
|
|
## 23. Stock-Movements ohne `processRowType`-Filter zählen
|
|
|
|
```javascript
|
|
// FALSCH: alle qty<0 als Outflow → Lager-Umbuchungen + Korrekturen verfälschen
|
|
for (const m of entries) {
|
|
if (Number(m.quantity) < 0) totalOutflow += Math.abs(m.quantity);
|
|
}
|
|
```
|
|
|
|
**Problem:** Plenty mischt im Movements-Endpoint alle Bestandsänderungen — Verkäufe, interne Umbuchungen (`processRowType=1`, z.B. „Warenkorrektur" oder „Warenumbuchung über plentyWarehouse"), Lager-Eingänge etc. Ohne Filter wird ein langjährig im Lager liegender Artikel mit gelegentlichen Umbuchungen fälschlich als „aktiv verkauft" eingestuft. Reichweite ist zu kurz, Lager-Lieger nie erkannt.
|
|
|
|
**Fix:** `if (Number(m.processRowType) !== 2) continue;` — nur `processRowType=2` (= order) ist ein echter Verkauf. **Siehe DOJO #39**
|
|
|
|
---
|
|
|
|
## 24. `data?.entries ?? data` als Iterable annehmen
|
|
|
|
```javascript
|
|
// FALSCH: data.entries ist die Array.prototype.entries-Method, wenn data Array ist
|
|
const rows = resp.data?.entries ?? resp.data ?? [];
|
|
for (const r of rows) { ... } // → "entries is not iterable"
|
|
```
|
|
|
|
**Problem:** `??` greift nur bei `null`/`undefined`, nicht bei truthy-aber-falscher-Type. Wenn `data` direkt ein Array ist (wie bei Plenty-Catalog-Downloads), gibt `data.entries` die Method-Referenz zurück (truthy aber keine Iterable). `for...of` crashed dann mit „entries is not iterable".
|
|
|
|
**Fix:** Erst explizit auf Array prüfen, dann auf `entries`-Property:
|
|
```javascript
|
|
const rows = Array.isArray(data) ? data
|
|
: Array.isArray(data?.entries) ? data.entries
|
|
: [];
|
|
```
|
|
Oder einen `findFirstArray()`-Helper. **Siehe DOJO #37, #38**
|
|
|
|
---
|
|
|
|
## 25. `POST /rest/orders/search` ohne `conditionType` aufrufen
|
|
|
|
```javascript
|
|
// FALSCH — wirkt wie KEIN Filter, gibt alle 500.000+ Aufträge zurück
|
|
const data = await api.search('/rest/orders/search', {
|
|
page: 1, itemsPerPage: 250,
|
|
fields: [{ field: 'statusId', operator: 'eq', value: 5.1 }],
|
|
});
|
|
// → totalsCount: 496410 (kompletter Bestand!)
|
|
|
|
// RICHTIG — Filter werden korrekt AND-verknüpft angewendet
|
|
const data = await api.search('/rest/orders/search', {
|
|
page: 1, itemsPerPage: 250,
|
|
conditionType: 'and', // ⚠️ PFLICHT — sonst werden fields ignoriert
|
|
fields: [{ field: 'statusId', operator: 'eq', value: 5.1 }],
|
|
});
|
|
// → totalsCount: 1 (korrekt gefiltert)
|
|
```
|
|
|
|
**Problem:** Ohne `conditionType: 'and'` (oder `'or'`) wird der gesamte
|
|
`fields`-Array von Plenty **stumm ignoriert**. Kein Fehler, HTTP 200, kein
|
|
Hinweis in der Response — nur das Filterergebnis ist falsch. Symptom: jede
|
|
Pagination zieht die kompletten 1.578 Seiten Auftragshistorie statt der
|
|
erwarteten Treffer.
|
|
|
|
**Beweis:** Sechs Filtervarianten (eq/lt/in/Range) ergaben alle dieselben
|
|
496.410 Treffer wie ein Call ganz ohne Filter. Sobald `conditionType: 'and'`
|
|
ergänzt wurde, wirkten die Filter korrekt (1, 3, 91, 7.744 Treffer je nach
|
|
Bedingung).
|
|
|
|
**Fix:** Immer `conditionType` setzen, selbst bei nur einem Filter. Beim
|
|
Erstellen eines Search-Bodys folgendes Pattern verwenden:
|
|
|
|
```javascript
|
|
const body = {
|
|
page: 1, itemsPerPage: 250,
|
|
conditionType: 'and', // immer setzen, nicht vergessen
|
|
fields: [...],
|
|
with: [...],
|
|
};
|
|
```
|
|
|
|
**Entdeckt:** 2026-06-01 beim Aufbau eines doppelten Boden-Syncs für
|
|
Fulfilment (ServerShop24, PID 7843). Erste Smoke-Tests deuteten auf einen
|
|
kaputten Filter — der echte Bug war das fehlende `conditionType`.
|
|
**Siehe DOJO.md §5.**
|
|
|
|
---
|