firstOrCreate( ['code' => 'DE'], ['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true] ); } function draftUser(int $adminLevel = 7): User { draftCountry(); $user = User::query()->create(['email' => uniqid('draft_', true).'@test.example', 'password' => bcrypt('password')]); $user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function draftRecipeProduct(Ingredient $ing): Product { $product = Product::query()->create(['name' => 'Draft-Produkt', 'title' => 'Draft-Produkt', 'active' => true]); // Hersteller-Rezeptur: 10 g * 1.0 * 1 Stück = 10 g Soll ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); return $product; } function draftIngredient(): Ingredient { return Ingredient::query()->create([ 'name' => 'Draft-Roh', 'trans_name' => '', 'inci' => 'DR', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } function draftStock(Ingredient $ing, Location $location, Supplier $supplier, User $user): StockEntry { return 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' => 1000, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-B', 'best_before' => '2030-12-31', 'quality_id' => null, ]); } test('AP-43: entwurf bucht keinen produktbestand und bleibt status draft', function () { $user = draftUser(); $location = Location::factory()->create(); $product = Product::query()->create(['name' => 'Draft-Simpel', 'title' => 'Draft-Simpel', 'active' => true, 'no_recipe_required' => true]); $production = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20, 'notes' => 'WIP'], [], $user->id, [['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]], true // draft ); expect($production->isDraft())->toBeTrue() ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0); }); test('AP-43: entwurf reduziert den rohstoffbestand nicht', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); $stock = draftStock($ing, $location, $supplier, $user); $before = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true // draft ); $after = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; expect((float) $after)->toBe((float) $before); // Entwurf verbraucht nichts }); test('AP-43: entwurf abschließen bucht bestand und wird completed', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); $stock = draftStock($ing, $location, $supplier, $user); $service = app(ProductionService::class); $draft = $service->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true ); expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0); $completed = $service->updateProduction( $draft->fresh(), ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], false // abschließen ); expect($completed->isDraft())->toBeFalse() ->and($completed->status)->toBe(Production::STATUS_COMPLETED) ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(1); // Jetzt zählt der Rohstoffverbrauch: 1000 - 10 = 990 $remaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; expect((float) $remaining)->toBe(990.0); }); test('AP-43: abgeschlossene produktion kann nicht zum entwurf zurück', function () { $user = draftUser(); $location = Location::factory()->create(); $product = Product::query()->create(['name' => 'Draft-Done', 'title' => 'Draft-Done', 'active' => true, 'no_recipe_required' => true]); $completed = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null], [], $user->id, [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]], false ); expect(fn () => app(ProductionService::class)->updateProduction( $completed->fresh(), ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null], [], $user->id, [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]], true // Rück-Entwurf verboten ))->toThrow(ValidationException::class); }); test('AP-43/44: neue-produktion-maske zeigt abschließen + zwischenspeichern und die aufgehübschte rezeptur-optik', function () { $user = draftUser(); Location::factory()->create(); $this->actingAs($user, 'user'); $this->get(route('admin.inventory.productions.create')) ->assertOk() // AP-43: umbenannter Abschluss-Button + Entwurf-Button ->assertSee('Produktion abschließen') ->assertSee('Zwischenspeichern (Entwurf)') ->assertSee('name="save_mode"', false) // AP-44: Optik-Hooks (Phasen-Beschreibung formatiert, kein innerer Kasten, iPad-Felder) ->assertSee('recipe-phase-note', false) ->assertSee('recipe-item', false) ->assertSee('pointer: coarse', false); }); test('AP-43: HTTP zwischenspeichern ohne ergebnis speichert entwurf; abschließen erzwingt ergebnis', function () { $user = draftUser(); $location = Location::factory()->create(); $product = Product::query()->create(['name' => 'Draft-HTTP', 'title' => 'Draft-HTTP', 'active' => true, 'no_recipe_required' => true]); Passport::actingAs($user); $this->actingAs($user, 'user'); // Entwurf: kein Produktionsergebnis nötig → wird gespeichert (Redirect zur Bearbeitung) $this->post(route('admin.inventory.productions.store'), [ 'save_mode' => 'draft', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3, ])->assertRedirect(); $draft = Production::query()->where('product_id', $product->id)->sole(); expect($draft->isDraft())->toBeTrue(); // Abschließen ohne outputs → Validierungsfehler (outputs required) $this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), [ 'save_mode' => 'complete', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3, ])->assertSessionHasErrors('outputs'); }); test('F-01: entwurf reduziert weder chargen- noch standortbestand und blockiert keine andere produktion', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); $stock = draftStock($ing, $location, $supplier, $user); $draft = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true // draft ); $productionService = app(ProductionService::class); $inventory = app(InventoryService::class); // Chargenverbrauch ohne Ausschluss der eigenen Produktion — wie ihn andere Produktionen/Ausschuss sehen expect($productionService->consumedByStockEntry([$stock->id]))->toBe([]) ->and((float) $productionService->availableStockEntriesForIngredient($ing->id, $location->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0) ->and((float) $inventory->chargesWithRemainingForIngredient($ing->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0) ->and((float) ($inventory->remainingByLocationForIngredient($ing->id)[$location->id] ?? 0.0))->toBe(1000.0); // Eine zweite Produktion kann die komplette Charge trotz Entwurf buchen $completed = $productionService->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-02', 'quantity' => 100, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '1000']], $user->id, [['product_id' => $product->id, 'quantity' => 100, 'batch_number' => null]] ); expect($completed->isDraft())->toBeFalse() ->and($productionService->consumedByStockEntry([$stock->id])[$stock->id])->toBe(1000.0) ->and($productionService->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0); // Ausschuss mit Charge: Entwurf darf die zulässige Menge nicht verkleinern (vor Abschluss der zweiten Produktion wäre 1000 g erlaubt) $completed->delete(); $this->actingAs($user, 'user') ->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'location_id' => $location->id, 'quantity' => '1000', 'disposed_at' => '2026-08-03', 'reason' => 'Test', ]) ->assertSessionHasNoErrors(); }); test('F-03: entwurf sichert alle chargenzeilen roh, auch eine nicht verfügbare, und löst erst beim abschluss auf', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); $stock = draftStock($ing, $location, $supplier, $user); $validKey = $stock->batchGroupKey(); $missingKey = 'b:'.$supplier->id.':GIBT-ES-NICHT'; Passport::actingAs($user); $this->actingAs($user, 'user'); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1]; $this->post(route('admin.inventory.productions.store'), $head + [ 'save_mode' => 'draft', 'ingredient_lines' => [ ['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10'], ['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => ''], ], ])->assertRedirect()->assertSessionHasNoErrors(); $draft = Production::query()->where('product_id', $product->id)->sole(); $lines = $draft->productionIngredients()->orderBy('id')->get(); expect($draft->isDraft())->toBeTrue() ->and($lines)->toHaveCount(2) ->and($lines[0]->stock_entry_id)->toBeNull() ->and($lines[0]->batch_key)->toBe($validKey) ->and((float) $lines[0]->quantity_used)->toBe(10.0) ->and($lines[1]->batch_key)->toBe($missingKey) ->and((float) $lines[1]->quantity_used)->toBe(0.0); // Wiederöffnen: beide Chargen-Schlüssel werden ins Formular gegeben. $this->get(route('admin.inventory.productions.edit', $draft)) ->assertOk() ->assertSee($validKey) ->assertSee($missingKey); // Abschluss mit der nicht verfügbaren Charge wird abgelehnt — nichts geht verloren. $this->from(route('admin.inventory.productions.edit', $draft)) ->put(route('admin.inventory.productions.update', $draft), $head + [ 'save_mode' => 'complete', 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => '10']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasErrors('ingredient_lines'); expect($draft->fresh()->productionIngredients()->count())->toBe(2); // Abschluss mit der gültigen Charge löst auf den Wareneingang auf und bucht. $this->put(route('admin.inventory.productions.update', $draft), $head + [ 'save_mode' => 'complete', 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasNoErrors(); $done = $draft->fresh(); expect($done->isDraft())->toBeFalse() ->and($done->productionIngredients()->count())->toBe(1) ->and($done->productionIngredients()->first()->stock_entry_id)->toBe($stock->id) ->and($done->productionIngredients()->first()->batch_key)->toBeNull() ->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0); }); test('F-03: entwurf mit gewähltem ergebnisprodukt ohne stückzahl wird gespeichert', function () { $user = draftUser(); $location = Location::factory()->create(); $product = Product::query()->create(['name' => 'Draft-Out0', 'title' => 'Draft-Out0', 'active' => true, 'no_recipe_required' => true]); Passport::actingAs($user); $this->actingAs($user, 'user'); $this->post(route('admin.inventory.productions.store'), [ 'save_mode' => 'draft', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3, 'outputs' => [['product_id' => $product->id, 'quantity' => 0, 'batch_number' => 'VORLAEUFIG']], ])->assertRedirect()->assertSessionHasNoErrors(); $draft = Production::query()->where('product_id', $product->id)->sole(); expect($draft->outputs()->count())->toBe(1) ->and($draft->outputs()->first()->quantity)->toBe(0) ->and($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG') ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0); }); test('F-04: derselbe rohstoff in zwei phasen behält beim wiederöffnen je phase seinen anteil und lässt sich unverändert abschließen', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $stock = draftStock($ing, $location, $supplier, $user); $product = Product::query()->create(['name' => 'Draft-Phasen', 'title' => 'Draft-Phasen', 'active' => true]); $phaseA = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase A', 'pos' => 0]); $phaseB = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase B', 'pos' => 1]); ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseA->id]); ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseB->id]); Passport::actingAs($user); $this->actingAs($user, 'user'); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1]; $lines = [ ['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'], ['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'], ]; $this->post(route('admin.inventory.productions.store'), $head + ['save_mode' => 'draft', 'ingredient_lines' => $lines]) ->assertRedirect()->assertSessionHasNoErrors(); $draft = Production::query()->where('product_id', $product->id)->sole(); $html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent(); $existing = json_decode(substr($html, strpos($html, 'var existingLines = ') + strlen('var existingLines = '), strpos($html, ';', strpos($html, 'var existingLines = ')) - strpos($html, 'var existingLines = ') - strlen('var existingLines = ')), true); expect($existing)->toHaveKeys([$ing->id.'|'.$phaseA->id, $ing->id.'|'.$phaseB->id]) ->and($existing[$ing->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6) ->and($existing[$ing->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4); // Unverändert abschließen: Summe 10 g = Soll 10 g, je Phase bleibt der Anteil erhalten. $this->put(route('admin.inventory.productions.update', $draft), $head + [ 'save_mode' => 'complete', 'ingredient_lines' => $lines, 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasNoErrors(); $done = $draft->fresh(); expect($done->isDraft())->toBeFalse() ->and($done->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6) ->and($done->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4) ->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0); }); test('F-06: nach einem validierungsfehler bleiben die eingegebenen chargenzeilen im formular erhalten', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); // Soll 10 g $stock = draftStock($ing, $location, $supplier, $user); $key = $stock->batchGroupKey(); Passport::actingAs($user); $this->actingAs($user, 'user'); // 20 g statt 10 g → Toleranzfehler beim Abschluss, Redirect zurück mit old(). $this->from(route('admin.inventory.productions.create')) ->post(route('admin.inventory.productions.store'), [ 'save_mode' => 'complete', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $key, 'quantity_used' => '20,0']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ]) ->assertRedirect(route('admin.inventory.productions.create')) ->assertSessionHasErrors('ingredient_lines'); $html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent(); $start = strpos($html, 'var existingLines = ') + strlen('var existingLines = '); $existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true); expect($existing)->toHaveKey($ing->id.'|') ->and($existing[$ing->id.'|'][0]['batch_key'])->toBe($key) ->and($existing[$ing->id.'|'][0]['quantity_used'])->toBe('20,0'); }); test('F-18: mehrere zwischenspeichern-buttons und touch-regeln für das notizfeld; keine entwurfs-buttons bei gebuchter produktion', function () { $user = draftUser(); $location = Location::factory()->create(); $this->actingAs($user, 'user'); $create = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent(); expect(substr_count($create, 'btn-save-draft'))->toBeGreaterThanOrEqual(3) ->and($create)->toContain('#notes {')->toContain('autoGrowNotes'); $product = Product::query()->create(['name' => 'Draft-F18', 'title' => 'Draft-F18', 'active' => true, 'no_recipe_required' => true]); $done = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]] ); $edit = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent(); expect(substr_count($edit, 'btn-save-draft'))->toBe(0); }); test('N-01: ist-menge ohne gewählte charge bleibt im entwurf erhalten und wird beim wiederöffnen vorbelegt', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $product = draftRecipeProduct($ing); $stock = draftStock($ing, $location, $supplier, $user); Passport::actingAs($user); $this->actingAs($user, 'user'); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1]; $this->post(route('admin.inventory.productions.store'), $head + [ 'save_mode' => 'draft', 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => '', 'quantity_used' => '9,8']], ])->assertRedirect()->assertSessionHasNoErrors(); $draft = Production::query()->where('product_id', $product->id)->sole(); $line = $draft->productionIngredients()->sole(); expect((float) $line->quantity_used)->toBe(9.8) ->and($line->stock_entry_id)->toBeNull() ->and($line->batch_key)->toBeNull(); $html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent(); $start = strpos($html, 'var existingLines = ') + strlen('var existingLines = '); $existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true); expect($existing[$ing->id.'|'][0]['batch_key'])->toBe('') ->and((float) $existing[$ing->id.'|'][0]['quantity_used'])->toBe(9.8); // Abschluss ohne Charge scheitert (Charge ist Pflicht), mit Charge klappt es. $this->from(route('admin.inventory.productions.edit', $draft))->put(route('admin.inventory.productions.update', $draft), $head + [ 'save_mode' => 'complete', 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => '', 'quantity_used' => '9,8']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasErrors(); $this->put(route('admin.inventory.productions.update', $draft), $head + [ 'save_mode' => 'complete', 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '9,8']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], ])->assertSessionHasNoErrors(); expect($draft->fresh()->isDraft())->toBeFalse(); }); test('N-02: unverändertes artikelspeichern erhält die phasenzuordnung der produktionszeilen; doppelter rohstoff je phase wird abgelehnt', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = draftIngredient(); $stock = draftStock($ing, $location, $supplier, $user); $product = Product::query()->create(['name' => 'Draft-N02', 'title' => 'Draft-N02', 'active' => true]); $repo = new ProductRepository($product); $recipe = [ 'id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1', 'manufacturer_inci_sync_sent' => '1', 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''], 'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '1'], ]; $repo->update($recipe); $phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get(); expect($phases)->toHaveCount(2); [$phaseA, $phaseB] = [$phases[0], $phases[1]]; $draft = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [ ['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'], ['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'], ], $user->id, [], true ); // Artikel unverändert erneut speichern (wie das Produktformular) → Phasen-IDs bleiben. (new ProductRepository($product->fresh()))->update($recipe); $after = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->pluck('id')->all(); expect($after)->toBe([$phaseA->id, $phaseB->id]) ->and($draft->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6) ->and($draft->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4); // Umbenennen + zusätzliche Phase: bestehende Phasen bleiben, neue kommt dazu. (new ProductRepository($product->fresh()))->update(array_merge($recipe, [ 'mfg_phase_name' => ['Phase A', 'Phase B neu', 'Phase C'], 'mfg_phase_note' => ['', '', ''], 'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_row_phase' => ['0', '1'], ])); expect(RecipePhase::query()->where('product_id', $product->id)->count())->toBe(3) ->and($phaseB->fresh()->name)->toBe('Phase B neu') ->and($draft->productionIngredients()->whereNull('recipe_phase_id')->count())->toBe(0); // Derselbe Rohstoff zweimal in derselben Phase wird abgelehnt. expect(fn () => (new ProductRepository($product->fresh()))->update(array_merge($recipe, [ 'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_row_phase' => ['0', '0'], ])))->toThrow(ValidationException::class); }); test('N-04: bearbeitungsformular einer gebuchten produktion liefert den gespeicherten verpackungs-snapshot und die ergebnissummen', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $material = PackagingMaterial::factory()->create(); $pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'N04-Tiegel', 'category' => 'packaging']); $product = Product::query()->create(['name' => 'Draft-N04', 'title' => 'Draft-N04', 'active' => true, 'no_recipe_required' => true]); $product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]); $done = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]] ); $product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]); $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent(); $start = strpos($html, "data-packaging-snapshot='") + strlen("data-packaging-snapshot='"); $snapshot = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true); $start = strpos($html, "data-initial-output-totals='") + strlen("data-initial-output-totals='"); $totals = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true); expect($snapshot)->toBe([['name' => 'N04-Tiegel', 'pieces' => 1]]) ->and($totals)->toBe([(string) $product->id => 1]) ->and($html)->toContain('Gespeicherter Verpackungsverbrauch'); }); test('R-01: neue textphase voranstellen und bestehende phase umbenennen erhält die phasenzuordnung (mit und ohne formular-IDs)', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $x = draftIngredient(); $y = Ingredient::query()->create(['name' => 'Draft-Roh-Y', 'trans_name' => '', 'inci' => 'DRY', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0]); $stockX = draftStock($x, $location, $supplier, $user); $stockY = StockEntry::query()->create([ 'entry_type' => 'ingredient', 'ingredient_id' => $y->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-Y', 'best_before' => '2030-12-31', ]); $product = Product::query()->create(['name' => 'Draft-R01', 'title' => 'Draft-R01', 'active' => true]); $base = ['id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1', 'manufacturer_inci_sync_sent' => '1']; (new ProductRepository($product))->update($base + [ 'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''], 'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '1'], ]); [$phaseA, $phaseB] = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get()->all(); $draft = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], [ ['ingredient_id' => $x->id, 'stock_entry_id' => $stockX->id, 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'], ['ingredient_id' => $y->id, 'stock_entry_id' => $stockY->id, 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'], ], $user->id, [], true ); // Formular-Weg: Textphase „Vorbereitung“ voranstellen, B umbenennen — mit stabilen IDs. (new ProductRepository($product->fresh()))->update($base + [ 'mfg_phase_id' => ['', (string) $phaseA->id, (string) $phaseB->id], 'mfg_phase_name' => ['Vorbereitung', 'Phase A', 'Phase B neu'], 'mfg_phase_note' => ['Alles bereitlegen', '', ''], 'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['1', '2'], ]); $phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get(); expect($phases->pluck('name')->all())->toBe(['Vorbereitung', 'Phase A', 'Phase B neu']) ->and($phases[1]->id)->toBe($phaseA->id) ->and($phases[2]->id)->toBe($phaseB->id) ->and($phases[0]->id)->not->toBe($phaseB->id) ->and($draft->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6) ->and($draft->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4); // Ohne IDs (Direktaufruf): Umordnen + erneutes Umbenennen → Zuordnung über gemeinsame Rohstoffe. (new ProductRepository($product->fresh()))->update($base + [ 'mfg_phase_name' => ['Phase B final', 'Vorbereitung', 'Phase A'], 'mfg_phase_note' => ['', 'Alles bereitlegen', ''], 'mfg_ingredient_id' => [$y->id, $x->id], 'mfg_gram' => ['4', '6'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '2'], ]); $phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get(); expect($phases->pluck('name')->all())->toBe(['Phase B final', 'Vorbereitung', 'Phase A']) ->and($phases[0]->id)->toBe($phaseB->id) ->and($phases[2]->id)->toBe($phaseA->id) ->and($draft->productionIngredients()->whereNull('recipe_phase_id')->count())->toBe(0); // Wiederöffnen: beide Ist-Mengen stehen in ihrer Phase. $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent(); $start = strpos($html, 'var existingLines = ') + strlen('var existingLines = '); $existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true); expect($existing[$x->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6) ->and($existing[$y->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4); // Produktformular trägt die Phasen-IDs. $form = $this->get(route('admin_product_edit', $product->id))->assertOk()->getContent(); expect($form)->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseA->id.'"') ->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseB->id.'"'); }); test('R-02: ohne verpackung gebuchte produktion zeigt nach stücklistenänderung den leeren snapshot statt der neuen stückliste', function () { $user = draftUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $material = PackagingMaterial::factory()->create(); $pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'R02-Tiegel', 'category' => 'packaging']); $product = Product::query()->create(['name' => 'Draft-R02', 'title' => 'Draft-R02', 'active' => true, 'no_recipe_required' => true]); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null]; $done = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]); expect($done->productionPackagings)->toHaveCount(0); $product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]); $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent(); expect($html)->toContain("data-packaging-snapshot='[]'") ->toContain("data-packaging-snapshot-applies='1'") ->toContain('Kein Verpackungsverbrauch gebucht.'); // Entwurf: kein gültiger Snapshot → Vorschau rechnet mit der Stückliste. $draft = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true); expect($this->get(route('admin.inventory.productions.edit', $draft))->getContent())->toContain("data-packaging-snapshot-applies='0'"); // Notiz speichern → Verbrauch bleibt 0; Mengenänderung → Neuberechnung 2 × 3. $done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['notes' => 'x'], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]); expect($done->productionPackagings)->toHaveCount(0); $done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['quantity' => 3], [], $user->id, [['product_id' => $product->id, 'quantity' => 3, 'batch_number' => null]]); expect($done->productionPackagings->sum('quantity_used'))->toBe(6); });