gruene-seele/tests/Feature/RawMaterialStockTest.php
Kevin Adametz 3ee2d756e9 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>
2026-06-03 11:04:22 +00:00

269 lines
9.7 KiB
PHP

<?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');
});