diff --git a/app/Http/Controllers/Admin/Inventory/PackagingItemController.php b/app/Http/Controllers/Admin/Inventory/PackagingItemController.php index a9f67f7..539a05d 100644 --- a/app/Http/Controllers/Admin/Inventory/PackagingItemController.php +++ b/app/Http/Controllers/Admin/Inventory/PackagingItemController.php @@ -9,6 +9,7 @@ use App\Models\PackagingItem; use App\Models\PackagingMaterial; use App\Models\Supplier; use App\Repositories\PackagingItemRepository; +use App\Services\DeletionGuard; use Illuminate\Http\Request; class PackagingItemController extends Controller @@ -58,13 +59,6 @@ class PackagingItemController extends Controller { $validated = $request->validated(); - \Log::debug('PackagingItem STORE – raw input', [ - 'category_raw' => $request->input('category'), - 'category_hex' => bin2hex((string) $request->input('category')), - 'all_input' => $request->all(), - ]); - \Log::debug('PackagingItem STORE – validated', $validated); - $item = $this->packagingItemRepository->create($validated); \Session::flash('alert-save', '1'); @@ -90,13 +84,6 @@ class PackagingItemController extends Controller { $validated = $request->validated(); - \Log::debug('PackagingItem UPDATE – raw input', [ - 'category_raw' => $request->input('category'), - 'category_hex' => bin2hex((string) $request->input('category')), - 'all_input' => $request->all(), - ]); - \Log::debug('PackagingItem UPDATE – validated', $validated); - $this->packagingItemRepository->update($packagingItem, $validated); \Session::flash('alert-save', '1'); @@ -110,6 +97,14 @@ class PackagingItemController extends Controller { $category = $packagingItem->category === 'shipping' ? 'shipping' : 'packaging'; + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($packagingItem); + if ($blockers !== []) { + \Session::flash('alert-error', $guard->message($blockers)); + + return redirect()->route('admin.inventory.packaging-items.index', ['category' => $category]); + } + $packagingItem->delete(); \Session::flash('alert-success', __('Eintrag gelöscht')); diff --git a/app/Http/Controllers/Admin/Inventory/ProductionController.php b/app/Http/Controllers/Admin/Inventory/ProductionController.php index e120284..91fdffd 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductionController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductionController.php @@ -9,6 +9,7 @@ use App\Models\Product; use App\Models\Production; use App\Repositories\ProductionRepository; use App\Services\ProductionService; +use App\Services\ProductStockService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -179,6 +180,28 @@ class ProductionController extends Controller ]); } + /** + * Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete). + * Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt; + * der Rohstoffverbrauch der Produktion zählt danach nicht mehr + * (Bestandsqueries schließen gelöschte Produktionen aus). + */ + public function destroy(Production $production, ProductStockService $productStockService): RedirectResponse + { + if (! auth()->user()->isAdmin()) { + return redirect()->route('home'); + } + + \DB::transaction(function () use ($production, $productStockService) { + $productStockService->reverseProductionStock($production, (int) auth()->id()); + $production->delete(); + }); + + \Session::flash('alert-success', __('Produktion gelöscht — Produktbestand zurückgebucht, Rohstoffverbrauch freigegeben.')); + + return redirect()->route('admin.inventory.productions.index'); + } + public function recipeJson(Request $request, Product $product): JsonResponse { $locationId = (int) $request->query('location_id', 0); diff --git a/app/Http/Controllers/Admin/Inventory/SettingCrudController.php b/app/Http/Controllers/Admin/Inventory/SettingCrudController.php index f4c0cba..36815fe 100644 --- a/app/Http/Controllers/Admin/Inventory/SettingCrudController.php +++ b/app/Http/Controllers/Admin/Inventory/SettingCrudController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin\Inventory; use App\Http\Controllers\Controller; +use App\Services\DeletionGuard; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; @@ -100,7 +101,17 @@ abstract class SettingCrudController extends Controller public function destroy(): RedirectResponse { - $this->routeModel()->delete(); + $model = $this->routeModel(); + + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($model); + if ($blockers !== []) { + \Session::flash('alert-error', $guard->message($blockers)); + + return redirect()->route($this->redirectRoute()); + } + + $model->delete(); \Session::flash('alert-success', __('Eintrag gelöscht')); diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php index 680d1c4..ce09455 100644 --- a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -75,6 +75,24 @@ class StockDisposalController extends Controller return redirect()->route('admin.inventory.stock-disposals.index'); } + /** + * Anforderung 15.07.2026: Ausschuss-Buchungen löschbar (Soft-Delete). + * Die Bestandsaggregationen schließen gelöschte Buchungen automatisch + * aus — der Bestand steht danach wieder zur Verfügung. + */ + public function destroy(StockDisposal $stockDisposal): RedirectResponse + { + if (! auth()->user()->isAdmin()) { + return redirect()->route('home'); + } + + $stockDisposal->delete(); + + \Session::flash('alert-success', __('Ausschuss-Buchung gelöscht — die Menge steht dem Bestand wieder zur Verfügung.')); + + return redirect()->route('admin.inventory.stock-disposals.index'); + } + public function ingredientCharges(Ingredient $ingredient): JsonResponse { $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index b3e8ebf..e710dc5 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -14,6 +14,7 @@ use App\Models\StockEntry; use App\Models\Supplier; use App\Models\TaxRate; use App\Repositories\StockEntryRepository; +use App\Services\DeletionGuard; use App\Services\InventoryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -168,8 +169,13 @@ class StockEntryController extends Controller if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } - if (! $stockEntry->isPending()) { - \Session::flash('alert-error', __('Nur offene Bestellungen können gelöscht werden.')); + + // Anforderung 15.07.2026: auch gebuchte Eingänge löschbar (Soft-Delete), + // sofern keine Abhängigkeiten (Produktions-Verbrauch, Ausschuss) bestehen. + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($stockEntry); + if ($blockers !== []) { + \Session::flash('alert-error', $guard->message($blockers)); return redirect()->route('admin.inventory.stock-entries.index'); } diff --git a/app/Http/Controllers/Admin/Inventory/SupplierController.php b/app/Http/Controllers/Admin/Inventory/SupplierController.php index 1933621..187a34c 100644 --- a/app/Http/Controllers/Admin/Inventory/SupplierController.php +++ b/app/Http/Controllers/Admin/Inventory/SupplierController.php @@ -12,6 +12,7 @@ use App\Models\PackagingItem; use App\Models\Supplier; use App\Models\SupplierCategory; use App\Repositories\SupplierRepository; +use App\Services\DeletionGuard; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; @@ -137,6 +138,14 @@ class SupplierController extends Controller public function destroy(Supplier $supplier) { + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($supplier); + if ($blockers !== []) { + \Session::flash('alert-error', $guard->message($blockers)); + + return redirect()->route('admin.inventory.suppliers.index'); + } + $supplier->delete(); \Session::flash('alert-success', __('Eintrag gelöscht')); diff --git a/app/Http/Controllers/IngredientController.php b/app/Http/Controllers/IngredientController.php index 6898c98..bff5286 100755 --- a/app/Http/Controllers/IngredientController.php +++ b/app/Http/Controllers/IngredientController.php @@ -5,12 +5,12 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreIngredientRequest; use App\Models\DeliveryTime; use App\Models\Ingredient; -use App\Models\ProductIngredient; use App\Models\StorageRoom; use App\Models\StorageShelf; use App\Models\StorageSlot; use App\Models\Supplier; use App\Models\TaxRate; +use App\Services\DeletionGuard; use Illuminate\Http\Request; class IngredientController extends Controller @@ -89,13 +89,18 @@ class IngredientController extends Controller public function delete($id) { + $model = Ingredient::findOrFail($id); - if (ProductIngredient::where('ingredient_id', $id)->count()) { - \Session()->flash('alert-error', 'Eintrag wird als Produkt-Inhaltsstoff verwendet'); + // Anforderung 15.07.2026: Soft-Delete mit vollständiger Abhängigkeitsprüfung + // (Rezepturen, Wareneingänge, Ausschuss, Produktions-Verbräuche). + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($model); + if ($blockers !== []) { + \Session()->flash('alert-error', $guard->message($blockers)); return redirect(route('admin_product_ingredients')); } - $model = Ingredient::findOrFail($id); + $model->delete(); \Session()->flash('alert-success', 'Eintrag gelöscht'); diff --git a/app/Models/DeliveryTime.php b/app/Models/DeliveryTime.php index 223d283..f5fb2e4 100644 --- a/app/Models/DeliveryTime.php +++ b/app/Models/DeliveryTime.php @@ -6,12 +6,15 @@ use Database\Factories\DeliveryTimeFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class DeliveryTime extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'label', 'days', diff --git a/app/Models/DisposalReason.php b/app/Models/DisposalReason.php index 4a1daa2..d1f8bd0 100644 --- a/app/Models/DisposalReason.php +++ b/app/Models/DisposalReason.php @@ -6,12 +6,15 @@ use Database\Factories\DisposalReasonFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class DisposalReason extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'label', 'active', diff --git a/app/Models/Ingredient.php b/app/Models/Ingredient.php index 5ff16ec..5e57fb3 100644 --- a/app/Models/Ingredient.php +++ b/app/Models/Ingredient.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * Class Ingredient @@ -51,6 +52,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; */ class Ingredient extends Model { + use SoftDeletes; + protected $table = 'ingredients'; protected $casts = [ diff --git a/app/Models/Location.php b/app/Models/Location.php index ec85de4..c6091a8 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -5,12 +5,15 @@ namespace App\Models; use Database\Factories\LocationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class Location extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'name', 'active', diff --git a/app/Models/MaterialQuality.php b/app/Models/MaterialQuality.php index 81d6d5e..b2cf457 100644 --- a/app/Models/MaterialQuality.php +++ b/app/Models/MaterialQuality.php @@ -5,12 +5,15 @@ namespace App\Models; use Database\Factories\MaterialQualityFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class MaterialQuality extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'name', 'pos', diff --git a/app/Models/PackagingMaterial.php b/app/Models/PackagingMaterial.php index 0544b73..f252636 100644 --- a/app/Models/PackagingMaterial.php +++ b/app/Models/PackagingMaterial.php @@ -6,12 +6,15 @@ use Database\Factories\PackagingMaterialFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class PackagingMaterial extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'name', 'pos', diff --git a/app/Models/Production.php b/app/Models/Production.php index 35d9de6..c9e0bb9 100644 --- a/app/Models/Production.php +++ b/app/Models/Production.php @@ -6,9 +6,12 @@ use App\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Production extends Model { + use SoftDeletes; + protected $fillable = [ 'product_id', 'location_id', diff --git a/app/Models/StockDisposal.php b/app/Models/StockDisposal.php index 6261d3d..b46147e 100644 --- a/app/Models/StockDisposal.php +++ b/app/Models/StockDisposal.php @@ -5,9 +5,12 @@ namespace App\Models; use App\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; class StockDisposal extends Model { + use SoftDeletes; + protected $fillable = [ 'disposal_type', 'ingredient_id', diff --git a/app/Models/StockEntry.php b/app/Models/StockEntry.php index 5037337..15b8bbd 100644 --- a/app/Models/StockEntry.php +++ b/app/Models/StockEntry.php @@ -7,12 +7,15 @@ use Database\Factories\StockEntryFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; class StockEntry extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'entry_type', 'ingredient_id', diff --git a/app/Models/StorageSegment.php b/app/Models/StorageSegment.php index 67a2943..86720f3 100644 --- a/app/Models/StorageSegment.php +++ b/app/Models/StorageSegment.php @@ -4,12 +4,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; /** * Gemeinsame Basis der Lagerort-Bausteine Raum/Regal/Fach (AP-21). */ abstract class StorageSegment extends Model { + use SoftDeletes; + protected $fillable = [ 'label', 'active', diff --git a/app/Models/SupplierCategory.php b/app/Models/SupplierCategory.php index 4b17b5c..4c3bc03 100644 --- a/app/Models/SupplierCategory.php +++ b/app/Models/SupplierCategory.php @@ -6,12 +6,15 @@ use Database\Factories\SupplierCategoryFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\SoftDeletes; class SupplierCategory extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'name', 'pos', diff --git a/app/Models/TaxRate.php b/app/Models/TaxRate.php index ae99d8c..e2ac195 100644 --- a/app/Models/TaxRate.php +++ b/app/Models/TaxRate.php @@ -6,12 +6,15 @@ use Database\Factories\TaxRateFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class TaxRate extends Model { /** @use HasFactory */ use HasFactory; + use SoftDeletes; + protected $fillable = [ 'name', 'percent', diff --git a/app/Repositories/StockEntryRepository.php b/app/Repositories/StockEntryRepository.php index 629d3cb..88103d5 100644 --- a/app/Repositories/StockEntryRepository.php +++ b/app/Repositories/StockEntryRepository.php @@ -134,7 +134,9 @@ class StockEntryRepository ->whereNotNull('supplier_id') ->whereNotNull('price_per_kg'); - return StockEntry::query() + // DB::query statt StockEntry::query: der SoftDeletes-Global-Scope würde + // sonst "stock_entries.deleted_at" auf dem Sub-Select-Alias referenzieren. + return \DB::query() ->fromSub($ranked, 'ranked') ->where('rn', 1) ->pluck('price_per_kg', 'supplier_id') diff --git a/app/Services/DeletionGuard.php b/app/Services/DeletionGuard.php new file mode 100644 index 0000000..cbddcce --- /dev/null +++ b/app/Services/DeletionGuard.php @@ -0,0 +1,230 @@ + + */ + 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 StockDisposal, + $model instanceof Production => [], + default => [], + }; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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)'); + + return $blockers; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 $blockers + */ + public function message(array $blockers): string + { + return __('Löschen nicht möglich — der Eintrag wird noch verwendet: :list', [ + 'list' => implode(', ', $blockers), + ]); + } + + /** + * @param array $blockers + */ + protected function push(array &$blockers, int $count, string $template): void + { + if ($count > 0) { + $blockers[] = str_replace(':n', (string) $count, __($template)); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 50c3f10..9ad7300 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -45,9 +45,11 @@ class InventoryService ->pluck('qty', 'ingredient_id'); $consumed = ProductionIngredient::query() - ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) - ->selectRaw('ingredient_id, SUM(quantity_used) as qty') - ->groupBy('ingredient_id') + ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') + ->whereNull('productions.deleted_at') + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) + ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') + ->groupBy('production_ingredients.ingredient_id') ->pluck('qty', 'ingredient_id'); $disposed = $this->disposedByIngredient($ingredientIds); @@ -101,6 +103,8 @@ class InventoryService $consumed = ProductionIngredient::query() ->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id') + ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') + ->whereNull('productions.deleted_at') ->where('production_ingredients.ingredient_id', $ingredientId) ->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty') ->groupBy('stock_entries.location_id') @@ -139,9 +143,11 @@ class InventoryService ->pluck('qty', 'packaging_item_id'); $consumed = ProductionPackaging::query() - ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) - ->selectRaw('packaging_item_id, SUM(quantity_used) as qty') - ->groupBy('packaging_item_id') + ->join('productions', 'productions.id', '=', 'production_packagings.production_id') + ->whereNull('productions.deleted_at') + ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('production_packagings.packaging_item_id', $packagingItemIds)) + ->selectRaw('production_packagings.packaging_item_id as packaging_item_id, SUM(production_packagings.quantity_used) as qty') + ->groupBy('production_packagings.packaging_item_id') ->pluck('qty', 'packaging_item_id'); $disposed = $this->disposedByPackagingItem($packagingItemIds); @@ -216,6 +222,7 @@ class InventoryService $rows = ProductionIngredient::query() ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') + ->whereNull('productions.deleted_at') ->where('productions.produced_at', '>=', $since) ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php index 53a5b84..959f8e9 100644 --- a/app/Services/ProductStockService.php +++ b/app/Services/ProductStockService.php @@ -127,6 +127,46 @@ class ProductStockService } } + /** + * 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). * diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 39bcc42..9459f53 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -352,10 +352,12 @@ class ProductionService } $rows = ProductionIngredient::query() - ->selectRaw('stock_entry_id, SUM(quantity_used) as used') - ->whereIn('stock_entry_id', $stockEntryIds) - ->when($excludeProductionId !== null, fn ($q) => $q->where('production_id', '!=', $excludeProductionId)) - ->groupBy('stock_entry_id') + ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') + ->whereNull('productions.deleted_at') + ->selectRaw('production_ingredients.stock_entry_id as stock_entry_id, SUM(production_ingredients.quantity_used) as used') + ->whereIn('production_ingredients.stock_entry_id', $stockEntryIds) + ->when($excludeProductionId !== null, fn ($q) => $q->where('production_ingredients.production_id', '!=', $excludeProductionId)) + ->groupBy('production_ingredients.stock_entry_id') ->pluck('used', 'stock_entry_id'); $result = []; diff --git a/database/migrations/2026_07_15_100000_add_soft_deletes_to_inventory_tables.php b/database/migrations/2026_07_15_100000_add_soft_deletes_to_inventory_tables.php new file mode 100644 index 0000000..c945e52 --- /dev/null +++ b/database/migrations/2026_07_15_100000_add_soft_deletes_to_inventory_tables.php @@ -0,0 +1,50 @@ +softDeletes(); + }); + } + } + + public function down(): void + { + foreach (self::TABLES as $table) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropSoftDeletes(); + }); + } + } +}; diff --git a/resources/views/admin/ingredient/index.blade.php b/resources/views/admin/ingredient/index.blade.php index 7e65489..e943830 100755 --- a/resources/views/admin/ingredient/index.blade.php +++ b/resources/views/admin/ingredient/index.blade.php @@ -40,7 +40,14 @@ {{ $value->inci }} {{ $value->effect }} @if($value->active) @else@endif - + +
+ @csrf + @method('DELETE') + +
+ @endforeach diff --git a/resources/views/admin/inventory/productions/index.blade.php b/resources/views/admin/inventory/productions/index.blade.php index 6aa94b5..fb42b20 100644 --- a/resources/views/admin/inventory/productions/index.blade.php +++ b/resources/views/admin/inventory/productions/index.blade.php @@ -42,6 +42,14 @@ + @if(Auth::user()->isAdmin()) +
+ @csrf + @method('DELETE') + +
+ @endif {{ $row->produced_at?->format('d.m.Y') }} {{ $row->product?->name ?? '—' }} diff --git a/resources/views/admin/inventory/stock-disposals/index.blade.php b/resources/views/admin/inventory/stock-disposals/index.blade.php index 8311007..ae79c81 100644 --- a/resources/views/admin/inventory/stock-disposals/index.blade.php +++ b/resources/views/admin/inventory/stock-disposals/index.blade.php @@ -41,6 +41,7 @@ {{ __('Grund') }} {{ __('Hinweis') }} {{ __('Mitarbeiter') }} + @@ -70,10 +71,20 @@ {{ $disposal->reason }} {{ $disposal->note }} {{ $disposal->user?->getFullName(false) ?: ($disposal->user?->email ?? '—') }} + + @if(Auth::user()->isAdmin()) +
+ @csrf + @method('DELETE') + +
+ @endif + @empty - +
{{ __('Noch keine Ausgänge erfasst.') }} diff --git a/resources/views/admin/inventory/stock-entries/index.blade.php b/resources/views/admin/inventory/stock-entries/index.blade.php index f226f07..36e4070 100644 --- a/resources/views/admin/inventory/stock-entries/index.blade.php +++ b/resources/views/admin/inventory/stock-entries/index.blade.php @@ -78,9 +78,9 @@ @endif - @if(Auth::user()->isAdmin() && $row->status === 'pending') + @if(Auth::user()->isAdmin())
+ onsubmit="return confirm(@json($row->status === 'pending' ? __('Eintrag wirklich löschen?') : __('Gebuchten Wareneingang wirklich löschen? Der Bestand wird entsprechend reduziert. Nicht möglich, wenn bereits Verbräuche oder Ausschuss darauf gebucht sind.')));"> @csrf @method('DELETE') diff --git a/routes/web.php b/routes/web.php index a4ba99e..a52e2d1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -321,10 +321,11 @@ Route::domain(config('app.domain'))->group(function () { Route::get('stock-disposals', [InventoryStockDisposalController::class, 'index'])->name('stock-disposals.index'); Route::get('stock-disposals/create', [InventoryStockDisposalController::class, 'create'])->name('stock-disposals.create'); Route::post('stock-disposals', [InventoryStockDisposalController::class, 'store'])->name('stock-disposals.store'); + Route::delete('stock-disposals/{stock_disposal}', [InventoryStockDisposalController::class, 'destroy'])->name('stock-disposals.destroy'); Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive'); Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy'); Route::resource('stock-entries', InventoryStockEntryController::class); - Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']); + Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']); Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy'); Route::get('product-development', [InventoryProductDevelopmentController::class, 'index'])->name('product-development'); }); @@ -352,7 +353,7 @@ Route::domain(config('app.domain'))->group(function () { Route::get('/admin/product/ingredients', [IngredientController::class, 'index'])->name('admin_product_ingredients'); Route::get('/admin/product/ingredient/edit/{id}', [IngredientController::class, 'edit'])->name('admin_product_ingredient_edit'); Route::post('/admin/product/ingredient/store', [IngredientController::class, 'store'])->name('admin_product_ingredient_store'); - Route::get('/admin/product/ingredient/delete/{id}', [IngredientController::class, 'delete'])->name('admin_product_ingredient_delete'); + Route::delete('/admin/product/ingredient/delete/{id}', [IngredientController::class, 'delete'])->name('admin_product_ingredient_delete'); // products images Route::post('/admin/product/category/image/upload', [CategoryController::class, 'imageUpload'])->name('admin_product_category_image_upload'); diff --git a/tests/Feature/InventoryDeletionTest.php b/tests/Feature/InventoryDeletionTest.php new file mode 100644 index 0000000..fc3e746 --- /dev/null +++ b/tests/Feature/InventoryDeletionTest.php @@ -0,0 +1,325 @@ +create([ + 'email' => uniqid('del_', true).'@test.example', + 'password' => bcrypt('password'), + ]); + $user->forceFill([ + 'admin' => $adminLevel, + 'confirmed' => true, + 'active' => true, + 'wizard' => 100, + 'blocked' => false, + ])->save(); + + return $user->fresh(); +} + +function delIngredient(string $name): Ingredient +{ + return Ingredient::query()->create([ + 'name' => $name, + 'trans_name' => '', + 'inci' => $name, + 'trans_inci' => '', + 'effect' => '', + 'trans_effect' => '', + 'active' => true, + 'pos' => 0, + ]); +} + +/** + * @param array $overrides + */ +function delStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry +{ + return StockEntry::query()->create(array_merge([ + 'entry_type' => 'ingredient', + 'ingredient_id' => $ing->id, + 'supplier_id' => $supplier->id, + 'location_id' => $location->id, + 'unit' => 'gram', + 'ordered_by' => $user->id, + 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 1000, + 'status' => 'received', + 'received_by' => $user->id, + 'received_at' => '2026-01-15', + 'received_quantity' => 1000, + 'batch_number' => 'DEL-B1', + 'best_before' => '2030-12-31', + ], $overrides)); +} + +function delSimpleProduct(string $name): Product +{ + return Product::query()->create([ + 'name' => $name, + 'title' => $name, + 'active' => true, + 'no_recipe_required' => true, + ]); +} + +// --- Stammdaten (SettingCrudController) --- + +test('verwendeter steuersatz kann nicht geloescht werden', function () { + $this->actingAs(delUser(8), 'user'); + + $taxRate = TaxRate::factory()->create(); + $user = delUser(); + $ing = delIngredient('Del-Tax-INCI'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + delStock($ing, $location, $supplier, $user, ['tax_rate_id' => $taxRate->id]); + + $this->delete(route('admin.inventory.tax-rates.destroy', $taxRate)); + + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(TaxRate::query()->find($taxRate->id))->not->toBeNull(); +}); + +test('unbenutzter steuersatz wird soft-geloescht', function () { + $this->actingAs(delUser(8), 'user'); + + $taxRate = TaxRate::factory()->create(); + + $this->delete(route('admin.inventory.tax-rates.destroy', $taxRate)); + + expect(TaxRate::query()->find($taxRate->id))->toBeNull(); + expect(TaxRate::withTrashed()->find($taxRate->id))->not->toBeNull(); +}); + +test('lagerort mit wareneingang kann nicht geloescht werden (kein 500)', function () { + $this->actingAs(delUser(8), 'user'); + + $user = delUser(); + $ing = delIngredient('Del-Loc-INCI'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + delStock($ing, $location, $supplier, $user); + + $this->delete(route('admin.inventory.locations.destroy', $location)) + ->assertRedirect(); + + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(Location::query()->find($location->id))->not->toBeNull(); +}); + +test('lieferant mit wareneingang kann nicht geloescht werden', function () { + $this->actingAs(delUser(7), 'user'); + + $user = delUser(); + $ing = delIngredient('Del-Sup-INCI'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + delStock($ing, $location, $supplier, $user); + + $this->delete(route('admin.inventory.suppliers.destroy', $supplier)); + + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(Supplier::query()->find($supplier->id))->not->toBeNull(); +}); + +// --- Wareneingänge --- + +test('gebuchter wareneingang ohne abhaengigkeiten wird geloescht und bestand sinkt', function () { + $admin = delUser(7); + $this->actingAs($admin, 'user'); + + $ing = delIngredient('Del-SE-INCI'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = delStock($ing, $location, $supplier, $admin); + + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0)->toBe(1000.0); + + $this->delete(route('admin.inventory.stock-entries.destroy', $entry)) + ->assertRedirect(route('admin.inventory.stock-entries.index')); + + expect(StockEntry::query()->find($entry->id))->toBeNull(); + expect(StockEntry::withTrashed()->find($entry->id))->not->toBeNull(); + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0)->toBe(0.0); +}); + +test('wareneingang mit produktions-verbrauch kann nicht geloescht werden', function () { + $admin = delUser(7); + $this->actingAs($admin, 'user'); + + $ing = delIngredient('Del-SE-Blocked'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = delStock($ing, $location, $supplier, $admin); + + $product = Product::query()->create(['name' => 'Del-Prod', 'title' => 'Del-Prod', 'active' => true]); + ProductIngredient::create([ + 'product_id' => $product->id, + 'ingredient_id' => $ing->id, + 'pos' => 0, + 'gram' => 10, + 'factor' => 1, + 'recipe_type' => 'manufacturer', + ]); + + app(ProductionService::class)->store( + [ + 'product_id' => $product->id, + 'location_id' => $location->id, + 'produced_at' => '2026-07-01', + 'quantity' => 10, + 'notes' => null, + ], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'quantity_used' => 100]], + $admin->id, + ); + + $this->delete(route('admin.inventory.stock-entries.destroy', $entry)); + + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(StockEntry::query()->find($entry->id))->not->toBeNull(); +}); + +// --- Ausschuss --- + +test('geloeschter ausschuss gibt bestand wieder frei', function () { + $admin = delUser(7); + $this->actingAs($admin, 'user'); + + $ing = delIngredient('Del-Disposal'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + delStock($ing, $location, $supplier, $admin); + + $disposal = StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', + 'ingredient_id' => $ing->id, + 'location_id' => $location->id, + 'quantity' => 400, + 'unit' => 'gram', + 'reason' => 'MHD abgelaufen', + 'disposed_at' => '2026-07-01', + 'user_id' => $admin->id, + ]); + + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(600.0); + + $this->delete(route('admin.inventory.stock-disposals.destroy', $disposal)) + ->assertRedirect(route('admin.inventory.stock-disposals.index')); + + expect(StockDisposal::query()->find($disposal->id))->toBeNull(); + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(1000.0); +}); + +// --- Produktionen --- + +test('geloeschte produktion bucht produktbestand zurueck und gibt rohstoffe frei', function () { + $admin = delUser(7); + $this->actingAs($admin, 'user'); + + $ing = delIngredient('Del-Prod-INCI'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = delStock($ing, $location, $supplier, $admin); + + $product = Product::query()->create(['name' => 'Del-Prod2', 'title' => 'Del-Prod2', 'active' => true]); + ProductIngredient::create([ + 'product_id' => $product->id, + 'ingredient_id' => $ing->id, + 'pos' => 0, + 'gram' => 10, + 'factor' => 1, + 'recipe_type' => 'manufacturer', + ]); + + $production = app(ProductionService::class)->store( + [ + 'product_id' => $product->id, + 'location_id' => $location->id, + 'produced_at' => '2026-07-01', + 'quantity' => 10, + 'notes' => null, + ], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'quantity_used' => 100]], + $admin->id, + ); + + expect(app(ProductStockService::class)->currentStock($product->id))->toBe(10); + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(900.0); + + $this->delete(route('admin.inventory.productions.destroy', $production)) + ->assertRedirect(route('admin.inventory.productions.index')); + + // Produktion soft-gelöscht, Bestand zurückgebucht (append-only), Rohstoff frei. + expect(Production::query()->find($production->id))->toBeNull(); + expect(Production::withTrashed()->find($production->id))->not->toBeNull(); + expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0); + expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(1000.0); + expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2); +}); + +test('nicht-admin darf produktion nicht loeschen', function () { + $admin = delUser(7); + $location = Location::factory()->create(); + $product = delSimpleProduct('Del-Guard'); + + $production = app(ProductionService::class)->store( + [ + 'product_id' => $product->id, + 'location_id' => $location->id, + 'produced_at' => '2026-07-01', + 'quantity' => 5, + 'notes' => null, + ], + [], + $admin->id, + ); + + $this->actingAs(delUser(1), 'user'); + $this->delete(route('admin.inventory.productions.destroy', $production)) + ->assertRedirect(route('home')); + + expect(Production::query()->find($production->id))->not->toBeNull(); +}); + +// --- INCI --- + +test('inci mit wareneingang kann nicht geloescht werden, sauberer inci schon', function () { + $admin = delUser(7); + $this->actingAs($admin, 'user'); + + $used = delIngredient('Del-INCI-used'); + $clean = delIngredient('Del-INCI-clean'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + delStock($used, $location, $supplier, $admin); + + $this->delete(route('admin_product_ingredient_delete', $used->id)); + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(Ingredient::query()->find($used->id))->not->toBeNull(); + + $this->delete(route('admin_product_ingredient_delete', $clean->id)); + expect(Ingredient::query()->find($clean->id))->toBeNull(); + expect(Ingredient::withTrashed()->find($clean->id))->not->toBeNull(); +}); diff --git a/tests/Feature/StorageSettingsTest.php b/tests/Feature/StorageSettingsTest.php index ae0e204..206166f 100644 --- a/tests/Feature/StorageSettingsTest.php +++ b/tests/Feature/StorageSettingsTest.php @@ -104,7 +104,9 @@ test('nur aktive segmente ueber active-scope', function () { expect(StorageShelf::query()->active()->pluck('label')->all())->toBe(['Aktiv']); }); -test('geloeschter lagerort setzt ingredient-referenz auf null', function () { +test('verwendeter lagerort-baustein kann nicht geloescht werden', function () { + $this->actingAs(storageSettingsUser(8), 'user'); + $room = StorageRoom::factory()->create(['label' => 'Raum 9']); $ingredient = Ingredient::query()->create([ 'name' => 'Storage-FK-Test', @@ -112,9 +114,24 @@ test('geloeschter lagerort setzt ingredient-referenz auf null', function () { 'storage_room_id' => $room->id, ]); - $room->delete(); + $this->delete(route('admin.inventory.storage-rooms.destroy', $room)) + ->assertRedirect(route('admin.inventory.general')); - expect($ingredient->fresh()->storage_room_id)->toBeNull(); + expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(StorageRoom::query()->find($room->id))->not->toBeNull(); + expect($ingredient->fresh()->storage_room_id)->toBe($room->id); +}); + +test('unbenutzter lagerort-baustein wird soft-geloescht', function () { + $this->actingAs(storageSettingsUser(8), 'user'); + + $room = StorageRoom::factory()->create(['label' => 'Raum leer']); + + $this->delete(route('admin.inventory.storage-rooms.destroy', $room)) + ->assertRedirect(route('admin.inventory.general')); + + expect(StorageRoom::query()->find($room->id))->toBeNull(); + expect(StorageRoom::withTrashed()->find($room->id))->not->toBeNull(); }); test('nicht-superadmin hat keinen zugriff auf lagerort-anlage', function () {