Warenwirtschaft: AP-13 — Stichtags-Schutz für vor Einführung versendete Bestellungen

Erneutes Speichern des Versandstatus einer Alt-Bestellung (shipped_at vor
16.07.2026) hätte rückwirkend vom heutigen Bestand abgezogen. syncSaleStock()
überspringt historische Bestellungen jetzt per Stichtag SALE_STOCK_SINCE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-16 09:31:23 +00:00
parent f51da96354
commit c0476ebe24
4 changed files with 33 additions and 0 deletions

View file

@ -6,10 +6,18 @@ use App\Models\Product;
use App\Models\Production; use App\Models\Production;
use App\Models\ProductStockMovement; use App\Models\ProductStockMovement;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ProductStockService 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). * 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 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 = []; $target = [];
if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) { if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) {
$order->loadMissing('shopping_order_items'); $order->loadMissing('shopping_order_items');

View file

@ -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:<supplier>:<lot>`, 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** | | 15.07.2026 | **N-1/N-6/N-10 (Kundenentscheide)** | **N-1:** Chargen-Gruppierung je Lieferant (`batchGroupKey` = `b:<supplier>:<lot>`, 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 | **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 (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** | | 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-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 | – | | _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |

View file

@ -95,6 +95,10 @@
zurückgesetzt oder die Bestellung **storniert**, führt eine automatische zurückgesetzt oder die Bestellung **storniert**, führt eine automatische
**Gegenbuchung** den Bestand zurück – auch beim automatischen Storno durch **Gegenbuchung** den Bestand zurück – auch beim automatischen Storno durch
die Zahlungserinnerung. Doppelte Statuswechsel buchen nie doppelt. 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 - **API-Bestellungen (neu 16.07.2026):** Eigener Menüpunkt in der
Warenwirtschaft. Jede über die Partnercenter-API eingehende Warenwirtschaft. Jede über die Partnercenter-API eingehende
Bestell-Übertragung (WooCommerce-Fulfillment) wird protokolliert – auch Bestell-Übertragung (WooCommerce-Fulfillment) wird protokolliert – auch

View file

@ -247,6 +247,20 @@ test('service-abgleich ist auch direkt idempotent (cron-storno)', function () {
expect(ap13Net($order, $creme))->toBe(0); 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 () { test('verkaufs-abgaenge zaehlen im monatsverbrauch', function () {
$service = app(ProductStockService::class); $service = app(ProductStockService::class);
$creme = ap13MakeProduct('AP13 Verbrauch'); $creme = ap13MakeProduct('AP13 Verbrauch');