Warenwirtschaft: AP-42 — Rohstoff-Dichte (g/ml) für ml-Erfassung im Wareneingang

Kundenentscheid 26.08.: Rohstoffe werden in Gramm geführt, dürfen aber in ml
erfasst werden; über die Dichte ergeben sich die Gramm (100 ml Propolistinktur
× 0,79 = 79 g).

- Neues Feld ingredients.density (g/ml, optional) inkl. Feld im INCI-Formular.
- Wareneingang: Einheiten-Umschalter g/ml am Mengenfeld (nur wenn Dichte
  hinterlegt; Live-Hinweis mit Gramm-Umrechnung, Dichte kommt aus der
  Rohstoff-Suche mit). Erfassung in ml wird über die Dichte in Gramm
  umgerechnet und kanonisch als Gramm gespeichert — InventoryService und alle
  Aggregationen bleiben unverändert. purchase_unit + density_used (Snapshot)
  protokollieren die ml-Erfassung für Anzeige/Bearbeitung; Detailseite zeigt
  „790 g (= 1.000 ml)". Wareneingang-Buchung rechnet gelieferte ml ebenfalls um.
- Serverseitige Validierung: ml nur mit hinterlegter Dichte.

Migrationen add_density_* auf Dev-/Live-DB. 4 Tests, Suite 318 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 09:05:07 +00:00
parent d51860af39
commit fa852340ee
13 changed files with 332 additions and 11 deletions

View file

@ -182,6 +182,12 @@
<label class="form-label" for="min_stock_alert">{{ __('Meldebestand (g)') }}</label>
{{ Form::text('min_stock_alert', $model->min_stock_alert !== null ? formatNumber($model->min_stock_alert) : '', array('placeholder'=>__('optional'), 'class'=>'form-control', 'id'=>'min_stock_alert')) }}
</div>
<div class="form-group col-sm-3">
{{-- AP-42: Dichte g/ml — erlaubt die Erfassung des Wareneingangs in ml (100 ml × 0,79 = 79 g). --}}
<label class="form-label" for="density">{{ __('Dichte (g/ml)') }}</label>
{{ Form::text('density', $model->density !== null ? formatNumber($model->density) : '', array('placeholder'=>__('z. B. 0,79 – optional'), 'class'=>'form-control', 'id'=>'density')) }}
<small class="form-text text-muted">{{ __('Leer = Erfassung in Gramm.') }}</small>
</div>
</div>
<div class="form-row">

View file

@ -21,7 +21,7 @@
<select name="ingredient_id" id="ingredient_id" class="w-100"
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}">
@if ($model->ingredient_id && $model->ingredient)
<option value="{{ $model->ingredient_id }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
<option value="{{ $model->ingredient_id }}" data-density="{{ $model->ingredient->density }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
({{ $model->ingredient->inci }})
@endif
</option>
@ -103,15 +103,31 @@
@enderror
</div>
@php
// AP-42: in ml erfasste Rohstoffe im Formular auch in ml anzeigen (Rückrechnung
// über die gespeicherte Dichte); Bestand bleibt intern in Gramm.
$orderedIsMl = $model->purchase_unit === 'ml' && $model->density_used;
$orderedDisplay = $model->ordered_quantity !== null
? \App\Services\Util::formatNumber($orderedIsMl ? $model->ordered_quantity / $model->density_used : $model->ordered_quantity)
: '';
@endphp
<div class="form-group">
<label for="ordered_quantity">{{ __('Bestellte Menge') }} <span class="text-danger">*</span></label>
<input type="text" name="ordered_quantity" id="ordered_quantity"
class="form-control @error('ordered_quantity') is-invalid @enderror"
value="{{ old('ordered_quantity', $model->ordered_quantity !== null ? \App\Services\Util::formatNumber($model->ordered_quantity) : '') }}"
required>
<small class="text-muted">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
<div class="input-group">
<input type="text" name="ordered_quantity" id="ordered_quantity"
class="form-control @error('ordered_quantity') is-invalid @enderror"
value="{{ old('ordered_quantity', $orderedDisplay) }}"
required>
<div class="input-group-append" id="ordered-unit-wrap" style="display:none;">
<select name="ordered_unit" id="ordered_unit" class="form-control">
<option value="gram" @selected(old('ordered_unit', $orderedIsMl ? 'ml' : 'gram') === 'gram')>g</option>
<option value="ml" @selected(old('ordered_unit', $orderedIsMl ? 'ml' : 'gram') === 'ml')>ml</option>
</select>
</div>
</div>
<small class="text-muted" id="ordered-quantity-hint">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
@error('ordered_quantity')
<div class="invalid-feedback">{{ $message }}</div>
<div class="invalid-feedback d-block">{{ $message }}</div>
@enderror
</div>

