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 $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', ['scope' => 'all'])); $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'); }); test('kein-rohstoffbestand-flag blendet inci aus uebersicht und kritisch-zaehler aus', function () { $user = ap10MakeUser(1); $normal = ap10MakeIngredient('AP20-Sichtbar', 500); $excluded = ap10MakeIngredient('AP20-Allergen', 500); $excluded->update(['exclude_from_stock' => true]); // Beide ohne Bestand => beide unter Meldebestand, aber nur der sichtbare zählt. expect(app(InventoryService::class)->criticalIngredientCount())->toBe(1); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.raw-material-stock.index', ['scope' => 'all'])); $response->assertSuccessful(); $response->assertSee('AP20-Sichtbar'); $response->assertDontSee('AP20-Allergen'); }); test('modus recipe zeigt nur rohstoffe aus herstellerrezepturen aktiver produkte', function () { $user = ap10MakeUser(1); $inRecipe = ap10MakeIngredient('AP20-InRezeptur'); $notInRecipe = ap10MakeIngredient('AP20-Neuentwicklung'); $product = Product::query()->create(['name' => 'AP20-Produkt', 'title' => 'AP20-Produkt', 'active' => true]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $inRecipe->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $this->actingAs($user, 'user'); // Default (Kundenentscheid 17.07.2026): nur Rohstoffe aus Herstellerrezepturen $this->get(route('admin.inventory.raw-material-stock.index')) ->assertSuccessful() ->assertSee('AP20-InRezeptur') ->assertDontSee('AP20-Neuentwicklung') ->assertSee('Nur aus Herstellerrezepturen'); // Modus „alle": beide sichtbar $this->get(route('admin.inventory.raw-material-stock.index', ['scope' => 'all'])) ->assertSuccessful() ->assertSee('AP20-InRezeptur') ->assertSee('AP20-Neuentwicklung'); }); test('recipe-scope weist auf kritische rohstoffe ausserhalb der rezepturen hin', function () { // Review 20.07.2026: Badge zählt alle aktiven kritischen Rohstoffe, die // Standardansicht blendet rezepturlose aus — der Hinweis schließt die Lücke. $user = ap10MakeUser(1); $inRecipe = ap10MakeIngredient('AP20-InRezeptur', 500); // rezepturloser Rohstoff mit Meldebestand, aber ohne Bestand => kritisch ap10MakeIngredient('AP20-Neuentwicklung', 500); $product = Product::query()->create(['name' => 'AP20-Produkt', 'title' => 'AP20-Produkt', 'active' => true]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $inRecipe->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $this->actingAs($user, 'user'); // Badge zählt beide, die recipe-Ansicht nur einen => Hinweis auf den versteckten expect(app(InventoryService::class)->criticalIngredientCount())->toBe(2); $this->get(route('admin.inventory.raw-material-stock.index')) ->assertSuccessful() ->assertSee('außerhalb der Herstellerrezepturen'); // Im „alle"-Modus kein Hinweis (alles sichtbar) $this->get(route('admin.inventory.raw-material-stock.index', ['scope' => 'all'])) ->assertSuccessful() ->assertDontSee('außerhalb der Herstellerrezepturen'); }); test('letzter netto-preis je lieferant kommt aus dem neuesten einkauf', function () { $user = ap10MakeUser(); $location = Location::factory()->create(); $supplierA = Supplier::factory()->create(); $supplierB = Supplier::factory()->create(); $ing = ap10MakeIngredient('OPT4-Preis'); ap10MakeStock($ing, $location, $supplierA, $user, ['ordered_at' => '2026-01-01', 'price_per_kg' => 10]); ap10MakeStock($ing, $location, $supplierA, $user, ['ordered_at' => '2026-03-01', 'price_per_kg' => 12.5]); ap10MakeStock($ing, $location, $supplierB, $user, ['ordered_at' => '2026-02-01', 'price_per_kg' => 9]); $prices = app(StockEntryRepository::class)->lastNetPricePerSupplier($ing->id); expect($prices[$supplierA->id])->toBe(12.5) ->and($prices[$supplierB->id])->toBe(9.0); }); test('wareneingang-liste deckelt eingegangene eintraege, offene bleiben vollstaendig', function () { $user = ap10MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap10MakeIngredient('OPT3-Limit'); foreach (range(1, 3) as $i) { ap10MakeStock($ing, $location, $supplier, $user, ['received_at' => '2026-01-0'.$i]); } foreach (range(1, 2) as $i) { ap10MakeStock($ing, $location, $supplier, $user, [ 'status' => 'pending', 'ordered_at' => '2026-02-0'.$i, 'received_quantity' => null, 'received_at' => null, 'received_by' => null, ]); } $values = app(StockEntryRepository::class)->listForIndex(2); expect($values)->toHaveCount(4) // 2 pending + 2 (gedeckelt) received ->and($values->take(2)->pluck('status')->unique()->all())->toBe(['pending']) ->and($values->skip(2)->pluck('received_at')->map(fn ($d) => $d->format('Y-m-d'))->values()->all()) ->toBe(['2026-01-03', '2026-01-02']); // neueste zuerst expect(app(StockEntryRepository::class)->listForIndex(null))->toHaveCount(5); }); test('bestellweg folgt der inci-lieferanten-url', function () { $user = ap10MakeUser(1); $shopSupplier = Supplier::factory()->create(['name' => 'AP21-Shop', 'email' => 'shop@example.test']); $mailSupplier = Supplier::factory()->create(['name' => 'AP21-Mail', 'email' => 'mail@example.test']); $ing = ap10MakeIngredient('AP21-Bestellweg'); // URL gesetzt ⇒ Shop-Link (INCI-spezifisch), URL leer ⇒ Mailbestellung $ing->suppliers()->attach($shopSupplier->id, ['url' => 'https://shop.example/zinclear?menge=25kg']); $ing->suppliers()->attach($mailSupplier->id, ['url' => null]); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); $response->assertSuccessful(); $response->assertSee('https://shop.example/zinclear?menge=25kg', false); $response->assertSee(__('Zum Shop')); $response->assertSee(__('Per Mail')); $response->assertSee('mailto:mail@example.test', false); }); test('detailansicht zeigt alternativnamen und lagerort', function () { $user = ap10MakeUser(1); $room = StorageRoom::factory()->create(['label' => 'Raum 1']); $shelf = StorageShelf::factory()->create(['label' => 'Regal 3']); $slot = StorageSlot::factory()->create(['label' => 'O']); $ing = ap10MakeIngredient('AP21-Lagerort'); $ing->update([ 'alt_name' => 'Phospholipon 80 H', 'storage_room_id' => $room->id, 'storage_shelf_id' => $shelf->id, 'storage_slot_id' => $slot->id, ]); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); $response->assertSuccessful(); $response->assertSee('Phospholipon 80 H'); $response->assertSee('Raum 1 | Regal 3 | O'); }); test('AP-37: enthalten-in listet ein produkt trotz doppelter rezeptzeilen nur einmal', function () { $user = ap10MakeUser(1); $ing = ap10MakeIngredient('AP37-Rohstoff'); $product = Product::query()->create(['name' => 'AP37-Zaubercreme-Unikat', 'title' => 'AP37-Zaubercreme-Unikat', 'active' => true]); // Realfall: jedes Produkt trägt den Rohstoff in ZWEI product_ingredients-Zeilen // (Hersteller- und Produktrezeptur). Die „Enthalten in"-Liste darf ihn dennoch // nur einmal zeigen. foreach (['manufacturer', 'product'] as $type) { ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => $type, ]); } $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.raw-material-stock.show', $ing)) ->assertSuccessful() ->getContent(); expect(substr_count($html, 'AP37-Zaubercreme-Unikat'))->toBe(1); }); function f11Setup(): array { $admin = ap10MakeUser(7); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(['name' => 'F11-Lieferant']); $ing = ap10MakeIngredient('F11-Preis'); $ing->suppliers()->attach($supplier->id, ['preferred' => true]); ap10MakeStock($ing, $location, $supplier, $admin, ['price_per_kg' => 123.45]); return [$admin, $ing]; } test('F-11: copyreader sehen auf der rohstoff-detailseite keine einkaufspreise', function () { [, $ing] = f11Setup(); $this->actingAs(ap10MakeUser(1), 'user') ->get(route('admin.inventory.raw-material-stock.show', $ing)) ->assertSuccessful() ->assertSee('F11-Lieferant') ->assertDontSee('123,45') ->assertDontSee('Letzter Einkauf'); }); test('F-11: admins sehen den letzten einkaufspreis weiterhin', function () { [$admin, $ing] = f11Setup(); $this->actingAs($admin, 'user') ->get(route('admin.inventory.raw-material-stock.show', $ing)) ->assertSuccessful() ->assertSee('123,45'); }); test('F-14: enthalten-in zeigt anteil in prozent und verbrauch je stück inkl. faktor, phasen summiert', function () { $user = ap10MakeUser(1); $ing = ap10MakeIngredient('F14-Anteil'); // 50-g-Produkt, 20 % mit Faktor 1,1 → netto 10 g, Verbrauch 11 g je Stück. $single = Product::query()->create(['name' => 'F14-Einfach', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 50]); ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 20, 'factor' => 1.1, 'recipe_type' => 'manufacturer']); // INCI-Zeile desselben Produkts darf nicht mitzählen. ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 99, 'factor' => 1.0, 'recipe_type' => 'product']); // Zwei Phasen 6 % + 4 % bei 100 g → 10 %, Verbrauch 10 g (Faktor 1,0). $phased = Product::query()->create(['name' => 'F14-Phasen', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 100]); ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer']); ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer']); $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.raw-material-stock.show', $ing))->assertSuccessful()->getContent(); expect($html)->toContain('20,00 %')->toContain('11,00 g')->toContain('netto 10,00 g') ->toContain('10,00 %')->toContain('10,00 g') ->not->toContain('99,00'); expect(substr_count($html, 'F14-Einfach'))->toBe(1); });