diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php index 175ce4d..e1dafad 100644 --- a/app/Services/ProductStockService.php +++ b/app/Services/ProductStockService.php @@ -6,10 +6,18 @@ 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). * @@ -182,6 +190,12 @@ class ProductStockService */ public function syncSaleStock(ShoppingOrder $order, ?int $userId = null): void { + // Historische Bestellungen (vor dem 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))) { + return; + } + $target = []; if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) { $order->loadMissing('shopping_order_items'); diff --git a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md index 7613bc9..62753cd 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-06-07-2026.md @@ -220,6 +220,7 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom | 15.07.2026 | **N-1/N-6/N-10 (Kundenentscheide)** | **N-1:** Chargen-Gruppierung je Lieferant (`batchGroupKey` = `b::`, Batch-Check prüft Lieferant) — Kundenentscheid: Lot-Nummern kommen von extern und können kollidieren (`9b6c9de`). **N-6:** WaWi-Eingaben lesen einen einzelnen Punkt mit 1–2 Nachkommastellen als Dezimaltrenner („5.1" = 5,1; „1.500" = 1500) — neuer Helper `reFormatWawiNumber()` + JS-Parser vereinheitlicht (`016e8bb`). **N-10:** Bewegungs-Modal zeigt aktuellen Bestand + Bestätigung bei Minus-Buchung (`6af6760`). | +2 Tests. Volle Suite: **233 grün, 832 Assertions** | | 16.07.2026 | **Vorbereitung AP-13: SalesController-Härtung** | `SalesController::store()` (Versand-Statuswechsel) validiert Eingaben (Status aus `$shippedTypes`, Action-Whitelist, txaction/payment_id) und läuft in `DB::transaction` mit `lockForUpdate` gegen Doppelklick/parallele Tabs. Verhalten unverändert. Commit `6b8fcad`. | Neuer `SalesShippedStatusTest` (6 grün). Suite: **239 grün, 847 Assertions** | | 16.07.2026 | **AP-13 (Shop-Bestandsabzug bei Versand)** | **`ProductStockService::syncSaleStock()`**: idempotenter Differenz-Abgleich der Verkaufs-Abgänge (`source='sale'`, Referenz = Bestellung) mit dem Versandstatus — Abzug je Bestellposition bei `sent`/`close`, **Sets rekursiv** über `product_set_items` in Einzelprodukte aufgelöst (Zyklus-Schutz, `withTrashed` wie N-9); Zurücksetzen/Storno bucht per Gegenbuchung zurück. **Behebt das Doppelabzug-Risiko** Versand→Zurücksetzen→Versand (Ledger-Abgleich statt `shipped_at`-Flag). Andockpunkte: `SalesController::store` (in der Transaktion) **und** `PaymentReminderService` (Auto-Storno per Cron setzt Status 10 auch bei versendeten Bestellungen). „Verbrauch/Monat" zählt Verkaufs-Abgänge automatisch mit. Self-Shipping bewusst ohne Abzug (kein Warenkorb übertragen). Commit `211aeed`. **Erkenntnis für künftige HTTP-Tests:** mehrere Requests im selben Pest-Test müssen denselben `actingAs`-User verwenden, sonst endet der Folge-Request als stiller Login-Redirect. | Neuer `SaleStockDeductionTest` (10 grün: Positionen, Sets/verschachtelt, Idempotenz, Rückbuchung offen/Storno, Wiederversand, Cron-Pfad, Monatsverbrauch). Suite: **249 grün, 903 Assertions** | +| 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** | | _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 | – | | _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 b5c0fae..8db1eb9 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -95,6 +95,10 @@ zurückgesetzt oder die Bestellung **storniert**, führt eine automatische **Gegenbuchung** den Bestand zurück – auch beim automatischen Storno durch die Zahlungserinnerung. Doppelte Statuswechsel buchen nie doppelt. + 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. - **API-Bestellungen (neu 16.07.2026):** Eigener Menüpunkt 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 9f8c2c1..44d7252 100644 --- a/tests/Feature/SaleStockDeductionTest.php +++ b/tests/Feature/SaleStockDeductionTest.php @@ -247,6 +247,20 @@ test('service-abgleich ist auch direkt idempotent (cron-storno)', function () { expect(ap13Net($order, $creme))->toBe(0); }); +test('historische bestellung (vor stichtag versendet) bucht bei erneutem speichern nichts', function () { + $creme = ap13MakeProduct('AP13 Historisch'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 5]], [ + 'shipped' => 3, + 'shipped_at' => '2025-12-12 14:05:26', + ]); + + // Admin bestätigt den Status einer Alt-Bestellung erneut — darf nicht rückwirkend abziehen + ap13SetShipped($order, 3); + + expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0) + ->and($order->fresh()->shipped_at?->format('Y-m-d'))->toBe('2025-12-12'); +}); + test('verkaufs-abgaenge zaehlen im monatsverbrauch', function () { $service = app(ProductStockService::class); $creme = ap13MakeProduct('AP13 Verbrauch');