Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)

- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -0,0 +1,41 @@
<?php
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function noticeUser(int $adminLevel = 8): User
{
$user = User::query()->create([
'email' => uniqid('notice_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
test('hinweise-seite rendert das markdown gerendert als html', function () {
$this->actingAs(noticeUser(8), 'user');
$response = $this->get(route('admin.inventory.notices'));
$response->assertSuccessful();
$response->assertSee('Hinweise');
$response->assertSee('Entwicklungsstand', false);
$response->assertSee('<h2', false);
});
test('nicht-superadmin hat keinen zugriff auf hinweise-seite', function () {
$this->actingAs(noticeUser(7), 'user');
$this->get(route('admin.inventory.notices'))->assertRedirect('/home');
});

View file

@ -0,0 +1,135 @@
<?php
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function oosMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('oos_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function oosMakeProduct(string $name): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
]);
}
test('repository berechnet out_of_stock_until aus tagesangabe', function () {
$product = oosMakeProduct('OOS-Tage');
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'out_of_stock_active' => '1',
'out_of_stock_days' => '14',
]);
$product->refresh();
expect($product->out_of_stock_indefinite)->toBeFalse();
expect($product->out_of_stock_until)->not->toBeNull();
expect($product->out_of_stock_until->isSameDay(now()->addDays(14)))->toBeTrue();
expect($product->isOutOfStock())->toBeTrue();
});
test('unbestimmt hat vorrang und nullt das datum', function () {
$product = oosMakeProduct('OOS-Unbestimmt');
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'out_of_stock_active' => '1',
'out_of_stock_days' => '14',
'out_of_stock_indefinite' => '1',
]);
$product->refresh();
expect($product->out_of_stock_indefinite)->toBeTrue();
expect($product->out_of_stock_until)->toBeNull();
expect($product->isOutOfStock())->toBeTrue();
expect($product->outOfStockNotice())->toBe('Zur Zeit nicht vorrätig');
});
test('ohne aktivierung werden die felder geleert', function () {
$product = oosMakeProduct('OOS-Aus');
$product->update(['out_of_stock_until' => now()->addDays(5), 'out_of_stock_indefinite' => false]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
]);
$product->refresh();
expect($product->out_of_stock_until)->toBeNull();
expect($product->out_of_stock_indefinite)->toBeFalse();
expect($product->isOutOfStock())->toBeFalse();
});
test('vergangenes datum gilt nicht als nicht vorrätig', function () {
$product = oosMakeProduct('OOS-Vergangen');
$product->update(['out_of_stock_until' => now()->subDays(2)]);
$product->refresh();
expect($product->isOutOfStock())->toBeFalse();
expect($product->outOfStockRemainingDays())->toBeNull();
expect($product->outOfStockNotice())->toBeNull();
});
test('hinweis zeigt verbleibende tage', function () {
$product = oosMakeProduct('OOS-Hinweis');
$product->update(['out_of_stock_until' => now()->addDays(5)]);
$product->refresh();
expect($product->isOutOfStock())->toBeTrue();
expect($product->outOfStockRemainingDays())->toBe(5);
expect($product->outOfStockNotice())->toBe('In ca. 5 Tagen wieder da!');
});
test('http-store speichert nicht-vorrätig mit tagen', function () {
$this->actingAs(oosMakeUser(1), 'user');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'OOS-HTTP',
'out_of_stock_active' => '1',
'out_of_stock_days' => '7',
]);
$response->assertRedirect();
$product = Product::query()->where('name', 'OOS-HTTP')->firstOrFail();
expect($product->out_of_stock_until)->not->toBeNull();
expect($product->out_of_stock_until->isSameDay(now()->addDays(7)))->toBeTrue();
expect($product->isOutOfStock())->toBeTrue();
});

View file

