gruene-seele/app/Services/ProductStockService.php
2026-09-08 11:04:07 +00:00

364 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Production;
use App\Models\ProductStockMovement;
use App\Models\ShoppingOrder;
use Illuminate\Database\Eloquent\Model;
class ProductStockService
{
/**
* Aktueller Bestand je Produkt (Summe Eingänge − Summe Ausgänge).
*
* @param array<int, int>|null $productIds
* @return array<int, int> [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<int, bool> $visited Zyklus-Schutz über bereits besuchte Set-IDs
* @return array<int, int> [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.
* Prüfung 08.09.2026 F-12: Technische Verkaufs-Gegenbuchungen (source=sale,
* direction=in — Rücksetzung/Storno des idempotenten Abgleichs) werden netto
* verrechnet, sonst zählt Versand→Rücksetzung→Wiederversand doppelt. Manuelle
* Eingänge (Initialbestand, Korrekturen) bleiben bewusst außen vor.
*
* @param array<int, int>|null $productIds
* @return array<int, float> [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('source', '!=', 'production')
->where(fn ($q) => $q
->where('direction', 'out')
->orWhere(fn ($q2) => $q2->where('direction', 'in')->where('source', 'sale')))
->where('created_at', '>=', now()->subMonths($months))
->selectRaw("product_id, SUM(CASE WHEN direction = 'out' THEN quantity ELSE -quantity END) as consumed")
->groupBy('product_id')
->pluck('consumed', 'product_id');
$result = [];
foreach ($rows as $id => $consumed) {
$result[(int) $id] = round(max(0, (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();
}
}