create([ 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } function ap23MakeProduct(string $name): Product { return Product::query()->create([ 'name' => $name, 'title' => $name, 'active' => true, ]); } /** * @param array $extra */ function ap23SaveManufacturerRecipe(Product $product, array $extra): void { $repo = new ProductRepository($product); $repo->update(array_merge([ 'id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1', 'manufacturer_inci_sync_sent' => '1', ], $extra)); } test('phasen mit zeilen, notiz und reihenfolge werden gespeichert', function () { $product = ap23MakeProduct('AP23-Phasen'); $a = ap23MakeIngredient('AP23-Sheabutter'); $b = ap23MakeIngredient('AP23-Lecithin'); $c = ap23MakeIngredient('AP23-Wasser'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['Fettphase auf 70 °C erhitzen', ''], 'mfg_ingredient_id' => [(string) $a->id, (string) $b->id, (string) $c->id], 'mfg_row_phase' => ['0', '0', '1'], 'mfg_gram' => ['61,456', '8,544', '30'], 'mfg_factor' => ['1,10', '1,10', '1,00'], ]); $phases = $product->recipePhases()->get(); expect($phases)->toHaveCount(2) ->and($phases[0]->name)->toBe('Phase A') ->and($phases[0]->note)->toBe('Fettphase auf 70 °C erhitzen') ->and($phases[0]->pos)->toBe(0) ->and($phases[1]->name)->toBe('Phase B') ->and($phases[1]->note)->toBeNull(); $rows = $product->manufacturer_ingredients()->get(); expect($rows)->toHaveCount(3) ->and($rows->firstWhere('id', $a->id)->pivot->recipe_phase_id)->toBe($phases[0]->id) ->and($rows->firstWhere('id', $b->id)->pivot->recipe_phase_id)->toBe($phases[0]->id) ->and($rows->firstWhere('id', $c->id)->pivot->recipe_phase_id)->toBe($phases[1]->id); }); test('leerer phasenname wird automatisch benannt', function () { $product = ap23MakeProduct('AP23-AutoName'); $a = ap23MakeIngredient('AP23-Auto-A'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['', 'Wasserphase'], 'mfg_phase_note' => ['', ''], 'mfg_ingredient_id' => [(string) $a->id], 'mfg_row_phase' => ['1'], 'mfg_gram' => ['100'], 'mfg_factor' => ['1,00'], ]); expect($product->recipePhases()->pluck('name')->all())->toBe(['Phase A', 'Wasserphase']); }); test('einzelner unbenannter block bleibt phasenlos (altverhalten)', function () { $product = ap23MakeProduct('AP23-Implizit'); $a = ap23MakeIngredient('AP23-Implizit-A'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => [''], 'mfg_phase_note' => [''], 'mfg_ingredient_id' => [(string) $a->id], 'mfg_row_phase' => ['0'], 'mfg_gram' => ['100'], 'mfg_factor' => ['1,00'], ]); expect(RecipePhase::query()->where('product_id', $product->id)->count())->toBe(0) ->and($product->manufacturer_ingredients()->first()->pivot->recipe_phase_id)->toBeNull(); }); test('erneutes speichern ersetzt phasen vollstaendig', function () { $product = ap23MakeProduct('AP23-Resave'); $a = ap23MakeIngredient('AP23-Resave-A'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''], 'mfg_ingredient_id' => [(string) $a->id], 'mfg_row_phase' => ['0'], 'mfg_gram' => ['100'], 'mfg_factor' => ['1,00'], ]); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Nur noch eine'], 'mfg_phase_note' => ['Neue Notiz'], 'mfg_ingredient_id' => [(string) $a->id], 'mfg_row_phase' => ['0'], 'mfg_gram' => ['100'], 'mfg_factor' => ['1,00'], ]); $phases = RecipePhase::query()->where('product_id', $product->id)->get(); expect($phases)->toHaveCount(1) ->and($phases[0]->name)->toBe('Nur noch eine') ->and($phases[0]->note)->toBe('Neue Notiz'); }); test('produkt-kopie uebernimmt phasen und zuordnung', function () { $product = ap23MakeProduct('AP23-Original'); $a = ap23MakeIngredient('AP23-Kopie-A'); $b = ap23MakeIngredient('AP23-Kopie-B'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['Notiz A', ''], 'mfg_ingredient_id' => [(string) $a->id, (string) $b->id], 'mfg_row_phase' => ['0', '1'], 'mfg_gram' => ['60', '40'], 'mfg_factor' => ['1,10', '1,00'], ]); $repo = new ProductRepository(new Product); $repo->copy($product->fresh()); $copy = Product::query()->where('name', 'Kopie: AP23-Original')->firstOrFail(); $copyPhases = $copy->recipePhases()->get(); expect($copyPhases)->toHaveCount(2) ->and($copyPhases[0]->name)->toBe('Phase A') ->and($copyPhases[0]->note)->toBe('Notiz A'); $copyRows = $copy->manufacturer_ingredients()->get(); expect($copyRows)->toHaveCount(2) ->and($copyRows->firstWhere('id', $a->id)->pivot->recipe_phase_id)->toBe($copyPhases[0]->id) ->and($copyRows->firstWhere('id', $b->id)->pivot->recipe_phase_id)->toBe($copyPhases[1]->id) // Kopie hat eigene Phasen, nicht die des Originals ->and($copyPhases[0]->id)->not->toBe($product->recipePhases()->first()->id); }); test('produktion rechnet unveraendert ueber alle phasen', function () { $product = ap23MakeProduct('AP23-Produktion'); $a = ap23MakeIngredient('AP23-Prod-A'); $b = ap23MakeIngredient('AP23-Prod-B'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''], 'mfg_ingredient_id' => [(string) $a->id, (string) $b->id], 'mfg_row_phase' => ['0', '1'], 'mfg_gram' => ['60', '40'], 'mfg_factor' => ['1,00', '1,00'], ]); // 5 Stück à (60 g + 40 g) => Soll je Rohstoff phasenunabhängig $required = app(ProductionService::class)->requiredGramsByIngredient($product->fresh(), 5); expect($required[$a->id])->toBe(300.0) ->and($required[$b->id])->toBe(200.0); }); test('AP-36: recipeJson liefert auch phasen ohne rohstoffe (textphasen)', function () { // Die eigentliche Anzeige-Korrektur sitzt im JS (buildPhaseGroups rendert leere // benannte Phasen). Dieser Test sichert den Datenvertrag ab, auf den das JS baut: // eine Phase ohne Rohstoffzeile muss im recipeJson-Payload erhalten bleiben. $product = ap23MakeProduct('AP36-Textphase'); $a = ap23MakeIngredient('AP36-Sheabutter'); ap23SaveManufacturerRecipe($product, [ 'mfg_phase_name' => ['Vorbereitung', 'Phase A'], 'mfg_phase_note' => ['Arbeitsplatz desinfizieren', ''], // nur die zweite Phase trägt einen Rohstoff — „Vorbereitung" bleibt leer 'mfg_ingredient_id' => [(string) $a->id], 'mfg_row_phase' => ['1'], 'mfg_gram' => ['100'], 'mfg_factor' => ['1,00'], ]); $user = phaseJsonUser(); $this->actingAs($user, 'user'); $location = Location::factory()->create(); $response = $this->getJson(route('admin.inventory.api.products.recipe', [ 'product' => $product->id, 'location_id' => $location->id, 'quantity' => 1, ]))->assertSuccessful(); $phases = collect($response->json('phases')); expect($phases)->toHaveCount(2); $vorbereitung = $phases->firstWhere('name', 'Vorbereitung'); expect($vorbereitung)->not->toBeNull() ->and($vorbereitung['note'])->toBe('Arbeitsplatz desinfizieren'); // Vorbereitung steht vor Phase A (pos-Reihenfolge) expect($phases[0]['name'])->toBe('Vorbereitung'); }); function phaseJsonUser(): User { $user = User::query()->create([ 'email' => uniqid('ap36_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill([ 'admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, ])->save(); return $user->fresh(); }