diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 0452bce..8364054 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -205,6 +205,7 @@ class ShoppingOrder extends Model 'shipped_at' => 'datetime', 'user_white_label' => 'boolean', 'shipped_status_locked' => 'boolean', + 'sale_stock_exempt' => 'boolean', ]; public static $shippedTypes = [ diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php index e1dafad..87a874e 100644 --- a/app/Services/ProductStockService.php +++ b/app/Services/ProductStockService.php @@ -6,18 +6,10 @@ use App\Models\Product; use App\Models\Production; use App\Models\ProductStockMovement; use App\Models\ShoppingOrder; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; class ProductStockService { - /** - * AP-13-Stichtag: Bestellungen, die vor Einführung des Verkaufs-Abzugs versendet - * wurden, sind historisch — ein erneutes Speichern ihres Versandstatus darf nicht - * rückwirkend vom heutigen Bestand abziehen. - */ - public const SALE_STOCK_SINCE = '2026-07-16'; - /** * Aktueller Bestand je Produkt (Summe Eingänge − Summe Ausgänge). * @@ -190,9 +182,11 @@ class ProductStockService */ public function syncSaleStock(ShoppingOrder $order, ?int $userId = null): void { - // Historische Bestellungen (vor dem Stichtag versendet) nicht anfassen: + // Historische Bestellungen (vor dem AP-13-Stichtag versendet) nicht anfassen: // vor dem Stichtag wurde nichts gebucht, also gibt es auch nichts abzugleichen. - if ($order->shipped_at && $order->shipped_at->lt(Carbon::parse(self::SALE_STOCK_SINCE))) { + // Das Flag wird per Migration einmalig gesetzt und ist bewusst unveränderlich — + // es überlebt damit auch Status-Toggles, die shipped_at nullen und neu stempeln. + if ($order->sale_stock_exempt) { return; } diff --git a/database/migrations/2026_07_20_090000_add_sale_stock_exempt_to_shopping_orders_table.php b/database/migrations/2026_07_20_090000_add_sale_stock_exempt_to_shopping_orders_table.php new file mode 100644 index 0000000..b059d41 --- /dev/null +++ b/database/migrations/2026_07_20_090000_add_sale_stock_exempt_to_shopping_orders_table.php @@ -0,0 +1,40 @@ +boolean('sale_stock_exempt')->default(false)->after('shipped_status_locked'); + }); + + // Backfill: shipped_at vor dem Stichtag ⇒ physisch vor Einführung versendet, + // im Anfangsbestand also bereits nicht mehr enthalten — nie abziehen. + DB::table('shopping_orders') + ->whereNotNull('shipped_at') + ->where('shipped_at', '<', self::SALE_STOCK_SINCE) + ->update(['sale_stock_exempt' => true]); + } + + public function down(): void + { + Schema::table('shopping_orders', function (Blueprint $table) { + $table->dropColumn('sale_stock_exempt'); + }); + } +}; diff --git a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md index f9df724..64604fe 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md @@ -274,6 +274,7 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom | 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** | | 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** | | 17.07.2026 | **Kundenentscheid: Rohstoffbestand-Default „Nur aus Herstellerrezepturen"** | `RawMaterialStockController@index`: Default-Scope von `all` auf `recipe` gedreht (`?scope=all` zeigt weiterhin alles); Button-Gruppe entsprechend verlinkt, Hinweise-Doku angepasst. | `RawMaterialStockTest` angepasst (Default filtert, `scope=all` zeigt alle). Volle Suite: **286 grün, 1052 Assertions** | +| 20.07.2026 | **Review-Fix: Stichtags-Schutz unveränderlich (`sale_stock_exempt`)** | Code-Review vor Live-Release: Der AP-13-Stichtags-Schutz hing an `shipped_at`, das `OrderStatusService::setShipped()` bei jedem Wechsel weg von versendet/abgeschlossen **nullt** und beim Zurückwechseln mit `now()` neu stempelt — Storno + erneutes Versenden einer Alt-Bestellung hätte rückwirkend vom heutigen Bestand abgezogen (Verletzung der dokumentierten Garantie, hinweise.md). Jetzt: Migration `2026_07_20_090000` ergänzt `shopping_orders.sale_stock_exempt` (bool) mit **einmaligem Backfill** (`shipped_at` < 16.07.2026 ⇒ exempt) — das Flag wird danach nie verändert; `syncSaleStock()` prüft nur noch das Flag, die Konstante `SALE_STOCK_SINCE` wandert in die Migration. | `SaleStockDeductionTest` +1: Toggle-Test storniert→versendet bucht nichts (12 grün) | | _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 | – | diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index 5ae6230..2b2c369 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -98,7 +98,10 @@ Gilt für **alle Bestellquellen** (WooCommerce-Fulfillment, Whitelabel-Shop, direkt im Partnercenter angelegte Bestellungen). **Alt-Bestellungen**, die vor dem 16.07.2026 versendet wurden, sind geschützt: erneutes Speichern - ihres Status zieht nicht rückwirkend vom heutigen Bestand ab. + ihres Status zieht nicht rückwirkend vom heutigen Bestand ab. Der Schutz ist + seit 20.07.2026 als **unveränderliches Merkmal** an der Bestellung hinterlegt + (einmalig beim Update gesetzt) und übersteht damit auch Storno und erneutes + Versenden einer Alt-Bestellung. - **API-Bestellungen (neu 16.07.2026):** Unterpunkt von **Bestellungen** in der Warenwirtschaft. Jede über die Partnercenter-API eingehende Bestell-Übertragung (WooCommerce-Fulfillment) wird protokolliert – auch diff --git a/tests/Feature/SaleStockDeductionTest.php b/tests/Feature/SaleStockDeductionTest.php index 44d7252..ea8eaaa 100644 --- a/tests/Feature/SaleStockDeductionTest.php +++ b/tests/Feature/SaleStockDeductionTest.php @@ -249,9 +249,11 @@ test('service-abgleich ist auch direkt idempotent (cron-storno)', function () { test('historische bestellung (vor stichtag versendet) bucht bei erneutem speichern nichts', function () { $creme = ap13MakeProduct('AP13 Historisch'); + // sale_stock_exempt entspricht dem Migrations-Backfill (shipped_at vor dem Stichtag) $order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [ 'shipped' => 3, 'shipped_at' => '2025-12-12 14:05:26', + 'sale_stock_exempt' => true, ]); // Admin bestätigt den Status einer Alt-Bestellung erneut — darf nicht rückwirkend abziehen @@ -261,6 +263,24 @@ test('historische bestellung (vor stichtag versendet) bucht bei erneutem speiche ->and($order->fresh()->shipped_at?->format('Y-m-d'))->toBe('2025-12-12'); }); +test('stichtags-schutz ueberlebt storno und erneutes versenden (status-toggle)', function () { + $creme = ap13MakeProduct('AP13 Historisch-Toggle'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [ + 'shipped' => 3, + 'shipped_at' => '2025-12-12 14:05:26', + 'sale_stock_exempt' => true, + ]); + + // Storno nullt shipped_at, erneutes Versenden stempelt es neu (nach Stichtag) — + // das unveränderliche Flag muss den rückwirkenden Abzug trotzdem verhindern + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 10, $admin); + ap13SetShipped($order, 2, $admin); + + expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0) + ->and($order->fresh()->sale_stock_exempt)->toBeTrue(); +}); + test('verkaufs-abgaenge zaehlen im monatsverbrauch', function () { $service = app(ProductStockService::class); $creme = ap13MakeProduct('AP13 Verbrauch');