gruene-seele/app/Services/DeletionGuard.php
Kevin Adametz 30d4e28696 Warenwirtschaft: AP-51 — Lagerplatz-Felder für Produkte & Verpackung
Lagerplatz-Zuordnung (Raum/Regal/Fach) gab es bisher nur bei Rohstoffen. Jetzt
dieselbe Storage-Hierarchie (StorageRoom/Shelf/Slot) auch für Produkte und
Verpackungsartikel, inkl. Anzeige an den passenden Stellen.

- Migration: storage_room_id/shelf/slot an products + packaging_items
  (nullable, FK constrained, nullOnDelete).
- Models Product + PackagingItem: fillable + storageRoom/Shelf/Slot-Relationen +
  storageLabel() („Raum 1 | Regal 3 | A").
- Produktformular (Inventar-Abschnitt) + Verpackungsformular: Auswahl-Zeile
  Raum/Regal/Fach; Optionen kommen aktiv/sortiert aus den zentral pflegbaren
  Bausteinen (Einstellungen → Allgemein).
- Speichern: ProductRepository normalisiert leere Auswahl -> null (FK);
  PackagingItem-Requests + Repository um die drei Felder erweitert.
- Anzeige: Produktbestand (Sub-Zeile mit Marker), Produktverpackung-Übersicht
  (neue Spalte „Lagerplatz").
- DeletionGuard: Lagerbaustein ist nicht löschbar, solange Produkte/Verpackung
  darauf verweisen (analog Rohstoffe).

Tests: 4 neue (Produkt speichern + leer->null, Verpackung speichern + Anzeige,
Löschsperre, freier Baustein löschbar). Migration auf Dev-/Live-DB gelaufen.
Suite 331 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:46:40 +00:00

236 lines
8.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\DeliveryTime;
use App\Models\DisposalReason;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
use App\Models\ProductionIngredient;
use App\Models\ProductionPackaging;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\StockMovementReason;
use App\Models\StorageSegment;
use App\Models\Supplier;
use App\Models\SupplierCategory;
use App\Models\TaxRate;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Zentrale Abhängigkeitsprüfung vor dem Löschen von WaWi-Einträgen
* (Anforderung 15.07.2026): Referenzierte Einträge dürfen nicht gelöscht
* werden. Liefert je Blocker eine deutschsprachige Begründung; leeres
* Array = Löschen erlaubt. Soft-gelöschte Referenzen zählen nicht
* (SoftDeletes-Global-Scope bzw. explizite whereNull-Joins).
*/
class DeletionGuard
{
/**
* @return array<int, string>
*/
public function blockersFor(Model $model): array
{
return match (true) {
$model instanceof TaxRate => $this->taxRateBlockers($model),
$model instanceof MaterialQuality => $this->materialQualityBlockers($model),
$model instanceof Location => $this->locationBlockers($model),
$model instanceof SupplierCategory => $this->supplierCategoryBlockers($model),
$model instanceof PackagingMaterial => $this->packagingMaterialBlockers($model),
$model instanceof StorageSegment => $this->storageSegmentBlockers($model),
$model instanceof Ingredient => $this->ingredientBlockers($model),
$model instanceof StockEntry => $this->stockEntryBlockers($model),
$model instanceof Supplier => $this->supplierBlockers($model),
$model instanceof PackagingItem => $this->packagingItemBlockers($model),
// Bewusst immer löschbar (keine eingehenden Referenzen):
$model instanceof DeliveryTime,
$model instanceof DisposalReason,
$model instanceof StockMovementReason,
$model instanceof StockDisposal,
$model instanceof Production => [],
default => [],
};
}
/**
* @return array<int, string>
*/
protected function taxRateBlockers(TaxRate $taxRate): array
{
$blockers = [];
$this->push($blockers, Ingredient::query()->where('tax_rate_id', $taxRate->id)->count(), ':n Rohstoff(e)');
$this->push($blockers, StockEntry::query()->where('tax_rate_id', $taxRate->id)->count(), ':n Wareneingänge/Bestellungen');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function materialQualityBlockers(MaterialQuality $quality): array
{
$blockers = [];
$this->push($blockers, Ingredient::query()->where('material_quality_id', $quality->id)->count(), ':n Rohstoff(e)');
$this->push($blockers, StockEntry::query()->where('quality_id', $quality->id)->count(), ':n Wareneingänge/Bestellungen');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function locationBlockers(Location $location): array
{
$blockers = [];
$this->push($blockers, StockEntry::query()->where('location_id', $location->id)->count(), ':n Wareneingänge/Bestellungen');
$this->push($blockers, Production::query()->where('location_id', $location->id)->count(), ':n Produktion(en)');
$this->push($blockers, StockDisposal::query()->where('location_id', $location->id)->count(), ':n Ausschuss-Buchung(en)');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function supplierCategoryBlockers(SupplierCategory $category): array
{
$count = DB::table('supplier_supplier_category')
->join('suppliers', 'suppliers.id', '=', 'supplier_supplier_category.supplier_id')
->whereNull('suppliers.deleted_at')
->where('supplier_supplier_category.supplier_category_id', $category->id)
->count();
$blockers = [];
$this->push($blockers, $count, ':n Lieferant(en)');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function packagingMaterialBlockers(PackagingMaterial $material): array
{
$blockers = [];
$this->push($blockers, PackagingItem::query()->where('packaging_material_id', $material->id)->count(), ':n Verpackungsartikel');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function storageSegmentBlockers(StorageSegment $segment): array
{
$column = match ($segment->getTable()) {
'storage_rooms' => 'storage_room_id',
'storage_shelves' => 'storage_shelf_id',
'storage_slots' => 'storage_slot_id',
};
$blockers = [];
$this->push($blockers, Ingredient::query()->where($column, $segment->id)->count(), ':n Rohstoff(e)');
// AP-51: Lagerplatz jetzt auch an Produkten und Verpackungsartikeln.
$this->push($blockers, Product::query()->where($column, $segment->id)->count(), ':n Produkt(e)');
$this->push($blockers, PackagingItem::query()->where($column, $segment->id)->count(), ':n Verpackungsartikel');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function ingredientBlockers(Ingredient $ingredient): array
{
$blockers = [];
$this->push($blockers, ProductIngredient::query()->where('ingredient_id', $ingredient->id)->count(), ':n Rezeptur-Zeile(n)');
$this->push($blockers, StockEntry::query()->where('ingredient_id', $ingredient->id)->count(), ':n Wareneingänge/Bestellungen');
$this->push($blockers, StockDisposal::query()->where('ingredient_id', $ingredient->id)->count(), ':n Ausschuss-Buchung(en)');
$this->push(
$blockers,
ProductionIngredient::query()->where('ingredient_id', $ingredient->id)->whereHas('production')->count(),
':n Produktions-Verbräuche'
);
return $blockers;
}
/**
* @return array<int, string>
*/
protected function stockEntryBlockers(StockEntry $stockEntry): array
{
$blockers = [];
$this->push(
$blockers,
ProductionIngredient::query()->where('stock_entry_id', $stockEntry->id)->whereHas('production')->count(),
':n Produktions-Verbräuche'
);
$this->push($blockers, StockDisposal::query()->where('stock_entry_id', $stockEntry->id)->count(), ':n Ausschuss-Buchung(en)');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function supplierBlockers(Supplier $supplier): array
{
$blockers = [];
$this->push($blockers, StockEntry::query()->where('supplier_id', $supplier->id)->count(), ':n Wareneingänge/Bestellungen');
return $blockers;
}
/**
* @return array<int, string>
*/
protected function packagingItemBlockers(PackagingItem $item): array
{
$blockers = [];
$this->push($blockers, StockEntry::query()->where('packaging_item_id', $item->id)->count(), ':n Wareneingänge/Bestellungen');
$this->push($blockers, StockDisposal::query()->where('packaging_item_id', $item->id)->count(), ':n Ausschuss-Buchung(en)');
$this->push(
$blockers,
ProductionPackaging::query()->where('packaging_item_id', $item->id)->whereHas('production')->count(),
':n Produktions-Verbräuche'
);
$bomCount = DB::table('product_packagings')
->join('products', 'products.id', '=', 'product_packagings.product_id')
->whereNull('products.deleted_at')
->where('product_packagings.packaging_item_id', $item->id)
->count();
$this->push($blockers, $bomCount, ':n Produkt-Stückliste(n)');
return $blockers;
}
/**
* Formatiert die Lösch-Blocker als Fehlermeldung fürs Flash-Alert.
*
* @param array<int, string> $blockers
*/
public function message(array $blockers): string
{
return __('Löschen nicht möglich — der Eintrag wird noch verwendet: :list', [
'list' => implode(', ', $blockers),
]);
}
/**
* @param array<int, string> $blockers
*/
protected function push(array &$blockers, int $count, string $template): void
{
if ($count > 0) {
$blockers[] = str_replace(':n', (string) $count, __($template));
}
}
}