From e08c8dec401d241f08e537c9e77bb207561ceca1 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 28 Aug 2026 09:17:28 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20AP-50=20=E2=80=94=20Lagerort?= =?UTF-8?q?=20l=C3=B6schbar=20(Buchungen=20=C3=BCbernehmen)=20+=20K=C3=B6l?= =?UTF-8?q?n=20als=20Default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kundenentscheid 26.08.: „Waldbröl" gibt es nicht mehr, alles wird Köln; die Standort-Funktion bleibt für künftige Standorte. - LocationController::destroy überschrieben: ein belegter Lagerort lässt sich löschen, indem seine Buchungen (Wareneingänge, Produktionen, Ausschuss) transaktional auf einen Ziel-Lagerort übernommen werden. Ohne Ziel-Lagerort bleibt die Sperre bestehen; unbelegte Orte werden direkt gelöscht. - Lagerort-Liste: Ziel-Auswahl („→ Köln" vorbelegt) im Lösch-Formular. - Wareneingang: Köln als Standard-Lagerort vorbelegt (wie in der Produktion). Außerdem AP-49-Folgeanpassung: Test-Helper hängt die Kategorie „Kosmetikrohstoffe" an, damit Lieferanten im gefilterten INCI-Formular erscheinen; Lagerort-Sperr-Test prüft die neue Meldung. Suite 322 grün. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Admin/Inventory/LocationController.php | 51 ++++++++++++++ .../Admin/Inventory/StockEntryController.php | 3 + .../admin/inventory/locations/index.blade.php | 14 +++- tests/Feature/IngredientOrderFieldsTest.php | 8 ++- tests/Feature/InventoryDeletionTest.php | 3 +- tests/Feature/LocationDeletionTest.php | 67 +++++++++++++++++++ 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/LocationDeletionTest.php diff --git a/app/Http/Controllers/Admin/Inventory/LocationController.php b/app/Http/Controllers/Admin/Inventory/LocationController.php index 380d4fe..5165e65 100644 --- a/app/Http/Controllers/Admin/Inventory/LocationController.php +++ b/app/Http/Controllers/Admin/Inventory/LocationController.php @@ -4,9 +4,60 @@ namespace App\Http\Controllers\Admin\Inventory; use App\Http\Requests\Inventory\StoreLocationRequest; use App\Models\Location; +use App\Models\Production; +use App\Models\StockDisposal; +use App\Models\StockEntry; +use App\Services\DeletionGuard; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\DB; class LocationController extends SettingCrudController { + /** + * AP-50 (Kundenentscheid 26.08.2026): Ein belegter Lagerort (z. B. „Waldbröl") + * lässt sich löschen, indem seine Buchungen (Wareneingänge, Produktionen, + * Ausschuss) auf einen Ziel-Lagerort (z. B. Köln) übernommen werden. Ohne + * Referenzen wird direkt gelöscht; die Standard-Sperre bleibt bestehen, solange + * kein Ziel-Lagerort gewählt ist. + */ + public function destroy(): RedirectResponse + { + /** @var Location $location */ + $location = $this->routeModel(); + $reassignTo = (int) request()->input('reassign_to', 0); + + $guard = app(DeletionGuard::class); + $blockers = $guard->blockersFor($location); + + if ($blockers !== []) { + $target = $reassignTo > 0 && $reassignTo !== $location->id + ? Location::query()->find($reassignTo) + : null; + + if (! $target) { + \Session::flash('alert-error', __('Lagerort ist noch belegt — bitte einen Ziel-Lagerort zum Übernehmen der Buchungen wählen.')); + + return redirect()->route($this->redirectRoute()); + } + + DB::transaction(function () use ($location, $target) { + StockEntry::query()->where('location_id', $location->id)->update(['location_id' => $target->id]); + Production::query()->where('location_id', $location->id)->update(['location_id' => $target->id]); + StockDisposal::query()->where('location_id', $location->id)->update(['location_id' => $target->id]); + $location->delete(); + }); + + \Session::flash('alert-success', __('Lagerort gelöscht — Buchungen auf „:target" übernommen.', ['target' => $target->name])); + + return redirect()->route($this->redirectRoute()); + } + + $location->delete(); + \Session::flash('alert-success', __('Eintrag gelöscht')); + + return redirect()->route($this->redirectRoute()); + } + protected function modelClass(): string { return Location::class; diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index b3e407f..11a7059 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -46,6 +46,9 @@ class StockEntryController extends Controller $model = new StockEntry([ 'ordered_at' => now()->toDateString(), 'entry_type' => 'ingredient', + // AP-50: Köln als Standard-Lagerort vorbelegen (wie in der Produktion). + 'location_id' => Location::query()->where('name', 'like', '%öln%')->value('id') + ?? Location::query()->where('active', true)->orderBy('name')->value('id'), ]); $ingredientId = (int) $request->query('ingredient_id'); diff --git a/resources/views/admin/inventory/locations/index.blade.php b/resources/views/admin/inventory/locations/index.blade.php index 6347eee..c48dcfe 100644 --- a/resources/views/admin/inventory/locations/index.blade.php +++ b/resources/views/admin/inventory/locations/index.blade.php @@ -41,10 +41,20 @@ @endif -
+ @php $others = $values->where('id', '!=', $value->id); @endphp + {{-- AP-50: belegte Lagerorte löschbar, indem die Buchungen auf einen Ziel-Lagerort übernommen werden --}} + @csrf @method('DELETE') + @if($others->isNotEmpty()) + + @endif
diff --git a/tests/Feature/IngredientOrderFieldsTest.php b/tests/Feature/IngredientOrderFieldsTest.php index 796aae6..8ad5540 100644 --- a/tests/Feature/IngredientOrderFieldsTest.php +++ b/tests/Feature/IngredientOrderFieldsTest.php @@ -6,6 +6,7 @@ use App\Models\StorageRoom; use App\Models\StorageShelf; use App\Models\StorageSlot; use App\Models\Supplier; +use App\Models\SupplierCategory; use App\Models\TaxRate; use App\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -32,10 +33,15 @@ function ingredientOrderUser(int $adminLevel): User function ingredientOrderSupplier(string $name): Supplier { - return Supplier::factory()->create([ + $supplier = Supplier::factory()->create([ 'name' => $name, 'active' => true, ]); + // AP-49: Das INCI-Formular zeigt nur Rohstoff-Lieferanten (Kategorie „Kosmetikrohstoffe"). + $category = SupplierCategory::query()->firstOrCreate(['name' => 'Kosmetikrohstoffe'], ['pos' => 0]); + $supplier->supplierCategories()->attach($category->id); + + return $supplier; } test('ingredient form shows suppliers, tax rates and delivery time templates', function () { diff --git a/tests/Feature/InventoryDeletionTest.php b/tests/Feature/InventoryDeletionTest.php index fc3e746..80037cb 100644 --- a/tests/Feature/InventoryDeletionTest.php +++ b/tests/Feature/InventoryDeletionTest.php @@ -121,10 +121,11 @@ test('lagerort mit wareneingang kann nicht geloescht werden (kein 500)', functio $supplier = Supplier::factory()->create(); delStock($ing, $location, $supplier, $user); + // AP-50: belegter Lagerort ohne Ziel-Lagerort bleibt gesperrt (Übernahme-Ziel nötig). $this->delete(route('admin.inventory.locations.destroy', $location)) ->assertRedirect(); - expect(session('alert-error'))->toContain('Löschen nicht möglich'); + expect(session('alert-error'))->toContain('Ziel-Lagerort'); expect(Location::query()->find($location->id))->not->toBeNull(); }); diff --git a/tests/Feature/LocationDeletionTest.php b/tests/Feature/LocationDeletionTest.php new file mode 100644 index 0000000..339ae07 --- /dev/null +++ b/tests/Feature/LocationDeletionTest.php @@ -0,0 +1,67 @@ +create(['email' => uniqid('loc_', true).'@test.example', 'password' => bcrypt('password')]); + $user->forceFill(['admin' => 8, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +function locDelStock(Location $location): StockEntry +{ + $ing = Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0]); + $supplier = Supplier::factory()->create(); + + return StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, + 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => 1, 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 500, 'status' => 'received', 'received_at' => '2026-01-10', + 'received_quantity' => 500, 'batch_number' => 'LOC-1', 'best_before' => '2030-12-31', + ]); +} + +test('AP-50: belegter lagerort wird gelöscht und buchungen auf ziel-lagerort übernommen', function () { + $this->actingAs(locDelAdmin(), 'user'); + $waldbroel = Location::query()->create(['name' => 'Waldbröl', 'active' => true]); + $koeln = Location::query()->create(['name' => 'Köln', 'active' => true]); + $entry = locDelStock($waldbroel); + + $this->delete(route('admin.inventory.locations.destroy', $waldbroel), ['reassign_to' => $koeln->id]) + ->assertRedirect(route('admin.inventory.locations.index')); + + expect(Location::query()->find($waldbroel->id))->toBeNull() + ->and($entry->fresh()->location_id)->toBe($koeln->id); +}); + +test('AP-50: belegter lagerort ohne ziel-lagerort bleibt gesperrt', function () { + $this->actingAs(locDelAdmin(), 'user'); + $waldbroel = Location::query()->create(['name' => 'Waldbröl', 'active' => true]); + Location::query()->create(['name' => 'Köln', 'active' => true]); + locDelStock($waldbroel); + + $this->delete(route('admin.inventory.locations.destroy', $waldbroel)) + ->assertRedirect(route('admin.inventory.locations.index')); + + expect(Location::query()->find($waldbroel->id))->not->toBeNull(); +}); + +test('AP-50: unbelegter lagerort wird direkt gelöscht', function () { + $this->actingAs(locDelAdmin(), 'user'); + $leer = Location::query()->create(['name' => 'Leerstand', 'active' => true]); + + $this->delete(route('admin.inventory.locations.destroy', $leer)) + ->assertRedirect(route('admin.inventory.locations.index')); + + expect(Location::query()->find($leer->id))->toBeNull(); +});