@ -212,7 +212,7 @@ test('produktion kann bearbeitet und kopiert werden (views rendern)', function (
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'product',
'recipe_type' => 'manufacturer',
]);
$stock = StockEntry::query()->create([

View file

@ -0,0 +1,272 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Repositories\ProductRepository;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function setMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('set_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function setMakeProduct(string $name, bool $active = true): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => $active,
]);
}
test('repository speichert ein set mit bestandteilen und mengen', function () {
$set = setMakeProduct('SET-Bundle');
$a = setMakeProduct('SET-Einzel-A');
$b = setMakeProduct('SET-Einzel-B');
$repo = new ProductRepository($set);
$repo->update([
'id' => (string) $set->id,
'name' => $set->name,
'title' => $set->title,
'active' => '1',
'is_set' => '1',
'set_component_id' => [(string) $a->id, (string) $b->id],
'set_quantity' => ['2', '3'],
]);
$set->refresh();
expect($set->is_set)->toBeTrue();
expect($set->setItems)->toHaveCount(2);
expect((int) $set->setItems->firstWhere('id', $a->id)->pivot->quantity)->toBe(2);
expect((int) $set->setItems->firstWhere('id', $b->id)->pivot->quantity)->toBe(3);
});
test('set leert rezeptur und verpackung', function () {
$ing = Ingredient::query()->create([
'name' => 'SET-Roh', 'trans_name' => '', 'inci' => 'SR', 'trans_inci' => '',
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
]);
$packaging = PackagingItem::factory()->create();
$component = setMakeProduct('SET-Komp');
$product = setMakeProduct('SET-Wandelbar');
ProductIngredient::query()->create([
'product_id' => $product->id, 'ingredient_id' => $ing->id,
'pos' => 0, 'gram' => 50, 'factor' => 1.1, 'recipe_type' => 'manufacturer',
]);
$product->packagings()->attach($packaging->id, ['quantity' => 1, 'pos' => 0]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'is_set' => '1',
'set_component_id' => [(string) $component->id],
'set_quantity' => ['1'],
]);
$product->refresh();
expect($product->is_set)->toBeTrue();
expect($product->manufacturer_ingredients)->toHaveCount(0);
expect($product->packagings)->toHaveCount(0);
expect($product->setItems)->toHaveCount(1);
});
test('hauptprodukt-zuordnung wird gespeichert und bei set genullt', function () {
$main = setMakeProduct('SET-Haupt');
$variant = setMakeProduct('SET-Variante');
$repo = new ProductRepository($variant);
$repo->update([
'id' => (string) $variant->id,
'name' => $variant->name,
'title' => $variant->title,
'active' => '1',
'main_product_id' => (string) $main->id,
'main_product_quantity' => '50',
]);
$variant->refresh();
expect($variant->main_product_id)->toBe($main->id);
expect($variant->main_product_quantity)->toBe(50);
// Wird das Produkt zum Set, ist es keine Variante mehr.
$repo->update([
'id' => (string) $variant->id,
'name' => $variant->name,
'title' => $variant->title,
'active' => '1',
'is_set' => '1',
'main_product_id' => (string) $main->id,
'set_component_id' => [(string) $main->id],
'set_quantity' => ['1'],
]);
$variant->refresh();
expect($variant->main_product_id)->toBeNull();
});
test('scopes trennen einzelprodukte, sets und hauptprodukte', function () {
$single = setMakeProduct('SCOPE-Einzel');
$set = setMakeProduct('SCOPE-Set');
$set->update(['is_set' => true]);
$variant = setMakeProduct('SCOPE-Variante');
$variant->update(['main_product_id' => $single->id]);
expect(Product::query()->singleProducts()->pluck('id')->all())
->toContain($single->id, $variant->id)
->not->toContain($set->id);
expect(Product::query()->sets()->pluck('id')->all())
->toContain($set->id)
->not->toContain($single->id);
expect(Product::query()->mainProducts()->pluck('id')->all())
->toContain($single->id, $set->id)
->not->toContain($variant->id);
});
test('set kann nicht produziert werden', function () {
$location = Location::factory()->create();
$set = setMakeProduct('SET-NichtProduzierbar');
$set->update(['is_set' => true]);
$service = app(ProductionService::class);
expect(fn () => $service->store(
[
'product_id' => $set->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[],
setMakeUser()->id
))->toThrow(ValidationException::class);
});
test('produktions-formular zeigt keine sets', function () {
p51EnsureCountryForSet();
Location::factory()->create(['name' => 'Köln']);
$user = setMakeUser(1);
setMakeProduct('SET-FORM-EINZEL');
$set = setMakeProduct('SET-FORM-SET');
$set->update(['is_set' => true]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.productions.create'));
$response->assertSuccessful();
$response->assertSee('SET-FORM-EINZEL');
$response->assertDontSee('SET-FORM-SET');
});
test('set ohne bestandteile wird abgelehnt', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-LEER',
'is_set' => '1',
]);
$response->assertSuccessful();
$response->assertSee('mindestens ein Einzelprodukt', false);
expect(Product::query()->where('name', 'SET-LEER')->exists())->toBeFalse();
});
test('set mit set-bestandteil wird abgelehnt', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$innerSet = setMakeProduct('SET-INNEN');
$innerSet->update(['is_set' => true]);
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-AUSSEN',
'is_set' => '1',
'set_component_id' => [(string) $innerSet->id],
'set_quantity' => ['1'],
]);
$response->assertSuccessful();
$response->assertSee('dürfen selbst keine Sets sein', false);
});
test('gueltiges set wird per http gespeichert', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$component = setMakeProduct('SET-HTTP-KOMP');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-HTTP',
'is_set' => '1',
'set_component_id' => [(string) $component->id],
'set_quantity' => ['4'],
]);
$response->assertRedirect();
$set = Product::query()->where('name', 'SET-HTTP')->firstOrFail();
expect($set->is_set)->toBeTrue();
expect($set->setItems)->toHaveCount(1);
expect((int) $set->setItems->first()->pivot->quantity)->toBe(4);
});
test('produkt-kopie uebernimmt set-bestandteile', function () {
$component = setMakeProduct('COPY-KOMP');
$set = setMakeProduct('COPY-SET');
$set->update(['is_set' => true]);
$set->setItems()->attach($component->id, ['quantity' => 5, 'pos' => 0]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($set->fresh());
expect($copy->is_set)->toBeTrue();
expect($copy->setItems()->count())->toBe(1);
expect((int) $copy->setItems()->first()->pivot->quantity)->toBe(5);
});
function p51EnsureCountryForSet(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany',
'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true,
]
);
}

