diff --git a/app/Http/Controllers/SalesController.php b/app/Http/Controllers/SalesController.php index 6ca8575..cd3aa73 100755 --- a/app/Http/Controllers/SalesController.php +++ b/app/Http/Controllers/SalesController.php @@ -10,6 +10,7 @@ use App\Services\CustomerPriority; use App\Services\Invoice; use App\Services\Payment; use App\Services\PaymentService; +use App\Services\ProductStockService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Request; @@ -485,6 +486,9 @@ class SalesController extends Controller $shopping_order->shopping_order_margin->save(); } } + + // AP-13: Verkaufs-Abgänge (Produktbestand) mit dem neuen Versandstatus abgleichen + app(ProductStockService::class)->syncSaleStock($shopping_order, auth()->id()); }); } diff --git a/app/Services/PaymentReminderService.php b/app/Services/PaymentReminderService.php index b00f1da..33c624c 100644 --- a/app/Services/PaymentReminderService.php +++ b/app/Services/PaymentReminderService.php @@ -187,6 +187,8 @@ class PaymentReminderService $this->setNoNPayment($payment); $payment->shopping_order->shipped = 10; $payment->shopping_order->save(); + // AP-13: war die Bestellung bereits versendet, gebuchte Verkaufs-Abgänge zurückführen + app(ProductStockService::class)->syncSaleStock($payment->shopping_order); $this->createLog( 'action_completed', "Action abgeschlossen: Bestellung auf 'Storniert' gesetzt, Payment auf 'non' gesetzt", diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php index 80a6d8e..175ce4d 100644 --- a/app/Services/ProductStockService.php +++ b/app/Services/ProductStockService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Product; use App\Models\Production; use App\Models\ProductStockMovement; +use App\Models\ShoppingOrder; use Illuminate\Database\Eloquent\Model; class ProductStockService @@ -170,6 +171,113 @@ class ProductStockService } } + /** + * AP-13: Verkaufs-Abgänge einer Bestellung idempotent mit dem Versandstatus abgleichen. + * + * Soll = Positionsmengen bei versendeter/abgeschlossener Bestellung (Sets rekursiv in + * Einzelprodukte aufgelöst), sonst 0 — Zurücksetzen des Versandstatus oder Storno führt + * bereits gebuchte Abgänge per Gegenbuchung zurück. Gebucht wird nur die Differenz zum + * bereits gebuchten Stand (append-only, wie recordProductionStock) — mehrfache Aufrufe + * und Versand→Zurücksetzen→Versand buchen dadurch nie doppelt. + */ + public function syncSaleStock(ShoppingOrder $order, ?int $userId = null): void + { + $target = []; + if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) { + $order->loadMissing('shopping_order_items'); + $itemProducts = Product::withTrashed() + ->whereIn('id', $order->shopping_order_items->pluck('product_id')->unique()) + ->get() + ->keyBy('id'); + + foreach ($order->shopping_order_items as $item) { + $product = $itemProducts->get((int) $item->product_id); + if (! $product) { + continue; + } + foreach ($this->resolveSaleComponents($product, (int) $item->qty) as $productId => $qty) { + $target[$productId] = ($target[$productId] ?? 0) + $qty; + } + } + } + + $booked = ProductStockMovement::query() + ->where('source', 'sale') + ->where('reference_type', $order->getMorphClass()) + ->where('reference_id', $order->getKey()) + ->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END), 0) as net") + ->groupBy('product_id') + ->pluck('net', 'product_id'); + + $productIds = collect(array_keys($target)) + ->merge($booked->keys()) + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + + if ($productIds->isEmpty()) { + return; + } + + // withTrashed: Gegenbuchungen auch für inzwischen soft-gelöschte Produkte (wie N-9). + $products = Product::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id'); + + foreach ($productIds as $productId) { + $alreadyBooked = (int) ($booked[$productId] ?? 0); + $targetQty = (int) ($target[$productId] ?? 0); + $delta = $targetQty - $alreadyBooked; + if ($delta === 0 || ! isset($products[$productId])) { + continue; + } + + $reason = $alreadyBooked === 0 + ? __('Verkauf') + : ($targetQty === 0 ? __('Verkauf storniert') : __('Verkaufskorrektur')); + + $this->recordMovement( + $products[$productId], + $delta > 0 ? 'out' : 'in', + abs($delta), + $reason, + 'sale', + __('Bestellung').' #'.$order->id, + $userId, + $order, + ); + } + } + + /** + * Löst ein Produkt in bestandsführende Einzelprodukte auf: Sets rekursiv über ihre + * Komponenten (product_set_items, Pivot-Menge je Set-Stück), Einzelprodukte 1:1. + * + * @param array $visited Zyklus-Schutz über bereits besuchte Set-IDs + * @return array [product_id => Stückzahl] + */ + private function resolveSaleComponents(Product $product, int $qty, array $visited = []): array + { + if ($qty <= 0) { + return []; + } + if (! $product->is_set) { + return [$product->id => $qty]; + } + if (isset($visited[$product->id])) { + return []; + } + $visited[$product->id] = true; + + $result = []; + foreach ($product->setItems()->withTrashed()->get() as $component) { + $componentQty = $qty * max(1, (int) $component->pivot->quantity); + foreach ($this->resolveSaleComponents($component, $componentQty, $visited) as $productId => $componentTotal) { + $result[$productId] = ($result[$productId] ?? 0) + $componentTotal; + } + } + + return $result; + } + /** * Durchschnittlicher Verbrauch pro Monat je Produkt (Ø der letzten X Monate, Default 6). * diff --git a/tests/Feature/SaleStockDeductionTest.php b/tests/Feature/SaleStockDeductionTest.php new file mode 100644 index 0000000..9f8c2c1 --- /dev/null +++ b/tests/Feature/SaleStockDeductionTest.php @@ -0,0 +1,259 @@ +create([ + 'email' => uniqid('ap13_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill([ + 'admin' => $adminLevel, + 'confirmed' => true, + 'active' => true, + 'wizard' => 100, + 'blocked' => false, + ])->save(); + + return $user->fresh(); +} + +function ap13MakeProduct(string $name, array $overrides = []): Product +{ + return Product::query()->create(array_merge([ + 'name' => $name, + 'title' => $name, + 'active' => true, + 'is_set' => false, + ], $overrides)); +} + +function ap13MakeOrder(array $items, array $overrides = []): ShoppingOrder +{ + $country = Country::query()->forceCreate([ + 'code' => 'DE', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Germanija', + ]); + $shipping = Shipping::query()->forceCreate(['name' => 'Standard']); + $shippingCountry = ShippingCountry::query()->forceCreate([ + 'shipping_id' => $shipping->id, + 'country_id' => $country->id, + ]); + $shoppingUser = ShoppingUser::query()->forceCreate([ + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_firstname' => 'Test', + 'billing_lastname' => 'Kunde', + ]); + + $order = ShoppingOrder::query()->forceCreate(array_merge([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => ap13MakeAdmin(0)->id, + 'country_id' => $shippingCountry->id, + 'shipped' => 0, + ], $overrides)); + + foreach ($items as $item) { + ShoppingOrderItem::query()->forceCreate([ + 'shopping_order_id' => $order->id, + 'product_id' => $item['product']->id, + 'qty' => $item['qty'], + ]); + } + + return $order->fresh(); +} + +/** + * Mehrere Statuswechsel im selben Test müssen denselben Admin verwenden — + * ein User-Wechsel zwischen Requests kollidiert mit der bestehenden Session + * und endet als unauthentifizierter Redirect auf /login. + */ +function ap13SetShipped(ShoppingOrder $order, int $shipped, ?User $admin = null): void +{ + $response = test()->actingAs($admin ?? ap13MakeAdmin())->post(route('admin_sales_store'), [ + 'id' => $order->id, + 'action' => 'store_shipped', + 'shipped' => $shipped, + ]); + $response->assertSessionHasNoErrors(); + $response->assertRedirect(); +} + +function ap13Net(ShoppingOrder $order, Product $product): int +{ + return (int) ProductStockMovement::query() + ->where('source', 'sale') + ->where('reference_type', $order->getMorphClass()) + ->where('reference_id', $order->id) + ->where('product_id', $product->id) + ->selectRaw("COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END), 0) as net") + ->value('net'); +} + +test('versand bucht abgang je bestellposition', function () { + $service = app(ProductStockService::class); + $creme = ap13MakeProduct('AP13 Creme'); + $seife = ap13MakeProduct('AP13 Seife'); + $service->recordMovement($creme, 'in', 50, 'Inventur'); + $service->recordMovement($seife, 'in', 20, 'Inventur'); + + $order = ap13MakeOrder([ + ['product' => $creme, 'qty' => 3], + ['product' => $seife, 'qty' => 1], + ]); + + ap13SetShipped($order, 2); + + expect($service->currentStock($creme->id))->toBe(47) + ->and($service->currentStock($seife->id))->toBe(19) + ->and(ap13Net($order, $creme))->toBe(3) + ->and(ap13Net($order, $seife))->toBe(1); +}); + +test('set wird in einzelprodukte aufgeloest, set selbst bucht nichts', function () { + $creme = ap13MakeProduct('AP13 Set-Creme'); + $oel = ap13MakeProduct('AP13 Set-Öl'); + $set = ap13MakeProduct('AP13 Geschenkset', ['is_set' => true]); + $set->setItems()->attach($creme->id, ['quantity' => 2, 'pos' => 1]); + $set->setItems()->attach($oel->id, ['quantity' => 1, 'pos' => 2]); + + $order = ap13MakeOrder([['product' => $set, 'qty' => 3]]); + + ap13SetShipped($order, 2); + + expect(ap13Net($order, $creme))->toBe(6) + ->and(ap13Net($order, $oel))->toBe(3) + ->and(ap13Net($order, $set))->toBe(0); +}); + +test('verschachteltes set wird rekursiv aufgeloest', function () { + $creme = ap13MakeProduct('AP13 Basis-Creme'); + $innerSet = ap13MakeProduct('AP13 Duo', ['is_set' => true]); + $innerSet->setItems()->attach($creme->id, ['quantity' => 2, 'pos' => 1]); + $outerSet = ap13MakeProduct('AP13 Großpaket', ['is_set' => true]); + $outerSet->setItems()->attach($innerSet->id, ['quantity' => 3, 'pos' => 1]); + + $order = ap13MakeOrder([['product' => $outerSet, 'qty' => 2]]); + + ap13SetShipped($order, 2); + + // 2 Großpakete × 3 Duos × 2 Cremes = 12 + expect(ap13Net($order, $creme))->toBe(12) + ->and(ap13Net($order, $innerSet))->toBe(0) + ->and(ap13Net($order, $outerSet))->toBe(0); +}); + +test('wiederholter versand-statuswechsel bucht nicht doppelt', function () { + $creme = ap13MakeProduct('AP13 Idempotent'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 5]]); + + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 2, $admin); + ap13SetShipped($order, 3, $admin); + ap13SetShipped($order, 2, $admin); + + expect(ap13Net($order, $creme))->toBe(5) + ->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(1); +}); + +test('zuruecksetzen auf offen fuehrt abgang per gegenbuchung zurueck', function () { + $service = app(ProductStockService::class); + $creme = ap13MakeProduct('AP13 Rückbuchung'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 4]]); + + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 2, $admin); + ap13SetShipped($order, 0, $admin); + + expect(ap13Net($order, $creme))->toBe(0) + ->and($service->currentStock($creme->id))->toBe(0) + ->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(2); +}); + +test('erneuter versand nach zuruecksetzen bucht wieder genau einmal ab', function () { + $creme = ap13MakeProduct('AP13 Wiederversand'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 4]]); + + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 2, $admin); + ap13SetShipped($order, 0, $admin); + ap13SetShipped($order, 2, $admin); + + expect(ap13Net($order, $creme))->toBe(4) + ->and(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(3); +}); + +test('storno einer versendeten bestellung bucht zurueck', function () { + $creme = ap13MakeProduct('AP13 Storno'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 2]]); + + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 2, $admin); + ap13SetShipped($order, 10, $admin); + + expect(ap13Net($order, $creme))->toBe(0); + + $reversal = ProductStockMovement::query() + ->where('product_id', $creme->id) + ->where('direction', 'in') + ->first(); + expect($reversal)->not->toBeNull() + ->and($reversal->reason)->toBe('Verkauf storniert'); +}); + +test('nicht versendete bestellung bucht nichts', function () { + $creme = ap13MakeProduct('AP13 Offen'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 2]]); + + $admin = ap13MakeAdmin(); + ap13SetShipped($order, 1, $admin); + ap13SetShipped($order, 5, $admin); + + expect(ProductStockMovement::query()->where('product_id', $creme->id)->count())->toBe(0); +}); + +test('service-abgleich ist auch direkt idempotent (cron-storno)', function () { + $service = app(ProductStockService::class); + $creme = ap13MakeProduct('AP13 Cron'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 3]], ['shipped' => 2]); + + $service->syncSaleStock($order); + $service->syncSaleStock($order); + expect(ap13Net($order, $creme))->toBe(3); + + // Zahlungserinnerungs-Storno: Status 10 ohne Controller + $order->forceFill(['shipped' => 10])->save(); + $service->syncSaleStock($order->fresh()); + expect(ap13Net($order, $creme))->toBe(0); +}); + +test('verkaufs-abgaenge zaehlen im monatsverbrauch', function () { + $service = app(ProductStockService::class); + $creme = ap13MakeProduct('AP13 Verbrauch'); + $order = ap13MakeOrder([['product' => $creme, 'qty' => 6]]); + + ap13SetShipped($order, 2); + + $consumption = $service->monthlyConsumptionByProduct([$creme->id]); + expect($consumption[$creme->id])->toBe(1.0); // 6 Stück / 6 Monate +});