create([ 'email' => uniqid('ap11_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill([ 'admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, ])->save(); return $user->fresh(); } /** * @param array $overrides */ function ap11MakeProduct(string $name, array $overrides = []): Product { return Product::query()->create(array_merge([ 'name' => $name, 'title' => $name, 'active' => true, 'is_set' => false, ], $overrides)); } test('bestand ist summe eingaenge minus ausgaenge', function () { $product = ap11MakeProduct('AP11-Bestand'); $service = app(ProductStockService::class); $service->recordMovement($product, 'in', 100, 'Initialbestand'); $service->recordMovement($product, 'out', 30, 'Verkauf'); $service->recordMovement($product, 'in', 5, 'Retoure'); expect($service->currentStock($product->id))->toBe(75); }); test('status liefert kritisch, warnung und ok', function () { $service = app(ProductStockService::class); expect($service->productStatus(5, 20, 10))->toBe('critical') ->and($service->productStatus(15, 20, 10))->toBe('warning') ->and($service->productStatus(30, 20, 10))->toBe('ok') ->and($service->productStatus(0, null, null))->toBe('ok'); }); test('kritisch-zaehler beruecksichtigt nur hauptprodukte mit schwellwert', function () { $service = app(ProductStockService::class); $critical = ap11MakeProduct('AP11-Krit', ['critical_product_stock' => 10]); $service->recordMovement($critical, 'in', 5, 'Initialbestand'); $ok = ap11MakeProduct('AP11-OK', ['critical_product_stock' => 10]); $ok->stockMovements()->create(['direction' => 'in', 'quantity' => 50, 'reason' => 'Initialbestand', 'source' => 'manual']); // ohne Schwellwert => nie kritisch ap11MakeProduct('AP11-NoThreshold'); // Set wird ignoriert ap11MakeProduct('AP11-Set', ['is_set' => true, 'critical_product_stock' => 10]); expect($service->criticalProductCount())->toBe(1); }); test('produktion bucht produktbestand als eingang', function () { $user = ap11MakeUser(); $location = Location::factory()->create(); $product = ap11MakeProduct('AP11-Produktion', ['no_recipe_required' => true]); app(ProductionService::class)->store([ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-03-01', 'quantity' => 40, 'notes' => null, ], [], $user->id); expect(app(ProductStockService::class)->currentStock($product->id))->toBe(40); $movement = ProductStockMovement::query()->where('product_id', $product->id)->where('source', 'production')->first(); expect($movement)->not->toBeNull() ->and($movement->direction)->toBe('in') ->and((int) $movement->quantity)->toBe(40); }); test('produktions-update korrigiert den produktbestand per differenz', function () { $user = ap11MakeUser(); $location = Location::factory()->create(); $product = ap11MakeProduct('AP11-ProdUpdate', ['no_recipe_required' => true]); $production = app(ProductionService::class)->store([ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-03-01', 'quantity' => 40, 'notes' => null, ], [], $user->id); app(ProductionService::class)->updateProduction($production, [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-03-01', 'quantity' => 25, 'notes' => null, ], [], $user->id); expect(app(ProductStockService::class)->currentStock($product->id))->toBe(25); // append-only: zwei Bewegungen (Produktion + Korrektur) expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2); }); test('uebersicht zeigt produkt und bestand', function () { $user = ap11MakeUser(1); $product = ap11MakeProduct('AP11-Liste', ['critical_product_stock' => 10]); app(ProductStockService::class)->recordMovement($product, 'in', 13, 'Initialbestand'); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.product-stock.index')); $response->assertSuccessful(); $response->assertSee('Produktbestand'); $response->assertSee('AP11-Liste'); $response->assertSee('nur kritische anzeigen'); }); test('manuelle bewegung wird per http gebucht', function () { $user = ap11MakeUser(7); $product = ap11MakeProduct('AP11-HTTP'); $this->actingAs($user, 'user'); $response = $this->post(route('admin.inventory.product-stock.movement', $product), [ 'direction' => 'in', 'quantity' => 12, 'reason' => 'Initialbestand', 'note' => 'Erstbefüllung', ]); $response->assertRedirect(); $this->assertDatabaseHas('product_stock_movements', [ 'product_id' => $product->id, 'direction' => 'in', 'quantity' => 12, 'reason' => 'Initialbestand', ]); }); test('nicht-admin darf keine bewegung buchen', function () { $user = ap11MakeUser(1); $product = ap11MakeProduct('AP11-NoAdmin'); $this->actingAs($user, 'user'); $response = $this->post(route('admin.inventory.product-stock.movement', $product), [ 'direction' => 'in', 'quantity' => 5, 'reason' => 'Initialbestand', ]); $response->assertForbidden(); }); test('historie rendert und filtert nach richtung', function () { $user = ap11MakeUser(1); $product = ap11MakeProduct('AP11-Hist'); $service = app(ProductStockService::class); $service->recordMovement($product, 'in', 50, 'Produktion', 'production'); $service->recordMovement($product, 'out', 2, 'Verkauf', 'sale'); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.product-stock.history', ['direction' => 'out'])); $response->assertSuccessful(); $response->assertSee('Produktbestand Historie'); $response->assertSee('Verkauf'); });