gruene-seele/app/Services/ProductStockService.php
Kevin Adametz 99a6c92e6c Warenwirtschaft: AP-28 Neue Produktions-Maske + Produktionsergebnis
- 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>
2026-07-06 14:02:54 +00:00

199 lines
6.9 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, 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();
}
}