View file

@ -0,0 +1,191 @@
<?php
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap11MakeUser(int $adminLevel = 7): User
{
$user = User::query()->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<string, mixed> $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');
});

View file

@ -0,0 +1,375 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap09MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap09_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap09EnsureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
function ap09MakeIngredient(string $name): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap09MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'packaging_item_id' => null,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 1,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP09-B',
'best_before' => '2030-12-31',
'quality_id' => null,
], $overrides));
}
test('production rechnet soll-verbrauch aus hersteller-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-Soll');
$product = Product::query()->create([
'name' => 'AP09-Soll-Produkt',
'title' => 'AP09-Soll-Produkt',
'active' => true,
]);
// Produkt-Rezeptur (darf NICHT verwendet werden)
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 999,
'factor' => 1.0,
'recipe_type' => 'product',
]);
// Hersteller-Rezeptur (maßgeblich): 10 g * 1.2 * 5 Stück = 60 g
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.2,
'recipe_type' => 'manufacturer',
]);
$stock = ap09MakeStock($ing, $location, $supplier, $user);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 5,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '60'],
],
$user->id
);
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(60.0);
});
test('production blockiert ohne hersteller-rezeptur trotz vorhandener produkt-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-NoMfg');
$product = Product::query()->create([
'name' => 'AP09-NoMfg-Produkt',
'title' => 'AP09-NoMfg-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'product',
]);
$stock = ap09MakeStock($ing, $location, $supplier, $user);
expect(fn () => app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10'],
],
$user->id
))->toThrow(ValidationException::class);
});
test('recipe json meldet fehlende hersteller-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Recipe-Empty',
'title' => 'AP09-Recipe-Empty',
'active' => true,
]);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=1');
$response->assertSuccessful();
$response->assertJsonPath('has_recipe', false);
expect($response->json('ingredients'))->toBe([]);
});
test('chargen ohne restbestand erscheinen nicht im rezept', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-Rest');
$product = Product::query()->create([
'name' => 'AP09-Rest-Produkt',
'title' => 'AP09-Rest-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 100,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
// Charge komplett aufgebraucht
$emptyStock = ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP09-LEER',
'received_quantity' => 100,
'best_before' => '2030-01-01',
]);
// Charge mit Restbestand
$fullStock = ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP09-VOLL',
'received_quantity' => 100,
'best_before' => '2030-06-01',
]);
// 1 Produktion verbraucht die erste Charge vollständig (100 g * 1.0 * 1 Stück)
app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $emptyStock->id, 'quantity_used' => '100'],
],
$user->id
);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
$entryIds = collect($payload['ingredients'][0]['stock_entries'])->pluck('id')->all();
expect($entryIds)->toContain($fullStock->id);
expect($entryIds)->not->toContain($emptyStock->id);
});
test('chargen-label zeigt lieferant, charge und datum ohne mhd-text', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create(['name' => 'AP09-Lieferant']);
$ing = ap09MakeIngredient('AP09-Label');
$product = Product::query()->create([
'name' => 'AP09-Label-Produkt',
'title' => 'AP09-Label-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'CHARGE-123',
'best_before' => '2030-12-31',
]);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
$label = $payload['ingredients'][0]['stock_entries'][0]['label'];
expect($label)->toBe('AP09-Lieferant - CHARGE-123 - 31.12.2030');
expect($label)->not->toContain('MHD');
});
test('produkt ohne rezeptur kann ohne chargen produziert werden', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Broschuere',
'title' => 'AP09-Broschuere',
'active' => true,
'no_recipe_required' => true,
]);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 25,
'notes' => null,
],
[],
$user->id
);
expect($production->productionIngredients)->toHaveCount(0);
expect($production->quantity)->toBe(25);
});
test('recipe json meldet recipe_required false bei eigenprodukt', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Etikett',
'title' => 'AP09-Etikett',
'active' => true,
'no_recipe_required' => true,
]);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=10');
$response->assertSuccessful();
$response->assertJsonPath('recipe_required', false);
});
test('produktion per http speichert eigenprodukt ohne chargen', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-HTTP-Eigen',
'title' => 'AP09-HTTP-Eigen',
'active' => true,
'no_recipe_required' => true,
]);
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '01.03.2026',
'quantity' => 12,
]);
$response->assertRedirect();
$this->assertDatabaseHas('productions', [
'product_id' => $product->id,
'quantity' => 12,
]);
});
test('produktentwicklung platzhalter rendert briefing-hinweis', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-development'));
$response->assertSuccessful();
$response->assertSee('Briefing ausstehend');
});

