gruene-seele/resources/views/admin/inventory/stock-entries/show.blade.php
Kevin Adametz 736cb087ae 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>
2026-09-14 14:51:27 +00:00

403 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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