This repository has been archived on 2026-06-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
plenty-dojo/DOJO.md
T
Sebastian Poll ba08f6894e Anti-Pattern §25 + DOJO §5 Warnung: conditionType ist Pflicht
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>
2026-06-05 12:04:38 +00:00

1252 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plenty Dojo — Allgemeine Plentymarkets-Wissensbasis
> Gesammelte Lektionen aus der Arbeit mit der Plentymarkets REST API.
> Diese Datei enthält **allgemeingültige Patterns und Erkenntnisse**, die für jeden Plentymarkets-Entwickler relevant sind — unabhängig vom konkreten Shop oder Projekt.
>
> Shop-spezifische Lektionen (eigene Stati, Workflows, Konfigurationen) stehen in `instances/<PID>.md`.
> Kurzform-Anti-Patterns stehen in `ANTI-PATTERNS.md`.
---
# I. Authentifizierung & API-Client
## 1. Token-Refresh: Konservativ vor Ablauf erneuern
**Lektion:** Der Access-Token läuft nach ca. 60 Minuten ab. Refresh nach 50 Minuten statt bei Ablauf einplanen.
**Warum:** Wenn ein Token während eines laufenden Requests abläuft, schlägt der Request fehl. Ein 10-Min-Puffer gibt genug Spielraum, auch bei langsamen Paginierungs-Schleifen.
**Pattern:**
```javascript
const TOKEN_LIFETIME_MS = 50 * 60 * 1000; // 50 Min (konservativer Puffer)
if (Date.now() >= this.tokenExpiresAt) await this.authenticate();
```
**Entdeckt:** Produktiv seit 2025. Mehrere Projekte nutzen 50min-Threshold ohne Probleme.
---
## 2. Rate Limiting: Fester Delay zwischen Requests
**Lektion:** Die Plenty API hat kein dokumentiertes Rate-Limit-Header-System wie z.B. GitHub. Stattdessen muss man einen eigenen festen Delay zwischen Requests einbauen.
**Warum:** Ohne Delay kommen ab ~20 Requests/Sekunde `429 Too Many Requests`-Fehler. Die API gibt kein `Retry-After`-Header zurück.
**Pattern:**
```javascript
const REQUEST_DELAY = 500; // 500600ms zwischen Requests
async _rateLimit() {
const elapsed = Date.now() - this.lastRequestTime;
if (elapsed < REQUEST_DELAY) await sleep(REQUEST_DELAY - elapsed);
this.lastRequestTime = Date.now();
}
```
**Best Practice:** 500ms für Read-Heavy-Workloads, 600ms wenn auch Writes dabei sind. Bei 429-Fehlern exponentiell backoff: 2s → 4s → 8s.
**ACHTUNG — Long Period Write Limit:** Bei zu vielen Writes über einen längeren Zeitraum (z.B. tausende POSTs in Folge) gibt es ein **zweites, härteres Rate Limit**: `"long period write limit reached"`. Dieses blockiert **ALLE Requests** inklusive `/rest/login` — nicht nur 429, sondern die gesamte API ist für den Account gesperrt. Dauer: **~2 Stunden** (getestet: nach 60min noch gesperrt, nach 2h wieder frei). Nicht dokumentiert. Kein `Retry-After` Header. Lösung: Bulk-Endpoints nutzen (z.B. `POST /rest/items/variations/variation_properties` für bis zu 50 pro Request), damit die Gesamtzahl der Writes drastisch sinkt.
**Entdeckt:** Produktiv seit 2025. 500ms hat sich als zuverlässig erwiesen. Long Period Limit entdeckt 2026-04-08 nach ~15.000 Einzel-POSTs auf `/rest/properties/relations`.
---
## 3. Retry-Strategie: 401 und 429 unterschiedlich behandeln
**Lektion:** `401 Unauthorized` und `429 Rate Limit` erfordern verschiedene Retry-Strategien.
**Pattern:**
```javascript
const MAX_RETRIES = 3;
// 401: Token abgelaufen → neu authentifizieren, einmal retrien
if (res.status === 401 && attempt === 0) {
await this.authenticate();
continue; // retry
}
// 429: Rate-Limited → exponentieller Backoff
if (res.status === 429) {
const delay = 2000 * Math.pow(2, attempt);
await sleep(delay);
continue; // retry
}
```
**Wichtig:** Bei 401 nur **einmal** retrien (nach Re-Auth). Wenn der zweite Versuch auch 401 gibt, sind die Credentials falsch — nicht endlos loopen.
---
# II. Order-Suche & Sync
## 4. Delta-Sync: Großzügige Zeitüberlappung (24h) verwenden
**Lektion:** Beim Delta-Sync (Orders seit `updatedAt >= X` laden) niemals exakt den letzten Sync-Timestamp verwenden. **Mindestens 24 Stunden** Overlap einbauen.
**Warum:**
- **Race Condition:** Wenn ein Sync exakt zur gleichen Sekunde läuft, in der eine Order geändert wird, kann die Order durchfallen. Der Sync speichert seinen Timestamp NACH dem API-Call, aber die Order wurde WÄHREND des Calls geändert. Jeder folgende Sync startet nach der Order und übersieht sie permanent.
- **Zeitumstellung (DST):** Bei der Umstellung CET → CEST (oder umgekehrt) verschiebt sich die lokale Zeit um 1 Stunde.
- **NTP-Drift:** Server-Uhren können um Sekunden bis Minuten voneinander abweichen.
- **Plenty-interne Verzögerungen:** Das `updatedAt`-Feld wird nicht immer sofort aktualisiert.
- **Fehlgeschlagene Syncs / Container-Restarts:** Wenn ein Sync fehlschlägt oder der Container neu startet, kann die Lücke Stunden betragen.
**Pattern:**
```javascript
const SYNC_OVERLAP_MS = 24 * 60 * 60 * 1000; // 24 Stunden
const syncSince = lastSyncTimestamp
? new Date(new Date(lastSyncTimestamp).getTime() - SYNC_OVERLAP_MS)
.toISOString().replace(/\.\d+Z$/, '+00:00')
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString().replace(/\.\d+Z$/, '+00:00');
```
**Voraussetzung:** Die Upsert-Logik muss idempotent sein (`ON CONFLICT DO UPDATE`), damit doppelt geladene Orders keinen Schaden anrichten.
**Aufwand:** Etwas mehr Orders pro Sync (idempotent, also kein Problem). Bei typischen Sync-Intervallen absolut akzeptabel.
**Warum nicht 3h?** 3h wurde initial implementiert, erwies sich aber als zu knapp: Eine Order wurde sekundengenau zum Sync-Zeitpunkt geändert. Durch Container-Restarts (Deploys) kann sich der nächste Sync so weit verschieben, dass ein 3h-Overlap die Order nicht mehr erfasst.
**Entdeckt:** 2026-04-02. Race Condition bei Sync und gleichzeitigem Statuswechsel. 3h-Overlap half nicht, 24h löste das Problem sofort.
---
## 5. Order-Search-Endpoint: Korrekte Struktur
**Lektion:** Die Auftragssuche in Plenty läuft über `POST /rest/orders/search`, nicht über einen GET-Endpoint.
**Pattern:**
```javascript
const data = await client.search('/rest/orders/search', {
page: 1,
itemsPerPage: 250, // Maximum pro Seite
with: [
'orderItems', // Positionen
'orderItems.variation', // Variations-Details (Kategorien etc.)
'comments', // Auftragskommentare
'dates', // Datumsfelder (Warenausgang etc.)
'properties', // Versandart, Zahlart, Zahlstatus
'relations', // Kontakt-ID (Fallback)
'addresses', // Rechnungs-/Lieferadresse
'amounts' // Rechnungsbetrag, bezahlter Betrag
],
conditionType: 'and', // Oder 'or' für OR-Verknüpfung
fields: [
{ field: 'updatedAt', operator: 'gte', value: '2026-01-15T12:00:00+00:00' }
]
});
```
**Response-Struktur:**
```javascript
{
entries: [...], // Aufträge dieser Seite
isLastPage: true/false, // Letzte Seite?
lastPageNumber: 5 // Gesamtzahl Seiten
}
```
**Pagination:** Seiten durchlaufen bis `isLastPage === true` ODER `page >= lastPageNumber`.
**Operatoren:** `gte` (>=), `gt` (>), `lte` (<=), `lt` (<), `eq` (=), `in` (in Array).
**⚠️ 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. 12 Sekunden warten
4. Status erneut abfragen
5. Bei Abweichung: Statushistorie holen und analysieren
6. Aussagekräftige Meldung ausgeben
---
## 16. Status-Werte sind Floats, nicht Integers
**Lektion:** Plentymarkets verwendet dezimale Statuswerte wie `5.1`, `5.12`, `5.32`, `6.01`. Diese müssen als Floats behandelt werden.
**Pattern:**
```javascript
const statusId = parseFloat(order.statusId || 0);
if (statusId >= 5 && statusId < 6) { /* im Pool */ }
```
**Anti-Pattern:**
```javascript
if (order.statusId === 5) { /* FALSCH: 5.1 !== 5 */ }
if (parseInt(order.statusId) === 5) { /* FALSCH: verliert Dezimalstellen */ }
```
---
# V. Dokumente
## 17. Dokument-Generierung ist asynchron
**Lektion:** `POST /rest/orders/{id}/documents/{type}/generate` generiert das Dokument asynchron. Die Response bedeutet nicht, dass das Dokument sofort verfügbar ist.
**Wichtige Dokumenttypen:**
| Typ-String | Dokument |
|------------|----------|
| `order_custom` | Kommissionierschein (Picking List) |
| `invoice` | Rechnung |
**Pattern (Invoice mit Retry):**
```javascript
// 1. Generierung anstoßen
await client.post(`/rest/orders/${orderId}/documents/invoice/generate`);
// 2. Mit Retry warten und abholen
const delays = [3000, 5000, 8000, 10000, 15000];
for (const delay of delays) {
await sleep(delay);
const docs = await client.get(`/rest/orders/${orderId}/documents`);
const invoice = docs.entries
?.filter(d => d.type === 'invoice' && d.status === 'done')
?.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
if (invoice) {
const res = await client.getRaw(`/rest/documents/${invoice.id}`);
const buffer = Buffer.from(await res.arrayBuffer());
return buffer; // PDF-Binary
}
}
```
**Wichtig:**
- Dokument-Download geht über `/rest/documents/{docId}` (nicht über den Order-Endpoint!)
- Immer `status === 'done'` prüfen — pending Dokumente sind noch nicht fertig
- Nach `createdAt` absteigend sortieren, um das neueste zu bekommen
---
## 18. Dokument-Listing: Endpoint und Filterung
**Lektion:** Alle Dokumente eines Auftrags auflisten über `GET /rest/orders/{id}/documents`.
**Response:**
```javascript
{
entries: [
{
id: 12345, // Dokument-ID (für Download)
type: 'order_custom', // Dokumenttyp
status: 'done', // 'done' oder 'pending'
createdAt: '2026-...' // Erstellungszeitpunkt
}
]
}
```
**Download:** Binärdaten über `GET /rest/documents/{docId}` — liefert den PDF-Inhalt direkt. Methode `getRaw()` verwenden, dann `res.arrayBuffer()``Buffer.from()`.
---
# VI. Timestamps & Zeitzonen
## 19. Timestamps: Immer explizit UTC mit Offset
**Lektion:** Alle Timestamps an die Plenty API immer mit explizitem Timezone-Offset senden (`+00:00`), nie ohne.
**Warum:** Plenty's Interpretation von Timestamps ohne Offset ist nicht dokumentiert und kann sich je nach Endpoint unterscheiden. Bei DST-Wechseln besonders kritisch.
**Pattern:**
```javascript
new Date().toISOString().replace(/\.\d+Z$/, '+00:00')
// → "2026-04-02T10:15:00+00:00"
```
**Anti-Pattern:**
```javascript
new Date().toISOString().replace('Z', '')
// → "2026-04-02T10:15:00.000" — Interpretation unklar
```
---
# VII. Kategorie-System
## 20. Kategorie-Baum: Flache Liste mit Parent-Referenzen
**Lektion:** Kategorien in Plenty sind eine flache Liste mit `parentCategoryId`-Referenzen. Es gibt keinen Tree-Endpoint — den Baum muss man selbst aufbauen.
**Pattern:**
```javascript
// 1. Alle Kategorien laden (paginiert)
const allCategories = [];
let page = 1, hasMore = true;
while (hasMore) {
const data = await client.get(`/rest/categories?itemsPerPage=250&page=${page}`);
allCategories.push(...data.entries);
hasMore = !data.isLastPage;
page++;
}
// 2. Nachfahren einer Root-Kategorie per BFS finden
function findDescendants(rootId, categories) {
const ids = new Set([rootId]);
const queue = [rootId];
while (queue.length > 0) {
const current = queue.shift();
for (const cat of categories) {
if (cat.parentCategoryId === current && !ids.has(cat.id)) {
ids.add(cat.id);
queue.push(cat.id);
}
}
}
return ids;
}
```
---
## 21. Item-Kategorien: Über die Variation abfragen
**Lektion:** Die Kategorien eines Items bekommt man nicht direkt am Item, sondern über die Variation.
**Endpoint:** `GET /rest/items/{itemId}/variations/{variationId}?with=variationCategories`
**Response:**
```javascript
{
variationCategories: [
{ categoryId: 93 }, // Kategorie-ID
{ categoryId: 142 }
]
}
```
**Tipp:** Ergebnisse lokal cachen (z.B. SQLite-Tabelle `item_category_cache`), um die API nicht bei jedem Sync zu belasten. Kategorien ändern sich selten.
---
# VIII. Tags & Item-Management
## 22. Tags sind Variations-bezogen, nicht Item-bezogen
**Lektion:** Tags werden pro Variation gesetzt, nicht pro Item.
**Endpoints:**
```
POST /rest/items/{itemId}/variations/{variationId}/variation_tags
Body: { tagId: 123 }
DELETE /rest/items/{itemId}/variations/{variationId}/variation_tags/{tagId}
```
**Besonderheiten:**
- `409 Conflict` beim Hinzufügen = Tag existiert bereits → ignorieren
- `404 Not Found` beim Löschen = Tag existiert nicht → ignorieren
- Beides sind erwartete Zustände, keine Fehler
> **⚠ 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 400500ms runtergeregelt, gelegentliche 429er werden automatisch abgefangen
**Entdeckt:** 2026-04-09. Inspiriert von TCP Congestion Control.
---
# XI. API-Endpunkt-Referenz
| Endpoint | Methode | Zweck |
|----------|---------|-------|
| `/rest/login` | POST | Authentifizierung (Username + Password) |
| `/rest/orders/search` | POST | Aufträge suchen (mit Filtern & Pagination) |
| `/rest/orders/{id}` | GET | Einzelauftrag laden |
| `/rest/orders/{id}` | PUT | Auftrag aktualisieren (z.B. Status ändern) |
| `/rest/orders/{id}/status-history` | GET | Statushistorie abrufen |
| `/rest/orders/{id}/documents` | GET | Dokumente eines Auftrags auflisten |
| `/rest/orders/{id}/documents/{type}/generate` | POST | Dokument generieren (async) |
| `/rest/documents/{docId}` | GET | Dokument-Binary herunterladen |
| `/rest/categories` | GET | Alle Kategorien (paginiert) |
| `/rest/items/{itemId}/variations/{varId}` | GET | Variation mit Kategorien laden |
| `/rest/items/{itemId}/variations/{varId}/variation_tags` | POST | Tag hinzufügen |
| `/rest/items/{itemId}/variations/{varId}/variation_tags/{tagId}` | DELETE | Tag entfernen |
| `/rest/properties/{id}?with=selections` | GET | Eigenschaft mit Auswahlwerten laden |
| `/rest/properties/relations` | POST | Eigenschafts-Verknüpfung erstellen (neue Properties) |
| `/rest/properties/relations/{relId}` | PUT | Eigenschafts-Verknüpfung aktualisieren |
| `/rest/properties/relations/{relId}` | DELETE | Eigenschafts-Verknüpfung löschen |
| `/rest/batch` | POST | Bis zu 20 API-Calls in einem Request bündeln |
| `/rest/user` | GET | Eigene User-ID und Name des API-Users abrufen |
| `/rest/messages` | POST | Kontaktnotiz erstellen (categoryId: 3 = Kontaktnotizen-Tab) |
| `/rest/accounts/contacts` | GET | Kontakte laden (filter: classId, with: options) |
| `/rest/accounts/contacts/{id}` | PUT | Kontakt aktualisieren (z.B. classId ändern) |
| `/rest/accounts/contacts/classes` | GET | Alle Kundenklassen laden (flaches Objekt, kein Array!) |
---
# XI. Bekannte API-Eigenheiten (Quirks)
1. **contactId fehlt manchmal:** Nicht direkt am Auftrag, nur über `relations.find(r => r.referenceType === 'contact')` erreichbar. Deshalb `relations` immer mit-laden.
2. **PUT-Response kann leer sein:** `PUT /rest/orders/{id}` gibt manchmal einen leeren Body zurück. Immer prüfen ob Text vorhanden bevor JSON-Parse.
3. **Amounts-Array kann leer sein:** `order.amounts` ist ein Array, meistens 1 Eintrag, kann aber `[]` sein. Immer `amounts?.[0]?.invoiceTotal` mit Null-Check.
4. **Status-History-Format variiert:** Manchmal Array, manchmal `{ entries: [] }`. Immer `Array.isArray(x) ? x : (x.entries || [])`.
5. **Dokument-Download-URL ist anders:** Nicht `/rest/orders/{id}/documents/{docId}`, sondern `/rest/documents/{docId}` — ohne Order-Prefix!
6. **Generierte Dokumente haben erstmal status=pending:** Nach `generate` sofort fetchen liefert ein unfertiges Dokument. Immer auf `status === 'done'` filtern.
7. **Fraktionale Status-IDs:** 5.1, 5.12, 5.32, 6.01 etc. — `parseFloat()` verwenden, nie `parseInt()`.
8. **Property-Relation PUT ohne valueId wird silent ignoriert:** Wenn `relationValues[0].id` fehlt, akzeptiert Plenty das PUT mit HTTP 200, speichert aber nichts. Immer DELETE + POST als Fallback. **Siehe #32**
---
# XII. Kontakt-API
## 31. Kontaktnotizen via Messenger-API erstellen
**Lektion:** Kontaktnotizen im "Kontaktnotizen"-Tab erscheinen **nicht** über einen dedizierten Notiz-Endpoint, sondern über `POST /rest/messages` mit `categoryId: 3`.
**Warum:** Der alte Notiz-Endpoint (`/rest/contacts/{id}/notes` o.ä.) erzeugt Einträge, die im falschen Tab landen oder nicht sichtbar sind. Die Messenger-API mit `categoryId: 3` ist der korrekte Weg.
**Pattern:**
```javascript
// 1. Erst API-User-ID ermitteln (Pflichtfeld für 'from'!)
const userResp = await client.get('/rest/user');
const userId = userResp.data?.id;
const userName = userResp.data?.real_name || userResp.data?.user || 'System';
// 2. Text HTML-escapen und Zeilenumbrüche konvertieren
const htmlText = escapeHtml(text).replace(/\n/g, '<br>');
// 3. Notiz erstellen
await client.post('/rest/messages', {
from: { type: 'user', value: userId, name: userName },
to: { user: [], role: [], allUsers: false },
whispered: true,
title: 'Customer notes',
message: `<p>${htmlText}</p>`,
referrer: { type: 'backend', value: userId, name: userName },
linkedTo: [{ type: 'contact', value: contactId }],
categoryId: 3, // 3 = Kontaktnotizen-Tab
});
```
**Wichtig:**
- `from.value` muss eine gültige User-ID sein — nicht weglassen oder erfinden
- `message` muss HTML sein (kein Plaintext), Text vorher escapen
- `linkedTo` verknüpft die Notiz mit dem Kontakt
- `whispered: true` macht die Notiz intern (nicht an Kunden sichtbar)
**Entdeckt:** 2026-04-10. Atradius-LimitMgmt-Projekt, Kontaktnotizen nach Limit-Schließung.
---
## 32. Property-Relation aktualisieren: PUT ohne valueId ist ein silent Noop
**Lektion:** Wenn eine bestehende Property-Relation in `relationValues[0]` keine `id` hat, wird ein `PUT` auf die Relation mit HTTP 200 beantwortet, aber der Wert **nicht** gespeichert.
**Warum:** Plenty erwartet beim PUT die interne Value-ID aus `relationValues[0].id`. Fehlt sie, akzeptiert die API den Request, tut aber nichts.
**Pattern (sicher):**
```javascript
const existing = entries.find(r => r.relationTargetId === contactId);
if (existing) {
const valueId = existing.relationValues?.[0]?.id;
if (valueId) {
// Value-ID bekannt → normaler PUT
await client.put(`/rest/properties/relations/${existing.id}`, {
relationValues: [{ id: valueId, value: newValue, lang: 'de' }],
});
} else {
// Keine Value-ID → DELETE + POST (PUT würde silent ignoriert)
await client.delete(`/rest/properties/relations/${existing.id}`);
await client.post('/rest/properties/relations', {
propertyId, relationTypeIdentifier: 'contact', relationTargetId: contactId,
relationValues: [{ value: newValue, lang: 'de' }],
});
}
} else {
// Noch keine Relation → POST
await client.post('/rest/properties/relations', {
propertyId, relationTypeIdentifier: 'contact', relationTargetId: contactId,
relationValues: [{ value: newValue, lang: 'de' }],
});
}
```
**Anti-Pattern:**
```javascript
// FALSCH: PUT ohne id in relationValues → HTTP 200, aber kein Update!
await client.put(`/rest/properties/relations/${existing.id}`, {
relationValues: [{ value: newValue, lang: 'de' }], // id fehlt → noop
});
```
**Entdeckt:** 2026-04-10. Atradius-LimitMgmt. Wert nach PUT immer noch der alte.
---
## 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.
---