View file

@ -65,6 +65,44 @@
});
}
// AP-42: aktuelle Dichte des gewählten Rohstoffs (aus dem AJAX-Ergebnis oder
// — bei vorbelegtem Rohstoff — aus der data-density-Option).
function currentDensity() {
var sel = $('#ingredient_id').select2('data');
if (sel && sel[0] && sel[0].density != null && sel[0].density !== '') {
return parseFloat(sel[0].density);
}
var d = $('#ingredient_id option:selected').data('density');
return (d != null && d !== '') ? parseFloat(d) : null;
}
function applyOrderedUnit() {
var isIng = $('#entry_type').val() === 'ingredient';
$('#ordered-unit-wrap').toggle(isIng);
var density = isIng ? currentDensity() : null;
var hasDensity = density != null && density > 0;
$('#ordered_unit option[value="ml"]').prop('disabled', !hasDensity);
if (!hasDensity && $('#ordered_unit').val() === 'ml') {
$('#ordered_unit').val('gram');
}
var $hint = $('#ordered-quantity-hint');
if (!isIng) {
$hint.text('{{ __('Bei Verpackung in Stück.') }}');
return;
}
if ($('#ordered_unit').val() === 'ml' && hasDensity) {
var ml = parseNumber($('#ordered_quantity').val());
var g = ml != null ? formatNumber(ml * density) : '?';
$hint.text('{{ __('Erfassung in ml — entspricht ca.') }} ' + g + ' {{ __('g (Dichte') }} ' + formatNumber(density) + ' g/ml).');
} else if (hasDensity) {
$hint.text('{{ __('In Gramm — oder Einheit auf ml stellen (Dichte hinterlegt).') }}');
} else {
$hint.text('{{ __('In Gramm. Für ml-Erfassung zuerst die Dichte am Rohstoff pflegen.') }}');
}
}
function parseNumber(value) {
if (value === null || value === undefined) {
return null;
@ -106,12 +144,17 @@
toggleBlocks();
initIngredientSelect2();
initPackagingSelect2();
applyOrderedUnit();
$('#entry_type').on('change', function () {
toggleBlocks();
applyOrderedUnit();
$('#packaging_item_id').val(null).trigger('change');
initPackagingSelect2();
});
$('#ingredient_id').on('change', applyOrderedUnit);
$('#ordered_unit').on('change', applyOrderedUnit);
$('#ordered_quantity').on('input', applyOrderedUnit);
$('#price_per_kg').on('input', recalcFromNet);
$('#price_per_kg_gross').on('input', recalcFromGross);

View file

@ -85,6 +85,9 @@
<span class="wawi-deflist__value">
@if($model->unit === 'gram')
{{ \App\Services\Util::formatNumber($model->ordered_quantity) }} g
@if($model->purchase_unit === 'ml' && $model->density_used)
<span class="text-muted">(= {{ \App\Services\Util::formatNumber($model->ordered_quantity / $model->density_used) }} ml)</span>
@endif
@else
{{ \App\Services\Util::formatNumber($model->ordered_quantity, 0) }} {{ __('Stk.') }}
@endif
@ -134,6 +137,9 @@
<span class="wawi-deflist__value">
@if($model->unit === 'gram')
{{ \App\Services\Util::formatNumber($model->received_quantity) }} g
@if($model->purchase_unit === 'ml' && $model->density_used && $model->received_quantity !== null)
<span class="text-muted">(= {{ \App\Services\Util::formatNumber($model->received_quantity / $model->density_used) }} ml)</span>
@endif
@else
{{ \App\Services\Util::formatNumber($model->received_quantity, 0) }} {{ __('Stk.') }}
@endif
@ -184,13 +190,23 @@
@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
? \App\Services\Util::formatNumber($model->ordered_quantity / $model->density_used)
: \App\Services\Util::formatNumber($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', \App\Services\Util::formatNumber($model->ordered_quantity)) }}">
value="{{ old('received_quantity', $receiveDefault) }}">
<small class="text-muted">
@if($model->unit === 'gram')
@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') }}