gruene-seele/app/Services/ProductStockService.php
Kevin Adametz 211aeed384 Warenwirtschaft: AP-13 Shop-Bestandsabzug bei Versand (inkl. Sets)
ProductStockService::syncSaleStock(): idempotenter Differenz-Abgleich der
Verkaufs-Abgänge (source='sale', Referenz = Bestellung) mit dem Versandstatus —
Abzug je Bestellposition bei versendet/abgeschlossen, Sets rekursiv über
product_set_items in Einzelprodukte aufgelöst; Zurücksetzen/Storno führt per
Gegenbuchung zurück (behebt auch das Doppelabzug-Risiko Versand→Zurücksetzen→
Versand). Andockpunkte: SalesController::store (in der Transaktion) und
PaymentReminderService (Auto-Storno per Cron). Verbrauch/Monat zählt
Verkaufs-Abgänge automatisch mit (source != production).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 07:53:52 +00:00

350 lines
13 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
{
$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.
*
* @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('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();
}
}