|null $ingredientIds Optionaler Filter; null = alle. * @return array [ingredient_id => grams] */ public function remainingByIngredient(?array $ingredientIds = null): array { $received = StockEntry::query() ->where('status', 'received') ->where('entry_type', 'ingredient') ->whereNotNull('ingredient_id') ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) ->selectRaw('ingredient_id, SUM(received_quantity) as qty') ->groupBy('ingredient_id') ->pluck('qty', 'ingredient_id'); $consumed = ProductionIngredient::query() ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) ->selectRaw('ingredient_id, SUM(quantity_used) as qty') ->groupBy('ingredient_id') ->pluck('qty', 'ingredient_id'); $disposed = $this->disposedByIngredient($ingredientIds); $result = []; foreach ($received as $id => $qty) { $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); } return $result; } /** * Per Ausgang/Ausschuss entnommene Menge je Rohstoff in Gramm. * * @param array|null $ingredientIds * @return array [ingredient_id => grams] */ public function disposedByIngredient(?array $ingredientIds = null): array { $rows = StockDisposal::query() ->where('disposal_type', 'ingredient') ->whereNotNull('ingredient_id') ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) ->selectRaw('ingredient_id, SUM(quantity) as qty') ->groupBy('ingredient_id') ->pluck('qty', 'ingredient_id'); $result = []; foreach ($rows as $id => $qty) { $result[(int) $id] = round((float) $qty, 2); } return $result; } /** * Restbestand eines Rohstoffs je Lagerort. * * @return array [location_id => grams] */ public function remainingByLocationForIngredient(int $ingredientId): array { $received = StockEntry::query() ->where('status', 'received') ->where('entry_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->selectRaw('location_id, SUM(received_quantity) as qty') ->groupBy('location_id') ->pluck('qty', 'location_id'); $consumed = ProductionIngredient::query() ->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id') ->where('production_ingredients.ingredient_id', $ingredientId) ->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty') ->groupBy('stock_entries.location_id') ->pluck('qty', 'location_id'); $disposed = StockDisposal::query() ->where('disposal_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->selectRaw('location_id, SUM(quantity) as qty') ->groupBy('location_id') ->pluck('qty', 'location_id'); $result = []; foreach ($received as $locationId => $qty) { $result[(int) $locationId] = round((float) $qty - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2); } return $result; } /** * Restbestand je Verpackungsartikel in Stück (Wareneingang − Produktionsverbrauch − Ausschuss). * * @param array|null $packagingItemIds * @return array [packaging_item_id => pieces] */ public function remainingByPackagingItem(?array $packagingItemIds = null): array { $received = StockEntry::query() ->where('status', 'received') ->whereIn('entry_type', ['packaging', 'shipping']) ->whereNotNull('packaging_item_id') ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) ->selectRaw('packaging_item_id, SUM(received_quantity) as qty') ->groupBy('packaging_item_id') ->pluck('qty', 'packaging_item_id'); $consumed = ProductionPackaging::query() ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) ->selectRaw('packaging_item_id, SUM(quantity_used) as qty') ->groupBy('packaging_item_id') ->pluck('qty', 'packaging_item_id'); $disposed = $this->disposedByPackagingItem($packagingItemIds); $result = []; foreach ($received as $id => $qty) { $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); } return $result; } /** * Per Ausgang/Ausschuss entnommene Menge je Verpackungsartikel in Stück. * * @param array|null $packagingItemIds * @return array [packaging_item_id => pieces] */ public function disposedByPackagingItem(?array $packagingItemIds = null): array { $rows = StockDisposal::query() ->where('disposal_type', 'packaging') ->whereNotNull('packaging_item_id') ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) ->selectRaw('packaging_item_id, SUM(quantity) as qty') ->groupBy('packaging_item_id') ->pluck('qty', 'packaging_item_id'); $result = []; foreach ($rows as $id => $qty) { $result[(int) $id] = round((float) $qty, 2); } return $result; } /** * Offene (bestellte, noch nicht eingegangene) Menge je Rohstoff in Gramm. * * @param array|null $ingredientIds * @return array [ingredient_id => grams] */ public function openOrderQuantityByIngredient(?array $ingredientIds = null): array { $rows = StockEntry::query() ->where('status', 'pending') ->where('entry_type', 'ingredient') ->whereNotNull('ingredient_id') ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) ->selectRaw('ingredient_id, SUM(ordered_quantity) as qty') ->groupBy('ingredient_id') ->pluck('qty', 'ingredient_id'); $result = []; foreach ($rows as $id => $qty) { $result[(int) $id] = round((float) $qty, 2); } return $result; } /** * Durchschnittlicher Tagesverbrauch je Rohstoff (Gramm/Tag) aus der Produktionshistorie. * * @param array|null $ingredientIds * @return array [ingredient_id => grams_per_day] */ public function dailyConsumptionByIngredient(?array $ingredientIds = null, int $windowDays = self::CONSUMPTION_WINDOW_DAYS): array { $windowDays = max(1, $windowDays); $since = Carbon::now()->subDays($windowDays)->startOfDay(); $rows = ProductionIngredient::query() ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') ->where('productions.produced_at', '>=', $since) ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') ->groupBy('production_ingredients.ingredient_id') ->pluck('qty', 'ingredient_id'); $result = []; foreach ($rows as $id => $qty) { $result[(int) $id] = round((float) $qty / $windowDays, 4); } return $result; } /** * Tage bis der Bestand bei gleichbleibendem Verbrauch aufgebraucht ist (null = kein Verbrauch). */ public function daysUntilEmpty(float $remaining, ?float $dailyConsumption): ?int { if ($dailyConsumption === null || $dailyConsumption <= 0) { return null; } if ($remaining <= 0) { return 0; } return (int) floor($remaining / $dailyConsumption); } /** * Voraussichtliches Datum, an dem der Bestand auf null fällt (null = kein Verbrauch). */ public function expectedEmptyDate(float $remaining, ?float $dailyConsumption): ?Carbon { $days = $this->daysUntilEmpty($remaining, $dailyConsumption); if ($days === null) { return null; } return Carbon::now()->startOfDay()->addDays($days); } /** * Bestands-Status eines Rohstoffs: * - "critical": Meldebestand gepflegt und unterschritten (rot, Badge-relevant). * - "critical_ordered": kritisch, aber es existiert bereits eine offene Bestellung (entschärft). * - "warning": Bestand reicht voraussichtlich nicht mehr bis zur nächsten Lieferung. * - "ok": ausreichend. */ public function stockStatus(?float $minStockAlert, float $remaining, ?float $dailyConsumption, ?int $leadDays, bool $hasOpenOrder = false): string { if ($minStockAlert !== null && $minStockAlert > 0 && $remaining <= $minStockAlert) { return $hasOpenOrder ? 'critical_ordered' : 'critical'; } $days = $this->daysUntilEmpty($remaining, $dailyConsumption); if ($days !== null) { $lead = $leadDays !== null && $leadDays > 0 ? $leadDays : self::DEFAULT_LEAD_DAYS; if ($days <= $lead) { return 'warning'; } } return 'ok'; } /** * Anzahl der kritischen (Meldebestand unterschritten) aktiven Rohstoffe – für das Sidenav-Badge. */ public function criticalIngredientCount(): int { $ingredients = Ingredient::query() ->where('active', true) ->whereNotNull('min_stock_alert') ->where('min_stock_alert', '>', 0) ->get(['id', 'min_stock_alert']); if ($ingredients->isEmpty()) { return 0; } $ids = $ingredients->pluck('id')->all(); $remaining = $this->remainingByIngredient($ids); $openOrders = $this->openOrderQuantityByIngredient($ids); return $ingredients->filter(function (Ingredient $ingredient) use ($remaining, $openOrders) { $rem = $remaining[$ingredient->id] ?? 0.0; if ($rem > (float) $ingredient->min_stock_alert) { return false; } // Kritisch, aber bereits nachbestellt => entschärft, nicht im Badge zählen. return ($openOrders[$ingredient->id] ?? 0.0) <= 0; })->count(); } }