View file

@ -81,6 +81,7 @@ test('production service stores ingredients and packaging snapshot', function ()
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
$material = PackagingMaterial::factory()->create();
@ -168,6 +169,7 @@ test('production service rejects wrong gram sum', function () {
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
$stock = StockEntry::query()->create([
@ -240,6 +242,7 @@ test('production api recipe json returns ingredients and packagings', function (
'pos' => 0,
'gram' => 2,
'factor' => 1.5,
'recipe_type' => 'manufacturer',
]);
StockEntry::query()->create([
@ -300,6 +303,7 @@ test('production mhd warning when stock expires before product end', function ()
'pos' => 0,
'gram' => 1,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
$stock = StockEntry::query()->create([

View file

@ -0,0 +1,269 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\Production;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap10MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap10_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap10MakeIngredient(string $name, ?float $minStockAlert = null): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'min_stock_alert' => $minStockAlert,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap10MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 15,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP10-B',
'best_before' => '2030-12-31',
], $overrides));
}
function ap10ConsumeViaProduction(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams, string $producedAt): void
{
$product = Product::query()->create([
'name' => 'AP10-Produkt-'.uniqid(),
'title' => 'AP10-Produkt',
'active' => true,
]);
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_by' => $user->id,
'produced_at' => $producedAt,
'quantity' => 1,
'mhd_warning' => false,
]);
$production->productionIngredients()->create([
'ingredient_id' => $ing->id,
'stock_entry_id' => $stock->id,
'quantity_used' => $grams,
]);
}
test('restbestand ist wareneingang abzueglich produktionsverbrauch', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Rest');
$stock = ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
ap10ConsumeViaProduction($ing, $stock, $location, $user, 2500, now()->subDays(10)->toDateString());
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]);
expect($remaining[$ing->id])->toBe(7500.0);
});
test('verbrauch pro tag wird aus produktionshistorie gemittelt', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Verbrauch');
$stock = ap10MakeStock($ing, $location, $supplier, $user);
// 9000 g innerhalb des 90-Tage-Fensters => 100 g/Tag
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(5)->toDateString());
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
expect($daily[$ing->id])->toBe(100.0);
});
test('verbrauch ausserhalb des fensters zaehlt nicht', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Alt');
$stock = ap10MakeStock($ing, $location, $supplier, $user);
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(200)->toDateString());
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
expect($daily[$ing->id] ?? 0.0)->toBe(0.0);
});
test('status meldet kritisch bei unterschrittenem meldebestand', function () {
$service = app(InventoryService::class);
expect($service->stockStatus(1000.0, 500.0, null, null))->toBe('critical')
->and($service->stockStatus(1000.0, 1500.0, 5.0, 30))->toBe('ok');
});
test('status meldet warnung wenn bestand vor lieferung leer ist', function () {
$service = app(InventoryService::class);
// 200 g Rest, 20 g/Tag => 10 Tage Reichweite, Lieferzeit 14 Tage => Warnung
expect($service->stockStatus(null, 200.0, 20.0, 14))->toBe('warning');
});
test('kritisch-zaehler zaehlt nur unterschrittene meldebestaende', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$critical = ap10MakeIngredient('AP10-Kritisch', 5000);
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
$ok = ap10MakeIngredient('AP10-OK', 500);
ap10MakeStock($ok, $location, $supplier, $user, ['received_quantity' => 9000]);
// ohne Meldebestand => nie kritisch
$noAlert = ap10MakeIngredient('AP10-OhneMelde');
ap10MakeStock($noAlert, $location, $supplier, $user, ['received_quantity' => 0]);
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(1);
});
test('offene bestellungen werden je rohstoff summiert', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Offen');
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 200, 'received_quantity' => null]);
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 300, 'received_quantity' => null]);
$open = app(InventoryService::class)->openOrderQuantityByIngredient([$ing->id]);
expect($open[$ing->id])->toBe(500.0);
});
test('kritischer rohstoff mit offener bestellung wird entschaerft', function () {
$service = app(InventoryService::class);
expect($service->stockStatus(1000.0, 500.0, null, null, true))->toBe('critical_ordered')
->and($service->stockStatus(1000.0, 500.0, null, null, false))->toBe('critical');
});
test('kritisch-zaehler ignoriert rohstoffe mit offener bestellung', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$critical = ap10MakeIngredient('AP10-KritischOffen', 5000);
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
// offene Nachbestellung => entschärft
ap10MakeStock($critical, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 8000, 'received_quantity' => null]);
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(0);
});
test('detailansicht zeigt offene bestellung', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create(['name' => 'AP10-OffenLieferant']);
$ing = ap10MakeIngredient('AP10-OffenDetail');
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 750, 'received_quantity' => null]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
$response->assertSuccessful();
$response->assertSee('Offene Bestellungen');
$response->assertSee('AP10-OffenLieferant');
});
test('uebersicht rendert rohstoff mit bestand', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Sheabutter', 5000);
ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 2578]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.index'));
$response->assertSuccessful();
$response->assertSee('Rohstoffbestand');
$response->assertSee('AP10-Sheabutter');
$response->assertSee('nur kritische anzeigen');
});
test('einkauf-formular ist mit rohstoff und inhaltsstoff vorbelegt', function () {
$user = ap10MakeUser(7);
$ing = ap10MakeIngredient('AP10-Vorbelegt');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.stock-entries.create', ['ingredient_id' => $ing->id]));
$response->assertSuccessful();
$response->assertSee('AP10-Vorbelegt');
// Art "Rohstoff" ist als Standard ausgewählt
$response->assertSee('value="ingredient" selected', false);
});
test('detailansicht zeigt charge und lieferant', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create(['name' => 'AP10-Lager']);
$supplier = Supplier::factory()->create(['name' => 'AP10-Lieferant']);
$ing = ap10MakeIngredient('AP10-Detail');
$ing->suppliers()->attach($supplier->id, ['preferred' => true]);
ap10MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP10-CHARGE-7',
'received_quantity' => 4000,
]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
$response->assertSuccessful();
$response->assertSee('AP10-Detail');
$response->assertSee('AP10-CHARGE-7');
$response->assertSee('AP10-Lieferant');
});

