boolean('all') ? null : 500; return view('admin.inventory.stock-entries.index', array_merge($this->formSharedData(), [ 'values' => $this->stockEntryRepository->listForIndex($limit), 'listLimit' => $limit, ])); } public function create(Request $request): View|RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $model = new StockEntry([ 'ordered_at' => now()->toDateString(), 'entry_type' => 'ingredient', // AP-50/F-17: Köln (aktiv) als Standard-Lagerort — zentral in Location::defaultId(). 'location_id' => Location::defaultId(), ]); $ingredientId = (int) $request->query('ingredient_id'); if ($ingredientId > 0) { $ingredient = Ingredient::query()->find($ingredientId); if ($ingredient) { $model->entry_type = 'ingredient'; $model->ingredient_id = $ingredient->id; $model->setRelation('ingredient', $ingredient); } } return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [ 'model' => $model, ])); } public function store(StoreStockEntryRequest $request): RedirectResponse { if (! $request->user()->isAdmin()) { return redirect()->route('home'); } $data = $request->validatedPayload(); $data['ordered_by'] = (int) auth()->id(); $this->stockEntryRepository->create($data); \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-entries.index'); } public function show(StockEntry $stockEntry): View { $stockEntry->load([ 'ingredient', 'packagingItem.packagingMaterial', 'supplier', 'location', 'quality', 'orderedByUser.account', 'receivedByUser.account', ]); return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [ 'model' => $stockEntry, 'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(), ])); } public function edit(StockEntry $stockEntry): View|RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } if (! $stockEntry->isPending()) { \Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.')); return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); } $stockEntry->load(['ingredient', 'packagingItem']); return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [ 'model' => $stockEntry, ])); } public function update(UpdateStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse { if (! $request->user()->isAdmin()) { return redirect()->route('home'); } if (! $stockEntry->isPending()) { \Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.')); return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); } $this->stockEntryRepository->update($stockEntry, $request->validatedPayload()); \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-entries.index'); } public function copy(StockEntry $stockEntry): RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } $data = $stockEntry->only([ 'entry_type', 'ingredient_id', 'packaging_item_id', 'supplier_id', 'location_id', 'ordered_quantity', 'price_per_kg', 'price_per_kg_gross', 'price_total', 'tax_rate_id', 'tax_rate_percent', 'quality_id', ]); $data['ordered_at'] = now()->toDateString(); $data['ordered_by'] = (int) auth()->id(); $data['status'] = 'pending'; // Prüfung 08.09.2026 F-15: In ml erfasste Bestellungen als ml kopieren — Menge // zurück in ml (über den Dichte-Snapshot), Einheit und Dichte mitgeben. Sonst wird // die Kopie zur Gramm-Bestellung und ein Wareneingang „100“ bucht 100 g statt 79 g. if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && (float) $stockEntry->density_used > 0) { $data['ordered_unit'] = 'ml'; $data['ordered_quantity'] = roundWawiQuantity((float) $stockEntry->ordered_quantity / (float) $stockEntry->density_used); $data['density_used'] = (float) $stockEntry->density_used; } $copy = $this->stockEntryRepository->create($data); \Session::flash('alert-warning', __('Kopie angelegt – bitte Menge/Charge prüfen und speichern.')); return redirect()->route('admin.inventory.stock-entries.edit', $copy); } public function destroy(StockEntry $stockEntry): RedirectResponse { if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } // Anforderung 15.07.2026: auch gebuchte Eingänge löschbar (Soft-Delete), // sofern keine Abhängigkeiten (Produktions-Verbrauch, Ausschuss) bestehen. $guard = app(DeletionGuard::class); $blockers = $guard->blockersFor($stockEntry); if ($blockers !== []) { \Session::flash('alert-error', $guard->message($blockers)); return redirect()->route('admin.inventory.stock-entries.index'); } $stockEntry->delete(); \Session::flash('alert-success', __('Eintrag gelöscht')); return redirect()->route('admin.inventory.stock-entries.index'); } public function receive(ReceiveStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse { if (! $stockEntry->isPending()) { \Session::flash('alert-error', __('Eintrag nicht mehr offen.')); return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); } $data = $request->validated(); if ($stockEntry->entry_type !== 'ingredient') { $data['quality_id'] = null; $data['best_before'] = null; } try { $this->stockEntryRepository->receive($stockEntry, $data); } catch (ValidationException $e) { \Session::flash('alert-error', $e->errors()['status'][0] ?? __('Eintrag nicht mehr offen.')); return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); } \Session::flash('alert-save', '1'); return redirect()->route('admin.inventory.stock-entries.show', $stockEntry->fresh()); } /** * AP-27: Existenz-Check beim Buchen des Wareneingangs — ist die Charge * (batch_number + ingredient_id) schon im Haus? Liefert Hinweis-Daten * und Vorbelegungswerte (MHD/Qualität) aus dem letzten Eingang der Charge. */ public function batchCheck(Request $request, InventoryService $inventoryService): JsonResponse { $ingredientId = (int) $request->query('ingredient_id'); $batchNumber = trim((string) $request->query('batch_number', '')); $excludeId = (int) $request->query('exclude', 0); // Audit N-1: Chargen sind je Lieferant getrennt — gleiche Lot-Nummer // eines anderen Lieferanten ist keine vorhandene Charge. $supplierId = (int) $request->query('supplier_id', 0); if ($ingredientId < 1 || $batchNumber === '') { return response()->json(['exists' => false]); } $entries = StockEntry::query() ->with('location') ->where('entry_type', 'ingredient') ->where('status', 'received') ->where('ingredient_id', $ingredientId) ->where('batch_number', $batchNumber) ->when($supplierId > 0, fn ($q) => $q->where('supplier_id', $supplierId)) ->when($excludeId > 0, fn ($q) => $q->where('id', '!=', $excludeId)) ->orderByDesc('received_at') ->orderByDesc('id') ->get(); if ($entries->isEmpty()) { return response()->json(['exists' => false]); } $latest = $entries->first(); $remaining = $inventoryService->chargesWithRemainingForIngredient($ingredientId) ->filter(fn (StockEntry $e) => trim((string) $e->batch_number) === $batchNumber && ($supplierId === 0 || (int) $e->supplier_id === $supplierId) && $e->id !== $excludeId) ->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')); return response()->json([ 'exists' => true, 'purchases' => $entries->count(), 'remaining' => roundWawiQuantity((float) $remaining), 'best_before' => $latest->best_before?->format('d.m.Y'), 'location' => $latest->location?->name, 'quality_id' => $latest->quality_id, ]); } public function searchIngredients(Request $request): JsonResponse { $term = trim((string) $request->query('q', '')); if (mb_strlen($term) < 1) { return response()->json(['results' => []]); } $rows = Ingredient::query() ->where('active', true) ->where(function ($query) use ($term) { $query->where('name', 'like', '%'.$term.'%') ->orWhere('inci', 'like', '%'.$term.'%'); }) ->orderBy('name') ->limit(30) ->get(['id', 'name', 'inci', 'density']); $results = $rows->map(function (Ingredient $i) { $text = $i->name; if ($i->inci) { $text .= ' ('.$i->inci.')'; } // AP-42: Dichte mitliefern, damit die Maske die ml-Erfassung anbieten kann. return ['id' => $i->id, 'text' => $text, 'density' => $i->density !== null ? (float) $i->density : null]; })->values()->all(); return response()->json(['results' => $results]); } public function searchPackagingItems(Request $request): JsonResponse { $term = trim((string) $request->query('q', '')); $entryType = $request->query('entry_type'); $categoryMap = [ 'packaging' => 'packaging', 'shipping' => 'shipping', ]; $query = PackagingItem::query() ->where('active', true); if ($entryType && isset($categoryMap[$entryType])) { $query->where('category', $categoryMap[$entryType]); } if (mb_strlen($term) >= 1) { $query->where('name', 'like', '%'.$term.'%'); } $rows = $query->orderBy('name')->limit(30)->get(['id', 'name']); $results = $rows->map(fn (PackagingItem $p) => [ 'id' => $p->id, 'text' => $p->name, ])->values()->all(); return response()->json(['results' => $results]); } /** * @return array */ protected function formSharedData(): array { return [ 'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(), 'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(), 'entryTypeLabels' => [ 'ingredient' => __('Rohstoff'), 'packaging' => __('Produktverpackung'), 'shipping' => __('Versandverpackung'), ], ]; } }