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>
236 lines
8.6 KiB
PHP
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));
|
|
}
|
|
}
|
|
}
|