diff --git a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php index 7321b0a..a7e9844 100644 --- a/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php +++ b/app/Http/Controllers/Admin/Inventory/OrderFulfillmentController.php @@ -166,9 +166,20 @@ class OrderFulfillmentController extends Controller */ public function document(int $id, string $type) { - abort_unless(in_array($type, ['invoice', 'delivery', 'combined'], true), 404); + abort_unless(in_array($type, ['invoice', 'delivery', 'combined', 'label'], true), 404); $order = ShoppingOrder::query()->findOrFail($id); + + // AP-32 Brückenlösung: lokal gespeicherte Kopie des DHL-Labels aus dem + // WooCommerce-Shop (privater Storage — bewusst nicht die öffentliche Shop-URL) + if ($type === 'label') { + abort_unless($order->wp_label_path && Storage::disk('local')->exists($order->wp_label_path), 404); + + return Response::make(Storage::disk('local')->get($order->wp_label_path), 200) + ->header('Content-Type', 'application/pdf') + ->header('Content-disposition', 'inline; filename="DHL-Label-Bestellung-'.$order->id.'.pdf"'); + } + abort_unless(Invoice::isInvoice($order), 404); if ($type === 'combined') { diff --git a/app/Http/Controllers/Api/ShoppingUserController.php b/app/Http/Controllers/Api/ShoppingUserController.php index a3c85f3..dd5e2f7 100755 --- a/app/Http/Controllers/Api/ShoppingUserController.php +++ b/app/Http/Controllers/Api/ShoppingUserController.php @@ -16,7 +16,10 @@ use App\Services\Shop; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Yard; class ShoppingUserController extends Controller @@ -64,6 +67,7 @@ class ShoppingUserController extends Controller 'user' => $shopping_user ? true : false, 'order' => ($shopping_user && $shopping_user->shopping_order) ? true : false, 'status' => $shopping_user ? $shopping_user->getAPIShippedType() : false, + 'tracking_number' => ($shopping_user && $shopping_user->shopping_order) ? $shopping_user->shopping_order->tracking : null, ]; } @@ -364,6 +368,8 @@ class ShoppingUserController extends Controller } $wp_invoice_path = isset($request->wp_invoice_path) ? $request->wp_invoice_path : null; + $wp_label_url = isset($request->wp_label_url) ? $request->wp_label_url : null; + $wp_tracking_number = isset($request->wp_tracking_number) ? $request->wp_tracking_number : null; $wp_advertising = isset($request->wp_advertising) ? $request->wp_advertising : ''; $wp_incentives = isset($request->wp_incentives) ? $request->wp_incentives : ''; @@ -375,6 +381,9 @@ class ShoppingUserController extends Controller $wp_order = $this->prepareOrder($wp_order, $shopping_user, $wp_invoice_path, $wp_notice); if ($wp_order) { + // AP-32 Brückenlösung: DHL-Label aus dem Shop übernehmen (Kopie lokal speichern) + $this->storeWpLabel($shopping_user->shopping_order, $wp_label_url, $wp_tracking_number); + // AP-13: Übertragung inkl. SKU-Zuordnungsergebnis je Position protokollieren ApiOrderLog::recordTransfer($request->wp_order_number, $shopping_user, $shopping_user->shopping_order, $wp_order); $user = $this->prepareForShow($shopping_user); @@ -385,6 +394,9 @@ class ShoppingUserController extends Controller 'data' => [ 'wp_order_number' => $shopping_user->wp_order_number, 'wp_invoice_path' => $wp_invoice_path, + 'wp_label_url' => $shopping_user->shopping_order->wp_label_url, + 'wp_label_stored' => (bool) $shopping_user->shopping_order->wp_label_path, + 'wp_tracking_number' => $shopping_user->shopping_order->tracking, 'wp_advertising' => $wp_advertising, 'wp_incentives' => $wp_incentives, 'wp_order' => $wp_order, @@ -405,6 +417,90 @@ class ShoppingUserController extends Controller ], 500); } + /** + * AP-32 Brückenlösung: DHL-Label nachreichen bzw. aktualisieren — für den Fall, + * dass das Label im Shop erst NACH der Bestell-Übertragung erstellt (oder neu + * erstellt) wird. Der Connector kann dafür den Plugin-Hook + * `pr_save_dhl_label_tracking` von "DHL for WooCommerce" nutzen. + */ + public function label(Request $request) + { + $request->validate([ + 'wp_order_number' => 'required|int', + 'wp_label_url' => 'nullable|url', + 'wp_tracking_number' => 'nullable', + ]); + + $shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->first(); + if (! $shopping_user || ! $shopping_user->shopping_order) { + return response()->json([ + 'success' => false, + 'message' => 'Order with wp_order_number '.$request->wp_order_number.' not found', + 'time' => Carbon::now()->toDateTimeString(), + ], 400); + } + + $shopping_order = $shopping_user->shopping_order; + $this->storeWpLabel($shopping_order, $request->wp_label_url, $request->wp_tracking_number); + + return response()->json([ + 'success' => true, + 'data' => [ + 'wp_order_number' => $shopping_user->wp_order_number, + 'wp_label_url' => $shopping_order->wp_label_url, + 'wp_label_stored' => (bool) $shopping_order->wp_label_path, + 'wp_tracking_number' => $shopping_order->tracking, + ], + 'time' => Carbon::now()->toDateTimeString(), + ], 200); + } + + /** + * Label-URL + Sendungsnummer aus dem WooCommerce-Shop an der Bestellung speichern. + * Das PDF wird als lokale Kopie in den privaten Storage geladen (Auslieferung nur + * über die Admin-Route) — so bleibt es verfügbar, auch wenn es im Shop gelöscht + * wird, und die öffentliche Upload-URL muss nicht verlinkt werden. Ein + * fehlgeschlagener Download darf die Übertragung nie scheitern lassen. + */ + private function storeWpLabel($shopping_order, $wp_label_url, $wp_tracking_number): void + { + if (! $shopping_order || (! $wp_label_url && ! $wp_tracking_number)) { + return; + } + + if (is_array($wp_tracking_number)) { + $wp_tracking_number = implode(', ', array_filter($wp_tracking_number)); + } + if ($wp_tracking_number) { + $shopping_order->tracking = $wp_tracking_number; + } + + if ($wp_label_url) { + $shopping_order->wp_label_url = $wp_label_url; + try { + $response = Http::timeout(20)->get($wp_label_url); + if ($response->successful() && str_starts_with($response->body(), '%PDF')) { + $path = 'wp-labels/label-'.$shopping_order->id.'.pdf'; + Storage::disk('local')->put($path, $response->body()); + $shopping_order->wp_label_path = $path; + } else { + Log::warning('WP-Label-Download fehlgeschlagen (kein PDF oder HTTP-Fehler)', [ + 'shopping_order_id' => $shopping_order->id, + 'wp_label_url' => $wp_label_url, + 'status' => $response->status(), + ]); + } + } catch (\Throwable $e) { + Log::warning('WP-Label-Download fehlgeschlagen: '.$e->getMessage(), [ + 'shopping_order_id' => $shopping_order->id, + 'wp_label_url' => $wp_label_url, + ]); + } + } + + $shopping_order->save(); + } + public function delete(Request $request) { $request->validate([ diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 03737d3..0452bce 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -142,6 +142,12 @@ use Illuminate\Support\Carbon; * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereCancellationInvoice($value) * + * @property string|null $wp_label_url + * @property string|null $wp_label_path + * + * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereWpLabelUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereWpLabelPath($value) + * * @mixin \Eloquent */ class ShoppingOrder extends Model @@ -181,6 +187,8 @@ class ShoppingOrder extends Model 'invoice_number', 'txaction', 'wp_invoice_path', + 'wp_label_url', + 'wp_label_path', 'wp_notice', 'mode', 'shipped', diff --git a/database/migrations/2026_07_17_090000_add_wp_label_to_shopping_orders_table.php b/database/migrations/2026_07_17_090000_add_wp_label_to_shopping_orders_table.php new file mode 100644 index 0000000..aafb81d --- /dev/null +++ b/database/migrations/2026_07_17_090000_add_wp_label_to_shopping_orders_table.php @@ -0,0 +1,30 @@ +string('wp_label_url', 500)->nullable()->after('wp_invoice_path'); + $table->string('wp_label_path', 255)->nullable()->after('wp_label_url'); + }); + } + + public function down(): void + { + Schema::table('shopping_orders', function (Blueprint $table) { + $table->dropColumn(['wp_label_url', 'wp_label_path']); + }); + } +}; diff --git a/dev/product management /ap-32-brueckenloesung-connector-update.md b/dev/product management /ap-32-brueckenloesung-connector-update.md new file mode 100644 index 0000000..dd89629 --- /dev/null +++ b/dev/product management /ap-32-brueckenloesung-connector-update.md @@ -0,0 +1,111 @@ +# AP-32 Brückenlösung — DHL-Label aus WooCommerce übernehmen (17.07.2026) + +## Kontext + +Die vollständige DHL-API-Anbindung (AP-32) ist nachgelagert (Kundenentscheid 16.07.2026). +Bis dahin: Das Label wird wie bisher **im WooCommerce-Shop** über das Plugin +**„DHL for WooCommerce"** erstellt; bei der Übertragung der Bestellung ins +Partnercenter kommen **Label-URL und Sendungsnummer mit**. + +Das Partnercenter lädt die Label-PDF **einmalig herunter** und speichert sie im +privaten Storage (`storage/app/wp-labels/`) — Gründe: + +1. Die Shop-URL liegt unter `wp-content/uploads/woocommerce_dhl_label/` und ist + **öffentlich erreichbar + erratbar** (Bestellnummer im Dateinamen, Kundenadresse + auf dem Label). Die lokale Kopie wird nur über die eingeloggte Admin-Route + ausgeliefert. +2. Die Kopie bleibt verfügbar, auch wenn das Label im Shop gelöscht/storniert wird. + +## Was das Partnercenter jetzt kann (Laravel-Seite, umgesetzt 17.07.2026) + +- `POST /api/wp/order` akzeptiert zusätzlich (beides optional): + - `wp_label_url` — öffentliche URL der Label-PDF im Shop + - `wp_tracking_number` — Sendungsnummer (String **oder** Array bei Mehrpaket-Sendung) +- **Neu** `POST /api/wp/label` (Passport-Auth wie die übrigen `wp/*`-Endpoints) — + Label **nachreichen/aktualisieren**, wenn es erst nach der Übertragung erstellt wird: + `{ wp_order_number, wp_label_url, wp_tracking_number }` +- `POST /api/wp/status` liefert je Bestellung zusätzlich `tracking_number` zurück + (für den Tracking-Link an den Endkunden im Shop). +- Anzeige: Abwicklungs-Board + Bestell-Details zeigen „Label"-Download + (lokale Kopie; Fallback: Shop-Link) und die Sendungsnummer. +- Ein fehlgeschlagener Label-Download blockiert die Bestell-Übertragung **nie** + (URL wird gespeichert, Warnung im Laravel-Log). + +## Nötiges Update im Verbindungs-Plugin (WooCommerce-Seite) + +Das DHL-Plugin speichert an der Bestellung das Meta-Feld +`_pr_shipment_dhl_label_tracking` mit `label_url`, `label_path`, `tracking_number` +(+ ggf. `return_label_url`). Zwei Ergänzungen im Connector: + +### 1. Label bei der Bestell-Übertragung mitschicken + +Im bestehenden Code, der den `POST /api/wp/order` aufbaut: + +```php +// DHL-Label (Plugin "DHL for WooCommerce") mitgeben, falls vorhanden +$label_meta = $order->get_meta( '_pr_shipment_dhl_label_tracking' ); +if ( is_array( $label_meta ) && ! empty( $label_meta['label_url'] ) ) { + $body['wp_label_url'] = $label_meta['label_url']; + $body['wp_tracking_number'] = isset( $label_meta['tracking_number'] ) + ? $label_meta['tracking_number'] // String oder Array — beides ok + : null; +} +``` + +### 2. Label automatisch nachreichen (wenn es nach der Übertragung erstellt wird) + +Das DHL-Plugin feuert beim Speichern eines Labels den Hook +`pr_save_dhl_label_tracking` (das Meta-Feld ist zu diesem Zeitpunkt bereits +gespeichert). Darauf lauschen und an den neuen Endpoint senden: + +```php +add_action( 'pr_save_dhl_label_tracking', 'gs_push_dhl_label_to_partnercenter', 10, 2 ); + +function gs_push_dhl_label_to_partnercenter( $order_id, $tracking_details ) { + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return; + } + + $label_meta = $order->get_meta( '_pr_shipment_dhl_label_tracking' ); + if ( ! is_array( $label_meta ) || empty( $label_meta['label_url'] ) ) { + return; + } + + // Auth-Token/Basis-URL wie bei den bestehenden API-Aufrufen des Connectors + wp_remote_post( gs_partnercenter_api_url( '/wp/label' ), array( + 'timeout' => 15, + 'headers' => array( + 'Authorization' => 'Bearer ' . gs_partnercenter_api_token(), + 'Accept' => 'application/json', + ), + 'body' => array( + 'wp_order_number' => $order->get_order_number(), + 'wp_label_url' => $label_meta['label_url'], + 'wp_tracking_number' => isset( $label_meta['tracking_number'] ) + ? $label_meta['tracking_number'] + : null, + ), + ) ); +} +``` + +Hinweise: + +- `gs_partnercenter_api_url()` / `gs_partnercenter_api_token()` stehen für die + im Connector bereits vorhandene Basis-URL- und Token-Logik — bitte die + dortigen Funktionsnamen verwenden. +- Der Hook feuert **je Sendungsnummer**; der Endpoint ist idempotent + (überschreibt Label + Sendungsnummer), Mehrfachaufrufe sind unkritisch. +- Ist die Bestellung noch nicht übertragen, antwortet `/wp/label` mit 400 + („not found") — das ist ok: beim späteren `wp/order` kommt das Label ohnehin mit. +- Wichtig: Die Label-Erstellung im Shop **vor** dem Übertragen ist der + Normalfall (ein Request weniger); das Nachreichen ist das Sicherheitsnetz. + +## Offene Punkte / bewusst nicht enthalten + +- **Retourenlabel** (`return_label_url` im Meta): bei Bedarf analog übertragbar — + bräuchte ein weiteres Feld an `shopping_orders`. +- **Etiketten-Sammel-PDF** (AP-33c) und **Label-Erstellung im Partnercenter** + (AP-32) bleiben nachgelagert; die neuen Spalten (`tracking`, + `wp_label_path`) sind dafür wiederverwendbar. diff --git a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md index 1c1c163..89c60ac 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md @@ -39,7 +39,7 @@ V6.0 basiert auf einer **vollständigen Code-Verifikation vom 06.07.2026** (alle | AP-27 | Wareneingang: Charge zusammenführen | ✅ fertig 06.07.2026 (`7a5b5e0`) | Batch-Check-Endpoint + Vorbelegung, Chargen-Gruppierung Detail/Produktion, FEFO-`expandBatchLines()`, `StockEntryChargeMergeTest` (7 grün) | | AP-28 | Neue Produktions-Maske + Produktionsergebnis | ✅ fertig 06.07.2026 (`99a6c92`) | `production_outputs` + Backfill (Migration `2026_07_06_153907`), Buchung je Output-Produkt, Phasen-Maske + INCI-Liste + Ergebnis-Repeater, `ProductionOutputTest` (8 grün) | | AP-13 | Shop-Bestandsabzug bei Versand (inkl. Sets) + API-Bestellungen-Protokoll | ✅ **fertig 16.07.2026** (`211aeed`, `71d5591`) | `ProductStockService::syncSaleStock()`, `api_order_logs`, Menüpunkt „API-Bestellungen"; `SaleStockDeductionTest` (10), `ApiOrderLogTest` (8) | -| AP-32 | Versand im System: DHL-Etiketten per API | ⏸ **nachgelagert** (Kundenentscheid 16.07.2026: erst Testphase) | Umsetzungsansatz in §4; Andockpunkt = Versand-Statuswechsel (wie AP-13) | +| AP-32 | Versand im System: DHL-Etiketten per API | ⏸ **nachgelagert** (Kundenentscheid 16.07.2026: erst Testphase) — **Brückenlösung aktiv seit 17.07.2026** (Label aus dem Shop, §4/§6) | Umsetzungsansatz in §4; Andockpunkt = Versand-Statuswechsel (wie AP-13) | | AP-33a | Bestell-Abwicklung: Nav „Bestellungen" + Fulfillment-Board | ✅ **fertig 16.07.2026** (`a367d85`) | `OrderFulfillmentController`, `OrderStatusService`, `shipped_status_locked`; `OrderFulfillmentTest` (10) | | AP-33b | Sammel-Dokumente (Lieferscheine/Rechnungen als ein PDF) | ✅ **fertig 16.07.2026** (`1252c30`) | Checkbox-Auswahl im Board, `batchDocuments()` via `PDFMerger`; `OrderFulfillmentTest` (14) | | AP-33c | Etiketten-Stapel | ⏸ nachgelagert (mit AP-32) | Ansatz in §4 | @@ -186,6 +186,8 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom **Aufwand:** 3–5 Tage (ohne Retourenlabel). **Reihenfolge:** direkt nach oder gemeinsam mit AP-13 — gleicher Andockpunkt am Versand-Statuswechsel, gemeinsamer Test-Aufbau. +> ➡️ **Brückenlösung umgesetzt 17.07.2026** (Protokoll §6): Das im WooCommerce-Shop erstellte DHL-Label wird bei der API-Übertragung übernommen (lokale PDF-Kopie + Sendungsnummer an der Bestellung, „Label"-Download im Abwicklungs-Board und in den Bestell-Details, Nachreich-Endpoint `wp/label`, Sendungsnummer im `wp/status`-Poll). Damit ist der Versand komplett über das Partnercenter abwickelbar, bis die DHL-Klärungspunkte beantwortet sind. Connector-Update-Anleitung: `ap-32-brueckenloesung-connector-update.md`. + ### Geklärte Punkte mit dem Kunden (beantwortet 06.07.2026) 1. **Lagerort-Modell (AP-21):** ✅ **drei getrennte Stammdaten-Tabellen** (Raum/Regal/Fach) — wie empfohlen. 2. **Chargen-Nr.-Datumsformat (AP-24):** ✅ `TP1` + `ddmmyy` bestätigt (Beispiel TP1150626 = 15.06.26). **Bei AP-24-Umsetzung in der Hinweise-Doku (AP-18) hinterlegen.** @@ -270,7 +272,8 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom | 16.07.2026 | **AP-13-Nachtrag: Stichtags-Schutz für Alt-Bestellungen** | Beim Blick auf Bestellung 1687 (versendet 12/2025) entdeckt: erneutes Speichern des Versandstatus einer **vor AP-13 versendeten** Bestellung hätte rückwirkend vom heutigen Bestand abgezogen. `syncSaleStock()` überspringt jetzt Bestellungen mit `shipped_at` vor dem Stichtag `ProductStockService::SALE_STOCK_SINCE` (16.07.2026). Kundenentscheid dokumentiert: Self-Shipping ohne Abzug bestätigt. | `SaleStockDeductionTest` +1 (11 grün). Suite: **258 grün, 944 Assertions** | | 16.07.2026 | **AP-13 (API-Bestellungen — Protokoll + SKU-Fehlermarkierung, Kundenanforderung 16.07.)** | Neue Tabelle **`api_order_logs`** (Migration `2026_07_16_090000`) + Model `ApiOrderLog`; `ShoppingUserController` protokolliert **jede** `wp/order`-Übertragung — erfolgreiche inkl. Zuordnungsergebnis je Position (SKU, Produkt, Menge, Preis, API-Meldungen), abgelehnte mit Grund (Kunde unbekannt, Bestellung existiert bereits, Speicherfehler). Positionen ohne Produktzuordnung ⇒ `has_errors` + Zusammenfassung („x von y Positionen ohne Produktzuordnung"). **Neuer Menüpunkt „API-Bestellungen"** (copyreader, `admin/inventory/api-orders`): Liste (Limit 500/`?all=1`, Filter „nur Fehler"), rote Markierung, **Modal „Ansehen"** mit Positionstabelle (Meldungen deutsch übersetzt, Roh-Payload bei abgelehnten). Dazu SKU-Konsistenz (Plan §4 „mitzulösen"): Warnkarte **doppelte WP-Nummern** aktiver Produkte, aufklappbare Liste **aktive Produkte ohne WP-Nummer**. Commit `71d5591`. | Neuer `ApiOrderLogTest` (8 grün, Ende-zu-Ende über `wp/order` mit `Passport::actingAs`). Suite: **257 grün, 940 Assertions** | | 16.07.2026 | **Bugfix Logistik-Mail (Kundenmeldung)** | Fehlversand an die Rechnungsadresse: die Versand-Mail (`MailLogistic`) hängte den **Lieferschein nur bei White-Label-Bestellungen** an. Jetzt wird er **immer** mitgesendet (unabhängig von Logo/White-Label); fehlt die PDF-Datei auf der Platte, wird sie mit bestehender Rechnungsnummer regeneriert. Dazu **tote Duplikat-Klasse `app/Mail/MailLogitic.php` entfernt** (Tippfehler-Dateiname, definierte `MailLogistic` in einer Altversion ohne Lieferschein-Anhang — Kollisionsrisiko im Classmap-Autoload). Commit `7d77314`. **Nachtrag:** gleiche Regeneration auch für die **Rechnungs**-PDF — vorher brach `build()` bei fehlender Rechnungsdatei still ab (Mail ohne Anhänge trotz Erfolgsmeldung im UI). | Neuer `LogisticMailTest` (4 grün: Anhang ohne White-Label, Regeneration Lieferschein + Rechnung, Versand-Button). Volle Suite: **276 grün, 1016 Assertions** | -| _offen_ | AP-32 | Versand im System: DHL-Etiketten per API — **neu aufgenommen + Ansatz dokumentiert (§4, 15.07.2026)**; Klärungspunkte (DHL-Konto, Versandprodukte, Druck-Setup) für nächste Anforderungsrunde notiert | – | +| 17.07.2026 | **AP-32-Brückenlösung (DHL-Label aus WooCommerce übernehmen)** | Bis zur echten DHL-API-Anbindung: Das im Shop erstellte Label (Plugin „DHL for WooCommerce", Meta `_pr_shipment_dhl_label_tracking`) kommt **bei der Bestell-Übertragung mit**. `POST wp/order` akzeptiert `wp_label_url` + `wp_tracking_number` (String oder Array); das System lädt die Label-PDF als **lokale Kopie** in den privaten Storage (`wp-labels/`, Migration `2026_07_17_090000`: `shopping_orders.wp_label_url/wp_label_path`; Sendungsnummer in bestehender Spalte `tracking`) — Begründung: Shop-URL ist öffentlich + erratbar (Kundenadresse!), Kopie überlebt Löschung im Shop; Download-Fehler blockieren die Übertragung nie. **Neuer Endpoint `POST wp/label`** zum Nachreichen (Connector-Hook `pr_save_dhl_label_tracking`); `wp/status` liefert je Bestellung `tracking_number` zurück (Tracking-Link Endkunde). **Anzeige:** Abwicklungs-Board + Bestell-Details mit „Label"-Download (lokale Kopie über copyreader-Dokumentroute `type=label`, Fallback Shop-Link) + Sendungsnummer. Nötiges Connector-Update (WP-Seite) dokumentiert in `dev/product management /ap-32-brueckenloesung-connector-update.md`. Hinweise-Doku + Testleitfaden (§11) fortgeschrieben. Nebenbei: `pint.json` schließt `dev/` (Fremdcode-Referenzen) von der Formatierung aus. | Neuer `WpLabelTransferTest` (10 grün: Übertragung mit Label/Sendungsnr., Download-Fehler + Nicht-PDF blockieren nicht, Mehrpaket-Nummern, Nachreichen, unbekannte Nummer, Status-Poll, Board-Anzeige, Label-Download + 404). Volle Suite: **286 grün, 1052 Assertions** | +| _offen_ | AP-32 | Versand im System: DHL-Etiketten per API — **neu aufgenommen + Ansatz dokumentiert (§4, 15.07.2026)**; Klärungspunkte (DHL-Konto, Versandprodukte, Druck-Setup) für nächste Anforderungsrunde notiert. **Zwischenstand 17.07.2026: Brückenlösung aktiv** (Label aus dem Shop, s. Protokollzeile) — deckt den Versand-Flow ab, bis die DHL-Klärungen da sind | – | | _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – | > **✅ BLOCK ABGESCHLOSSEN (06.07.2026).** Alle fachlichen Pakete der Anforderungsrunde vom 12.06.2026 sind umgesetzt (OPT-1 … OPT-4, AP-20/21/23/24/27/28); die Quelldateien `docs/nächsten Anforderungen an die WaWi.md` und `docs/Todos.md` sind vollständig eingearbeitet. Suite: 201 Tests grün, 731 Assertions. diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..411b1db --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "exclude": [ + "dev" + ] +} diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index f82c6fc..f110ef3 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -124,10 +124,22 @@ im Tabellenkopf = alle sichtbaren) und mit **„Lieferscheine drucken"** bzw. **„+ Rechnungen"** ein einziges PDF für den Stapeldruck erzeugen — fehlende Dokumente werden dabei automatisch miterstellt (ohne E-Mail). +- **DHL-Label aus dem Shop (Brückenlösung, neu 17.07.2026):** Wird das + Versandetikett im WooCommerce-Shop erstellt (Plugin „DHL for WooCommerce") + und die Bestellung danach übertragen, kommen **Label und Sendungsnummer + mit**: Das System lädt eine **eigene Kopie der Label-PDF** herunter (bleibt + verfügbar, auch wenn das Label im Shop gelöscht wird) und zeigt in der + Abwicklung und den Bestell-Details einen **„Label"-Download** plus die + Sendungsnummer. Wird das Label im Shop erst später (neu) erstellt, reicht + der Shop es automatisch nach. Die Sendungsnummer wird über die + Status-Abfrage auch an den Shop zurückgemeldet (Tracking-Link für den + Endkunden). Die vollständige DHL-Anbindung im Partnercenter (Labels direkt + hier erzeugen, AP-32) bleibt geplant. ### In Vorbereitung / geplant -- **Versand im System** (DHL-Etiketten per API, AP-32), **Audit-Trail**, +- **Versand im System** (DHL-Etiketten per API, AP-32 — bis dahin gilt die + Brückenlösung „DHL-Label aus dem Shop", s. o.), **Audit-Trail**, **blockbasierte Rechte**, **2FA für Admins**, **WaWi-Einstellungsseite** (Schwellwerte/Defaults zentral). - **Produktentwicklung:** Menüpunkt ist als Platzhalter angelegt – das genaue diff --git a/resources/docs/testleitfaden.md b/resources/docs/testleitfaden.md index 5f955d1..a301ba3 100644 --- a/resources/docs/testleitfaden.md +++ b/resources/docs/testleitfaden.md @@ -157,6 +157,20 @@ --- +## 11. DHL-Label aus dem Shop (Brückenlösung, neu 17.07.2026) + +**Wo:** Menü **[Bestellungen → Abwicklung](/admin/inventory/orders)** und Bestell-Details unter **Verkäufe** + +**Neu:** +- Wird das DHL-Etikett **im WooCommerce-Shop** erstellt und die Bestellung danach übertragen, speichert das System eine **eigene Kopie der Label-PDF** und die **Sendungsnummer** an der Bestellung. +- In der Abwicklung erscheint bei der Bestellung ein **„Label"-Download** (neben dem externen Rechnungslink) samt Sendungsnummer; ebenso in den Bestell-Details. +- Wird das Label im Shop erst **nach** der Übertragung erstellt (oder neu erstellt), reicht der Shop es automatisch nach — es taucht dann ohne weiteres Zutun in der Abwicklung auf. +- Voraussetzung: das kleine Update des Verbindungs-Plugins im WooCommerce-Shop ist eingespielt. + +**So testen:** Im Shop eine Testbestellung mit DHL-Label versehen → Bestellung ins Partnercenter übertragen → in der **Abwicklung** bei der Bestellung auf **„Label"** klicken (PDF öffnet sich) und die Sendungsnummer prüfen. Danach einmal andersherum: Bestellung zuerst übertragen, Label im Shop erst danach erstellen → Seite neu laden, der Label-Download muss nachträglich erscheinen. + +--- + ## Hinweis am Rande Die Listen **Einkauf & Wareneingang** und **Produktionen** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet. diff --git a/resources/views/admin/inventory/orders/index.blade.php b/resources/views/admin/inventory/orders/index.blade.php index 0da382a..3b9200f 100644 --- a/resources/views/admin/inventory/orders/index.blade.php +++ b/resources/views/admin/inventory/orders/index.blade.php @@ -185,6 +185,19 @@ @else {{ __('extern') }} @endif + {{-- AP-32 Brückenlösung: DHL-Label aus dem Shop (lokale Kopie, sonst Shop-Link) --}} + @if ($order->wp_label_path) + + {{ __('Label') }} + + @elseif ($order->wp_label_url) + + {{ __('Label') }} + + @endif + @if ($order->tracking) +
{{ $order->tracking }}
+ @endif @elseif (\App\Services\Invoice::isInvoice($order)) {{ __('Re.') }} {{ __('LS') }} diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index b26cca4..f7eb083 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -618,11 +618,24 @@ @if ($shopping_order->txaction === 'extern')
- @if ($shopping_order->wp_invoice_path) + @if ($shopping_order->wp_invoice_path || $shopping_order->wp_label_path || $shopping_order->wp_label_url)
- Link zur Rechnung + @if ($shopping_order->wp_invoice_path) + Link zur Rechnung + @endif + {{-- AP-32 Brückenlösung: DHL-Label aus dem WooCommerce-Shop --}} + @if ($shopping_order->wp_label_path) + DHL-Label + @elseif ($shopping_order->wp_label_url) + DHL-Label (Shop) + @endif + @if ($shopping_order->tracking) + Sendungsnummer: {{ $shopping_order->tracking }} + @endif
@endif diff --git a/routes/api.php b/routes/api.php index 758f052..4c0189e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,8 @@ name('api.payment_status'); -//Route::post('/payment/status', [App\Http\Controllers\Api\PayoneController::class, 'paymentStatus'])->name('api.payment_status'); -//Route::post('login', [App\Http\Controllers\Api\AuthController::class, 'login']); +// Route::get('/payment/status', [App\Http\Controllers\Api\PayoneController::class, 'paymentStatus'])->name('api.payment_status'); +// Route::post('/payment/status', [App\Http\Controllers\Api\PayoneController::class, 'paymentStatus'])->name('api.payment_status'); +// Route::post('login', [App\Http\Controllers\Api\AuthController::class, 'login']); Route::group([ - 'prefix' => 'auth' + 'prefix' => 'auth', ], function () { Route::post('login', [AuthController::class, 'login']); - //Route::post('signup', [AuthController::class, 'signup']); + // Route::post('signup', [AuthController::class, 'signup']); Route::get('test', [AuthController::class, 'test']); - - Route::group([ - 'middleware' => 'auth:api' - ], function() { + + Route::group([ + 'middleware' => 'auth:api', + ], function () { Route::post('logout', [AuthController::class, 'logout']); Route::post('checked', [AuthController::class, 'checked']); }); }); Route::group([ - 'prefix' => 'wp' + 'prefix' => 'wp', ], function () { Route::group([ - 'middleware' => 'auth:api' - ], function() { + 'middleware' => 'auth:api', + ], function () { Route::post('show', [ShoppingUserController::class, 'show']); Route::post('store', [ShoppingUserController::class, 'store']); Route::post('update', [ShoppingUserController::class, 'update']); Route::post('order', [ShoppingUserController::class, 'order']); + Route::post('label', [ShoppingUserController::class, 'label']); Route::post('status', [ShoppingUserController::class, 'status']); Route::post('delete', [ShoppingUserController::class, 'delete']); Route::post('cancel', [ShoppingUserController::class, 'cancel']); Route::post('open', [ShoppingUserController::class, 'open']); }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/WpLabelTransferTest.php b/tests/Feature/WpLabelTransferTest.php new file mode 100644 index 0000000..a384daa --- /dev/null +++ b/tests/Feature/WpLabelTransferTest.php @@ -0,0 +1,269 @@ +where('code', 'DE')->first() ?? Country::query()->forceCreate([ + 'code' => 'DE', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Germanija', + ]); +} + +function wplabelMakeUser(int $adminLevel = 7): User +{ + $user = User::query()->create([ + 'email' => uniqid('wplabel_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $account = UserAccount::query()->create([ + 'm_first_name' => 'Label', + 'm_last_name' => 'Partner', + 'country_id' => wplabelCountry()->id, + ]); + $user->forceFill([ + 'admin' => $adminLevel, + 'confirmed' => true, + 'active' => true, + 'wizard' => 100, + 'blocked' => false, + 'account_id' => $account->id, + ])->save(); + + return $user->fresh(); +} + +function wplabelMakeShoppingUser(int $wpOrderNumber): ShoppingUser +{ + $country = wplabelCountry(); + $shipping = Shipping::query()->forceCreate(['name' => 'Standard']); + ShippingCountry::query()->forceCreate([ + 'shipping_id' => $shipping->id, + 'country_id' => $country->id, + ]); + + return ShoppingUser::query()->forceCreate([ + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_firstname' => 'Label', + 'billing_lastname' => 'Kunde', + 'wp_order_number' => $wpOrderNumber, + 'member_id' => wplabelMakeUser(0)->id, + 'auth_user_id' => wplabelMakeUser(0)->id, + ]); +} + +function wplabelMakeProduct(int $wpNumber): Product +{ + return Product::query()->create([ + 'name' => 'Label Creme '.$wpNumber, + 'title' => 'Label Creme '.$wpNumber, + 'active' => true, + 'is_set' => false, + 'wp_number' => $wpNumber, + 'price' => '19,90', + 'tax' => '19', + 'weight' => 100, + ]); +} + +function wplabelTransfer(int $wpOrderNumber, array $extra = []) +{ + return test()->postJson('https://api.'.config('app.api_domain').'/wp/order', array_merge([ + 'wp_order_number' => $wpOrderNumber, + 'wp_order' => [['article' => 100, 'qty' => 1, 'price' => 1990]], + ], $extra)); +} + +test('uebertragung mit label-url speichert pdf-kopie und sendungsnummer', function () { + Mail::fake(); + Storage::fake('local'); + Http::fake(['*' => Http::response('%PDF-1.4 label-inhalt', 200)]); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5711); + + $response = wplabelTransfer(5711, [ + 'wp_label_url' => 'https://shop.example/wp-content/uploads/woocommerce_dhl_label/dhl-label-5711.pdf', + 'wp_tracking_number' => '00340434161094000001', + ]); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.wp_label_stored', true) + ->assertJsonPath('data.wp_tracking_number', '00340434161094000001'); + + $order = ShoppingOrder::query()->sole(); + expect($order->tracking)->toBe('00340434161094000001') + ->and($order->wp_label_url)->toContain('dhl-label-5711.pdf') + ->and($order->wp_label_path)->toBe('wp-labels/label-'.$order->id.'.pdf'); + Storage::disk('local')->assertExists($order->wp_label_path); +}); + +test('fehlgeschlagener label-download blockiert die uebertragung nicht', function () { + Mail::fake(); + Storage::fake('local'); + Http::fake(['*' => Http::response('Serverfehler', 500)]); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5712); + + wplabelTransfer(5712, [ + 'wp_label_url' => 'https://shop.example/label.pdf', + 'wp_tracking_number' => '00340434161094000002', + ])->assertOk()->assertJsonPath('data.wp_label_stored', false); + + $order = ShoppingOrder::query()->sole(); + expect($order->wp_label_path)->toBeNull() + ->and($order->wp_label_url)->toBe('https://shop.example/label.pdf') + ->and($order->tracking)->toBe('00340434161094000002'); +}); + +test('antwort ohne pdf-inhalt wird nicht als label gespeichert', function () { + Mail::fake(); + Storage::fake('local'); + Http::fake(['*' => Http::response('Login', 200)]); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5713); + + wplabelTransfer(5713, ['wp_label_url' => 'https://shop.example/label.pdf']) + ->assertOk()->assertJsonPath('data.wp_label_stored', false); + + expect(ShoppingOrder::query()->sole()->wp_label_path)->toBeNull(); +}); + +test('mehrere sendungsnummern werden zusammengefasst', function () { + Mail::fake(); + Storage::fake('local'); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5714); + + wplabelTransfer(5714, ['wp_tracking_number' => ['00340001', '00340002']])->assertOk(); + + expect(ShoppingOrder::query()->sole()->tracking)->toBe('00340001, 00340002'); +}); + +test('label kann per wp label nachgereicht werden', function () { + Mail::fake(); + Storage::fake('local'); + Http::fake(['*' => Http::response('%PDF-1.4 nachgereicht', 200)]); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5715); + + wplabelTransfer(5715)->assertOk(); + $order = ShoppingOrder::query()->sole(); + expect($order->wp_label_path)->toBeNull(); + + test()->postJson('https://api.'.config('app.api_domain').'/wp/label', [ + 'wp_order_number' => 5715, + 'wp_label_url' => 'https://shop.example/label-5715.pdf', + 'wp_tracking_number' => '00340434161094000005', + ])->assertOk()->assertJsonPath('data.wp_label_stored', true); + + $order->refresh(); + expect($order->wp_label_path)->toBe('wp-labels/label-'.$order->id.'.pdf') + ->and($order->tracking)->toBe('00340434161094000005'); + Storage::disk('local')->assertExists($order->wp_label_path); +}); + +test('wp label mit unbekannter bestellnummer wird abgelehnt', function () { + Passport::actingAs(wplabelMakeUser()); + + test()->postJson('https://api.'.config('app.api_domain').'/wp/label', [ + 'wp_order_number' => 99999, + 'wp_label_url' => 'https://shop.example/label.pdf', + ])->assertStatus(400); +}); + +test('status-poll liefert die sendungsnummer zurueck', function () { + Mail::fake(); + Storage::fake('local'); + Passport::actingAs(wplabelMakeUser()); + wplabelMakeProduct(100); + wplabelMakeShoppingUser(5716); + + wplabelTransfer(5716, ['wp_tracking_number' => '00340434161094000006'])->assertOk(); + + test()->postJson('https://api.'.config('app.api_domain').'/wp/status', [ + 'wp_order_numbers' => [5716], + ])->assertOk()->assertJsonPath('data.0.tracking_number', '00340434161094000006'); +}); + +test('abwicklungs-board zeigt label-download und sendungsnummer', function () { + Storage::fake('local'); + $shoppingUser = wplabelMakeShoppingUser(5717); + $order = ShoppingOrder::query()->forceCreate([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $shoppingUser->auth_user_id, + 'country_id' => ShippingCountry::query()->first()->id, + 'shipped' => 0, + 'txaction' => 'extern', + 'paid' => true, + 'wp_label_path' => 'wp-labels/label-1.pdf', + 'tracking' => '00340434161094000007', + ]); + + $this->actingAs(wplabelMakeUser(1)) + ->get(route('admin.inventory.orders.index')) + ->assertOk() + ->assertSee(route('admin.inventory.orders.document', [$order->id, 'label']), false) + ->assertSee('00340434161094000007'); +}); + +test('label-download liefert die lokale pdf-kopie aus', function () { + Storage::fake('local'); + $shoppingUser = wplabelMakeShoppingUser(5718); + $order = ShoppingOrder::query()->forceCreate([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $shoppingUser->auth_user_id, + 'country_id' => ShippingCountry::query()->first()->id, + 'shipped' => 0, + 'txaction' => 'extern', + 'wp_label_path' => 'wp-labels/label-test.pdf', + ]); + Storage::disk('local')->put('wp-labels/label-test.pdf', '%PDF-1.4 test'); + + $this->actingAs(wplabelMakeUser(1)) + ->get(route('admin.inventory.orders.document', [$order->id, 'label'])) + ->assertOk() + ->assertHeader('Content-Type', 'application/pdf'); +}); + +test('label-download ohne gespeichertes label liefert 404', function () { + Storage::fake('local'); + $shoppingUser = wplabelMakeShoppingUser(5719); + $order = ShoppingOrder::query()->forceCreate([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $shoppingUser->auth_user_id, + 'country_id' => ShippingCountry::query()->first()->id, + 'shipped' => 0, + 'txaction' => 'extern', + ]); + + $this->actingAs(wplabelMakeUser(1)) + ->get(route('admin.inventory.orders.document', [$order->id, 'label'])) + ->assertNotFound(); +});