Jedes Produkt trägt einen Rohstoff i. d. R. in zwei product_ingredients-Zeilen (recipe_type manufacturer + product); die belongsToMany-Relation Ingredient::products lieferte das Produkt dadurch doppelt in der „Enthalten in"-Box. In RawMaterialStockController::show() die Produktliste nach ID dedupliziert (setRelation), wovon auch der abgeleitete Produktbestand profitiert. Reine Anzeige-Korrektur, Pivot-Daten unangetastet. Test ergänzt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
465 lines
18 KiB
PHP
465 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
use App\Models\Product;
|
|
use App\Models\ProductIngredient;
|
|
use App\Models\Production;
|
|
use App\Models\StockEntry;
|
|
use App\Models\StorageRoom;
|
|
use App\Models\StorageShelf;
|
|
use App\Models\StorageSlot;
|
|
use App\Models\Supplier;
|
|
use App\Repositories\StockEntryRepository;
|
|
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', ['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);
|
|
});
|