diff --git a/app/Http/Controllers/Admin/Inventory/ProductionController.php b/app/Http/Controllers/Admin/Inventory/ProductionController.php index 33f6d5b..bf0da5e 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductionController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductionController.php @@ -10,6 +10,7 @@ use App\Models\Production; use App\Repositories\ProductionRepository; use App\Services\ProductionService; use App\Services\ProductStockService; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -46,7 +47,7 @@ class ProductionController extends Controller $defaultProductId = (int) $request->query('product_id') ?: null; return view('admin.inventory.productions.create', [ - 'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']), + ...$this->productCatalogs(null), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, 'defaultProductId' => $defaultProductId, @@ -159,10 +160,7 @@ class ProductionController extends Controller return view('admin.inventory.productions.edit', [ 'model' => $production, - 'products' => Product::query() - ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)->where('production_relevant', true)) - ->orWhere('id', $production->product_id) - ->orderBy('name')->get(['id', 'name', 'batch_prefix']), + ...$this->productCatalogs($production), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, ]); @@ -219,12 +217,44 @@ class ProductionController extends Controller return view('admin.inventory.productions.create', [ 'model' => $production, - 'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']), + ...$this->productCatalogs($production), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, ]); } + /** + * Prüfung 08.09.2026 F-02: Zwei getrennte Kataloge. + * - `products`: Planungsprodukt oben — nur produktionsrelevante Produkte (AP-47), + * plus das bereits geplante Produkt einer bestehenden Produktion. + * - `outputProducts`: Ergebniszeilen — alle aktiven Einzelprodukte, unabhängig von + * der Produktionsrelevanz (z. B. 5-ml-Variante aus der 50-ml-Masse), plus alle + * bereits gespeicherten Ergebnisprodukte, damit sie beim Bearbeiten/Kopieren + * nicht still verloren gehen. + * + * @return array{products: Collection, outputProducts: Collection} + */ + private function productCatalogs(?Production $production): array + { + $columns = ['id', 'name', 'batch_prefix']; + + $products = Product::query() + ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)->where('production_relevant', true)) + ->when($production !== null, fn ($q) => $q->orWhere('id', $production->product_id)) + ->orderBy('name') + ->get($columns); + + $existingOutputIds = $production?->outputs?->pluck('product_id')->map(fn ($id) => (int) $id)->all() ?? []; + + $outputProducts = Product::query() + ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)) + ->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds)) + ->orderBy('name') + ->get($columns); + + return ['products' => $products, 'outputProducts' => $outputProducts]; + } + /** * Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete). * Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt; diff --git a/resources/views/admin/inventory/productions/_form_fields.blade.php b/resources/views/admin/inventory/productions/_form_fields.blade.php index 8f6e04b..9a84d83 100644 --- a/resources/views/admin/inventory/productions/_form_fields.blade.php +++ b/resources/views/admin/inventory/productions/_form_fields.blade.php @@ -182,7 +182,7 @@
diff --git a/tests/Feature/ProductionScalingTest.php b/tests/Feature/ProductionScalingTest.php index a347d4b..8aed314 100644 --- a/tests/Feature/ProductionScalingTest.php +++ b/tests/Feature/ProductionScalingTest.php @@ -194,12 +194,62 @@ test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', funct psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing); psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing); - $this->get(route('admin.inventory.productions.create')) + $html = $this->get(route('admin.inventory.productions.create')) ->assertSuccessful() ->assertSee('PS47-Sichtbar') - ->assertDontSee('PS47-Versteckt'); + ->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');