|null $productIds * @return array [product_id => stock] */ public function currentStockByProduct(?array $productIds = null): array { $rows = ProductStockMovement::query() ->when($productIds !== null, fn ($q) => $q->whereIn('product_id', $productIds)) ->selectRaw("product_id, SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) as stock") ->groupBy('product_id') ->pluck('stock', 'product_id'); $result = []; foreach ($rows as $id => $stock) { $result[(int) $id] = (int) $stock; } return $result; } public function currentStock(int $productId): int { return $this->currentStockByProduct([$productId])[$productId] ?? 0; } /** * Bucht eine Bestandsbewegung. Menge wird immer positiv gespeichert, die Richtung steuert das Vorzeichen. * * @param array{type: string, id: int}|Model|null $reference */ public function recordMovement( Product $product, string $direction, int $quantity, string $reason, string $source = 'manual', ?string $note = null, ?int $userId = null, Model|array|null $reference = null, ): ProductStockMovement { $attributes = [ 'product_id' => $product->id, 'direction' => $direction === 'out' ? 'out' : 'in', 'quantity' => max(0, $quantity), 'reason' => $reason, 'source' => $source, 'note' => $note, 'user_id' => $userId, ]; if ($reference instanceof Model) { $attributes['reference_type'] = $reference->getMorphClass(); $attributes['reference_id'] = $reference->getKey(); } elseif (is_array($reference)) { $attributes['reference_type'] = $reference['type']; $attributes['reference_id'] = $reference['id']; } return ProductStockMovement::query()->create($attributes); } /** * Verbucht den Produktbestand einer Produktion idempotent: gleicht die bereits gebuchte * Menge dieser Produktion mit der Soll-Stückzahl ab und bucht nur die Differenz. */ public function recordProductionStock(Production $production, ?int $userId = null): void { $production->loadMissing('product'); $alreadyBooked = (int) ProductStockMovement::query() ->where('source', 'production') ->where('reference_type', $production->getMorphClass()) ->where('reference_id', $production->getKey()) ->selectRaw("COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") ->value('net'); $target = (int) $production->quantity; $delta = $target - $alreadyBooked; if ($delta === 0) { return; } $this->recordMovement( $production->product, $delta > 0 ? 'in' : 'out', abs($delta), $alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'), 'production', null, $userId ?? $production->produced_by, $production, ); } /** * Durchschnittlicher Verbrauch pro Monat je Produkt (Ø der letzten X Monate, Default 6). * * „Verbrauch" = alle Abgänge (direction=out) außer Produktions-Gegenbuchungen * (source=production sind Korrekturen der Eingangs-Buchung, kein echter Abgang). * Mit AP-13 fließen Verkaufs-Abgänge (source=sale) automatisch mit ein. * * @param array|null $productIds * @return array [product_id => Stück/Monat] */ public function monthlyConsumptionByProduct(?array $productIds = null, int $months = 6): array { $months = max(1, $months); $rows = ProductStockMovement::query() ->when($productIds !== null, fn ($q) => $q->whereIn('product_id', $productIds)) ->where('direction', 'out') ->where('source', '!=', 'production') ->where('created_at', '>=', now()->subMonths($months)) ->selectRaw('product_id, SUM(quantity) as consumed') ->groupBy('product_id') ->pluck('consumed', 'product_id'); $result = []; foreach ($rows as $id => $consumed) { $result[(int) $id] = round(((int) $consumed) / $months, 1); } return $result; } /** * Bestands-Status: "critical" (rot) ≤ kritischer Schwellwert, "warning" (gelb) ≤ Meldebestand, sonst "ok". */ public function productStatus(int $stock, ?int $minStock, ?int $criticalStock): string { if ($criticalStock !== null && $stock <= $criticalStock) { return 'critical'; } if ($minStock !== null && $stock <= $minStock) { return 'warning'; } return 'ok'; } /** * Anzahl kritischer Hauptprodukte (Bestand ≤ kritischer Schwellwert) – für das Sidenav-Badge. */ public function criticalProductCount(): int { $products = Product::query() ->where('active', true) ->where('is_set', false) ->where('show_in_product_stock', true) ->whereNull('main_product_id') ->whereNotNull('critical_product_stock') ->get(['id', 'critical_product_stock']); if ($products->isEmpty()) { return 0; } $stock = $this->currentStockByProduct($products->pluck('id')->all()); return $products->filter(function (Product $product) use ($stock) { return ($stock[$product->id] ?? 0) <= (int) $product->critical_product_stock; })->count(); } }