Warenwirtschaft: AP-50 — Lagerort löschbar (Buchungen übernehmen) + Köln als Default

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) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 09:17:28 +00:00
parent 4eb36486b0
commit e08c8dec40
6 changed files with 142 additions and 4 deletions

View file

@ -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;

View file

@ -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');

View file

@ -41,10 +41,20 @@
@endif
</td>
<td>
<form action="{{ route('admin.inventory.locations.destroy', $value) }}" method="post" class="d-inline"
onsubmit="return confirm('{{ __('Really delete entry?') }}');">
@php $others = $values->where('id', '!=', $value->id); @endphp
{{-- AP-50: belegte Lagerorte löschbar, indem die Buchungen auf einen Ziel-Lagerort übernommen werden --}}
<form action="{{ route('admin.inventory.locations.destroy', $value) }}" method="post" class="d-inline form-inline"
onsubmit="return confirm('{{ __('Lagerort wirklich löschen? Vorhandene Buchungen werden auf den gewählten Ziel-Lagerort übernommen.') }}');">
@csrf
@method('DELETE')
@if($others->isNotEmpty())
<select name="reassign_to" class="form-control form-control-sm d-inline-block mr-1" style="width:auto;"
title="{{ __('Buchungen übernehmen auf') }}">
@foreach($others as $other)
<option value="{{ $other->id }}" @selected(stripos($other->name, 'öln') !== false)>&rarr; {{ $other->name }}</option>
@endforeach
</select>
@endif
<button type="submit" class="btn btn-link text-danger p-0" title="{{ __('Delete') }}"><i class="far fa-trash-alt"></i></button>
</form>
</td>

View file

@ -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 () {

View file

@ -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();
});

View file

@ -0,0 +1,67 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function locDelAdmin(): User
{
$user = User::query()->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();
});