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('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 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('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('mahnwesen-storno bucht bestand zurueck und storniert die marge', function () { Mail::fake(); $creme = ap13MakeProduct('AP13 Mahn-Storno'); $order = ap13MakeOrder([['product' => $creme, 'qty' => 2]], ['total_shipping' => 1000]); $order->shopping_user->forceFill(['billing_email' => 'mahnung@test.example'])->save(); $margin = ShoppingOrderMargin::query()->forceCreate([ 'shopping_order_id' => $order->id, 'user_id' => $order->auth_user_id, 'net_partner_commission' => 500, ]); // Rechnungs-Bestellung wird vor Zahlungseingang versendet (Abzug + Provision pending) ap13SetShipped($order, 2); expect(ap13Net($order, $creme))->toBe(2) ->and($margin->fresh()->partner_commission_pending_to)->not->toBeNull(); $payment = ShoppingPayment::query()->forceCreate([ 'shopping_order_id' => $order->id, 'clearingtype' => 'fnc', 'txaction' => 'open', 'mode' => 'live', 'amount' => 1000, 'currency' => 'EUR', 'reminder' => 0, 'reference' => 'AP13-MAHN-1', ]); PaymentReminder::query()->forceCreate([ 'active' => true, 'clearingtype' => 'fnc', 'interval' => 7, 'action' => 'set_order_status_cancelled', 'subject' => 'Letzte Mahnung {order_number}', 'message' => 'Bestellung {order_number} wird storniert.', ]); app(PaymentReminderService::class)->sendReminder($payment); // Der zentrale Statuswechsel muss greifen: Rückbuchung UND Margen-Storno $order = $order->fresh(); expect($order->shipped)->toBe(10) ->and($order->shipped_at)->toBeNull() ->and(ap13Net($order, $creme))->toBe(0) ->and($margin->fresh()->cancellation)->toBeTruthy() ->and($margin->fresh()->partner_commission_pending_to)->toBeNull(); }); 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 });