Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0 (AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt: AP-26 Ausschuss-Gründe konfigurierbar: - Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein - StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste - Seeder übernimmt die bisherigen 6 Gründe idempotent AP-25 Lieferbestand — Datum statt Tage: - "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt; Resttage-Hinweis zählt täglich automatisch herunter - Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu den Mengen-Buttons (VP entscheidet selbst) AP-22 Produktbestand-Erweiterungen: - Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt - Alle vier Status-Kacheln als Filter klickbar - Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate) - Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock) Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8, ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben; nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
6 KiB
PHP
178 lines
6 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,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|