Warenwirtschaft: Einkäufe auch nach Buchung korrigierbar, mit Änderungsprotokoll
Bisher ließen sich nur offene Bestellungen bearbeiten; ein Tippfehler in einem gebuchten Wareneingang war nicht korrigierbar (Anforderung 14.09.2026). - Bearbeiten/Korrigieren für gebuchte Eingänge (Admin): Bestell- und Eingangsdaten (Menge, Datum, Charge, MHD, Qualität, Preise, Lieferant, Lagerort). Menge wirkt sofort auf den Bestand; ml-Eingänge werden in ml korrigiert und über die gespeicherte Dichte in Gramm gebucht. - Schutz: Menge nicht unter den bereits verbrauchten Anteil (Rohstoff je Eingang, Verpackung je Artikel); bei Verbrauch sind Art und Rohstoff/ Artikel gesperrt (Formular + Server). - Änderungsprotokoll (stock_entry_changes): wer/wann/Grund + geänderte Felder alt → neu; Anzeige auf der Detailseite. Grund bei gebuchten Eingängen Pflicht, bei offenen optional. Speichern ohne Änderung erzeugt keine Zeile. - Tests (6 neue), In-App-Doku (Hinweise + Leitfaden 3.2) nachgezogen. Migration: 2026_09_14_110000_create_stock_entry_changes_table. Suite: 396 grün. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
be1d8fdc39
commit
736cb087ae
14 changed files with 783 additions and 37 deletions
|
|
@ -1,11 +1,29 @@
|
|||
@php
|
||||
/** @var \App\Models\StockEntry $model */
|
||||
$isEdit = $model->exists;
|
||||
// Anforderung 14.09.2026: gebuchte Eingänge korrigierbar. Wurde daraus schon
|
||||
// verbraucht, sind Art und Rohstoff/Artikel gesperrt (Bestand bliebe sonst inkonsistent).
|
||||
$isReceivedEdit = $isEdit && $model->isReceived();
|
||||
$correctionBlockers = $correctionBlockers ?? [];
|
||||
$lockItem = $isReceivedEdit && $correctionBlockers !== [];
|
||||
@endphp
|
||||
|
||||
@if($isReceivedEdit)
|
||||
<div class="alert alert-warning py-2 px-3">
|
||||
<strong>{{ __('Korrektur eines gebuchten Eingangs.') }}</strong>
|
||||
{{ __('Änderungen wirken sofort auf den Bestand und werden mit Grund im Änderungsprotokoll festgehalten.') }}
|
||||
@if($lockItem)
|
||||
<br>{{ __('Aus diesem Eingang wurde bereits verbraucht (:blockers) — Art und Rohstoff/Artikel sind deshalb gesperrt.', ['blockers' => implode(', ', $correctionBlockers)]) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="form-group">
|
||||
<label for="entry_type">{{ __('Art') }} <span class="text-danger">*</span></label>
|
||||
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required>
|
||||
@if($lockItem)
|
||||
<input type="hidden" name="entry_type" value="{{ $model->entry_type }}">
|
||||
@endif
|
||||
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required @disabled($lockItem)>
|
||||
@foreach ($entryTypeLabels as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('entry_type', $model->entry_type ?? 'ingredient') === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
|
|
@ -17,9 +35,12 @@
|
|||
|
||||
<div id="stock-entry-ingredient-block" class="form-group" style="display:none;">
|
||||
<label for="ingredient_id">{{ __('Inhaltsstoff') }} <span class="text-danger">*</span></label>
|
||||
@if($lockItem && $model->ingredient_id)
|
||||
<input type="hidden" name="ingredient_id" value="{{ $model->ingredient_id }}">
|
||||
@endif
|
||||
<div class="light-style">
|
||||
<select name="ingredient_id" id="ingredient_id" class="w-100"
|
||||
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}">
|
||||
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}" @disabled($lockItem)>
|
||||
@if ($model->ingredient_id && $model->ingredient)
|
||||
{{-- N-06: fehlt die Stammdaten-Dichte inzwischen, gilt der Dichte-Snapshot der Bestellung --}}
|
||||
<option value="{{ $model->ingredient_id }}" data-density="{{ $model->ingredient->density ?? $model->density_used }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
|
||||
|
|
@ -51,9 +72,12 @@
|
|||
|
||||
<div id="stock-entry-packaging-block" class="form-group" style="display:none;">
|
||||
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
|
||||
@if($lockItem && $model->packaging_item_id)
|
||||
<input type="hidden" name="packaging_item_id" value="{{ $model->packaging_item_id }}">
|
||||
@endif
|
||||
<div class="light-style">
|
||||
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
|
||||
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}">
|
||||
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}" @disabled($lockItem)>
|
||||
@if ($model->packaging_item_id && $model->packagingItem)
|
||||
<option value="{{ $model->packaging_item_id }}" selected>{{ $model->packagingItem->name }}</option>
|
||||
@elseif(old('packaging_item_id'))
|
||||
|
|
@ -200,3 +224,71 @@
|
|||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@if($isReceivedEdit)
|
||||
@php
|
||||
$receivedDisplay = $model->received_quantity !== null
|
||||
? formatWawiQuantity($orderedIsMl ? $model->received_quantity / $model->density_used : $model->received_quantity)
|
||||
: '';
|
||||
@endphp
|
||||
<hr>
|
||||
<h6 class="font-weight-bold">{{ __('Wareneingang (gebucht)') }}</h6>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="received_at" id="received_at" autocomplete="off" required
|
||||
class="form-control datepicker-base @error('received_at') is-invalid @enderror"
|
||||
value="{{ old('received_at', $model->received_at?->format('d.m.Y')) }}">
|
||||
@error('received_at')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="received_quantity">{{ __('Eingegangene Menge') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="received_quantity" id="received_quantity" required
|
||||
class="form-control @error('received_quantity') is-invalid @enderror"
|
||||
value="{{ old('received_quantity', $receivedDisplay) }}">
|
||||
<small class="text-muted d-block" id="received-quantity-hint">{{ $model->unit === 'piece' ? __('Angabe in Stück.') : __('Angabe in der oben gewählten Einheit.') }}</small>
|
||||
<small class="d-block font-weight-bold text-body mt-1" id="received-quantity-preview" style="display:none;"></small>
|
||||
@error('received_quantity')
|
||||
<div class="invalid-feedback d-block">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div id="received-ingredient-block" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label for="batch_number">{{ __('Chargennummer') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="batch_number" id="batch_number" maxlength="100"
|
||||
class="form-control @error('batch_number') is-invalid @enderror"
|
||||
value="{{ old('batch_number', $model->batch_number) }}">
|
||||
@error('batch_number')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="best_before" id="best_before" autocomplete="off"
|
||||
class="form-control datepicker-base @error('best_before') is-invalid @enderror"
|
||||
value="{{ old('best_before', $model->best_before?->format('d.m.Y')) }}">
|
||||
@error('best_before')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($isEdit)
|
||||
<hr>
|
||||
<div class="form-group">
|
||||
<label for="change_reason">{{ __('Grund der Änderung') }} @if($isReceivedEdit)<span class="text-danger">*</span>@endif</label>
|
||||
<input type="text" name="change_reason" id="change_reason" maxlength="500" @required($isReceivedEdit)
|
||||
class="form-control @error('change_reason') is-invalid @enderror"
|
||||
value="{{ old('change_reason') }}" placeholder="{{ __('z. B. Tippfehler bei der Menge, Lieferschein nachgetragen') }}">
|
||||
<small class="text-muted d-block">{{ __('Wird mit Datum, Bearbeiter und den geänderten Werten im Änderungsprotokoll des Eintrags gespeichert.') }}</small>
|
||||
@error('change_reason')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -8,6 +8,34 @@
|
|||
$('#stock-entry-packaging-block').toggle(!isIng);
|
||||
$('#price-per-kg-block').toggle(isIng);
|
||||
$('#price-total-block').toggle(!isIng);
|
||||
$('#received-ingredient-block').toggle(isIng);
|
||||
}
|
||||
|
||||
// Korrektur eines gebuchten Eingangs: die eingegangene Menge folgt derselben
|
||||
// Einheit wie die Bestellung (Hinweis + ml→g-Vorschau am Feld).
|
||||
function applyReceivedUnit(isIng, unit, density, hasDensity) {
|
||||
var $rq = $('#received_quantity');
|
||||
if (!$rq.length) {
|
||||
return;
|
||||
}
|
||||
var $hint = $('#received-quantity-hint');
|
||||
var $preview = $('#received-quantity-preview');
|
||||
$preview.hide().text('');
|
||||
if (!isIng) {
|
||||
$hint.text('{{ __('Angabe in Stück.') }}');
|
||||
return;
|
||||
}
|
||||
if (unit === 'ml' && hasDensity) {
|
||||
$hint.text('{{ __('Angabe in ml — der Bestand wird über die Dichte in Gramm gebucht.') }}');
|
||||
var ml = parseNumber($rq.val());
|
||||
if (ml != null && ml > 0) {
|
||||
$preview.text(formatQuantity(ml) + ' ml × ' + formatDensity(density) + ' g/ml = ' + formatQuantity(ml * density) + ' g').show();
|
||||
}
|
||||
} else if (unit === 'gram') {
|
||||
$hint.text('{{ __('Angabe in Gramm.') }}');
|
||||
} else {
|
||||
$hint.text('{{ __('Bitte zuerst oben die Einheit wählen.') }}');
|
||||
}
|
||||
}
|
||||
|
||||
function initIngredientSelect2() {
|
||||
|
|
@ -95,6 +123,7 @@
|
|||
var $hint = $('#ordered-quantity-hint');
|
||||
var $preview = $('#ordered-quantity-preview');
|
||||
$preview.hide().text('');
|
||||
applyReceivedUnit(isIng, $unit.val(), density, hasDensity);
|
||||
if (!isIng) {
|
||||
$hint.text('{{ __('Bei Verpackung in Stück.') }}');
|
||||
return;
|
||||
|
|
@ -186,6 +215,7 @@
|
|||
$('#ingredient_id').on('change', applyOrderedUnit);
|
||||
$('#ordered_unit').on('change', applyOrderedUnit);
|
||||
$('#ordered_quantity').on('input', applyOrderedUnit);
|
||||
$('#received_quantity').on('input', applyOrderedUnit);
|
||||
|
||||
$('#price_per_kg').on('input', recalcFromNet);
|
||||
$('#price_per_kg_gross').on('input', recalcFromGross);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2">{{ __('Bestellung bearbeiten') }}</h4>
|
||||
<h4 class="font-weight-bold py-2 mb-2">{{ $model->isReceived() ? __('Wareneingang korrigieren') : __('Bestellung bearbeiten') }}</h4>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ route('admin.inventory.stock-entries.update', $model) }}">
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
@method('PUT')
|
||||
@include('admin.inventory.stock-entries._form')
|
||||
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
||||
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||
<a href="{{ $model->isReceived() ? route('admin.inventory.stock-entries.show', $model) : route('admin.inventory.stock-entries.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@
|
|||
<a href="{{ route('admin.inventory.stock-entries.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
||||
<span class="far fa-eye"></span>
|
||||
</a>
|
||||
@if(Auth::user()->isAdmin() && $row->status === 'pending')
|
||||
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
||||
@if(Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ $row->status === 'pending' ? __('Bearbeiten') : __('Korrigieren (mit Protokoll)') }}">
|
||||
<span class="far fa-edit"></span>
|
||||
</a>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@
|
|||
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
@if(Auth::user()->isAdmin() && $model->isPending())
|
||||
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>
|
||||
@if(Auth::user()->isAdmin())
|
||||
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ $model->isPending() ? __('Bearbeiten') : __('Korrigieren') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,6 +262,45 @@
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Anforderung 14.09.2026: Änderungsprotokoll nachträglicher Korrekturen --}}
|
||||
@if($model->changes->isNotEmpty())
|
||||
<div class="card wawi-card">
|
||||
<div class="wawi-card__header">{{ __('Änderungsprotokoll') }} <span class="text-muted font-weight-normal">({{ $model->changes->count() }})</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm wawi-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:11rem">{{ __('Wann / Wer') }}</th>
|
||||
<th>{{ __('Grund') }}</th>
|
||||
<th>{{ __('Geändert (alt → neu)') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($model->changes as $change)
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
{{ $change->created_at->format('d.m.Y H:i') }}<br>
|
||||
<span class="text-muted small">{{ $change->user?->getFullName(false) ?: $change->user?->email ?? '—' }}</span>
|
||||
@if($change->status_before === 'received')
|
||||
<br><span class="wawi-pill wawi-pill--warning">{{ __('nach Buchung') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $change->reason ?: '—' }}</td>
|
||||
<td>
|
||||
@forelse($change->describe() as $row)
|
||||
<div><span class="text-muted">{{ $row['label'] }}:</span> {{ $row['old'] }} → <strong>{{ $row['new'] }}</strong></div>
|
||||
@empty
|
||||
<span class="text-muted">{{ __('keine Feldänderung') }}</span>
|
||||
@endforelse
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue