diff --git a/docs/plans/woocommerce-batch-stock-sync.md b/docs/plans/woocommerce-batch-stock-sync.md new file mode 100644 index 000000000..fc9d54e2a --- /dev/null +++ b/docs/plans/woocommerce-batch-stock-sync.md @@ -0,0 +1,126 @@ +# WooCommerce Batch Stock Sync — Plan + +Companion document to [GitHub Issue #263](https://github.com/OpenXE-org/OpenXE/issues/263) +and the `feature/woocommerce-batch-stock-sync` branch. + +--- + +## §1 Ziel + +`ImportSendListLager()` bisher: 2 HTTP-Requests pro Artikel (SKU-Lookup + PUT). +Bei 1.000 Artikeln = 2.000 Requests — langsam, rate-limit-anfällig, kein Partial-Error-Handling. + +Ziel: Nutzung der offiziellen WC REST v3 Batch-Endpoints, um den Request-Count auf +`ceil(n/100)` SKU-Lookups + `ceil(n/100)` Batch-Updates zu reduzieren (~20 statt 2.000). + +--- + +## §2 Scope + +### IN +- Refactor von `ImportSendListLager()` in `www/pages/shopimporter_woocommerce.php` +- Neue private Hilfsmethode `processBatchResponse()` in derselben Klasse +- Plan-Dokument `docs/plans/woocommerce-batch-stock-sync.md` + +### OUT +- Kein neuer `postBatch()`-Helper auf `WCClient` — `post()` reicht direkt +- Keine Änderungen an `ImportGetAuftrag*`, `getKonfig`, `parseOrder` +- Kein Retry-Mechanismus bei HTTP-Fehlern (separater Task) +- Kein DB-Schema-Change +- Kein PR-Open (nach Review durch Maintainer) + +--- + +## §3 Fix-Parameter + +| Parameter | Wert | Begründung | +|---|---|---| +| Batch-Size | 100 Items | WC REST v3 Maximum | +| SKU-Chunk-Size | 100 SKUs | Passend zu `per_page=100` | +| Retry | Keiner | Out-of-scope für diesen PR | +| SKU-CSV-Support | `?sku=a,b,c` | WC REST v3 akzeptiert kommaseparierte Liste | + +--- + +## §4 Funktions-Landkarte + +Nur `ImportSendListLager()` wird geändert. Neue private Methode `processBatchResponse()`. + +``` +ImportSendListLager() + ├── Schritt 1: Alle SKUs + Stock-Params sammeln (pendingUpdates[]) + ├── Schritt 2: Bulk-SKU-Auflösung + │ └── GET products?sku=&per_page=100 (je Chunk à 100 SKUs) + │ → skuMap[sku] = {id, parent, isvariant} + ├── Schritt 3: Gruppieren + │ ├── simpleItems[] (für products/batch) + │ └── variationItems[parent_id][] (für products/{id}/variations/batch) + └── Schritt 4: Batch-Updates senden (je Chunk à 100) + ├── POST products/batch {update: [...]} + ├── POST products/{parent}/variations/batch {update: [...]} + └── processBatchResponse() → Partial-Error-Logging, zählt Erfolge + +processBatchResponse($response, $endpoint) + ├── Iteriert response->update[] + │ ├── item->error vorhanden → logger->error + │ └── kein Fehler → successCount++ + └── Iteriert response->errors[] (WC-Fallback) +``` + +--- + +## §5 Implementierungsschritte + +1. **Vorverlagerte Datensammlung:** Statt der bisherigen per-Artikel-Schleife erst alle SKUs + und Stock-Params in `$pendingUpdates[]` sammeln. + +2. **Bulk-SKU-Auflösung:** `GET products?sku=&per_page=100` in Chunks à 100 SKUs. + Ergebnis in `$skuMap[sku]` cachen. + +3. **Gruppierung:** Simple products in `$simpleItems[]`, Variations pro Parent-ID in + `$variationItems[parent_id][]`. + +4. **Batch-POST:** `$this->client->post('products/batch', ['update' => $chunk])` für + simple products; analog für Variations. + +5. **Partial-Error-Handling:** `processBatchResponse()` liest `response->update[]` und + `response->errors[]`, loggt Fehler per Item ohne den restlichen Sync abzubrechen. + +--- + +## §6 Test-Matrix + +Testumgebung: Docker-Shop auf `192.168.0.143:8080`, WC 10.7, Consumer Keys aktiv. + +| # | Szenario | Erwartetes Ergebnis | +|---|---|---| +| T1 | 1 Artikel | 1 SKU-GET + 1 Batch-POST, Lagerbestand korrekt | +| T2 | 100 Artikel | 1 SKU-GET + 1 Batch-POST (1 Chunk) | +| T3 | 250 Artikel | 3 SKU-GETs + 3 Batch-POSTs (100+100+50) | +| T4 | 1 falsche SKU in 100er-Batch | 99 korrekt updated, 1 Error geloggt, sync läuft durch | +| T5 | Variation (parent != 0) | Variation-Batch-Endpoint genutzt, nicht products/batch | +| T6 | `ausverkauft=1` | stock_quantity=0 im Batch-Item | +| T7 | `inaktiv=1` | status='private' im Batch-Item | +| T8 | `pseudolager` gesetzt | lageranzahl aus pseudolager, nicht anzahl_lager | +| T9 | Leere Artikelliste | Rückgabe 0, keine HTTP-Requests | + +--- + +## §7 Rollout & Rückwärts-Kompatibilität + +- **Interface unverändert:** `ImportSendListLager()` behält Signatur `(): int`. +- **Keine DB-Migration** erforderlich. +- **WC-Mindestversion:** REST v3 Batch-Endpoints seit WC 3.0 (2017) — unkritisch. +- **Rollback:** Git-Revert des Feature-Branch reicht. + +--- + +## §8 Risiken + +| Risiko | Mitigation | +|---|---| +| WC akzeptiert SKU-CSV nicht (`?sku=a,b`) | Verifikation in T1–T3 gegen 192.168.0.143:8080 — falls nicht, Fallback: sequentielle Einzellookups (wie vorher) | +| Batch-Response enthält keine `update`-Keys | `processBatchResponse()` defensiv mit `?? []` abgesichert | +| Hoster-seitiger `per_page`-Cap unter 100 | Derzeit nicht abgefangen; separater Task | +| Partial-Error zählt als nicht-erfolgreich | Korrekt: `$anzahl` zählt nur Items ohne Fehler | +| Variations mit unbekanntem Parent | Variation-Batch schlägt fehl → WCHttpClientException propagiert nach oben (bestehende Semantik) | diff --git a/docs/plans/woocommerce-pagination-fix.md b/docs/plans/woocommerce-pagination-fix.md new file mode 100644 index 000000000..5d874b990 --- /dev/null +++ b/docs/plans/woocommerce-pagination-fix.md @@ -0,0 +1,340 @@ +# WooCommerce-Bestellimport: Pagination + `after`-Filter + +**Issue:** [openxe-org/openxe#262](https://github.com/OpenXE-org/OpenXE/issues/262) +**Branch:** `fix/woocommerce-order-import-pagination` +**Base:** `development` @ `8bb13973` +**Typ:** Bugfix (stiller Datenverlust) + +--- + +## 1. Ziel + +Der WooCommerce-Shopimporter verliert Bestellungen, wenn mehr als 20 neue Aufträge +zwischen zwei Cron-Läufen eingehen. Dieser Fix macht den Bestellabruf vollständig +und verlustfrei, ohne neue Plugin-Abhängigkeiten und ohne Wechsel der API-Version +(bleibt auf `wc/v3`). + +## 2. Scope + +### IN-Scope + +- Single-Order-Abruf mit `after`-Filter + `exclude`-Tupel-Cursor; Caller-Loop iteriert. +- `after`-Filter (ISO-8601) statt 800er-`include`-Hack +- Persistenz "letzter erfolgreicher Import-Timestamp" pro Shop +- Fallback-Logik für Erstlauf (kein Timestamp vorhanden) +- Transitions-Kompatibilität zum Legacy-Parameter `ab_nummer` + +### OUT-of-Scope (eigene Issues/PRs) + +- Batch-Endpoints fuer Stock-Sync (`products/batch`) +- Retry / Backoff fuer 429/5xx +- Composer-Migration des inline-WC-SDK +- Webhook-Support (#239) +- HPOS-Testmatrix +- UI-Reset-Button fuer den Import-Timestamp + +## 3. Fix-Parameter (final) + +| Parameter | Wert | Begruendung | +|---|---|---| +| Erstlauf-Fallback | **30 Tage** | Sinnvoller Mittelweg: holt historische Bestellungen, aber nicht unbegrenzt. UI-Override in Folge-PR moeglich. | +| Persistenz-Feld | `shopexport.einstellungen_json` → `felder.letzter_import_timestamp` | Bestehende Struktur nutzen, keine DB-Migration. | +| Timestamp-Format | ISO-8601 `Y-m-d\TH:i:s` (UTC) | Direkt an `after=` durchreichbar. | +| Caller-Cap (pre-existing) | `shopexport.maxmanuell`, default 100 | Der Batch-Cap liegt beim shopimport.php-Caller, nicht im Importer. | +| Cursor-Format | Tupel `(letzter_import_timestamp, letzter_import_order_ids: [int])` | IDs sammeln den aktuellen Sekunden-Bucket; -1s-Verschiebung greift nur mit mindestens einer ID. | + +## 4. Datei- und Funktions-Landkarte + +Zielmodul: `www/pages/shopimporter_woocommerce.php` + +| Funktion | Zeilen | Betroffen? | Aenderung | +|---|---|---|---| +| `ImportGetAuftraegeAnzahl` | ~80–135 | ja | `include`-Hack raus, `after`-Filter rein, nur noch Count via `X-WP-Total` | +| `ImportGetAuftrag` | ~121–185 | ja | Single-Order-Query mit after-Filter; Caller-Loop in shopimport.php iteriert | +| `parseOrder` | ~222–305 | nein | unveraendert (liefert weiterhin pro Order) | +| `CatchRemoteCommand('data')` | mehrfach | mittelbar | stellt `letzter_import_timestamp` aus `einstellungen_json` bereit | +| `getKonfig` | ~802–837 | nein | nicht anfassen (separates Issue #224) | +| Inline-Client (`WCClient`, `WCHttpClient`, `WCResponse`) | 1021–2370 | ggf. ja | Header-Accessor ergaenzen falls nicht vorhanden | + +## 5. Implementierungsschritte + +### Schritt 1 — Header-Durchreichung im inline-Client pruefen + +**Aufgabe:** Klaeren, ob `WCResponse` die HTTP-Antwort-Header (`X-WP-Total`, +`X-WP-TotalPages`, `Link`) bereits als Array liefert oder ob der inline-Client +angepasst werden muss. + +**Deep-Read-Ziele (nur Lesen, kein Edit):** +- `WCHttpClient::processResponse()` / aequivalent (vermutlich ~Zeile 2140–2200) +- `WCResponse`-Constructor: werden Headers gespeichert? +- `curl_setopt`-Setup: ist `CURLOPT_HEADERFUNCTION` oder `CURLOPT_HEADER` aktiv? + +**Entscheidungs-Gate:** +- Wenn Headers bereits im Response-Objekt: direkt weiter mit Schritt 3. +- Wenn nicht: Schritt 2 vor Schritt 3. + +**Akzeptanzkriterium:** Klarer Plan, welche Client-Aenderung noetig ist (oder dass keine noetig ist). + +### Schritt 2 — Headers exponieren (bedingt) + +**Nur falls Schritt 1 ergibt, dass Headers nicht zur Businesslogik durchdringen.** + +- `WCHttpClient::processResponse()`: Header-String in assoziatives Array parsen. +- `WCResponse` um `getHeaders()` / `getHeader(string $name)` erweitern. +- cURL-Setup: `CURLOPT_HEADERFUNCTION` registrieren, Headers in Sammelarray schreiben. +- Case-insensitive Lookup (`strtolower`-normalisiert speichern), da WP Headers teils + `x-wp-totalpages` vs. `X-WP-TotalPages` sendet. + +**Akzeptanzkriterium:** `$response->getHeader('x-wp-totalpages')` liefert eine Zahl als +String (z.B. `"3"`). + +**Risiko:** Client-Klassen werden auch an anderen Stellen instanziiert (theoretisch). +→ Mit `grep` verifizieren, dass `WCClient`/`WCResponse` nur in +`shopimporter_woocommerce.php` verwendet werden (keine Referenzen in +anderen `www/pages/*.php`-Dateien). + +### Schritt 3 — Timestamp-Persistenz + +**3a — Lesen:** +- In `getKonfig()` oder `CatchRemoteCommand('data')` den Wert + `$felder['letzter_import_timestamp']` auslesen. +- Fallback: `date('Y-m-d\TH:i:s', strtotime('-30 days'))` wenn leer/null. +- Auf Klassen-Property `$this->lastImportTimestamp` ablegen. + +**3b — Schreiben:** +- Nach erfolgreichem Lauf (am Ende von `ImportGetAuftrag`): den Timestamp der + **zuletzt verarbeiteten Bestellung** (nicht `now()`) in `einstellungen_json` zurueckschreiben. +- Grund: wenn der Lauf bei Order #n+3 abbricht, beim naechsten Lauf mit Order #n+3 + weiterarbeiten, nicht mit `now()` (→ Datenverlust). +- SQL: `UPDATE shopexport SET einstellungen_json = :json WHERE id = :id`. +- Muss ueber den in `DatabaseService` vorhandenen Mechanismus laufen (named params, + Prepared Statement — siehe `CLAUDE.md`-Projektregel). + +**3c — Atomic Update:** +- Nur bei erfolgreichem Import-Ende Timestamp persistieren. +- Bei Exception mitten im Lauf: Timestamp NICHT auf den Absturzpunkt schreiben + — besser: pro erfolgreich verarbeiteter Order einzeln fortschreiben (Progress), + sodass ein Absturz nur den aktuellen, nicht alle bisherigen, verliert. + +**Akzeptanzkriterium:** +``` +SELECT einstellungen_json FROM shopexport WHERE id = +→ enthaelt 'letzter_import_timestamp': '2026-04-20T12:34:56' +``` + +### Schritt 4 — Refactor `ImportGetAuftraegeAnzahl` (Count-Funktion) + +**Alt (Zeile 80–135):** +- Query: `orders?status=…&include=<800 IDs>&per_page=100`. +- Liest `count($response)` als Return. + +**Neu:** +- Query: `orders?status[]=&status[]=&after=&per_page=1`. +- Return: `(int) $response->getHeader('x-wp-total')`. +- `per_page=1` reicht — wir brauchen nur den Count-Header, nicht die Daten. + +**Akzeptanzkriterium:** +- Bei 0 neuen Orders: liefert 0. +- Bei 250 neuen Orders: liefert 250 (nicht 100). + +### Schritt 5 — Refactor `ImportGetAuftrag` (Import-Funktion) + +**Alt:** Query mit `include`-Liste, Iteration ueber bis zu 20 Orders, kein Cursor. + +**Neu — Single-Order-Pseudocode:** + +``` +# Fenster aufbauen +if last_order_ids is not empty: + after = last_ts - 1s + exclude = last_order_ids +else: + after = last_ts + exclude = (nicht gesetzt) + +# Fetch +order = client.get(orders, after=after, exclude=exclude, per_page=1, orderby=date, order=asc)[0] + +if response is empty: + return [] + +wcOrder = response[0] +order = parseOrder(wcOrder) + +# Persist +if order.date_created_gmt == last_ts: + last_order_ids = last_order_ids + [order.id] +else: + last_ts = order.date_created_gmt + last_order_ids = [order.id] + +persistLastImportCursor(order.date_created_gmt, order.id) + +return [{ id: order.auftrag, sessionid: '', logdatei: '', warenkorb: ... }] +``` + +**Wichtig:** +- `orderby=date` + `order=asc` garantiert, dass die **aelteste** neue Order zuerst kommt. + Dadurch kann der Progress-Timestamp monoton wachsen. +- `date_created_gmt` als Referenz (nicht `date_created` — Zeitzonen-Fallen vermeiden). +- Volume-Handling liegt beim Caller (shopimport.php), nicht im Importer. + +**Akzeptanzkriterium:** +- Pro Call: exakt 1 Order zurueck (oder leer). +- Bei >100 neuen Orders: Caller-Schleife (maxmanuell-gekappt) iteriert bis Ende. + +### Schritt 5a — Caller-Kontrakt + +Der Caller in shopimport.php:1304-1306 ruft pro Order-Import-Iteration: +- `ImportGetAuftraegeAnzahl()` einmal fuer Count (mit maxmanuell-Cap) +- `ImportGetAuftrag()` in for-Schleife, verarbeitet `$result[0]` + +Der Importer muss diesen Kontrakt einhalten: pro Call max. 1 Order. +Der after-Filter sorgt dafuer, dass jede Iteration die naechste Order +holt. Ein Crash zwischen `RemoteGetAuftrag()` und `shopimport_auftraege`- +Insert verliert max. diese eine Order. + +### Schritt 5b — Migration-Helper + +Um die ab_nummer → timestamp Migration auch im Count-Pfad auszufuehren +(shopimport.php ruft ImportGetAuftraegeAnzahl() BEFORE ImportGetAuftrag()): + +- Extraktion in eine private Methode `migrateAbNummerIfNeeded()`. +- Aufruf am Anfang von beiden ImportGetAuftraegeAnzahl() und ImportGetAuftrag(). +- Idempotent durch `$lastImportTimestampIsFallback`-Check — nach einmaliger Migration + keine weiteren Reads. + +### Schritt 6 — Transitions-Kompatibilitaet `ab_nummer` + +**Ist-Zustand:** `CatchRemoteCommand('data')` liefert u.a. `ab_nummer` — die naechste +Bestell-Nummer, ab der gelesen werden soll (Legacy-Cursor). + +**Uebergangsregel:** +- Wenn `letzter_import_timestamp` gesetzt → `after`-Filter nutzen, `ab_nummer` ignorieren. +- Wenn `letzter_import_timestamp` leer aber `ab_nummer` > 0 → einmalig `ab_nummer` in + einen Timestamp uebersetzen (Query `GET orders/{ab_nummer}` → `date_created_gmt` lesen), + als `letzter_import_timestamp` persistieren, ab dann `after`-Logik. +- Wenn beides leer → 30-Tage-Fallback (Schritt 3a). + +**Akzeptanzkriterium:** Shop, der bisher mit `ab_nummer` lief, importiert nach +Update **keine Duplikate** und **keine Luecken**. + +### Schritt 7 — `include`-Hack entfernen + +- Loeschen: + - Zeile ~99–113 (Count-Pfad `include`-Aufbau) + - Zeile ~153–167 (Import-Pfad `include`-Aufbau) + - Die beiden selbstkritischen Code-Kommentare *"fake"-Filter* +- Keine Ersatz-Struktur — `after` uebernimmt den Job komplett. + +### Schritt 8 — Cleanup & Commits + +**Pre-Commit-Checks:** +- `php -l www/pages/shopimporter_woocommerce.php` +- Trailing Whitespace raus (Subagent-Reste) +- CRLF-Warnungen ignorieren (autocrlf-Artefakte per Projekt-Regel) + +**Commit-Struktur (atomar, auf `fix/woocommerce-order-import-pagination`):** + +1. `fix(woocommerce): expose response headers from inline WC client` + → nur Client-Aenderung (bedingt; entfaellt wenn Schritt 1 Headers schon freigibt) + +2. `fix(woocommerce): persist last-import timestamp in shopexport config` + → Timestamp-Read/Write + 30-Tage-Fallback + Progress-Update pro Order + +3. `fix(woocommerce): use after-filter and pagination for order import` + → Kernaenderung an `ImportGetAuftrag` und `ImportGetAuftraegeAnzahl` + +4. `refactor(woocommerce): remove 800-id include hack` + → Aufraeumen toter Code + Kommentare + +5. `docs: add plan for woocommerce pagination fix` + → Diese Datei (`docs/plans/woocommerce-pagination-fix.md`) + +**PR-Ziel:** `openxe-org/openxe:master` (Upstream hat kein `development`). +**PR-Body:** Verweis auf Issue #262 + knappe Zusammenfassung der 4 Commits. + +## 6. Test-Plan (Integration gegen `192.168.0.143`) + +Keine Unit-Tests (OpenXE hat keine Test-Suite fuer Shopimporter). +Manuelle Integrationstests mit seeded Test-Orders. + +### Setup +- WP-Backend: `admin:password` +- WC-REST: `consumer_key`/`consumer_secret` generieren (Admin → WooCommerce → Einstellungen → Erweitert → REST-API) +- OpenXE-Shop-Konfiguration: bestehende Testinstanz wiederverwenden + +### Test-Matrix + +| # | Szenario | Start-State | Aktion | Erwartung | +|---|---|---|---|---| +| T1 | Frischinstall, keine Orders | `letzter_import_timestamp` leer | Lauf starten | 0 Orders, Timestamp bleibt leer | +| T2 | Frischinstall, 10 Orders <30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 10 Orders importiert, Timestamp = neueste Order | +| T3 | Frischinstall, 10 Orders >30 Tage alt | `letzter_import_timestamp` leer | Lauf starten | 0 Orders (Fallback-Schwelle), Timestamp leer | +| T4 | Standardlauf, 30 neue Orders | Timestamp gesetzt | Lauf starten | 30 Orders, Timestamp fortgeschrieben | +| T5 | Spike, 150 neue Orders | Timestamp gesetzt | Lauf starten | 150 Orders, Caller-Schleife durch maxmanuell auf 100 gekappt; Folgelauf holt Rest | +| T6 | Rueckstand, >100 neue Orders | Timestamp gesetzt | Lauf 1 starten | 100 Orders (maxmanuell-Cap), Cursor fortgeschrieben | +| T6b | Rueckstand Teil 2 | nach T6 | Lauf 2 starten | restliche Orders bis maxmanuell-Cap, Timestamp final | +| T7 | Transition, Shop mit `ab_nummer`=12345, Timestamp leer | `ab_nummer=12345` | Lauf starten | `ab_nummer` in Timestamp uebersetzt, keine Duplikate | +| T8 | Abbruch mitten im Lauf | Exception nach Fetch, vor INSERT | neuer Lauf | bei Abbruch zwischen Fetch und Insert max. 1 Order verloren; ab naechster Order fortgesetzt | +| T9 | Idempotenz | T4 erfolgreich gelaufen | T4 nochmal starten | 0 neue Orders, keine Duplikate | +| T10 | URL-Laenge (Regression) | 800 alte Orders existieren | Lauf starten | URL bleibt kurz (<2 KB), keine `include`-Liste mehr | + +### Mess-Artefakte (vor/nach) + +- Anzahl HTTP-Requests pro Lauf (via Apache-Log auf Testinstanz) +- Laenge der URL im `GET /orders`-Request +- Laufzeit pro Lauf +- Anzahl importierte Orders pro Lauf + +## 7. Rollout & Rueckwaerts-Kompatibilitaet + +### Migration beim Update +- Keine DB-Migration noetig. +- Beim ersten Lauf nach Update: + - Wenn `ab_nummer` gesetzt → einmalige Uebersetzung (Schritt 6). + - Wenn nichts gesetzt → 30-Tage-Fallback. +- Kein Breaking Change fuer bestehende Shops. + +### Rollback-Szenario +- Rueckkehr zum alten Verhalten: Revert der 4 Commits aus Schritt 8. +- `letzter_import_timestamp` in `einstellungen_json` stoert alte Version nicht + (unbekannte Keys werden ignoriert). + +### Kompatibilitaet mit anderen Modulen +- `class.remote.php` → nicht beruehrt. +- Andere `shopimporter_*`-Module → nicht beruehrt (nur WooCommerce-spezifisch). +- `class.erpapi.php` → nicht beruehrt. + +## 8. Risiken & Mitigations + +| Risiko | Wahrscheinlichkeit | Auswirkung | Mitigation | +|---|---|---|---| +| Inline-Client liefert Headers nicht durch | mittel | hoch (blockiert Schritt 4/5) | Schritt 1 als Entscheidungs-Gate, Schritt 2 bedingter Vorarbeits-Schritt | +| Shop-Timezone vs. `after`-Zeitzone | mittel | mittel (Off-by-one-Tag) | `date_created_gmt` und `after` beide in UTC, explizit testen (T4, T9) | +| Kunden ohne Timestamp + >30 Tage alte neue Orders | niedrig | mittel | Dokumentiert in PR-Body, UI-Override in Folge-PR | +| `orderby=date` Ordering nicht deterministisch bei gleichem Timestamp | niedrig | niedrig | Zusaetzlich `orderby=date&order=asc` und WC liefert stabile Sekundaersortierung nach ID | +| Andere Shopimporter erben Basisklasse-Struktur | niedrig | niedrig | Nur `shopimporter_woocommerce.php` anfassen, `ShopimporterBase` nicht anruehren | +| URL-Laengenlimits der WAF beim `status[]`-Array | sehr niedrig | niedrig | Typisch 2-3 Status-Werte, URL bleibt kurz | +| Caller-Kontrakt-Abweichung (1 vs. n Orders) | niedrig | hoch | Per Design Single-Order-Return; Caller-Loop fuer Volume-Handling | +| Timestamp-Kollision bei identischem date_created_gmt | niedrig | mittel | Adressiert durch Tupel-Cursor + Bucket-Akkumulation (letzter_import_order_ids sammelt alle IDs des Buckets) | +| Unbounded exclude-Liste bei grossem Bucket | niedrig | niedrig | In der Praxis kleine Listen; URL < 2 KB bei ~100 IDs | + +## 9. Definition of Done + +- [ ] Schritt 1 abgeschlossen, Entscheidung fuer/gegen Schritt 2 dokumentiert im PR-Body +- [ ] Alle geplanten Commits auf `fix/woocommerce-order-import-pagination` +- [ ] `php -l` ohne Fehler +- [ ] Testmatrix T1–T10 auf `192.168.0.143` durchgelaufen, Ergebnisse im PR-Body +- [ ] Issue #262 im PR referenziert (`Fixes #262`) +- [ ] PR gegen `openxe-org/openxe:master` eroeffnet +- [ ] Diese Plan-Datei mitversioniert im Fix-Branch + +## 10. Referenzen + +- Issue: https://github.com/OpenXE-org/OpenXE/issues/262 +- WC REST API Docs (Orders): https://woocommerce.github.io/woocommerce-rest-api-docs/#orders +- WP-REST-API Pagination: https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/ +- Parallele Issues: + - #239 Webhook Support (push statt poll) + - #224 JSON-Error in `getKonfig` diff --git a/www/pages/shopimporter_woocommerce.php b/www/pages/shopimporter_woocommerce.php index 0d221f317..104158ca5 100644 --- a/www/pages/shopimporter_woocommerce.php +++ b/www/pages/shopimporter_woocommerce.php @@ -60,6 +60,18 @@ class Shopimporter_Woocommerce extends ShopimporterBase /** @var Logger $logger */ public $logger; + /** @var bool $ssl_ignore Whether to ignore SSL certificate validation */ + public $ssl_ignore; + + /** @var string $lastImportTimestamp ISO-8601 UTC timestamp of the last successful import */ + public $lastImportTimestamp; + + /** @var bool $lastImportTimestampIsFallback True when lastImportTimestamp was computed as 30-day fallback */ + public $lastImportTimestampIsFallback = false; + + /** @var int[] $lastImportOrderIds WooCommerce order IDs within the current timestamp bucket */ + public $lastImportOrderIds = []; + public function __construct($app, $intern = false) { $this->app = $app; @@ -75,135 +87,190 @@ public function ImportList() } /** - * This function returns the number of orders which have not yet been imported + * Returns the total number of orders pending import since the last import + * timestamp. Uses the WC v3 after= parameter and reads the count from + * the X-WP-Total response header (per_page=1 to minimise payload). + * + * @return int */ public function ImportGetAuftraegeAnzahl() { - // Query the API to get new orders, filtered by the order status as specifed by the user. - // We set per_page to 100 - this could lead to a situation where there are more than - // 100 new Orders, but we still only return 100. - - // Array containing additional settings, namely 'ab_nummer' (containting the next order number to get) - // and 'holeallestati' (an integer) - $tmp = $this->CatchRemoteCommand('data'); - - // Only orders having an order number greater or equal than this should be fetched. null otherwise - $number_from = empty($tmp['ab_nummer']) ? null : (int) $tmp['ab_nummer']; - - // pending orders will be fetched into this array. it's length is returned at the end of the funciton - $pendingOrders = array(); - - if ($number_from) { - // Number-based import is selected + $this->migrateAbNummerIfNeeded(); - // The WooCommerce API doenst allow for a proper "greater than id n" request. - // we fake this behavior by creating an array that contains 'many' (~ 1000) consecutive - // ids that are greater than $from_number and use this array with the 'include' property - // of the WooCommerce API - - $number_to = $number_from + 800; - if (!empty($tmp['bis_nummer'])) { - $number_to = $tmp['bis_nummer']; - } - - $fakeGreaterThanIds = range($number_from, $number_to); - - $pendingOrders = $this->client->get('orders', [ - 'per_page' => 100, - 'include' => implode(",", $fakeGreaterThanIds), - ]); + $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); + if (!empty($this->lastImportOrderIds)) { + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + 'exclude' => array_values($this->lastImportOrderIds), + ]; } else { - // fetch posts by status + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, + ]; + } - $pendingOrders = $this->client->get('orders', [ - 'status' => array_map('trim', explode(';', $this->statusPending)), - 'per_page' => 100 - ]); + try { + $this->client->get('orders', $queryArgs); + } catch (Exception $e) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: API request failed: ' . $e->getMessage()); + return 0; + } + + $wcResponse = $this->client->getLastResponse(); + if ($wcResponse === null) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: getLastResponse() returned null'); + return 0; + } + $total = $wcResponse->getHeader('x-wp-total'); + if ($total === null) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: X-WP-Total header missing'); + return 0; } - return (!empty($pendingOrders) ? count($pendingOrders) : 0); + return (int) $total; } /** - * Calling this function queries the api for pending orders and returns them - * as an array. + * Queries the WooCommerce API for the oldest pending order since the last + * import timestamp and returns it as a Xentral-formatted array with at most + * one element. The caller (shopimport.php::RemoteGetAuftrag loop) expects + * $result[0] per iteration; this contract must be maintained. * - * TODO: Only one single order is returned per invocation of this function. - * Given that we have to perform an exteremly expensive external HTTP call - * every time we call this function and could easily process more than one - * order this seems very bad performance-wise. + * The after-filter advances per order so each caller-iteration fetches the + * next order. A crash between RemoteGetAuftrag() and the shopimport_auftraege + * INSERT loses at most this one order (consistent with pre-#262 behaviour). + * + * @return array Array with at most one order entry, or empty array if none. */ public function ImportGetAuftrag() { - // Array containing additional settings, namely 'ab_nummer' (containting the next order number to get) - // and 'holeallestati' (an integer) - $tmp = $this->CatchRemoteCommand('data'); + $data = $this->CatchRemoteCommand('data'); - // Only orders having an order number greater or equal than this should be fetched. null otherwise - $number_from = empty($tmp['ab_nummer']) ? null : (int) $tmp['ab_nummer']; + $this->migrateAbNummerIfNeeded(); - // pending orders will be fetched into this array. it's length is returned at the end of the funciton - $pendingOrders = array(); + $configuredStatuses = array_map('trim', explode(';', (string) $this->statusPending)); - if ($number_from) { - // Number-based import is selected + if (!empty($this->lastImportOrderIds)) { + $afterTs = gmdate('Y-m-d\TH:i:s', max(0, strtotime($this->lastImportTimestamp) - 1)); + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $afterTs, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + 'exclude' => array_values($this->lastImportOrderIds), + ]; + } else { + $queryArgs = [ + 'status' => $configuredStatuses, + 'after' => $this->lastImportTimestamp, + 'per_page' => 1, + 'page' => 1, + 'orderby' => 'date', + 'order' => 'asc', + ]; + } - // The WooCommerce API doenst allow for a proper "greater than id n" request. - // we fake this behavior by creating an array that contains 'many' (~ 1000) consecutive - // ids that are greater than $from_number and use this array with the 'include' property - // of the WooCommerce API + try { + $pageOrders = $this->client->get('orders', $queryArgs); + } catch (Exception $e) { + $this->logger->warning('WooCommerce ImportGetAuftrag: ' . $e->getMessage()); + return null; + } - $number_to = $number_from + 800; - if (!empty($tmp['bis_nummer'])) { - $number_to = $tmp['bis_nummer']; - } + if (empty($pageOrders)) { + return null; + } - $fakeGreaterThanIds = range($number_from, $number_to); + $wcOrder = $pageOrders[0] ?? null; + if ($wcOrder === null) { + return null; + } - $pendingOrders = $this->client->get('orders', [ - 'per_page' => 20, - 'include' => implode(',', $fakeGreaterThanIds), - 'order' => 'asc', - 'orderby' => 'id' - ]); + $order = $this->parseOrder($wcOrder); - } else { - // fetch posts by status + // Persist tuple cursor so the next Caller-iteration advances past this order. + // Using ts-1s in the query means same-second peers are still fetched, while + // the exclude=[id] parameter prevents re-delivering this exact order. + if (!empty($wcOrder->date_created_gmt) && !empty($wcOrder->id)) { + $this->persistLastImportCursor((string) $wcOrder->date_created_gmt, (int) $wcOrder->id); + } - $pendingOrders = $this->client->get('orders', [ - 'status' => array_map('trim', explode(';', $this->statusPending)), - 'per_page' => 20, - 'order' => 'asc', - 'orderby' => 'id' - ]); + return [[ + 'id' => $order['auftrag'], + 'sessionid' => '', + 'logdatei' => '', + 'warenkorb' => base64_encode(serialize($order)), + ]]; + } + /** + * Resolves a legacy WooCommerce order ID (ab_nummer) to the order's + * date_created_gmt timestamp for the one-shot transition from cursor- + * based to timestamp-based import. + * + * @param int $abNummer WooCommerce order ID + * @return string|null ISO-8601 UTC timestamp or null on failure + */ + private function resolveAbNummerToTimestamp($abNummer) + { + try { + $order = $this->client->get('orders/' . $abNummer); + } catch (Exception $e) { + $this->logger->warning('WooCommerce resolveAbNummerToTimestamp(' . $abNummer . '): ' . $e->getMessage()); + return null; } - // Return an empty array in case there are no orders to import - if ((!empty($pendingOrders) ? count($pendingOrders) : 0) === 0) { + if (empty($order->date_created_gmt)) { + $this->logger->warning('WooCommerce resolveAbNummerToTimestamp(' . $abNummer . '): date_created_gmt missing'); return null; } - $tmp = []; - - foreach ($pendingOrders as $pendingOrder) { - $wcOrder = $pendingOrder; - $order = $this->parseOrder($wcOrder); - - if (is_null($wcOrder)) { - continue; - } + $ts = strtotime((string) $order->date_created_gmt); + if ($ts === false) { + return null; + } + return gmdate('Y-m-d\TH:i:s', $ts - 1); + } - $tmp[] = [ - 'id' => $order['auftrag'], - 'sessionid' => '', - 'logdatei' => '', - 'warenkorb' => base64_encode(serialize($order)), - ]; + /** + * Runs the one-shot legacy ab_nummer -> timestamp migration when the stored + * cursor is still the 30-day fallback and the caller passes an ab_nummer. + * Idempotent: once migrated, lastImportTimestampIsFallback flips to false + * and subsequent calls become no-ops. + */ + private function migrateAbNummerIfNeeded() + { + if (!$this->lastImportTimestampIsFallback) { + return; } - return $tmp; + $data = $this->CatchRemoteCommand('data'); + if (empty($data['ab_nummer'])) { + return; + } + $resolved = $this->resolveAbNummerToTimestamp((int) $data['ab_nummer']); + if ($resolved !== null) { + $this->persistLastImportTimestamp($resolved); + return; + } + // Resolution failed (order deleted, 404, missing date_created_gmt). Persist the + // current 30-day fallback so subsequent runs use a stable lower bound + // instead of sliding the window on every cron cycle. + $this->logger->warning( + sprintf( + 'WooCommerce ab_nummer=%d konnte nicht aufgeloest werden; persistiere 30-Tage-Fallback als Cursor', + (int) $data['ab_nummer'] + ) + ); + $this->persistLastImportTimestamp($this->lastImportTimestamp); } // This function searches the wcOrder for the specified WC Meta key @@ -492,8 +559,11 @@ public function ImportUpdateAuftrag() } /** - * This function syncs the current stock to the remote WooCommerce shop - * @return int + * This function syncs the current stock to the remote WooCommerce shop. + * Uses WC REST v3 batch endpoints to reduce HTTP round-trips from 2n to + * roughly ceil(n/100) + ceil(n/100) requests. + * + * @return int Number of articles successfully synced * @throws WCHttpClientException */ public function ImportSendListLager() @@ -502,8 +572,12 @@ public function ImportSendListLager() $anzahl = 0; $ctmp = (!empty($tmp) ? count($tmp) : 0); + // --- Step 1: Collect all SKUs and compute desired stock params --- + + // $pendingUpdates: sku => ['lageranzahl' => int, 'status' => string] + $pendingUpdates = []; + for ($i = 0; $i < $ctmp; $i++) { - // Get important values from input data $artikel = $tmp[$i]['artikel']; if ($artikel === 'ignore') { continue; @@ -517,52 +591,168 @@ public function ImportSendListLager() $inaktiv = $tmp[$i]['inaktiv']; $status = 'publish'; - // Do some computations, sanitize input - if ($pseudolager !== '') { $lageranzahl = $pseudolager; } - if ($tmp[$i]['ausverkauft']) { $lageranzahl = 0; } - if ($inaktiv) { $status = 'private'; } - // get the product id that WooCommerce uses to represent the current article - $remoteIdInformation = $this->getShopIdBySKU($nummer); + $pendingUpdates[$nummer] = [ + 'lageranzahl' => $lageranzahl, + 'status' => $status, + ]; + } - if (empty($remoteIdInformation['id'])) { - // The online shop doesnt know this article, write to log and continue with next product + if (empty($pendingUpdates)) { + return 0; + } + + // --- Step 2: Bulk-resolve SKUs to WC product IDs --- + // WC REST v3 accepts a comma-separated list in the ?sku= parameter. + // We fetch in chunks of 100 to stay within per_page limits. + + // $skuMap: sku => ['id' => int, 'parent' => int, 'isvariant' => bool] + $skuMap = []; + $skuChunks = array_chunk(array_keys($pendingUpdates), 100); - $this->logger->error("WooCommerce Artikel $nummer wurde im Online-Shop nicht gefunden! Falsche Artikelnummer im Shop hinterlegt?"); + foreach ($skuChunks as $skuChunk) { + $skuCsv = implode(',', $skuChunk); + try { + $products = $this->client->get('products', [ + 'sku' => $skuCsv, + 'per_page' => 100, + ]); + } catch (Exception $e) { + $this->logger->error( + 'WooCommerce SKU-Lookup-Chunk fehlgeschlagen: ' . $e->getMessage(), + ['chunk_size' => count($skuChunk)] + ); continue; } + if (!is_array($products)) { + continue; + } + foreach ($products as $product) { + if (!isset($product->sku)) { + continue; + } + $skuMap[$product->sku] = [ + 'id' => $product->id, + 'parent' => $product->parent_id, + 'isvariant' => !empty($product->parent_id), + ]; + } + } + + // --- Step 3: Split into simple products and variations --- + // simpleItems: list of batch-update items for POST products/batch + // variationItems: parent_id => list of batch-update items for POST products/{parent}/variations/batch + + $simpleItems = []; + $variationItems = []; - // Sync settings to online store - $updateProductParams = [ + foreach ($pendingUpdates as $sku => $params) { + if (!isset($skuMap[$sku])) { + $this->logger->error( + "WooCommerce Artikel $sku wurde im Online-Shop nicht gefunden! Falsche Artikelnummer im Shop hinterlegt?" + ); + continue; + } + + $info = $skuMap[$sku]; + $item = [ + 'id' => $info['id'], 'manage_stock' => true, - 'status' => $status, - 'stock_quantity' => $lageranzahl - // WooCommerce doesnt have a standard property for the other values, we're ignoring them + 'stock_quantity' => $params['lageranzahl'], + 'status' => $params['status'], ]; - if ($remoteIdInformation['isvariant']) { - $result = $this->client->put('products/' . $remoteIdInformation['parent'] . '/variations/' . $remoteIdInformation['id'], $updateProductParams); + + if ($info['isvariant']) { + $variationItems[$info['parent']][] = $item; } else { - $result = $this->client->put('products/' . $remoteIdInformation['id'], $updateProductParams); + $simpleItems[] = $item; } + } + + // --- Step 4: Send batch updates in chunks of 100, handle partial errors --- + + // Simple products + foreach (array_chunk($simpleItems, 100) as $chunk) { + try { + $response = $this->client->post('products/batch', ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, 'products/batch'); + } catch (Exception $e) { + $this->logger->error('WooCommerce Batch-Request fehlgeschlagen fuer products/batch: ' . $e->getMessage()); + } + } + // Variations (one batch endpoint per parent product) + foreach ($variationItems as $parentId => $items) { + foreach (array_chunk($items, 100) as $chunk) { + $endpoint = 'products/' . $parentId . '/variations/batch'; + try { + $response = $this->client->post($endpoint, ['update' => $chunk]); + $anzahl += $this->processBatchResponse($response, $endpoint); + } catch (Exception $e) { + $this->logger->error('WooCommerce Batch-Request fehlgeschlagen fuer ' . $endpoint . ': ' . $e->getMessage()); + } + } + } + + return $anzahl; + } + + /** + * Evaluates a WC batch response object, logs per-item errors, and returns + * the count of successfully updated items. + * + * @param object $response Decoded JSON response from the batch endpoint. + * @param string $endpoint Endpoint label used in log messages. + * @return int Number of items reported as updated without error. + */ + private function processBatchResponse($response, $endpoint) + { + $successCount = 0; + + if (!is_object($response) && !is_array($response)) { + $this->logger->error("WooCommerce Batch-Response ungueltig fuer $endpoint"); + return 0; + } + + // Successful updates are in response->update + $updated = is_object($response) ? ($response->update ?? []) : []; + foreach ($updated as $item) { + // WC embeds per-item errors inside the update array when an item fails + if (isset($item->error)) { + $code = $item->error->code ?? ''; + $message = $item->error->message ?? ''; + $this->logger->error( + "WooCommerce Batch-Fehler ($endpoint) fuer ID {$item->id}: [$code] $message" + ); + } else { + $this->logger->info( + "WooCommerce Lagerzahlenübertragung (Batch) fuer Artikel-ID {$item->id} erfolgreich", + ['endpoint' => $endpoint] + ); + $successCount++; + } + } + + // Top-level errors array (some WC versions use this) + $errors = is_object($response) ? ($response->errors ?? []) : []; + foreach ($errors as $err) { + $code = $err->code ?? ''; + $message = $err->message ?? ''; $this->logger->error( - "WooCommerce Lagerzahlenübertragung für Artikel: $nummer / $remoteIdInformation[id] - Anzahl: $lageranzahl", - [ - 'result' => $result - ] + "WooCommerce Batch-Fehler ($endpoint): [$code] $message" ); - $anzahl++; } - return $anzahl; + + return $successCount; } public function ImportStorniereAuftrag() @@ -891,6 +1081,108 @@ public function getKonfig($shopid, $data) $this->ssl_ignore ); + $storedTimestamp = $preferences['felder']['letzter_import_timestamp'] ?? null; + if (!empty($storedTimestamp)) { + $this->lastImportTimestamp = $storedTimestamp; + $this->lastImportTimestampIsFallback = false; + } else { + $this->lastImportTimestamp = gmdate('Y-m-d\TH:i:s', strtotime('-30 days')); + $this->lastImportTimestampIsFallback = true; + } + + $storedIds = $preferences['felder']['letzter_import_order_ids'] ?? null; + $this->lastImportOrderIds = is_array($storedIds) + ? array_values(array_filter(array_map('intval', $storedIds))) + : []; + + } + + /** + * Backwards-compatible wrapper: persists timestamp only (order id cleared). + * Use persistLastImportCursor() when both timestamp and order id are available. + * + * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' + * @return void + */ + public function persistLastImportTimestamp($isoUtcDate) + { + $this->persistLastImportCursor($isoUtcDate, null); + } + + /** + * Persists the tuple cursor (timestamp + accumulated order-id bucket) to + * shopexport.einstellungen_json. Does a read-modify-write to preserve all + * other fields. + * + * Bucket logic: + * - $orderId === null → migration path; ids list is cleared. + * - same timestamp as stored → append $orderId to the ids list. + * - new timestamp → reset ids list to [$orderId]. + * + * @param string $isoUtcDate ISO-8601 UTC timestamp, e.g. '2026-04-20T12:34:56' + * @param int|null $orderId WooCommerce order ID, or null (migration path) + * @return void + */ + public function persistLastImportCursor($isoUtcDate, $orderId = null) + { + $shopid = (int)$this->shopid; + // Prefer DatabaseService when available (web context), fall back to DB + // so this method also works in the CLI/cron context. + if (!empty($this->app->DatabaseService)) { + $einstellungen_json = $this->app->DatabaseService->selectValue( + "SELECT einstellungen_json FROM shopexport WHERE id = :id LIMIT 1", + ['id' => $shopid] + ); + } else { + $einstellungen_json = $this->app->DB->Select( + "SELECT einstellungen_json FROM shopexport WHERE id = '$shopid' LIMIT 1" + ); + } + $current = []; + if (!empty($einstellungen_json)) { + $current = json_decode($einstellungen_json, true) ?: []; + } + if (!isset($current['felder']) || !is_array($current['felder'])) { + $current['felder'] = []; + } + + $previousTs = $current['felder']['letzter_import_timestamp'] ?? null; + $previousIds = $current['felder']['letzter_import_order_ids'] ?? []; + if (!is_array($previousIds)) { + $previousIds = []; + } + + // Determine ids list for the new state. + if ($orderId === null) { + // Migration path — timestamp without a concrete order-id anchor. + $newIds = []; + } elseif ($previousTs !== null && $isoUtcDate === $previousTs) { + // Same timestamp bucket — append id if not already present. + $newIds = $previousIds; + if (!in_array((int) $orderId, $newIds, true)) { + $newIds[] = (int) $orderId; + } + } else { + // New timestamp bucket — reset list to only this id. + $newIds = [(int) $orderId]; + } + + $current['felder']['letzter_import_timestamp'] = $isoUtcDate; + $current['felder']['letzter_import_order_ids'] = $newIds; + + $jsonEncoded = $this->app->DB->real_escape_string(json_encode($current)); + if (!empty($this->app->DatabaseService)) { + $this->app->DatabaseService->execute( + "UPDATE shopexport SET einstellungen_json = :json WHERE id = :id", + ['json' => json_encode($current), 'id' => $shopid] + ); + } else { + $this->app->DB->Update( + "UPDATE shopexport SET einstellungen_json = '$jsonEncoded' WHERE id = '$shopid'" + ); + } + $this->lastImportTimestamp = $isoUtcDate; + $this->lastImportOrderIds = $newIds; } /** @@ -1189,6 +1481,16 @@ public function options($endpoint) { return $this->http->request($endpoint, 'OPTIONS'); } + + /** + * Get the WCResponse from the most recent HTTP request. + * + * @return WCResponse|null + */ + public function getLastResponse() + { + return $this->http->getResponse(); + } } class WCResponse @@ -1271,13 +1573,26 @@ public function getCode() /** * Get headers. * - * @return array $headers WCResponse headers. + * @return array $headers WCResponse headers (keys normalized to lowercase). */ public function getHeaders() { return $this->headers; } + /** + * Get a single response header by name (case-insensitive). + * + * @param string $name Header name (e.g. 'x-wp-totalpages'). + * + * @return string|null Header value or null if not present. + */ + public function getHeader($name) + { + $key = strtolower($name); + return isset($this->headers[$key]) ? $this->headers[$key] : null; + } + /** * Get body. * @@ -2163,6 +2478,7 @@ protected function getResponseHeaders() list($key, $value) = explode(': ', $line); + $key = strtolower($key); $headers[$key] = isset($headers[$key]) ? $headers[$key] . ', ' . trim($value) : trim($value); }