gruene-seele/app/Services/ProductStockService.php
Kevin Adametz 3ee2d756e9 Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)
- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:04:22 +00:00

146 lines
4.7 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 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: 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,
);
}
/**
* 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)
->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();
}
}