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>
403 lines
22 KiB
PHP
403 lines
22 KiB
PHP
@php
|
||
/** @var \App\Models\StockEntry $model */
|
||
@endphp
|
||
|
||
@extends('layouts.layout-2')
|
||
|
||
@section('content')
|
||
@include('admin.inventory.partials.wawi-ui')
|
||
<div class="wawi-page">
|
||
<div class="wawi-page-head">
|
||
<div>
|
||
<h1 class="wawi-page-head__title">
|
||
{{ __('Wareneingang') }}
|
||
@if($model->status === 'pending')
|
||
<span class="wawi-pill wawi-pill--warning">{{ __('Offen') }}</span>
|
||
@else
|
||
<span class="wawi-pill wawi-pill--ok">{{ __('Eingegangen') }}</span>
|
||
@endif
|
||
</h1>
|
||
<p class="wawi-page-head__subtitle">{{ $entryTypeLabels[$model->entry_type] ?? $model->entry_type }}</p>
|
||
</div>
|
||
<div class="wawi-page-head__actions">
|
||
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück zur Liste') }}</a>
|
||
@if(Auth::user()->isAdmin())
|
||
<form action="{{ route('admin.inventory.stock-entries.copy', $model) }}" method="post" class="d-inline">
|
||
@csrf
|
||
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
|
||
</form>
|
||
@endif
|
||
@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>
|
||
|
||
<div class="card wawi-card mb-3">
|
||
<div class="wawi-card__header">{{ __('Details') }}</div>
|
||
<div class="card-body">
|
||
<div class="wawi-deflist">
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Art') }}</span>
|
||
<span class="wawi-deflist__value">{{ $entryTypeLabels[$model->entry_type] ?? $model->entry_type }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Artikel') }}</span>
|
||
<span class="wawi-deflist__value">
|
||
@if($model->entry_type === 'ingredient' && $model->ingredient)
|
||
{{ $model->ingredient->name }}@if($model->ingredient->inci) ({{ $model->ingredient->inci }})@endif
|
||
@elseif($model->packagingItem)
|
||
{{ $model->packagingItem->name }}
|
||
@if($model->packagingItem->packagingMaterial)
|
||
<span class="text-muted">— {{ $model->packagingItem->packagingMaterial->name }}</span>
|
||
@endif
|
||
@else
|
||
—
|
||
@endif
|
||
</span>
|
||
</div>
|
||
|
||
@if($model->entry_type === 'ingredient' && $model->quality)
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Rohstoffqualität') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->quality->name }}</span>
|
||
</div>
|
||
@endif
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Lieferant') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->supplier?->name ?? '—' }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Lagerort') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->location?->name ?? '—' }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Bestelldatum') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->ordered_at?->format('d.m.Y') }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Bestellte Menge') }}</span>
|
||
<span class="wawi-deflist__value">
|
||
@if($model->unit === 'gram')
|
||
{{ formatWawiQuantity($model->ordered_quantity) }} g
|
||
@if($model->purchase_unit === 'ml' && $model->density_used)
|
||
<span class="text-muted">(= {{ formatWawiQuantity($model->ordered_quantity / $model->density_used) }} ml)</span>
|
||
@endif
|
||
@else
|
||
{{ \App\Services\Util::formatNumber($model->ordered_quantity, 0) }} {{ __('Stk.') }}
|
||
@endif
|
||
</span>
|
||
</div>
|
||
|
||
@if(Auth::user()->isAdmin())
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Preise (Stufe 1)') }}</span>
|
||
<span class="wawi-deflist__value">
|
||
@if($model->entry_type === 'ingredient')
|
||
@if($model->price_per_kg !== null)
|
||
{{ \App\Services\Util::formatNumber($model->price_per_kg) }} € / kg {{ __('netto') }}
|
||
@else
|
||
—
|
||
@endif
|
||
@if($model->price_per_kg_gross !== null)
|
||
<span class="text-muted">· {{ \App\Services\Util::formatNumber($model->price_per_kg_gross) }} € / kg {{ __('brutto') }}</span>
|
||
@endif
|
||
@if($model->tax_rate_percent !== null)
|
||
<span class="text-muted">· {{ __('USt.') }} {{ \App\Services\Util::formatNumber($model->tax_rate_percent) }} %</span>
|
||
@endif
|
||
@else
|
||
@if($model->price_total !== null)
|
||
{{ \App\Services\Util::formatNumber($model->price_total) }} € {{ __('netto') }}
|
||
@else
|
||
—
|
||
@endif
|
||
@endif
|
||
</span>
|
||
</div>
|
||
@endif
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Bestellt von') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->orderedByUser?->getFullName(false) ?: $model->orderedByUser?->email ?? '—' }}</span>
|
||
</div>
|
||
|
||
@if($model->isReceived())
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Eingangsdatum') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->received_at?->format('d.m.Y') }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Eingegangene Menge') }}</span>
|
||
<span class="wawi-deflist__value">
|
||
@if($model->unit === 'gram')
|
||
{{ formatWawiQuantity($model->received_quantity) }} g
|
||
@if($model->purchase_unit === 'ml' && $model->density_used && $model->received_quantity !== null)
|
||
<span class="text-muted">(= {{ formatWawiQuantity($model->received_quantity / $model->density_used) }} ml)</span>
|
||
@endif
|
||
@else
|
||
{{ \App\Services\Util::formatNumber($model->received_quantity, 0) }} {{ __('Stk.') }}
|
||
@endif
|
||
</span>
|
||
</div>
|
||
|
||
@if($model->entry_type === 'ingredient')
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Charge') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->batch_number ?: '—' }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Mindesthaltbarkeit') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->best_before?->format('d.m.Y') ?? '—' }}</span>
|
||
</div>
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Materialqualität') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->quality?->name ?? '—' }}</span>
|
||
</div>
|
||
@endif
|
||
|
||
<div class="wawi-deflist__item">
|
||
<span class="wawi-deflist__label">{{ __('Eingebucht von') }}</span>
|
||
<span class="wawi-deflist__value">{{ $model->receivedByUser?->getFullName(false) ?: $model->receivedByUser?->email ?? '—' }}</span>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@if($model->isPending())
|
||
<div class="card wawi-card">
|
||
<div class="wawi-card__header">{{ __('Wareneingang buchen (Stufe 2)') }}</div>
|
||
<div class="card-body">
|
||
<form method="post" action="{{ route('admin.inventory.stock-entries.receive', $model) }}">
|
||
@csrf
|
||
@method('PUT')
|
||
|
||
<div class="form-group">
|
||
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
|
||
<input type="text" name="received_at" id="received_at" required autocomplete="off"
|
||
class="form-control datepicker-base @error('received_at') is-invalid @enderror"
|
||
value="{{ old('received_at', now()->format('d.m.Y')) }}">
|
||
@error('received_at')
|
||
<div class="invalid-feedback">{{ $message }}</div>
|
||
@enderror
|
||
</div>
|
||
|
||
@php
|
||
// AP-42: Wurde in ml eingekauft, wird auch der Eingang in ml erfasst
|
||
// und über die gespeicherte Dichte in Gramm umgerechnet.
|
||
$receiveIsMl = $model->purchase_unit === 'ml' && $model->density_used;
|
||
$receiveDefault = $receiveIsMl
|
||
? formatWawiQuantity($model->ordered_quantity / $model->density_used)
|
||
: formatWawiQuantity($model->ordered_quantity);
|
||
@endphp
|
||
<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', $receiveDefault) }}">
|
||
<small class="text-muted d-block">
|
||
@if($receiveIsMl)
|
||
{{ __('Angabe in ml — Umrechnung in Gramm über die Dichte') }} ({{ \App\Services\Util::formatNumber($model->density_used) }} g/ml)
|
||
@elseif($model->unit === 'gram')
|
||
{{ __('Angabe in Gramm') }}
|
||
@else
|
||
{{ __('Angabe in Stück') }}
|
||
@endif
|
||
</small>
|
||
@if($receiveIsMl)
|
||
<small class="d-block font-weight-bold text-body mt-1" id="received-quantity-preview" style="display:none;"></small>
|
||
@endif
|
||
@error('received_quantity')
|
||
<div class="invalid-feedback">{{ $message }}</div>
|
||
@enderror
|
||
</div>
|
||
|
||
@if($model->entry_type === 'ingredient')
|
||
<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') }}">
|
||
@error('batch_number')
|
||
<div class="invalid-feedback">{{ $message }}</div>
|
||
@enderror
|
||
{{-- AP-27: Hinweis, wenn die Charge für diesen Rohstoff bereits im Haus ist --}}
|
||
<div id="batch-merge-hint" class="alert alert-info py-2 px-3 mt-2 mb-0 small" style="display:none;"></div>
|
||
</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') }}">
|
||
@error('best_before')
|
||
<div class="invalid-feedback">{{ $message }}</div>
|
||
@enderror
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="quality_id">{{ __('Rohstoffqualität') }} <span class="text-danger">*</span></label>
|
||
<select name="quality_id" id="quality_id" class="form-control @error('quality_id') is-invalid @enderror">
|
||
<option value="">{{ __('Bitte wählen') }}</option>
|
||
@foreach($materialQualities as $mq)
|
||
<option value="{{ $mq->id }}" @selected((string)old('quality_id', $model->quality_id) === (string)$mq->id)>{{ $mq->name }}</option>
|
||
@endforeach
|
||
</select>
|
||
@error('quality_id')
|
||
<div class="invalid-feedback">{{ $message }}</div>
|
||
@enderror
|
||
</div>
|
||
@endif
|
||
|
||
<button type="submit" class="btn btn-primary">{{ __('Als eingegangen buchen') }}</button>
|
||
</form>
|
||
</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
|
||
|
||
@if($model->isPending() && $model->entry_type === 'ingredient')
|
||
@section('scripts')
|
||
<script>
|
||
(function ($) {
|
||
// AP-42: Live-Vorschau der ml→g-Umrechnung auch beim Buchen des Eingangs.
|
||
var receiveDensity = @json($model->purchase_unit === 'ml' && $model->density_used ? (float) $model->density_used : null);
|
||
if (receiveDensity) {
|
||
var $rq = $('#received_quantity');
|
||
var $rqPreview = $('#received-quantity-preview');
|
||
var fmtQ = function (v) {
|
||
return Number(v).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: false});
|
||
};
|
||
var renderReceivePreview = function () {
|
||
var raw = String($rq.val() || '').trim();
|
||
var norm = /^-?(\d+\.\d{1,2}|0\.\d{3})$/.test(raw) ? raw : raw.replace(/\./g, '').replace(',', '.');
|
||
var ml = parseFloat(norm);
|
||
if (!raw || isNaN(ml) || ml <= 0) {
|
||
$rqPreview.hide().text('');
|
||
return;
|
||
}
|
||
$rqPreview.text(fmtQ(ml) + ' ml × ' + receiveDensity.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 4, useGrouping: false}) + ' g/ml = ' + fmtQ(ml * receiveDensity) + ' g').show();
|
||
};
|
||
$rq.on('input change', renderReceivePreview);
|
||
renderReceivePreview();
|
||
}
|
||
|
||
// AP-27: Beim Eintippen der Chargennummer prüfen, ob die Charge schon im Haus ist.
|
||
var checkUrl = @json(route('admin.inventory.api.stock-entries.batch-check'));
|
||
var ingredientId = @json($model->ingredient_id);
|
||
var excludeId = @json($model->id);
|
||
var $batch = $('#batch_number');
|
||
var $hint = $('#batch-merge-hint');
|
||
var timer = null;
|
||
var lastChecked = null;
|
||
|
||
function fmt(value) {
|
||
return Number(value).toLocaleString('de-DE', {maximumFractionDigits: 0});
|
||
}
|
||
|
||
function runCheck() {
|
||
var batch = ($batch.val() || '').trim();
|
||
if (!batch || !ingredientId) {
|
||
$hint.hide();
|
||
lastChecked = null;
|
||
return;
|
||
}
|
||
if (batch === lastChecked) {
|
||
return;
|
||
}
|
||
lastChecked = batch;
|
||
|
||
$.getJSON(checkUrl, {ingredient_id: ingredientId, batch_number: batch, exclude: excludeId, supplier_id: {{ (int) $model->supplier_id }}})
|
||
.done(function (data) {
|
||
if (!data.exists) {
|
||
$hint.hide();
|
||
return;
|
||
}
|
||
var parts = ['<strong>{{ __('Charge bereits im Haus') }}</strong> — {{ __('die Menge wird dieser Charge zugerechnet, der Einkauf bleibt separat erfasst.') }}'];
|
||
var facts = [];
|
||
facts.push('{{ __('Restbestand') }}: ' + fmt(data.remaining) + ' g');
|
||
if (data.purchases > 1) {
|
||
facts.push(data.purchases + ' {{ __('Einkäufe') }}');
|
||
}
|
||
if (data.location) {
|
||
facts.push('{{ __('Lagerort') }}: ' + $('<span>').text(data.location).html());
|
||
}
|
||
if (data.best_before) {
|
||
facts.push('{{ __('MHD') }}: ' + data.best_before);
|
||
}
|
||
parts.push('<br>' + facts.join(' · '));
|
||
|
||
// Vorbelegung (überschreibbar): MHD + Qualität aus dem letzten Eingang der Charge.
|
||
if (data.best_before && !$('#best_before').val()) {
|
||
$('#best_before').val(data.best_before);
|
||
parts.push('<br>{{ __('Mindesthaltbarkeit wurde vorbelegt.') }}');
|
||
}
|
||
var $quality = $('#quality_id');
|
||
if (data.quality_id && $quality.length && !$quality.val()) {
|
||
$quality.val(String(data.quality_id));
|
||
}
|
||
|
||
$hint.html(parts.join('')).show();
|
||
})
|
||
.fail(function () {
|
||
$hint.hide();
|
||
});
|
||
}
|
||
|
||
$batch.on('input', function () {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(runCheck, 400);
|
||
});
|
||
$batch.on('blur', runCheck);
|
||
})(jQuery);
|
||
</script>
|
||
@endsection
|
||
@endif
|