|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, seit AP-28 **je Output-Produkt** * (Produktionsergebnis): Soll = Summe der production_outputs je Produkt; gebucht wird nur * die Differenz zur bereits gebuchten Menge (append-only). Die geplante Stückzahl der * Produktion bucht nichts mehr. Entfernte Output-Produkte werden per Gegenbuchung * auf null zurückgeführt. */ public function recordProductionStock(Production $production, ?int $userId = null): void { $production->load('outputs'); $target = $production->outputs ->groupBy('product_id') ->map(fn ($rows) => (int) $rows->sum('quantity')); $booked = ProductStockMovement::query() ->where('source', 'production') ->where('reference_type', $production->getMorphClass()) ->where('reference_id', $production->getKey()) ->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") ->groupBy('product_id') ->pluck('net', 'product_id'); $productIds = $target->keys() ->merge($booked->keys()) ->map(fn ($id) => (int) $id) ->unique() ->values(); if ($productIds->isEmpty()) { return; } // Audit N-9: withTrashed — Gegenbuchungen entfernter Output-Produkte // dürfen nicht still übersprungen werden, wenn das Produkt inzwischen // soft-gelöscht wurde. $products = Product::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id'); foreach ($productIds as $productId) { $alreadyBooked = (int) ($booked[$productId] ?? 0); $delta = (int) ($target[$productId] ?? 0) - $alreadyBooked; if ($delta === 0 || ! isset($products[$productId])) { continue; } $this->recordMovement( $products[$productId], $delta > 0 ? 'in' : 'out', abs($delta), $alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'), 'production', null, $userId ?? $production->produced_by, $production, ); } } /** * Bucht den kompletten Produktbestand einer Produktion zurück (Löschen, * Anforderung 15.07.2026): je Output-Produkt wird der netto gebuchte Stand * per Gegenbuchung auf null geführt. Append-only wie alle Buchungen. */ public function reverseProductionStock(Production $production, ?int $userId = null): void { $booked = ProductStockMovement::query() ->where('source', 'production') ->where('reference_type', $production->getMorphClass()) ->where('reference_id', $production->getKey()) ->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") ->groupBy('product_id') ->pluck('net', 'product_id'); foreach ($booked as $productId => $net) { $net = (int) $net; if ($net === 0) { continue; } // withTrashed: auch Bestände soft-gelöschter Produkte sauber zurückführen. $product = Product::withTrashed()->find((int) $productId); if (! $product) { continue; } $this->recordMovement( $product, $net > 0 ? 'out' : 'in', abs($net), __('Produktion gelöscht'), 'production', null, $userId ?? $production->produced_by, $production, ); } } /** * AP-13: Verkaufs-Abgänge einer Bestellung idempotent mit dem Versandstatus abgleichen. * * Soll = Positionsmengen bei versendeter/abgeschlossener Bestellung (Sets rekursiv in * Einzelprodukte aufgelöst), sonst 0 — Zurücksetzen des Versandstatus oder Storno führt * bereits gebuchte Abgänge per Gegenbuchung zurück. Gebucht wird nur die Differenz zum * bereits gebuchten Stand (append-only, wie recordProductionStock) — mehrfache Aufrufe * und Versand→Zurücksetzen→Versand buchen dadurch nie doppelt. */ public function syncSaleStock(ShoppingOrder $order, ?int $userId = null): void { // Historische Bestellungen (vor dem AP-13-Stichtag versendet) nicht anfassen: // vor dem Stichtag wurde nichts gebucht, also gibt es auch nichts abzugleichen. // Das Flag wird per Migration einmalig gesetzt und ist bewusst unveränderlich — // es überlebt damit auch Status-Toggles, die shipped_at nullen und neu stempeln. if ($order->sale_stock_exempt) { return; } $target = []; if (in_array($order->getAPIShippedType(), ['sent', 'close'], true)) { $order->loadMissing('shopping_order_items'); $itemProducts = Product::withTrashed() ->whereIn('id', $order->shopping_order_items->pluck('product_id')->unique()) ->get() ->keyBy('id'); foreach ($order->shopping_order_items as $item) { $product = $itemProducts->get((int) $item->product_id); if (! $product) { continue; } foreach ($this->resolveSaleComponents($product, (int) $item->qty) as $productId => $qty) { $target[$productId] = ($target[$productId] ?? 0) + $qty; } } } $booked = ProductStockMovement::query() ->where('source', 'sale') ->where('reference_type', $order->getMorphClass()) ->where('reference_id', $order->getKey()) ->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END), 0) as net") ->groupBy('product_id') ->pluck('net', 'product_id'); $productIds = collect(array_keys($target)) ->merge($booked->keys()) ->map(fn ($id) => (int) $id) ->unique() ->values(); if ($productIds->isEmpty()) { return; } // withTrashed: Gegenbuchungen auch für inzwischen soft-gelöschte Produkte (wie N-9). $products = Product::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id'); foreach ($productIds as $productId) { $alreadyBooked = (int) ($booked[$productId] ?? 0); $targetQty = (int) ($target[$productId] ?? 0); $delta = $targetQty - $alreadyBooked; if ($delta === 0 || ! isset($products[$productId])) { continue; } $reason = $alreadyBooked === 0 ? __('Verkauf') : ($targetQty === 0 ? __('Verkauf storniert') : __('Verkaufskorrektur')); $this->recordMovement( $products[$productId], $delta > 0 ? 'out' : 'in', abs($delta), $reason, 'sale', __('Bestellung').' #'.$order->id, $userId, $order, ); } } /** * Löst ein Produkt in bestandsführende Einzelprodukte auf: Sets rekursiv über ihre * Komponenten (product_set_items, Pivot-Menge je Set-Stück), Einzelprodukte 1:1. * * @param array $visited Zyklus-Schutz über bereits besuchte Set-IDs * @return array [product_id => Stückzahl] */ private function resolveSaleComponents(Product $product, int $qty, array $visited = []): array { if ($qty <= 0) { return []; } if (! $product->is_set) { return [$product->id => $qty]; } if (isset($visited[$product->id])) { return []; } $visited[$product->id] = true; $result = []; foreach ($product->setItems()->withTrashed()->get() as $component) { $componentQty = $qty * max(1, (int) $component->pivot->quantity); foreach ($this->resolveSaleComponents($component, $componentQty, $visited) as $productId => $componentTotal) { $result[$productId] = ($result[$productId] ?? 0) + $componentTotal; } } return $result; } /** * 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(); } }