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

@ -274,7 +274,7 @@ class StockEntryController extends Controller
})
->orderBy('name')
->limit(30)
->get(['id', 'name', 'inci']);
->get(['id', 'name', 'inci', 'density']);
$results = $rows->map(function (Ingredient $i) {
$text = $i->name;
@ -282,7 +282,8 @@ class StockEntryController extends Controller
$text .= ' ('.$i->inci.')';
}
return ['id' => $i->id, 'text' => $text];
// AP-42: Dichte mitliefern, damit die Maske die ml-Erfassung anbieten kann.
return ['id' => $i->id, 'text' => $text, 'density' => $i->density !== null ? (float) $i->density : null];
})->values()->all();
return response()->json(['results' => $results]);

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests\Inventory;
use App\Models\Ingredient;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
@ -26,6 +27,7 @@ class StoreStockEntryRequest extends FormRequest
'location_id' => ['required', 'integer', 'exists:locations,id'],
'ordered_at' => ['required', 'date'],
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
'ordered_unit' => ['nullable', Rule::in(['gram', 'ml'])],
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
@ -67,6 +69,13 @@ class StoreStockEntryRequest extends FormRequest
if (empty($this->input('quality_id'))) {
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
}
// AP-42: ml-Erfassung nur, wenn für den Rohstoff eine Dichte hinterlegt ist.
if ($this->input('ordered_unit') === 'ml') {
$density = Ingredient::query()->whereKey($this->input('ingredient_id'))->value('density');
if ($density === null || (float) $density <= 0) {
$validator->errors()->add('ordered_quantity', __('Für diesen Rohstoff ist keine Dichte hinterlegt — bitte in Gramm erfassen oder zuerst die Dichte pflegen.'));
}
}
$net = $this->input('price_per_kg');
$gross = $this->input('price_per_kg_gross');
$hasNet = is_numeric($net) && (float) $net > 0;

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests\Inventory;
use App\Models\Ingredient;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
@ -26,6 +27,7 @@ class UpdateStockEntryRequest extends FormRequest
'location_id' => ['required', 'integer', 'exists:locations,id'],
'ordered_at' => ['required', 'date'],
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
'ordered_unit' => ['nullable', Rule::in(['gram', 'ml'])],
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
@ -67,6 +69,13 @@ class UpdateStockEntryRequest extends FormRequest
if (empty($this->input('quality_id'))) {
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
}
// AP-42: ml-Erfassung nur mit hinterlegter Dichte.
if ($this->input('ordered_unit') === 'ml') {
$density = Ingredient::query()->whereKey($this->input('ingredient_id'))->value('density');
if ($density === null || (float) $density <= 0) {
$validator->errors()->add('ordered_quantity', __('Für diesen Rohstoff ist keine Dichte hinterlegt — bitte in Gramm erfassen oder zuerst die Dichte pflegen.'));
}
}
$net = $this->input('price_per_kg');
$gross = $this->input('price_per_kg_gross');
$hasNet = is_numeric($net) && (float) $net > 0;

View file

@ -24,6 +24,7 @@ class StoreIngredientRequest extends FormRequest
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
'default_factor' => ['nullable', 'numeric', 'min:0'],
'min_stock_alert' => ['nullable', 'numeric', 'min:0'],
'density' => ['nullable', 'numeric', 'min:0'],
'material_quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
'delivery_time' => ['nullable', 'string', 'max:255'],
@ -46,6 +47,7 @@ class StoreIngredientRequest extends FormRequest
'exclude_from_stock' => $this->boolean('exclude_from_stock'),
'default_factor' => $this->normalizeDecimal($this->input('default_factor')),
'min_stock_alert' => $this->normalizeDecimal($this->input('min_stock_alert')),
'density' => $this->normalizeDecimal($this->input('density')),
'material_quality_id' => $this->nullIfEmpty($this->input('material_quality_id')),
'tax_rate_id' => $this->nullIfEmpty($this->input('tax_rate_id')),
'delivery_time_days' => $this->nullIfEmpty($this->input('delivery_time_days')),

View file

@ -62,6 +62,7 @@ class Ingredient extends Model
'pos' => 'int',
'default_factor' => 'decimal:2',
'min_stock_alert' => 'decimal:2',
'density' => 'decimal:4',
'delivery_time_days' => 'integer',
];
@ -77,6 +78,7 @@ class Ingredient extends Model
'pos',
'default_factor',
'min_stock_alert',
'density',
'exclude_from_stock',
'material_quality_id',
'tax_rate_id',

View file

@ -23,6 +23,8 @@ class StockEntry extends Model
'supplier_id',
'location_id',
'unit',
'purchase_unit',
'density_used',
'ordered_by',
'ordered_at',
'ordered_quantity',
@ -51,6 +53,7 @@ class StockEntry extends Model
'best_before' => 'date',
'ordered_quantity' => 'decimal:2',
'received_quantity' => 'decimal:2',
'density_used' => 'decimal:4',
'price_per_kg' => 'decimal:4',
'price_per_kg_gross' => 'decimal:4',
'price_total' => 'decimal:4',

View file

