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:
parent
f51da96354
commit
c0476ebe24
4 changed files with 33 additions and 0 deletions
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 | – |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue