create([ 'email' => uniqid('p5_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill([ 'admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, ])->save(); return $user->fresh(); } function phase5EnsureCountry(): Country { return Country::query()->firstOrCreate( ['code' => 'DE'], [ 'phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true, ] ); } test('production service stores ingredients and packaging snapshot', function () { phase5EnsureCountry(); $user = phase5MakeUser(7); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'P5-Rohstoff', 'trans_name' => '', 'inci' => 'INCI', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); $product = Product::query()->create([ 'name' => 'P5-Produkt', 'title' => 'P5-Produkt', 'active' => true, 'shelf_life_type' => null, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $material = PackagingMaterial::factory()->create(); $pk = PackagingItem::factory()->create([ 'packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'P5-Flasche', 'category' => 'packaging', ]); $product->packagings()->sync([ $pk->id => ['quantity' => 2, 'pos' => 0], ]); $stock = StockEntry::query()->create([ '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' => 'B-P5', 'best_before' => '2030-12-31', 'quality_id' => null, ]); $service = app(ProductionService::class); $production = $service->store( [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-03-01', 'quantity' => 5, 'notes' => 'test', ], [ [ 'ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '50', ], ], $user->id ); expect($production->productionIngredients)->toHaveCount(1); expect((float) $production->productionIngredients->first()->quantity_used)->toBe(50.0); expect($production->productionPackagings)->toHaveCount(1); expect($production->productionPackagings->first()->quantity_used)->toBe(10); }); test('production service rejects wrong gram sum', function () { phase5EnsureCountry(); $user = phase5MakeUser(7); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'P5-R2', 'trans_name' => '', 'inci' => 'X', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); $product = Product::query()->create([ 'name' => 'P5-P2', 'title' => 'P5-P2', 'active' => true, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $stock = StockEntry::query()->create([ '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' => 'B2', 'best_before' => '2030-12-31', 'quality_id' => null, ]); $service = app(ProductionService::class); expect(fn () => $service->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' => '100', ], ], $user->id ))->toThrow(ValidationException::class); }); test('production api recipe json returns ingredients and packagings', function () { phase5EnsureCountry(); $user = phase5MakeUser(1); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'P5-API', 'trans_name' => '', 'inci' => 'Y', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); $product = Product::query()->create([ 'name' => 'P5-API-P', 'title' => 'P5-API-P', 'active' => true, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 2, 'factor' => 1.5, 'recipe_type' => 'manufacturer', ]); StockEntry::query()->create([ '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' => 5000, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-02-01', 'received_quantity' => 5000, 'batch_number' => 'API-1', 'best_before' => '2028-06-01', 'quality_id' => null, ]); $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('product.id', $product->id); expect((float) $response->json('ingredients.0.required_grams_total'))->toBe(30.0); }); test('production mhd warning when stock expires before product end', function () { phase5EnsureCountry(); $user = phase5MakeUser(7); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'P5-MHD', 'trans_name' => '', 'inci' => 'Z', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); $product = Product::query()->create([ 'name' => 'P5-MHD-P', 'title' => 'P5-MHD-P', 'active' => true, 'shelf_life_type' => 'fixed', 'shelf_life_months' => 24, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 1, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $stock = StockEntry::query()->create([ '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' => 5000, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-02-01', 'received_quantity' => 5000, 'batch_number' => 'MHD-B', 'best_before' => '2026-06-01', 'quality_id' => null, ]); $service = app(ProductionService::class); $production = $service->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' => '1', ], ], $user->id ); expect($production->mhd_warning)->toBeTrue(); });