@ -2,6 +2,7 @@
namespace App\Repositories;
use App\Models\Ingredient;
use App\Models\StockEntry;
use App\Models\TaxRate;
use Illuminate\Database\Eloquent\Collection;
@ -14,6 +15,7 @@ class StockEntryRepository
public function create(array $data): StockEntry
{
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
$data = $this->applyPurchaseUnit($data);
$data = $this->resolvePrices($data);
return StockEntry::query()->create($data);
@ -26,6 +28,7 @@ class StockEntryRepository
{
if (array_key_exists('entry_type', $data)) {
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
$data = $this->applyPurchaseUnit($data);
}
$data = $this->resolvePrices($data);
@ -35,6 +38,44 @@ class StockEntryRepository
return $stockEntry->fresh();
}
/**
* AP-42: In ml erfasste Rohstoffmengen über die Dichte in Gramm umrechnen und
* kanonisch als Gramm speichern (Bestand bleibt in Gramm). purchase_unit +
* density_used protokollieren die Erfassungseinheit für Anzeige/Bearbeitung.
* Ohne hinterlegte Dichte gilt die Menge als Gramm.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function applyPurchaseUnit(array $data): array
{
$orderedUnit = $data['ordered_unit'] ?? 'gram';
unset($data['ordered_unit']);
if (($data['entry_type'] ?? '') !== 'ingredient') {
$data['purchase_unit'] = null;
$data['density_used'] = null;
return $data;
}
$data['purchase_unit'] = 'gram';
$data['density_used'] = null;
if ($orderedUnit === 'ml') {
$density = Ingredient::query()->whereKey($data['ingredient_id'] ?? null)->value('density');
if ($density !== null && (float) $density > 0) {
$data['purchase_unit'] = 'ml';
$data['density_used'] = (float) $density;
if (isset($data['ordered_quantity'])) {
$data['ordered_quantity'] = round((float) $data['ordered_quantity'] * (float) $density, 2);
}
}
}
return $data;
}
/**
* Ergänzt Netto-/Brutto-Preis pro kg und den UST-Snapshot.
* Es genügt, Netto oder Brutto anzugeben; der jeweils fehlende Wert wird
@ -92,6 +133,12 @@ class StockEntryRepository
$data['status'] = 'received';
$data['received_by'] = auth()->id();
// AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst
// und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet.
if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && isset($data['received_quantity'])) {
$data['received_quantity'] = round((float) $data['received_quantity'] * (float) $stockEntry->density_used, 2);
}
$stockEntry->update($data);
return $stockEntry->fresh();

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-42 (Kundenentscheid 26.08.2026): Rohstoffe werden in Gramm geführt, dürfen
* aber in ml erfasst werden. Über die hinterlegte Dichte (g/ml) ergeben sich die
* Gramm — z. B. 100 ml Propolistinktur (0,79 g/ml) = 79 g. Ohne Dichte gilt die
* Menge weiter als Gramm.
*
* Bestand bleibt kanonisch in Gramm (received_quantity/ordered_quantity), die
* Umrechnung passiert beim Erfassen. purchase_unit + density_used protokollieren
* die ml-Erfassung für Anzeige und Bearbeitung.
*/
public function up(): void
{
Schema::table('ingredients', function (Blueprint $table) {
$table->decimal('density', 8, 4)->nullable()->after('default_factor');
});
Schema::table('stock_entries', function (Blueprint $table) {
$table->string('purchase_unit', 4)->nullable()->after('unit');
$table->decimal('density_used', 8, 4)->nullable()->after('purchase_unit');
});
}
public function down(): void
{
Schema::table('ingredients', function (Blueprint $table) {
$table->dropColumn('density');
});
Schema::table('stock_entries', function (Blueprint $table) {
$table->dropColumn(['purchase_unit', 'density_used']);
});
}
};

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') }}

View file

@ -0,0 +1,128 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* AP-42: Rohstoffe werden in Gramm geführt, dürfen aber in ml erfasst werden.
* Über die Dichte (g/ml) ergeben sich die Gramm (100 ml Propolis × 0,79 = 79 g).
*/
function densAdmin(): User
{
$user = User::query()->create(['email' => uniqid('dens_', true).'@test.example', 'password' => bcrypt('password')]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
/**
* @return array{0: User, 1: Ingredient, 2: Location, 3: Supplier, 4: MaterialQuality}
*/
function densSetup(?float $density): array
{
$admin = densAdmin();
$ingredient = Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]);
return [$admin, $ingredient, Location::factory()->create(), Supplier::factory()->create(), MaterialQuality::factory()->create()];
}
test('AP-42: ml-erfassung wird über die dichte in gramm gespeichert', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->ordered_quantity)->toBe(79.0)
->and($entry->purchase_unit)->toBe('ml')
->and((float) $entry->density_used)->toBe(0.79);
});
test('AP-42: gramm-erfassung bleibt unverändert', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'gram',
'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->ordered_quantity)->toBe(100.0)
->and($entry->purchase_unit)->toBe('gram')
->and($entry->density_used)->toBeNull();
});
test('AP-42: ml-erfassung ohne hinterlegte dichte wird abgelehnt', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(null);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertSessionHasErrors('ordered_quantity');
expect(StockEntry::query()->count())->toBe(0);
});
test('AP-42: wareneingang in ml wird über die dichte in gramm gebucht', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.80);
$this->actingAs($admin, 'user');
// Bestellung in ml (250 ml × 0,80 = 200 g)
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '250',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
// Wareneingang: 250 ml geliefert => 200 g gebucht
$this->put(route('admin.inventory.stock-entries.receive', $entry), [
'received_at' => '2026-08-05',
'received_quantity' => '250',
'batch_number' => 'DENS-1',
'best_before' => '2030-12-31',
])->assertRedirect();
expect((float) $entry->fresh()->received_quantity)->toBe(200.0);
});