Warenwirtschaft: AP-48 — Produktbestand-Buchungsgründe pflegbar, getrennt Ein/Aus

Kundenentscheid 26.08. (D-5): Eingang und Ausgang haben eigene, pflegbare
Gründe-Listen statt einer festen Liste im Controller.

- Neues Modell/Tabelle stock_movement_reasons (label, direction in/out, pos,
  active, SoftDeletes) inkl. Seed der bisherigen Gründe sinnvoll auf Ein-/Ausgang
  aufgeteilt.
- CRUD wie DisposalReason (SettingCrudController + FormRequest + Formular mit
  Richtungs-Auswahl); zwei Listen (Eingangs-/Ausgangs-Gründe) auf der Seite
  Einstellungen → Allgemeines, „Neu anlegen" mit vorbelegter Richtung.
- Produktbestand-Maske: das Grund-Dropdown wird jetzt je nach Eingang/Ausgang
  per JS aus den passenden, gepflegten Gründen befüllt (manualReasons entfernt).
- DeletionGuard: StockMovementReason immer löschbar.

Migration create_stock_movement_reasons auf Dev-/Live-DB. 4 Tests, Suite 326 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:22:32 +00:00
parent e08c8dec40
commit 190713c21b
12 changed files with 381 additions and 22 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DeliveryTime; use App\Models\DeliveryTime;
use App\Models\DisposalReason; use App\Models\DisposalReason;
use App\Models\StockMovementReason;
use App\Models\StorageRoom; use App\Models\StorageRoom;
use App\Models\StorageShelf; use App\Models\StorageShelf;
use App\Models\StorageSlot; use App\Models\StorageSlot;
@ -19,6 +20,9 @@ class GeneralSettingController extends Controller
'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(), 'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(),
'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(), 'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(),
'disposalReasons' => DisposalReason::query()->orderBy('pos')->orderBy('label')->get(), 'disposalReasons' => DisposalReason::query()->orderBy('pos')->orderBy('label')->get(),
// AP-48: Buchungsgründe Produktbestand, getrennt nach Eingang/Ausgang.
'stockReasonsIn' => StockMovementReason::query()->where('direction', 'in')->orderBy('pos')->orderBy('label')->get(),
'stockReasonsOut' => StockMovementReason::query()->where('direction', 'out')->orderBy('pos')->orderBy('label')->get(),
'storageRooms' => StorageRoom::query()->orderBy('pos')->orderBy('label')->get(), 'storageRooms' => StorageRoom::query()->orderBy('pos')->orderBy('label')->get(),
'storageShelves' => StorageShelf::query()->orderBy('pos')->orderBy('label')->get(), 'storageShelves' => StorageShelf::query()->orderBy('pos')->orderBy('label')->get(),
'storageSlots' => StorageSlot::query()->orderBy('pos')->orderBy('label')->get(), 'storageSlots' => StorageSlot::query()->orderBy('pos')->orderBy('label')->get(),

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreProductStockMovementRequest; use App\Http\Requests\Inventory\StoreProductStockMovementRequest;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductStockMovement; use App\Models\ProductStockMovement;
use App\Models\StockMovementReason;
use App\Services\ProductStockService; use App\Services\ProductStockService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -59,10 +60,23 @@ class ProductStockController extends Controller
return view('admin.inventory.product-stock.index', [ return view('admin.inventory.product-stock.index', [
'rows' => $rows, 'rows' => $rows,
'reasons' => $this->manualReasons(), // AP-48: getrennte, pflegbare Buchungsgründe je Richtung.
'reasonsIn' => $this->reasonsFor('in'),
'reasonsOut' => $this->reasonsFor('out'),
]); ]);
} }
/**
* @return array<int, string>
*/
protected function reasonsFor(string $direction): array
{
return StockMovementReason::query()->active()
->where('direction', $direction)
->orderBy('pos')->orderBy('label')
->pluck('label')->all();
}
public function storeMovement(StoreProductStockMovementRequest $request, Product $product): RedirectResponse public function storeMovement(StoreProductStockMovementRequest $request, Product $product): RedirectResponse
{ {
$data = $request->validated(); $data = $request->validated();
@ -127,22 +141,6 @@ class ProductStockController extends Controller
]); ]);
} }
/**
* @return array<int, string>
*/
protected function manualReasons(): array
{
return [
__('Initialbestand'),
__('Korrektur'),
__('Inventur'),
__('Retoure'),
__('Testervergabe'),
__('Verlust / Bruch'),
__('Sonstiges'),
];
}
/** /**
* @return array<int, int> * @return array<int, int>
*/ */

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Requests\Inventory\StoreStockMovementReasonRequest;
use App\Models\StockMovementReason;
class StockMovementReasonController extends SettingCrudController
{
protected function modelClass(): string
{
return StockMovementReason::class;
}
protected function viewPrefix(): string
{
return 'admin.inventory.stock-movement-reasons';
}
protected function redirectRoute(): string
{
return 'admin.inventory.general';
}
protected function storeRequestClass(): string
{
return StoreStockMovementReasonRequest::class;
}
/**
* Neu-Formular mit vorbelegter Richtung (aus ?direction=in|out).
*
* @return array<string, mixed>
*/
protected function newModelAttributes(): array
{
$direction = in_array(request()->query('direction'), ['in', 'out'], true)
? request()->query('direction')
: 'in';
return ['active' => true, 'pos' => 0, 'direction' => $direction];
}
/**
* @return array<int, string>
*/
protected function indexOrder(): array
{
return ['direction', 'pos', 'label'];
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Inventory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreStockMovementReasonRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'label' => ['required', 'string', 'max:100'],
'direction' => ['required', Rule::in(['in', 'out'])],
'active' => ['sometimes', 'boolean'],
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'active' => $this->boolean('active'),
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* AP-48: Pflegbarer Buchungsgrund für den Produktbestand, getrennt nach Richtung
* (in = Eingang, out = Ausgang).
*/
class StockMovementReason extends Model
{
use SoftDeletes;
protected $fillable = [
'label',
'direction',
'active',
'pos',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'active' => 'boolean',
];
}
/**
* @param Builder<StockMovementReason> $query
* @return Builder<StockMovementReason>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

View file

@ -15,6 +15,7 @@ use App\Models\ProductionIngredient;
use App\Models\ProductionPackaging; use App\Models\ProductionPackaging;
use App\Models\StockDisposal; use App\Models\StockDisposal;
use App\Models\StockEntry; use App\Models\StockEntry;
use App\Models\StockMovementReason;
use App\Models\StorageSegment; use App\Models\StorageSegment;
use App\Models\Supplier; use App\Models\Supplier;
use App\Models\SupplierCategory; use App\Models\SupplierCategory;
@ -50,6 +51,7 @@ class DeletionGuard
// Bewusst immer löschbar (keine eingehenden Referenzen): // Bewusst immer löschbar (keine eingehenden Referenzen):
$model instanceof DeliveryTime, $model instanceof DeliveryTime,
$model instanceof DisposalReason, $model instanceof DisposalReason,
$model instanceof StockMovementReason,
$model instanceof StockDisposal, $model instanceof StockDisposal,
$model instanceof Production => [], $model instanceof Production => [],
default => [], default => [],

View file

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-48 (Kundenentscheid 26.08.2026): Buchungsgründe für den Produktbestand
* werden pflegbar — mit GETRENNTEN Listen für Eingang und Ausgang. Bisher waren
* die Gründe fest im Controller hinterlegt (eine Liste für beide Richtungen).
*/
public function up(): void
{
Schema::create('stock_movement_reasons', function (Blueprint $table) {
$table->id();
$table->string('label', 100);
$table->enum('direction', ['in', 'out']);
$table->unsignedTinyInteger('pos')->default(0);
$table->boolean('active')->default(true);
$table->timestamps();
$table->softDeletes();
});
$now = now();
$seed = [
'in' => ['Initialbestand', 'Korrektur', 'Inventur', 'Retoure', 'Sonstiges'],
'out' => ['Korrektur', 'Inventur', 'Testervergabe', 'Verlust / Bruch', 'Sonstiges'],
];
$rows = [];
foreach ($seed as $direction => $labels) {
foreach ($labels as $pos => $label) {
$rows[] = [
'label' => $label,
'direction' => $direction,
'pos' => $pos,
'active' => true,
'created_at' => $now,
'updated_at' => $now,
];
}
}
DB::table('stock_movement_reasons')->insert($rows);
}
public function down(): void
{
Schema::dropIfExists('stock_movement_reasons');
}
};

View file

@ -173,6 +173,59 @@
</div> </div>
</div> </div>
{{-- AP-48: Buchungsgründe Produktbestand — getrennte Listen für Eingang und Ausgang --}}
@foreach ([['in', __('Eingangs-Gründe (Produktbestand)'), $stockReasonsIn], ['out', __('Ausgangs-Gründe (Produktbestand)'), $stockReasonsOut]] as [$dir, $title, $reasons])
<div class="wawi-card">
<div class="wawi-card__header">
<span>{{ $title }}</span>
<a href="{{ route('admin.inventory.stock-movement-reasons.create', ['direction' => $dir]) }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th style="max-width: 60px;">&nbsp;</th>
<th>{{ __('Bezeichnung') }}</th>
<th>{{ __('Status') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($reasons as $reason)
<tr>
<td>
<a href="{{ route('admin.inventory.stock-movement-reasons.edit', $reason) }}" class="btn icon-btn btn-sm btn-primary">
<span class="far fa-edit"></span>
</a>
</td>
<td>{{ $reason->label }}</td>
<td>
@if ($reason->active)
<span class="wawi-pill wawi-pill--ok">{{ __('Aktiv') }}</span>
@else
<span class="wawi-pill wawi-pill--danger">{{ __('Inaktiv') }}</span>
@endif
</td>
<td>
<form action="{{ route('admin.inventory.stock-movement-reasons.destroy', $reason) }}" method="post" class="d-inline"
onsubmit="return confirm('{{ __('Really delete entry?') }}');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-link text-danger p-0" title="{{ __('Delete') }}"><i class="far fa-trash-alt"></i></button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4"><div class="wawi-empty">{{ __('Noch keine Gründe angelegt.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endforeach
@include('admin.inventory.partials.setting-list-card', [ @include('admin.inventory.partials.setting-list-card', [
'title' => __('Lagerorte: Räume'), 'title' => __('Lagerorte: Räume'),
'routePrefix' => 'admin.inventory.storage-rooms', 'routePrefix' => 'admin.inventory.storage-rooms',

View file

@ -176,11 +176,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="ps-move-reason">{{ __('Grund') }} <span class="text-danger">*</span></label> <label for="ps-move-reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="ps-move-reason" class="form-control" required> {{-- AP-48: Optionen werden je Richtung (Eingang/Ausgang) per JS gefüllt --}}
@foreach ($reasons as $reason) <select name="reason" id="ps-move-reason" class="form-control" required></select>
<option value="{{ $reason }}">{{ $reason }}</option>
@endforeach
</select>
</div> </div>
<div class="form-group mb-0"> <div class="form-group mb-0">
<label for="ps-move-note">{{ __('Hinweis') }}</label> <label for="ps-move-note">{{ __('Hinweis') }}</label>
@ -247,6 +244,14 @@
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}"; var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";
var moveStock = 0; var moveStock = 0;
// AP-48: getrennte, pflegbare Buchungsgründe je Richtung.
var psReasons = {in: @json($reasonsIn), out: @json($reasonsOut)};
function psPopulateReasons(direction) {
var $sel = $('#ps-move-reason').empty();
(psReasons[direction] || []).forEach(function (r) {
$sel.append($('<option></option>').val(r).text(r));
});
}
$('.js-ps-move').on('click', function () { $('.js-ps-move').on('click', function () {
var id = $(this).data('product'); var id = $(this).data('product');
var name = $(this).data('name'); var name = $(this).data('name');
@ -254,6 +259,7 @@
moveStock = parseInt($(this).data('stock'), 10) || 0; moveStock = parseInt($(this).data('stock'), 10) || 0;
$('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id)); $('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id));
$('#ps-move-direction').val(direction); $('#ps-move-direction').val(direction);
psPopulateReasons(direction);
$('#ps-move-product').text(name); $('#ps-move-product').text(name);
$('#ps-move-stock').text(moveStock); $('#ps-move-stock').text(moveStock);
$('#ps-move-title').text(direction === 'in' ? '{{ __('Eingang buchen') }}' : '{{ __('Ausgang buchen') }}'); $('#ps-move-title').text(direction === 'in' ? '{{ __('Eingang buchen') }}' : '{{ __('Ausgang buchen') }}');

View file

@ -0,0 +1,57 @@
@extends('layouts.layout-2')
@section('content')
<div class="card">
<h6 class="card-header">{{ $model->exists ? __('Buchungsgrund bearbeiten') : __('Buchungsgrund anlegen') }}</h6>
<div class="card-body">
<form method="post" action="{{ $model->exists ? route('admin.inventory.stock-movement-reasons.update', $model) : route('admin.inventory.stock-movement-reasons.store') }}">
@csrf
@if($model->exists)
@method('PUT')
@endif
<div class="form-group">
<label for="direction">{{ __('Richtung') }}</label>
<select name="direction" id="direction" class="form-control @error('direction') is-invalid @enderror">
<option value="in" @selected(old('direction', $model->direction) === 'in')>{{ __('Eingang') }}</option>
<option value="out" @selected(old('direction', $model->direction) === 'out')>{{ __('Ausgang') }}</option>
</select>
@error('direction')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="label">{{ __('Bezeichnung') }}</label>
<input type="text" name="label" id="label" maxlength="100"
class="form-control @error('label') is-invalid @enderror"
value="{{ old('label', $model->label) }}" placeholder="{{ __('z. B. Retoure') }}" required>
@error('label')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="pos">{{ __('Sortierung') }}</label>
<input type="number" name="pos" id="pos" min="0" max="255"
class="form-control @error('pos') is-invalid @enderror"
value="{{ old('pos', $model->pos) }}">
@error('pos')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label class="custom-control custom-checkbox">
<input type="checkbox" name="active" value="1" class="custom-control-input"
@checked(old('active', $model->active))>
<span class="custom-control-label">{{ __('Aktiv') }}</span>
</label>
</div>
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
<a href="{{ route('admin.inventory.general') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
</form>
</div>
</div>
@endsection

View file

@ -16,6 +16,7 @@ use App\Http\Controllers\Admin\Inventory\ProductStockController as InventoryProd
use App\Http\Controllers\Admin\Inventory\RawMaterialStockController as InventoryRawMaterialStockController; use App\Http\Controllers\Admin\Inventory\RawMaterialStockController as InventoryRawMaterialStockController;
use App\Http\Controllers\Admin\Inventory\StockDisposalController as InventoryStockDisposalController; use App\Http\Controllers\Admin\Inventory\StockDisposalController as InventoryStockDisposalController;
use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController; use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController;
use App\Http\Controllers\Admin\Inventory\StockMovementReasonController;
use App\Http\Controllers\Admin\Inventory\StorageRoomController as InventoryStorageRoomController; use App\Http\Controllers\Admin\Inventory\StorageRoomController as InventoryStorageRoomController;
use App\Http\Controllers\Admin\Inventory\StorageShelfController as InventoryStorageShelfController; use App\Http\Controllers\Admin\Inventory\StorageShelfController as InventoryStorageShelfController;
use App\Http\Controllers\Admin\Inventory\StorageSlotController as InventoryStorageSlotController; use App\Http\Controllers\Admin\Inventory\StorageSlotController as InventoryStorageSlotController;
@ -294,6 +295,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::resource('tax-rates', InventoryTaxRateController::class)->except(['index', 'show']); Route::resource('tax-rates', InventoryTaxRateController::class)->except(['index', 'show']);
Route::resource('delivery-times', InventoryDeliveryTimeController::class)->except(['index', 'show']); Route::resource('delivery-times', InventoryDeliveryTimeController::class)->except(['index', 'show']);
Route::resource('disposal-reasons', InventoryDisposalReasonController::class)->except(['index', 'show']); Route::resource('disposal-reasons', InventoryDisposalReasonController::class)->except(['index', 'show']);
Route::resource('stock-movement-reasons', StockMovementReasonController::class)->except(['index', 'show']);
Route::resource('storage-rooms', InventoryStorageRoomController::class)->except(['index', 'show']); Route::resource('storage-rooms', InventoryStorageRoomController::class)->except(['index', 'show']);
Route::resource('storage-shelves', InventoryStorageShelfController::class)->except(['index', 'show']); Route::resource('storage-shelves', InventoryStorageShelfController::class)->except(['index', 'show']);
Route::resource('storage-slots', InventoryStorageSlotController::class)->except(['index', 'show']); Route::resource('storage-slots', InventoryStorageSlotController::class)->except(['index', 'show']);

View file

@ -0,0 +1,57 @@
<?php
use App\Models\StockMovementReason;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function smrAdmin(): User
{
$user = User::query()->create(['email' => uniqid('smr_', true).'@test.example', 'password' => bcrypt('password')]);
$user->forceFill(['admin' => 9, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
test('AP-48: seed liefert getrennte gründe für eingang und ausgang', function () {
expect(StockMovementReason::query()->where('direction', 'in')->count())->toBeGreaterThan(0)
->and(StockMovementReason::query()->where('direction', 'out')->count())->toBeGreaterThan(0)
// „Testervergabe" ist ein Ausgangs-, kein Eingangsgrund
->and(StockMovementReason::query()->where('direction', 'out')->where('label', 'Testervergabe')->exists())->toBeTrue()
->and(StockMovementReason::query()->where('direction', 'in')->where('label', 'Testervergabe')->exists())->toBeFalse();
});
test('AP-48: grund kann mit richtung angelegt werden', function () {
$this->actingAs(smrAdmin(), 'user');
$this->post(route('admin.inventory.stock-movement-reasons.store'), [
'label' => 'AP48-Sonderentnahme',
'direction' => 'out',
'active' => '1',
'pos' => '3',
])->assertRedirect(route('admin.inventory.general'));
$reason = StockMovementReason::query()->where('label', 'AP48-Sonderentnahme')->firstOrFail();
expect($reason->direction)->toBe('out')->and($reason->active)->toBeTrue();
});
test('AP-48: produktbestand-maske liefert die gründe je richtung', function () {
$this->actingAs(smrAdmin(), 'user');
StockMovementReason::query()->create(['label' => 'AP48-NurEingang', 'direction' => 'in', 'active' => true, 'pos' => 9]);
$this->get(route('admin.inventory.product-stock.index'))
->assertSuccessful()
->assertSee('AP48-NurEingang');
});
test('AP-48: grund ist immer löschbar', function () {
$this->actingAs(smrAdmin(), 'user');
$reason = StockMovementReason::query()->create(['label' => 'AP48-Weg', 'direction' => 'in', 'active' => true, 'pos' => 9]);
$this->delete(route('admin.inventory.stock-movement-reasons.destroy', $reason))
->assertRedirect(route('admin.inventory.general'));
expect(StockMovementReason::query()->find($reason->id))->toBeNull();
});