- production_outputs (Migration inkl. idempotentem Backfill): eine Produktion kann mehrere Output-Produkte erzeugen (z. B. 40x50 ml + 50x5 ml) - ProductStockService bucht je Output-Produkt (idempotenter Diff, append-only); die geplante Stückzahl bucht nichts mehr, entfernte Outputs werden rückgebucht - ProductionService store/update mit outputs[] (leer = Fallback auf Planung inkl. suggestedBatchNumber — Backfill-Semantik, Alt-Aufrufer stabil) - Maske: INCI-Liste (Alternativname/Qualität/Anteil/Gramm/Regal), Chargen- Erfassung nach Rezeptur-Phasen mit Notiz + Status-Ampel, Produktionsergebnis- Repeater mit Chargen-Nr.-Vorbelegung (Präfix + ttmmjj), Planungs-Hinweis - Show: Produktionsergebnis-Karte; Index: Ergebnis-Spalte - Hinweise-Doku + Plan V6.0 §6 fortgeschrieben (nächster Schritt: AP-13) Tests: ProductionOutputTest (8 neu); Suite 201 grün, 731 Assertionen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
6.9 KiB
PHP
199 lines
6.9 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, 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;
|
||
}
|
||
|
||
$products = Product::query()->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,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|