View file

@ -0,0 +1,222 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap12MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap12_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap12MakeIngredient(string $name, ?float $minStockAlert = null): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'min_stock_alert' => $minStockAlert,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap12MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 15,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP12-B',
'best_before' => '2030-12-31',
], $overrides));
}
test('ausschuss wird je rohstoff summiert', function () {
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-Sum');
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 300, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 200, 'unit' => 'gram', 'reason' => 'Verfall', 'disposed_at' => '2026-03-02',
]);
expect(app(InventoryService::class)->disposedByIngredient([$ing->id])[$ing->id])->toBe(500.0);
});
test('restbestand zieht ausschuss ab', function () {
$user = ap12MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-Rest');
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
$service = app(InventoryService::class);
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(10000.0);
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 1500, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(8500.0)
->and($service->remainingByLocationForIngredient($ing->id)[$location->id])->toBe(8500.0);
});
test('restbestand verpackung zieht ausschuss ab', function () {
$user = ap12MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$item = PackagingItem::factory()->create();
StockEntry::query()->create([
'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id,
'location_id' => $location->id, 'unit' => 'piece', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
'ordered_quantity' => 500, 'price_total' => 100, 'status' => 'received', 'received_by' => $user->id,
'received_at' => '2026-01-15', 'received_quantity' => 500,
]);
$service = app(InventoryService::class);
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(500.0);
StockDisposal::query()->create([
'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id,
'quantity' => 40, 'unit' => 'piece', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(460.0);
});
test('admin bucht ausschuss per http und bestand sinkt', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-HTTP');
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '750',
'reason' => 'Verfall / MHD überschritten',
'note' => 'Charge verdorben',
'disposed_at' => '2026-03-10',
]);
$response->assertRedirect();
$this->assertDatabaseHas('stock_disposals', [
'ingredient_id' => $ing->id,
'quantity' => 750.00,
'reason' => 'Verfall / MHD überschritten',
'unit' => 'gram',
]);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(4250.0);
});
test('grund ist pflicht', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-NoReason');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '100',
'disposed_at' => '2026-03-10',
]);
$response->assertSessionHasErrors('reason');
});
test('nicht-admin darf keinen ausschuss buchen', function () {
$user = ap12MakeUser(1);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-NoAdmin');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '100',
'reason' => 'Bruch / Beschädigung',
'disposed_at' => '2026-03-10',
]);
$response->assertForbidden();
});
test('liste und formular rendern', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-Render');
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 50, 'unit' => 'gram', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-03-01',
]);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.stock-disposals.index'))->assertSuccessful()->assertSee('Ausgang / Ausschuss');
$this->get(route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ing->id]))
->assertSuccessful()
->assertSee('AP12-Render');
});
test('chargen-endpoint liefert eingegangene chargen', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-Charges');
ap12MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'CHG-1']);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.disposals.ingredient-charges', $ing));
$response->assertSuccessful();
$response->assertJsonFragment(['location_id' => $location->id]);
});