boolean('all') ? null : 500; return view('admin.inventory.productions.index', [ 'values' => $this->productionRepository->listForIndex($limit), 'listLimit' => $limit, ]); } public function create(Request $request): View|RedirectResponse { // Audit M-2: Produktionen buchen Bestand — nur admin >= 7. if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id') ?? Location::query()->where('active', true)->first()?->id; $defaultProductId = (int) $request->query('product_id') ?: null; return view('admin.inventory.productions.create', [ ...$this->productCatalogs(null), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, 'defaultProductId' => $defaultProductId, 'model' => null, 'existingLines' => $this->existingLines(null), ]); } public function store(StoreProductionRequest $request): RedirectResponse { $payload = $request->validatedPayload(); $isDraft = (bool) $payload['is_draft']; try { // AP-27: Chargen-Zeilen (batch_key) FEFO auf die Einzel-Wareneingänge der Charge auflösen. $ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], null, $isDraft); $production = $this->productionService->store( [ 'product_id' => $payload['product_id'], 'location_id' => $payload['location_id'], 'produced_at' => $payload['produced_at'], 'quantity' => $payload['quantity'], 'notes' => $payload['notes'], ], $ingredientLines, (int) $request->user()->id, $payload['outputs'], $isDraft ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); } return $this->afterSave($production, $isDraft); } /** * AP-27: Chargen-Zeilen auf Einzel-Wareneingänge auflösen (nur beim Abschluss). * Prüfung 08.09.2026 F-03: Im Entwurf wird nicht aufgelöst — die Zeilen werden * roh (batch_key) gesichert, damit eine nicht mehr verfügbare Charge nicht den * gesamten Arbeitsstand verwirft. Aufgelöst und geprüft wird erst beim Abschluss. * * @param array> $lines * @return array> */ private function expandLines(array $lines, int $locationId, ?int $excludeProductionId, bool $isDraft): array { if ($isDraft) { return $lines; } return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId); } /** * AP-43: Entwurf zurück in die Bearbeitung (Weiterarbeiten), abgeschlossene * Produktion in die Detailansicht. */ private function afterSave(Production $production, bool $isDraft): RedirectResponse { if ($isDraft) { \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.productions.edit', $production) ->with('alert-info', __('Entwurf zwischengespeichert. Die Produktion ist noch nicht gebucht.')); } if ($production->mhd_warning) { \Session::flash('alert-warning', __('Hinweis: Mindestens eine Rohstoff-Charge hat ein kürzeres MHD als das geplante Produkt-MHD.')); } \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.productions.show', $production); } public function show(Production $production): View { $production->load([ 'product', 'location', 'producedByUser.account', 'productionIngredients.ingredient', 'productionIngredients.stockEntry', 'productionPackagings.packagingItem.packagingMaterial', 'outputs.product', ]); return view('admin.inventory.productions.show', [ 'model' => $production, ]); } public function edit(Production $production): View|RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $production->load([ 'product', 'location', 'productionIngredients.ingredient', 'productionIngredients.stockEntry', 'outputs', ]); $defaultLocationId = $production->location_id; return view('admin.inventory.productions.edit', [ 'model' => $production, ...$this->productCatalogs($production), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, 'existingLines' => $this->existingLines($production), ]); } public function update(StoreProductionRequest $request, Production $production): RedirectResponse { $payload = $request->validatedPayload(); $isDraft = (bool) $payload['is_draft']; try { // AP-27: Beim Bearbeiten die eigene Produktion aus der Rest-Berechnung ausnehmen. $ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], $production->id, $isDraft); $production = $this->productionService->updateProduction( $production, [ 'product_id' => $payload['product_id'], 'location_id' => $payload['location_id'], 'produced_at' => $payload['produced_at'], 'quantity' => $payload['quantity'], 'notes' => $payload['notes'], ], $ingredientLines, (int) $request->user()->id, $payload['outputs'], $isDraft ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); } return $this->afterSave($production, $isDraft); } public function copy(Production $production): View|RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $production->load([ 'product', 'productionIngredients.ingredient', 'productionIngredients.stockEntry', 'outputs', ]); // Audit N-8: Chargen-Nummern nicht in die Kopie übernehmen — das neue // Produktionsdatum erzeugt sonst Duplikat-Chargen mit falschem Datum; // leer ⇒ JS-Vorschlag (Präfix + ttmmjj) greift. $production->outputs->each(fn ($output) => $output->setAttribute('batch_number', null)); $defaultLocationId = $production->location_id; return view('admin.inventory.productions.create', [ 'model' => $production, ...$this->productCatalogs($production), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, 'existingLines' => $this->existingLines($production), ]); } /** * Vorbelegung der Chargen-Zeilen im Formular, Schlüssel „Rohstoff|Phase“ (F-04). * Prüfung 08.09.2026 F-06: Nach einem Validierungsfehler haben die zuletzt * eingegebenen Zeilen (old()) Vorrang vor dem gespeicherten Stand — wie bei * Kopfdaten und Ergebniszeilen. Sonst AP-27: Zeilen je Charge (batch_key) * zusammenfassen, mehrere Einkäufe derselben Charge = eine Formularzeile. * * @return array> */ private function existingLines(?Production $production): array { $old = old('ingredient_lines'); if (is_array($old) && $old !== []) { return collect($old) ->filter(fn ($l) => is_array($l) && (int) ($l['ingredient_id'] ?? 0) > 0 && (trim((string) ($l['batch_key'] ?? '')) !== '' || trim((string) ($l['quantity_used'] ?? '')) !== '')) ->groupBy(fn ($l) => (int) $l['ingredient_id'].'|'.(! empty($l['recipe_phase_id']) ? (int) $l['recipe_phase_id'] : '')) ->map(fn ($group) => $group->map(fn ($l) => [ 'batch_key' => trim((string) ($l['batch_key'] ?? '')), 'quantity_used' => (string) ($l['quantity_used'] ?? ''), ])->values()->all()) ->all(); } if ($production === null) { return []; } return $production->productionIngredients ->groupBy(fn ($l) => $l->formGroupKey()) ->map(fn ($lines) => $lines ->groupBy(fn ($l) => $l->formBatchKey()) ->map(fn ($group, $key) => ['batch_key' => (string) $key, 'quantity_used' => (float) $group->sum('quantity_used')]) ->values()->all()) ->all(); } /** * 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() ->with('packagings:id,name') ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)) ->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds)) ->orderBy('name') ->get($columns); // F-07: Verpackungs-Stückliste je Ergebnisprodukt für die Vorschau im Browser — // dieselbe Basis wie die Buchung (syncPackagingSnapshot: Outputs × Stückliste). $packagingBom = $outputProducts ->mapWithKeys(fn (Product $p) => [(int) $p->id => $p->packagings->map(fn ($pk) => [ 'id' => (int) $pk->id, 'name' => $pk->name, 'per_unit' => (float) ($pk->pivot->quantity ?? 1), ])->values()->all()]) ->all(); return ['products' => $products, 'outputProducts' => $outputProducts, 'packagingBom' => $packagingBom]; } /** * Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete). * Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt; * der Rohstoffverbrauch der Produktion zählt danach nicht mehr * (Bestandsqueries schließen gelöschte Produktionen aus). */ public function destroy(Production $production, ProductStockService $productStockService): RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } \DB::transaction(function () use ($production, $productStockService) { // Audit M-9: Zeile sperren, damit Löschen nicht mit parallelem Update kollidiert. Production::query()->lockForUpdate()->findOrFail($production->id); $productStockService->reverseProductionStock($production, (int) auth()->id()); $production->delete(); }); \Session::flash('alert-success', __('Produktion gelöscht — Produktbestand zurückgebucht, Rohstoffverbrauch freigegeben.')); return redirect()->route('admin.inventory.productions.index'); } public function recipeJson(Request $request, Product $product): JsonResponse { $locationId = (int) $request->query('location_id', 0); $quantity = (int) $request->query('quantity', 1); $excludeProductionId = (int) $request->query('exclude_production', 0) ?: null; if ($locationId < 1) { return response()->json(['message' => __('location_id erforderlich')], 422); } return response()->json( $this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity), $excludeProductionId) ); } }