query('scope') === 'all' ? 'all' : 'recipe'; $ingredients = Ingredient::query() ->where('active', true) ->where('exclude_from_stock', false) ->when($scope === 'recipe', fn ($q) => $q->usedInActiveRecipes()) ->with('materialQuality') ->orderBy('pos') ->orderBy('name') ->get(); $ids = $ingredients->pluck('id')->all(); $remaining = $this->inventoryService->remainingByIngredient($ids); $daily = $this->inventoryService->dailyConsumptionByIngredient($ids); $openOrders = $this->inventoryService->openOrderQuantityByIngredient($ids); $rows = $ingredients->map(function (Ingredient $ingredient) use ($remaining, $daily, $openOrders) { $rem = $remaining[$ingredient->id] ?? 0.0; $perDay = $daily[$ingredient->id] ?? null; $open = $openOrders[$ingredient->id] ?? 0.0; $minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null; $leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null; return [ 'ingredient' => $ingredient, 'remaining' => $rem, 'daily' => $perDay, 'open_order' => $open, 'days_until_empty' => $this->inventoryService->daysUntilEmpty($rem, $perDay), 'expected_empty' => $this->inventoryService->expectedEmptyDate($rem, $perDay), 'status' => $this->inventoryService->stockStatus($minAlert, $rem, $perDay, $leadDays, $open > 0), ]; }); $criticalCount = $rows->whereIn('status', ['critical', 'critical_ordered'])->count(); // Review 20.07.2026: Das Sidebar-Badge zählt ALLE aktiven kritischen Rohstoffe, // die Standardansicht (Scope „recipe") blendet aber rezepturlose aus. Ohne // Hinweis widersprächen sich Badge (z. B. „1") und Seite („0 kritisch"). // Differenz nur im recipe-Scope ausweisen. $hiddenCriticalCount = $scope === 'recipe' ? max(0, $this->inventoryService->criticalIngredientCount() - $criticalCount) : 0; return view('admin.inventory.raw-material-stock.index', [ 'rows' => $rows, // Audit N-4: gleiche Zählweise wie die Kacheln (kritisch inkl. bereits bestellt). 'criticalCount' => $criticalCount, 'hiddenCriticalCount' => $hiddenCriticalCount, 'horizonOptions' => $this->horizonOptions(), 'defaultHorizon' => 90, 'scope' => $scope, ]); } public function show(Ingredient $ingredient): View { $ingredient->load([ 'materialQuality', 'taxRate', 'suppliers' => fn ($q) => $q->orderByPivot('preferred', 'desc')->orderBy('name'), // AP-47: nur produktionsrelevante Produkte in „Enthalten in" (blendet z. B. // Aloe-Handelsware und Zweitgrößen aus). 'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true), ]); // AP-37/Prüfung 08.09.2026 F-14: „Enthalten in" aus der Hersteller-Rezeptur — // alle Zeilen (Phasen) je Produkt summieren; Anteil (%) und Verbrauch je Stück // (Anteil × Füllgewicht-Skala × Faktor, wie in der Produktion) statt roher Pivot-Wert. $recipeUsage = $this->recipeUsageByProduct($ingredient); $ingredient->setRelation('products', $ingredient->products->unique('id') ->filter(fn (Product $p) => isset($recipeUsage[$p->id]))->values()); // AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry). $chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id); // Audit H-1: Kopfwert aus der Gesamt-Aggregation (zieht auch Ausschuss // ohne Chargenbezug ab) — identisch zur Index-Seite, nicht Chargen-Summe. $remaining = $this->inventoryService->remainingByIngredient([$ingredient->id])[$ingredient->id] ?? 0.0; $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); $openOrders = $this->inventoryService->openOrdersForIngredient($ingredient->id); $openTotal = roundWawiQuantity((float) $openOrders->sum('ordered_quantity')); $daily = ($this->inventoryService->dailyConsumptionByIngredient([$ingredient->id]))[$ingredient->id] ?? null; $minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null; $leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null; // Prüfung 08.09.2026 F-11: Einkaufspreise nur für Admins (wie Einkauf/Wareneingang), // Copyreader (Versand/Produktion) sehen die Rohstoffseite ohne Preise. $lastPriceBySupplier = auth()->user()->isAdmin() ? $this->stockEntryRepository->lastNetPricePerSupplier($ingredient->id) : []; $productStock = $this->productStockService->currentStockByProduct($ingredient->products->pluck('id')->all()); return view('admin.inventory.raw-material-stock.show', [ 'ingredient' => $ingredient, 'productStock' => $productStock, 'chargeGroups' => $chargeGroups, 'remaining' => roundWawiQuantity($remaining), 'remainingByLocation' => $remainingByLocation, 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'openOrders' => $openOrders, 'openTotal' => $openTotal, 'daily' => $daily, 'daysUntilEmpty' => $this->inventoryService->daysUntilEmpty($remaining, $daily), 'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily), 'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0), 'lastPriceBySupplier' => $lastPriceBySupplier, 'recipeUsage' => $recipeUsage, 'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(), ]); } /** * F-14: Anteil und Verbrauch je Stück aus der Hersteller-Rezeptur je Produkt * (nur aktive, produktionsrelevante Produkte — AP-47). * * @return array */ private function recipeUsageByProduct(Ingredient $ingredient): array { $rows = ProductIngredient::query() ->with('product') ->where('ingredient_id', $ingredient->id) ->where('recipe_type', 'manufacturer') ->get() ->filter(fn (ProductIngredient $row) => $row->product !== null && (bool) $row->product->active && (bool) $row->product->production_relevant); $usage = []; foreach ($rows->groupBy('product_id') as $productId => $productRows) { $product = $productRows->first()->product; $percent = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0)); $withFactor = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0) * (float) ($r->factor ?? 1.1)); $scale = $this->productionService->recipeGramScale($product); $hasMassBasis = $this->productionService->hasMassBasis($product); $usage[(int) $productId] = [ 'percent' => round($percent, 3), 'grams_per_piece' => $hasMassBasis ? roundWawiQuantity($percent * $scale) : null, 'consumption_per_piece' => $hasMassBasis ? roundWawiQuantity($withFactor * $scale) : null, ]; } return $usage; } /** * @return array [days => label] */ protected function horizonOptions(): array { return [ 30 => __('Verbrauch (nächster Monat)'), 90 => __('Verbrauch (nächste 3 Monate)'), 180 => __('Verbrauch (nächste 6 Monate)'), 365 => __('Verbrauch (nächste 12 Monate)'), ]; } }