Warenwirtschaft: AP-32-Brückenlösung — DHL-Label aus WooCommerce übernehmen

Bestell-Übertragung (wp/order) akzeptiert wp_label_url + wp_tracking_number:
Label-PDF wird als lokale Kopie im privaten Storage gespeichert (Shop-URL ist
öffentlich erratbar; Kopie überlebt Löschung im Shop), Sendungsnummer landet
in shopping_orders.tracking. Neuer Endpoint wp/label zum Nachreichen
(Connector-Hook pr_save_dhl_label_tracking), wp/status liefert die
Sendungsnummer an den Shop zurück. Abwicklungs-Board + Bestell-Details zeigen
Label-Download (copyreader-Dokumentroute type=label, Fallback Shop-Link) und
Sendungsnummer. Download-Fehler blockieren die Übertragung nie.

Doku: Connector-Update-Anleitung (dev/product management), Hinweise,
Testleitfaden §11, Plan-Protokoll. pint.json schließt dev/ (Fremdcode) aus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-17 11:35:40 +00:00
parent a1f94ea427
commit 0eed974576
13 changed files with 611 additions and 25 deletions

View file

@ -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') {

View file

@ -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([

View file

@ -142,6 +142,12 @@ use Illuminate\Support\Carbon;
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ShoppingOrder whereCancellationInvoice($value)
*
* @property string|null $wp_label_url
* @property string|null $wp_label_path
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ShoppingOrder whereWpLabelUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|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',

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-32 Brückenlösung (17.07.2026): DHL-Label wird im WooCommerce-Shop erstellt
* (Plugin "DHL for WooCommerce") und bei der API-Übertragung mitgeliefert.
* wp_label_url = Original-URL im Shop (Fallback), wp_label_path = lokal
* gespeicherte Kopie (privater Storage, Auslieferung nur über Admin-Route).
* Die Sendungsnummer landet in der bestehenden Spalte `tracking`.
*/
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -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.

View file

@ -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.

6
pint.json Normal file
View file

@ -0,0 +1,6 @@
{
"preset": "laravel",
"exclude": [
"dev"
]
}

View file

@ -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

View file

@ -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.

View file

@ -185,6 +185,19 @@
@else
<span class="text-muted small">{{ __('extern') }}</span>
@endif
{{-- AP-32 Brückenlösung: DHL-Label aus dem Shop (lokale Kopie, sonst Shop-Link) --}}
@if ($order->wp_label_path)
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'label']) }}" target="_blank" title="{{ __('DHL-Label') }}" class="ml-1">
<span class="fas fa-tag"></span> {{ __('Label') }}
</a>
@elseif ($order->wp_label_url)
<a href="{{ $order->wp_label_url }}" target="_blank" rel="noopener" title="{{ __('DHL-Label (extern im Shop)') }}" class="ml-1">
<span class="fas fa-tag"></span> {{ __('Label') }}
</a>
@endif
@if ($order->tracking)
<div class="small text-muted" title="{{ __('Sendungsnummer') }}">{{ $order->tracking }}</div>
@endif
@elseif (\App\Services\Invoice::isInvoice($order))
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'invoice']) }}" target="_blank" title="{{ __('Rechnung') }}" class="mr-1"><span class="far fa-file-alt"></span> {{ __('Re.') }}</a>
<a href="{{ route('admin.inventory.orders.document', [$order->id, 'delivery']) }}" target="_blank" title="{{ __('Lieferschein') }}" class="mr-1"><span class="fas fa-dolly"></span> {{ __('LS') }}</a>

View file

@ -618,11 +618,24 @@
@if ($shopping_order->txaction === 'extern')
<div class="card-body">
@if ($shopping_order->wp_invoice_path)
@if ($shopping_order->wp_invoice_path || $shopping_order->wp_label_path || $shopping_order->wp_label_url)
<div class="row">
<div class="col-sm-12">
<a href="{{ $shopping_order->wp_invoice_path }}" target="_blank"
class="btn btn-secondary btn-sm">Link zur Rechnung</a>
@if ($shopping_order->wp_invoice_path)
<a href="{{ $shopping_order->wp_invoice_path }}" target="_blank"
class="btn btn-secondary btn-sm">Link zur Rechnung</a>
@endif
{{-- AP-32 Brückenlösung: DHL-Label aus dem WooCommerce-Shop --}}
@if ($shopping_order->wp_label_path)
<a href="{{ route('admin.inventory.orders.document', [$shopping_order->id, 'label']) }}" target="_blank"
class="btn btn-secondary btn-sm"><span class="fas fa-tag"></span> DHL-Label</a>
@elseif ($shopping_order->wp_label_url)
<a href="{{ $shopping_order->wp_label_url }}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm"><span class="fas fa-tag"></span> DHL-Label (Shop)</a>
@endif
@if ($shopping_order->tracking)
<span class="text-muted small ml-2">Sendungsnummer: {{ $shopping_order->tracking }}</span>
@endif
</div>
</div>
@endif

View file

@ -1,9 +1,8 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ShoppingUserController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
@ -16,43 +15,44 @@ use App\Http\Controllers\Api\ShoppingUserController;
|
*/
//Route::post('login', [App\Http\Controllers\API\UserController::class, 'login']);
//Route::post('register', [App\Http\Controllers\API\UserController::class, 'register']);
// Route::post('login', [App\Http\Controllers\API\UserController::class, 'login']);
// Route::post('register', [App\Http\Controllers\API\UserController::class, 'register']);
//Route::post('login', [App\Http\Controllers\Api\APIController::class, 'login']);
// Route::post('login', [App\Http\Controllers\Api\APIController::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::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']);
});
});
});

View file

@ -0,0 +1,269 @@
<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function wplabelCountry(): Country
{
return Country::query()->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('<html>Login</html>', 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();
});