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..4f63e047a 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 - - // 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); + $this->migrateAbNummerIfNeeded(); - $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; } - return (!empty($pendingOrders) ? count($pendingOrders) : 0); + $total = $wcResponse->getHeader('x-wp-total'); + if ($total === null) { + $this->logger->warning('WooCommerce ImportGetAuftraegeAnzahl: X-WP-Total header missing'); + return 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; + } + $data = $this->CatchRemoteCommand('data'); + if (empty($data['ab_nummer'])) { + return; } - return $tmp; + $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 @@ -891,6 +958,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 +1358,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 +1450,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 +2355,7 @@ protected function getResponseHeaders() list($key, $value) = explode(': ', $line); + $key = strtolower($key); $headers[$key] = isset($headers[$key]) ? $headers[$key] . ', ' . trim($value) : trim($value); }