with(['ingredient', 'packagingItem', 'location', 'stockEntry', 'user.account']) ->latest('disposed_at') ->latest('id'); if (in_array($request->query('type'), ['ingredient', 'packaging'], true)) { $query->where('disposal_type', $request->query('type')); } // Audit N-5: Limit sichtbar machen statt still abzuschneiden (analog OPT-3). $limit = $request->boolean('all') ? null : 500; return view('admin.inventory.stock-disposals.index', [ 'values' => $limit !== null ? $query->limit($limit)->get() : $query->get(), 'typeFilter' => $request->query('type'), 'listLimit' => $limit, ]); } public function create(Request $request): View|RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $prefill = [ 'disposal_type' => 'ingredient', 'ingredient_id' => null, 'ingredient_label' => null, ]; $ingredientId = (int) $request->query('ingredient_id'); if ($ingredientId > 0 && ($ingredient = Ingredient::query()->find($ingredientId))) { $prefill['ingredient_id'] = $ingredient->id; $prefill['ingredient_label'] = $ingredient->inci ? $ingredient->name.' ('.$ingredient->inci.')' : $ingredient->name; } return view('admin.inventory.stock-disposals.create', [ 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'reasons' => $this->reasons(), 'prefill' => $prefill, // AP-50/F-17: Köln auch beim Ausschuss vorbelegen. 'defaultLocationId' => Location::defaultId(), ]); } public function store(StoreStockDisposalRequest $request, ProductionService $productionService): RedirectResponse { $data = $request->validatedPayload(); $data['user_id'] = (int) $request->user()->id; try { DB::transaction(function () use ($data, $productionService): void { $this->assertStillAvailable($data, $productionService); foreach ($this->splitAcrossBatches($data, $productionService) as $row) { StockDisposal::query()->create($row); } }); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); } \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-disposals.index'); } /** * Prüfung 08.09.2026 F-16: Bestandsprüfung innerhalb der Transaktion wiederholen, * nachdem die betroffenen Wareneingänge gesperrt wurden — die FormRequest-Prüfung * allein lässt zwei parallele Entnahmen denselben Rest bestehen. * * @param array $data * * @throws ValidationException */ private function assertStillAvailable(array $data, ProductionService $productionService): void { if (($data['disposal_type'] ?? null) !== 'ingredient') { return; } $ingredientId = (int) $data['ingredient_id']; $quantity = round((float) $data['quantity'], 2); if (! empty($data['stock_entry_id'])) { $entry = StockEntry::query()->lockForUpdate()->findOrFail((int) $data['stock_entry_id']); $available = $productionService->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)->firstWhere('id', $entry->id); $remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0; } else { // Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen. StockEntry::query()->lockForUpdate() ->where('entry_type', 'ingredient')->where('ingredient_id', $ingredientId) ->where('location_id', (int) $data['location_id']) ->get(); $remaining = round((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0), 2); } if ($quantity > $remaining) { throw ValidationException::withMessages([ 'quantity' => __('Die Menge (:qty g) übersteigt den Restbestand (:rest g) — Bestand hat sich zwischenzeitlich geändert.', [ 'qty' => number_format($quantity, 2, ',', '.'), 'rest' => number_format(max(0, $remaining), 2, ',', '.'), ]), ]); } } /** * Prüfung 08.09.2026 F-09: Rohstoff-Ausschuss ohne bestimmte Charge wird — wie der * Produktionsverbrauch (AP-27, FEFO) — auf die am Lagerort verfügbaren Chargen * verteilt und je Charge gebucht. Sonst sinkt nur der Gesamtbestand, während die * Chargen in Produktion/Ausschuss weiter als voll verfügbar angeboten würden. * Ohne verfügbare Charge (Altbestand ohne Chargenbezug) bleibt die Buchung ungebunden. * * @param array $data * @return array> */ private function splitAcrossBatches(array $data, ProductionService $productionService): array { if (($data['disposal_type'] ?? null) !== 'ingredient' || ! empty($data['stock_entry_id'])) { return [$data]; } $entries = $productionService->availableStockEntriesForIngredient((int) $data['ingredient_id'], (int) $data['location_id']); if ($entries->isEmpty()) { return [$data]; } $rows = []; $needed = round((float) $data['quantity'], 2); $lastIndex = $entries->count() - 1; foreach ($entries as $index => $entry) { $capacity = (float) $entry->getAttribute('remaining_quantity'); $take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2); if ($take > 0) { $rows[] = array_merge($data, ['stock_entry_id' => (int) $entry->id, 'quantity' => $take]); } $needed = round($needed - $take, 2); if ($needed <= 0) { break; } } return $rows; } /** * Anforderung 15.07.2026: Ausschuss-Buchungen löschbar (Soft-Delete). * Die Bestandsaggregationen schließen gelöschte Buchungen automatisch * aus — der Bestand steht danach wieder zur Verfügung. */ public function destroy(StockDisposal $stockDisposal): RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $stockDisposal->delete(); \Session::flash('alert-success', __('Ausschuss-Buchung gelöscht — die Menge steht dem Bestand wieder zur Verfügung.')); return redirect()->route('admin.inventory.stock-disposals.index'); } public function ingredientCharges(Ingredient $ingredient): JsonResponse { $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); $charges = StockEntry::query() ->where('status', 'received') ->where('entry_type', 'ingredient') ->where('ingredient_id', $ingredient->id) ->with('location') ->orderBy('best_before') ->get(['id', 'location_id', 'batch_number', 'best_before', 'received_quantity']); $results = $charges->map(function (StockEntry $charge) { $label = $charge->batch_number ? __('Charge').' '.$charge->batch_number : __('Charge #:id', ['id' => $charge->id]); if ($charge->location) { $label .= ' · '.$charge->location->name; } if ($charge->best_before) { $label .= ' · MHD '.$charge->best_before->format('d.m.Y'); } return [ 'id' => $charge->id, 'location_id' => $charge->location_id, 'text' => $label, ]; })->values()->all(); return response()->json([ 'charges' => $results, 'remaining_by_location' => $remainingByLocation, ]); } /** * Aktive Ausschuss-Gründe aus den Einstellungen (Warenwirtschaft → Allgemein). * * @return array */ protected function reasons(): array { return DisposalReason::query() ->active() ->orderBy('pos') ->orderBy('label') ->pluck('label') ->all(); } }