gruene-seele/resources/views/admin/inventory/stock-entries/show.blade.php
Kevin Adametz b8180a06a1 Warenwirtschaft: AP-53 — Gramm-Mengen mit drei Nachkommastellen (Produktion, Wareneingang, Ausschuss)
Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g.

- Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert.
- InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController.
- formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail.
- Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125).
- Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001.
- Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378).

Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:54:29 +00:00

339 lines
18 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')
{{ 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">
@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>
@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, 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