gruene-seele/app/Services/ProductStockService.php
Kevin Adametz 35b9076cbc Warenwirtschaft: AP-31 Löschen mit Soft-Delete + Abhängigkeitsprüfung
Anforderung 15.07.2026: Nahezu alle WaWi-Einträge sind jetzt löschbar
(Soft-Delete, deleted_at), mit Abhängigkeitsprüfung und Bestätigungs-
warnung vor jeder Löschung.

- Migration: deleted_at auf 14 WaWi-Tabellen (Stammdaten, ingredients,
  stock_entries, stock_disposals, productions); SoftDeletes-Trait in
  den Models (suppliers/packaging_items hatten es bereits).
- Neuer App\Services\DeletionGuard: zentrale Abhängigkeitsprüfung je
  Entität — referenzierte Einträge sind nicht löschbar, Fehlermeldung
  nennt die Verwendungen (behebt auch Audit M-4: kein 500er/stiller
  Cascade-Verlust mehr beim Löschen verwendeter Stammdaten).
- SettingCrudController::destroy() prüft Blocker; Supplier-,
  PackagingItem-, Ingredient-destroy ebenso (Ingredient: vollständige
  Prüfung inkl. Produktion/Ausschuss, Route GET→DELETE, Audit M-6).
- Wareneingänge: auch gebuchte Eingänge löschbar, sofern kein
  Produktions-Verbrauch/Ausschuss darauf gebucht ist.
- Ausschuss: löschbar (Menge steht dem Bestand wieder zur Verfügung).
- Produktionen: löschbar (admin >= 7); Produktbestand wird per
  Gegenbuchung zurückgeführt (reverseProductionStock, append-only,
  withTrashed), Rohstoffverbrauch zählt nicht mehr (Bestandsqueries
  schließen gelöschte Produktionen aus).
- lastNetPricePerSupplier: DB::query statt Model-Query (Global Scope
  auf Sub-Select-Alias).
- Debug-Log-Reste im PackagingItemController entfernt (Audit N-16).
- Views: Lösch-Buttons mit deutschen Bestätigungswarnungen (Ausschuss,
  Produktionen, gebuchte Wareneingänge, INCI als DELETE-Formular).

Tests: neuer InventoryDeletionTest (10 Tests), StorageSettingsTest an
neue Löschregel angepasst. Volle Suite: 214 grün, 783 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:31:11 +00:00

239 lines
8.3 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,
);
}
}
/**
* Bucht den kompletten Produktbestand einer Produktion zurück (Löschen,
* Anforderung 15.07.2026): je Output-Produkt wird der netto gebuchte Stand
* per Gegenbuchung auf null geführt. Append-only wie alle Buchungen.
*/
public function reverseProductionStock(Production $production, ?int $userId = null): void
{
$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');
foreach ($booked as $productId => $net) {
$net = (int) $net;
if ($net === 0) {
continue;
}
// withTrashed: auch Bestände soft-gelöschter Produkte sauber zurückführen.
$product = Product::withTrashed()->find((int) $productId);
if (! $product) {
continue;
}
$this->recordMovement(
$product,
$net > 0 ? 'out' : 'in',
abs($net),
__('Produktion gelöscht'),
'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();
}
}