364 lines
14 KiB
PHP
364 lines
14 KiB
PHP
<?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();
|
||
}
|
||
}
|