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