create(['email' => uniqid('dens_', true).'@test.example', 'password' => bcrypt('password')]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } /** * @return array{0: User, 1: Ingredient, 2: Location, 3: Supplier, 4: MaterialQuality} */ function densSetup(?float $density): array { $admin = densAdmin(); $ingredient = Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]); return [$admin, $ingredient, Location::factory()->create(), Supplier::factory()->create(), MaterialQuality::factory()->create()]; } test('AP-42: ml-erfassung wird über die dichte in gramm gespeichert', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertRedirect(route('admin.inventory.stock-entries.index')); $entry = StockEntry::query()->latest('id')->firstOrFail(); expect((float) $entry->ordered_quantity)->toBe(79.0) ->and($entry->purchase_unit)->toBe('ml') ->and((float) $entry->density_used)->toBe(0.79); }); test('AP-42: gramm-erfassung bleibt unverändert', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '100', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00', ])->assertRedirect(); $entry = StockEntry::query()->latest('id')->firstOrFail(); expect((float) $entry->ordered_quantity)->toBe(100.0) ->and($entry->purchase_unit)->toBe('gram') ->and($entry->density_used)->toBeNull(); }); test('AP-42: ml-erfassung ohne hinterlegte dichte wird abgelehnt', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(null); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertSessionHasErrors('ordered_quantity'); expect(StockEntry::query()->count())->toBe(0); }); test('AP-42: wareneingang in ml wird über die dichte in gramm gebucht', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.80); $this->actingAs($admin, 'user'); // Bestellung in ml (250 ml × 0,80 = 200 g) $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '250', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertRedirect(); $entry = StockEntry::query()->latest('id')->firstOrFail(); // Wareneingang: 250 ml geliefert => 200 g gebucht $this->put(route('admin.inventory.stock-entries.receive', $entry), [ 'received_at' => '2026-08-05', 'received_quantity' => '250', 'batch_number' => 'DENS-1', 'best_before' => '2030-12-31', ])->assertRedirect(); expect((float) $entry->fresh()->received_quantity)->toBe(200.0); }); test('F-15: kopie einer ml-bestellung bleibt ml-bestellung und bucht den wareneingang über die dichte', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertRedirect(); $original = StockEntry::query()->latest('id')->firstOrFail(); $this->post(route('admin.inventory.stock-entries.copy', $original))->assertRedirect(); $copy = StockEntry::query()->latest('id')->firstOrFail(); expect($copy->id)->not->toBe($original->id) ->and($copy->purchase_unit)->toBe('ml') ->and((float) $copy->density_used)->toBe(0.79) ->and((float) $copy->ordered_quantity)->toBe(79.0); // kanonisch Gramm = 100 ml // Wareneingang „100“ (ml) → 79 g, nicht 100 g. $this->put(route('admin.inventory.stock-entries.receive', $copy), [ 'received_at' => '2026-08-05', 'received_quantity' => '100', 'batch_number' => 'F15-1', 'best_before' => '2030-12-31', ])->assertRedirect(); expect((float) $copy->fresh()->received_quantity)->toBe(79.0); }); test('F-15: kopie nutzt den dichte-snapshot, wenn die rohstoff-dichte inzwischen fehlt', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.80); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '250', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertRedirect(); $original = StockEntry::query()->latest('id')->firstOrFail(); $ingredient->forceFill(['density' => null])->save(); $this->post(route('admin.inventory.stock-entries.copy', $original))->assertRedirect(); $copy = StockEntry::query()->latest('id')->firstOrFail(); expect($copy->purchase_unit)->toBe('ml') ->and((float) $copy->density_used)->toBe(0.80) ->and((float) $copy->ordered_quantity)->toBe(200.0); }); test('F-16: wareneingang prueft „offen“ innerhalb der sperre — zweite buchung wird abgelehnt', function () { [$admin, $ingredient, $location, $supplier, $quality] = densSetup(null); $this->actingAs($admin, 'user'); $this->post(route('admin.inventory.stock-entries.store'), [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01', 'ordered_quantity' => '500', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00', ])->assertRedirect(); $entry = StockEntry::query()->latest('id')->firstOrFail(); $repo = app(StockEntryRepository::class); $repo->receive($entry, ['received_at' => '2026-08-05', 'received_quantity' => 500, 'batch_number' => 'F16-1']); // Zweiter Request mit veraltetem Modell (noch „pending“ im Speicher) muss scheitern. expect(fn () => $repo->receive($entry, ['received_at' => '2026-08-06', 'received_quantity' => 999, 'batch_number' => 'F16-2'])) ->toThrow(ValidationException::class); expect((float) $entry->fresh()->received_quantity)->toBe(500.0) ->and($entry->fresh()->batch_number)->toBe('F16-1'); });