create([ 'email' => uniqid('ps_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function psIngredient(string $name): Ingredient { return Ingredient::query()->create([ 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } /** * @param array $attrs */ function psProduct(array $attrs, Ingredient $ing, float $gram = 100, float $factor = 1.0): Product { $product = Product::query()->create(array_merge(['name' => uniqid('PS-', true), 'title' => 'PS', 'active' => true], $attrs)); ProductIngredient::create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => $gram, 'factor' => $factor, 'recipe_type' => 'manufacturer', ]); return $product->fresh('manufacturer_ingredients'); } test('AP-35: 100%-rezeptur skaliert auf die füllmenge eines ml-produkts', function () { $ing = psIngredient('PS-Sheabutter'); // 50 ml (unit=1) => 50 g Basis, 100 % Anteil, Faktor 1 => 50 g je Stück. $product = psProduct(['contents_total' => 50, 'unit' => 1], $ing); $required = app(ProductionService::class)->requiredGramsByIngredient($product, 1); expect($required[$ing->id])->toBe(50.0); // 3 Stück => 150 g $required3 = app(ProductionService::class)->requiredGramsByIngredient($product, 3); expect($required3[$ing->id])->toBe(150.0); }); test('AP-35: explizites füllgewicht hat vorrang vor der ml-menge', function () { $ing = psIngredient('PS-Balm'); // 50 ml aber real 60 g Füllgewicht (dichter als Wasser) => 60 g Basis. $product = psProduct(['contents_total' => 50, 'unit' => 1, 'fill_weight' => 60], $ing); $required = app(ProductionService::class)->requiredGramsByIngredient($product, 1); expect($required[$ing->id])->toBe(60.0); }); test('AP-35: ohne massenbasis (stück-produkt) gilt das altverhalten anteil=gramm', function () { $ing = psIngredient('PS-Kapsel'); // unit=2 (Stück) ohne fill_weight => keine Massenbasis => Faktor 1,0. $product = psProduct(['contents_total' => 54, 'unit' => 2], $ing, gram: 30, factor: 1.0); $required = app(ProductionService::class)->requiredGramsByIngredient($product, 2); expect($required[$ing->id])->toBe(60.0); // 30 × 1 × 2 (unskaliert) }); test('AP-39: abwiegen darf bis 5 % vom soll abweichen und wird gebucht', function () { $user = psUser(); $this->actingAs($user, 'user'); $ing = psIngredient('PS-Tol'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); StockEntry::query()->create([ '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' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'PS-B1', 'best_before' => '2030-12-31', ]); // 100 ml, 100 % => Soll 100 g je Stück. $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); $stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id'); // Ist 103 g (±3 %) => darf gespeichert werden, realer Wert wird gebucht. $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '103']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasNoErrors(); expect(Production::query()->count())->toBe(1) ->and((float) Production::query()->first()->productionIngredients()->sum('quantity_used'))->toBe(103.0); }); test('AP-39: abweichung über 5 % wird abgelehnt (zahlendreher-schutz)', function () { $user = psUser(); $this->actingAs($user, 'user'); $ing = psIngredient('PS-TolBad'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); StockEntry::query()->create([ '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' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'PS-B2', 'best_before' => '2030-12-31', ]); $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); $stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id'); // Ist 120 g (+20 %) => Ablehnung, keine Produktion. $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '120']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasErrors('ingredient_lines'); expect(Production::query()->count())->toBe(0); }); test('AP-40: recipeJson-payload enthält aktuellen bestand je INCI (für differenz)', function () { $user = psUser(); $ing = psIngredient('PS-Bestand'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); StockEntry::query()->create([ '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' => 800, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 800, 'batch_number' => 'PS-BST', 'best_before' => '2030-12-31', ]); // 100 ml, 100 % => Soll 100 g/Stück; 10 Stück => 1000 g Bedarf, Bestand 800 g => Differenz -200 g. $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); $payload = app(ProductionService::class)->buildRecipePayload($product, $location->id, 10); $line = collect($payload['ingredients'])->firstWhere('id', $ing->id); expect($line['current_stock'])->toBe(800.0) ->and($line['required_grams_total'])->toBe(1000.0); }); test('AP-45: verpackung wird je ergebnisprodukt aus dem produktionsergebnis gebucht', function () { $user = psUser(); $supplier = Supplier::factory()->create(); $location = Location::factory()->create(); $material = PackagingMaterial::factory()->create(); $pkA = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-A', 'category' => 'packaging']); $pkB = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-B', 'category' => 'packaging']); // Beide Ergebnisprodukte ohne Rezepturpflicht (kein Rohstoff-Abwiegen nötig). $planned = Product::query()->create(['name' => uniqid('PS-Plan-', true), 'title' => 'x', 'active' => true, 'no_recipe_required' => true]); $planned->packagings()->sync([$pkA->id => ['quantity' => 1, 'pos' => 0]]); $other = Product::query()->create(['name' => uniqid('PS-Other-', true), 'title' => 'y', 'active' => true, 'no_recipe_required' => true]); $other->packagings()->sync([$pkB->id => ['quantity' => 2, 'pos' => 0]]); // Eine Charge, Ergebnis: 40× Plan (Tiegel A) + 50× Other (Tiegel B). $production = app(ProductionService::class)->store([ 'product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 40, ], [], $user->id, [ ['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'PS-A1'], ['product_id' => $other->id, 'quantity' => 50, 'batch_number' => 'PS-B1'], ]); $byItem = $production->productionPackagings->keyBy('packaging_item_id'); expect($byItem[$pkA->id]->quantity_used)->toBe(40) // 1 × 40 (Ergebnis Plan) ->and($byItem[$pkB->id]->quantity_used)->toBe(100); // 2 × 50 (Ergebnis Other) }); test('F-05: notizkorrektur behält den historischen verpackungs-snapshot, mengenkorrektur bildet ihn neu', function () { $user = psUser(); $supplier = Supplier::factory()->create(); $location = Location::factory()->create(); $material = PackagingMaterial::factory()->create(); $pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-F05-Tiegel', 'category' => 'packaging']); $product = Product::query()->create(['name' => uniqid('PS-F05-', true), 'title' => 'x', 'active' => true, 'no_recipe_required' => true]); $product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]); $service = app(ProductionService::class); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1]; $production = $service->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05']]); expect($production->productionPackagings->sum('quantity_used'))->toBe(1); // Stückliste nachträglich auf 2 Tiegel je Stück ändern. $product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]); // Nur die Notiz korrigieren: Snapshot bleibt bei 1. $production = $service->updateProduction($production->fresh(), $head + ['notes' => 'Korrektur'], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05']]); expect($production->productionPackagings->sum('quantity_used'))->toBe(1) ->and($production->notes)->toBe('Korrektur'); // Echte Mengenkorrektur (1 → 3 Stück): Snapshot wird mit der aktuellen Stückliste neu gebildet (2 × 3). $production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id, [['product_id' => $product->id, 'quantity' => 3, 'batch_number' => 'F05']]); expect($production->productionPackagings->sum('quantity_used'))->toBe(6); }); test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', function () { $user = psUser(); $this->actingAs($user, 'user'); $ing = psIngredient('PS-Rel'); psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing); psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing); $html = $this->get(route('admin.inventory.productions.create')) ->assertSuccessful() ->assertSee('PS47-Sichtbar') ->getContent(); // Planung oben: nur produktionsrelevante Produkte. expect(psSelectHtml($html, 'id="product_id"'))->toContain('PS47-Sichtbar')->not->toContain('PS47-Versteckt') // F-02: Ergebniszeilen: auch nicht-produktionsrelevante Produkte (5 ml aus der 50-ml-Masse). ->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS47-Sichtbar')->toContain('PS47-Versteckt'); }); test('F-02: bearbeiten behält ein nicht mehr produktionsrelevantes ergebnisprodukt und speichert unverändert', function () { $user = psUser(); $this->actingAs($user, 'user'); $ing = psIngredient('PS-F02'); $big = psProduct(['name' => 'PS-F02-50ml', 'no_recipe_required' => true, 'production_relevant' => true], $ing); $small = psProduct(['name' => 'PS-F02-5ml', 'no_recipe_required' => true, 'production_relevant' => false], $ing); $location = Location::factory()->create(); $production = app(ProductionService::class)->store( ['product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50, 'notes' => null], [], $user->id, [ ['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'], ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'], ] ); $html = $this->get(route('admin.inventory.productions.edit', $production))->assertSuccessful()->getContent(); expect(psSelectHtml($html, 'id="product_id"'))->not->toContain('PS-F02-5ml') ->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS-F02-5ml'); $this->put(route('admin.inventory.productions.update', $production), [ 'product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50, 'outputs' => [ ['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'], ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'], ], ])->assertSessionHasNoErrors(); expect($production->fresh()->outputs()->where('product_id', $small->id)->value('quantity'))->toBe(50); }); /** * Liefert das HTML eines ', $start); return substr($html, $start, $end - $start); } test('AP-47: enthalten-in blendet nicht-produktionsrelevante produkte aus', function () { $user = psUser(); $this->actingAs($user, 'user'); $ing = psIngredient('PS-EnthaltenRel'); psProduct(['name' => 'PS47-EnthaltenSichtbar', 'production_relevant' => true], $ing); psProduct(['name' => 'PS47-EnthaltenVersteckt', 'production_relevant' => false], $ing); $this->get(route('admin.inventory.raw-material-stock.show', $ing)) ->assertSuccessful() ->assertSee('PS47-EnthaltenSichtbar') ->assertDontSee('PS47-EnthaltenVersteckt'); });