- M-2: Produktionen buchen Bestand — StoreProductionRequest verlangt
jetzt admin >= 7 (wie manuelle Bestandsbewegungen); create/edit/copy
im Controller gegated, UI-Buttons für niedrigere Stufen ausgeblendet.
- M-3: Ausschuss-/Bewegungs-/Produktions-Buttons nur noch für
Berechtigte sichtbar (keine toten Formulare mehr).
- M-6: Zustandsändernde GET-Routen umgestellt — stock-entries/copy
jetzt POST mit CSRF; /admin/product/delete jetzt DELETE-Formulare
(Produktliste + Rezeptur-Zeile im Produktformular).
- M-7: ProductController::imageAttribute whitelistet Spalten
('active', 'pos') + numerische Werte, sonst 400 — kein beliebiger
Spalten-Write per URL mehr.
- M-10: URL-Felder (Lieferant, Verpackungsartikel, INCI-Lieferanten-
Repeater) validieren url:http,https — keine speicherbaren
javascript:-Links mehr.
Tests: ProductionHardeningTest +2; drei bestehende Tests an neue
Regeln angepasst (admin-Level 7 für Produktions-Formular, POST-Copy).
Suite: 231 grün.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
17 KiB
PHP
323 lines
17 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() && $model->isPending())
|
|
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</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')
|
|
{{ \App\Services\Util::formatNumber($model->ordered_quantity) }} g
|
|
@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')
|
|
{{ \App\Services\Util::formatNumber($model->received_quantity) }} g
|
|
@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>
|
|
|
|
<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', \App\Services\Util::formatNumber($model->ordered_quantity)) }}">
|
|
<small class="text-muted">
|
|
@if($model->unit === 'gram')
|
|
{{ __('Angabe in Gramm') }}
|
|
@else
|
|
{{ __('Angabe in Stück') }}
|
|
@endif
|
|
</small>
|
|
@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
|
|
</div>
|
|
@endsection
|
|
|
|
@if($model->isPending() && $model->entry_type === 'ingredient')
|
|
@section('scripts')
|
|
<script>
|
|
(function ($) {
|
|
// 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})
|
|
.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
|