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

@ -173,6 +173,59 @@
</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', [
'title' => __('Lagerorte: Räume'),
'routePrefix' => 'admin.inventory.storage-rooms',

View file

@ -176,11 +176,8 @@
</div>
<div class="form-group">
<label for="ps-move-reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="ps-move-reason" class="form-control" required>
@foreach ($reasons as $reason)
<option value="{{ $reason }}">{{ $reason }}</option>
@endforeach
</select>
{{-- AP-48: Optionen werden je Richtung (Eingang/Ausgang) per JS gefüllt --}}
<select name="reason" id="ps-move-reason" class="form-control" required></select>
</div>
<div class="form-group mb-0">
<label for="ps-move-note">{{ __('Hinweis') }}</label>
@ -247,6 +244,14 @@
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";
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 () {
var id = $(this).data('product');
var name = $(this).data('name');
@ -254,6 +259,7 @@
moveStock = parseInt($(this).data('stock'), 10) || 0;
$('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id));
$('#ps-move-direction').val(direction);
psPopulateReasons(direction);
$('#ps-move-product').text(name);
$('#ps-move-stock').text(moveStock);
$('#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