diff --git a/app/Http/Requests/Inventory/StoreStockEntryRequest.php b/app/Http/Requests/Inventory/StoreStockEntryRequest.php index 1ef922a..f20cff5 100644 --- a/app/Http/Requests/Inventory/StoreStockEntryRequest.php +++ b/app/Http/Requests/Inventory/StoreStockEntryRequest.php @@ -70,6 +70,10 @@ class StoreStockEntryRequest extends FormRequest if (empty($this->input('quality_id'))) { $validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.')); } + // Einheit ist Pflicht: bewusst g oder ml wählen (kein stiller Gramm-Default). + if (! in_array($this->input('ordered_unit'), ['gram', 'ml'], true)) { + $validator->errors()->add('ordered_unit', __('Bitte die Einheit der bestellten Menge wählen (Gramm oder Milliliter).')); + } // 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'); diff --git a/app/Http/Requests/Inventory/UpdateStockEntryRequest.php b/app/Http/Requests/Inventory/UpdateStockEntryRequest.php index 688f189..1eaf35e 100644 --- a/app/Http/Requests/Inventory/UpdateStockEntryRequest.php +++ b/app/Http/Requests/Inventory/UpdateStockEntryRequest.php @@ -71,6 +71,10 @@ class UpdateStockEntryRequest extends FormRequest if (empty($this->input('quality_id'))) { $validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.')); } + // Einheit ist Pflicht: bewusst g oder ml wählen (kein stiller Gramm-Default). + if (! in_array($this->input('ordered_unit'), ['gram', 'ml'], true)) { + $validator->errors()->add('ordered_unit', __('Bitte die Einheit der bestellten Menge wählen (Gramm oder Milliliter).')); + } // AP-42: ml-Erfassung nur mit hinterlegter Dichte. if ($this->input('ordered_unit') === 'ml') { $density = Ingredient::query()->whereKey($this->input('ingredient_id'))->value('density'); diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index e686c69..83be3a2 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -426,8 +426,10 @@ dadurch für die Bedienung ändert: höchstens ±5 % vom Soll abweicht. - **Rohstoff-Dichte für ml-Einkäufe (V7.0):** Wird ein Rohstoff in **ml** eingekauft (z. B. eine Tinktur), aber in **Gramm** geführt, hinterlegt man am - Rohstoff die **Dichte (g/ml)**. Im Wareneingang kann dann die Einheit **ml** - gewählt werden; gespeichert wird der über die Dichte errechnete Gramm-Wert. + Rohstoff die **Dichte (g/ml)**. Im Wareneingang wird die **Einheit** (g oder + ml) als Pflichtfeld **über der Menge** bewusst gewählt (seit 14.09.2026, vorher + unauffällig rechts neben dem Feld); bei ml zeigt das Mengenfeld live + „ml × Dichte = g“, gespeichert wird der errechnete Gramm-Wert. Ohne hinterlegte Dichte ist keine ml-Erfassung möglich. - **Produktion: Entwurf vs. Abschließen (V7.0):** **„Zwischenspeichern (Entwurf)"** sichert eine Produktion **ohne Bestandsbuchung** und ohne fachliche Prüfung — diff --git a/resources/docs/testleitfaden.md b/resources/docs/testleitfaden.md index f243065..965242f 100644 --- a/resources/docs/testleitfaden.md +++ b/resources/docs/testleitfaden.md @@ -223,12 +223,13 @@ **Neu (AP-42):** - Am Rohstoff optionales Feld **Dichte (g/ml)**. -- Im Einkauf/Wareneingang lässt sich die Einheit **g oder ml** wählen; bei **ml** wird über die Dichte in Gramm umgerechnet und **als Gramm gespeichert**. +- Im Einkauf/Wareneingang ist die **Einheit der bestellten Menge** (g oder ml) ein eigenes **Pflichtfeld über der Menge** („Bitte wählen“, seit 14.09.2026); bei **ml** wird über die Dichte in Gramm umgerechnet und **als Gramm gespeichert** — die Umrechnung erscheint live unter dem Mengenfeld. **So testen:** 1. Bei einer in ml gekauften Tinktur die **Dichte** hinterlegen (z. B. 0,79). 2. Einen Wareneingang mit Einheit **ml** erfassen (z. B. 100 ml) → gespeichert/gebucht werden 79 g. 3. Ohne hinterlegte Dichte ist die ml-Auswahl gesperrt (Prüfung). +4. Ohne gewählte Einheit lässt sich der Rohstoff-Einkauf nicht speichern (Prüfung). --- diff --git a/resources/views/admin/inventory/stock-entries/_form.blade.php b/resources/views/admin/inventory/stock-entries/_form.blade.php index e3fefc1..1fcfeff 100644 --- a/resources/views/admin/inventory/stock-entries/_form.blade.php +++ b/resources/views/admin/inventory/stock-entries/_form.blade.php @@ -112,20 +112,31 @@ ? formatWawiQuantity($orderedIsMl ? $model->ordered_quantity / $model->density_used : $model->ordered_quantity) : ''; @endphp +@php + // Einheit steht als eigenes Pflichtfeld VOR der Menge (nicht mehr als Anhang + // rechts am Feld — auf breiten Monitoren lag es außer Sicht). Neu: bewusst + // wählen („Bitte wählen"); beim Bearbeiten ist die gespeicherte Einheit vorbelegt. + $orderedUnitValue = old('ordered_unit', $isEdit ? ($orderedIsMl ? 'ml' : 'gram') : ''); +@endphp + +
-
- - -
+ {{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }} @error('ordered_quantity') diff --git a/resources/views/admin/inventory/stock-entries/_scripts.blade.php b/resources/views/admin/inventory/stock-entries/_scripts.blade.php index 0024816..d4c1946 100644 --- a/resources/views/admin/inventory/stock-entries/_scripts.blade.php +++ b/resources/views/admin/inventory/stock-entries/_scripts.blade.php @@ -78,15 +78,20 @@ function applyOrderedUnit() { var isIng = $('#entry_type').val() === 'ingredient'; + var $unit = $('#ordered_unit'); + // Einheit ist eigenes Pflichtfeld vor der Menge — nur bei Rohstoffen sichtbar + // und nur dann required (ein verstecktes required-Feld blockiert das Absenden). $('#ordered-unit-wrap').toggle(isIng); + $unit.prop('required', 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'); + $unit.find('option[value="ml"]').prop('disabled', !hasDensity); + if (!hasDensity && $unit.val() === 'ml') { + $unit.val(''); } + var $unitHint = $('#ordered-unit-hint'); var $hint = $('#ordered-quantity-hint'); var $preview = $('#ordered-quantity-preview'); $preview.hide().text(''); @@ -94,17 +99,27 @@ $hint.text('{{ __('Bei Verpackung in Stück.') }}'); return; } - if ($('#ordered_unit').val() === 'ml' && hasDensity) { + + if (!$('#ingredient_id').val()) { + $unitHint.text('{{ __('Zuerst den Inhaltsstoff wählen — davon hängt ab, ob ml möglich ist.') }}'); + } else if (hasDensity) { + $unitHint.text('{{ __('Dichte hinterlegt') }} (' + formatDensity(density) + ' g/ml) — {{ __('Erfassung in Gramm oder Milliliter möglich.') }}'); + } else { + $unitHint.text('{{ __('Keine Dichte am Rohstoff hinterlegt — nur Gramm möglich. Für ml zuerst die Dichte pflegen.') }}'); + } + + var unit = $unit.val(); + if (unit === 'ml' && hasDensity) { $hint.text('{{ __('Erfassung in ml — der Bestand wird über die Dichte in Gramm gebucht.') }}'); var ml = parseNumber($('#ordered_quantity').val()); if (ml != null && ml > 0) { // AP-42: Live-Vorschau der ml→g-Umrechnung direkt am Mengenfeld. $preview.text(formatQuantity(ml) + ' ml × ' + formatDensity(density) + ' g/ml = ' + formatQuantity(ml * density) + ' g').show(); } - } else if (hasDensity) { - $hint.text('{{ __('In Gramm — oder Einheit auf ml stellen (Dichte hinterlegt).') }}'); + } else if (unit === 'gram') { + $hint.text('{{ __('Erfassung in Gramm.') }}'); } else { - $hint.text('{{ __('In Gramm. Für ml-Erfassung zuerst die Dichte am Rohstoff pflegen.') }}'); + $hint.text('{{ __('Bitte zuerst oben die Einheit wählen.') }}'); } } diff --git a/tests/Feature/IngredientDensityTest.php b/tests/Feature/IngredientDensityTest.php index f377ab1..1f8f3da 100644 --- a/tests/Feature/IngredientDensityTest.php +++ b/tests/Feature/IngredientDensityTest.php @@ -3,6 +3,7 @@ use App\Models\Ingredient; use App\Models\Location; use App\Models\MaterialQuality; +use App\Models\PackagingItem; use App\Models\StockEntry; use App\Models\Supplier; use App\Repositories\StockEntryRepository; @@ -253,3 +254,30 @@ test('N-06: bei rohstoffwechsel wird keine fremde alte dichte übernommen', func 'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ])->assertSessionHasErrors('ordered_quantity'); }); + +test('einheit ist pflichtfeld: rohstoff-einkauf ohne einheit wird abgelehnt', function () { + [$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79); + $this->actingAs($admin, 'user'); + + $base = [ + '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-09-14', + 'ordered_quantity' => '100', 'price_per_kg' => '10,00', + ]; + + // Kein stiller Gramm-Default mehr: leer und fehlend werden beide abgewiesen. + $this->from(route('admin.inventory.stock-entries.create'))->post(route('admin.inventory.stock-entries.store'), $base + ['ordered_unit' => '']) + ->assertRedirect(route('admin.inventory.stock-entries.create')) + ->assertSessionHasErrors('ordered_unit'); + $this->from(route('admin.inventory.stock-entries.create'))->post(route('admin.inventory.stock-entries.store'), $base) + ->assertSessionHasErrors('ordered_unit'); + expect(StockEntry::query()->count())->toBe(0); + + // Verpackung kennt keine Einheit — dort bleibt das Feld ohne Wirkung. + $packaging = PackagingItem::factory()->create(); + $this->post(route('admin.inventory.stock-entries.store'), [ + 'entry_type' => 'packaging', 'packaging_item_id' => (string) $packaging->id, 'supplier_id' => (string) $supplier->id, + 'location_id' => (string) $location->id, 'ordered_at' => '2026-09-14', 'ordered_quantity' => '10', 'price_total' => '5,00', + ])->assertSessionHasNoErrors(); + expect(StockEntry::query()->count())->toBe(1); +}); diff --git a/tests/Feature/InventoryPhase3Test.php b/tests/Feature/InventoryPhase3Test.php index 84864aa..8cd6bd8 100644 --- a/tests/Feature/InventoryPhase3Test.php +++ b/tests/Feature/InventoryPhase3Test.php @@ -94,6 +94,7 @@ test('admin creates pending stock entry with unit gram', function () { 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-03-27', 'ordered_quantity' => '1000', + 'ordered_unit' => 'gram', 'price_per_kg' => '12,50', ])->assertRedirect(route('admin.inventory.stock-entries.index')); @@ -194,6 +195,7 @@ test('listForIndex sorts pending before received', function () { 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => '100', + 'ordered_unit' => 'gram', 'price_per_kg' => '1', ]); @@ -205,6 +207,7 @@ test('listForIndex sorts pending before received', function () { 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-06-01', 'ordered_quantity' => '200', + 'ordered_unit' => 'gram', 'price_per_kg' => '1', ]); diff --git a/tests/Feature/StockEntryPriceTest.php b/tests/Feature/StockEntryPriceTest.php index 10c7647..6e6b6a3 100644 --- a/tests/Feature/StockEntryPriceTest.php +++ b/tests/Feature/StockEntryPriceTest.php @@ -53,6 +53,7 @@ test('net price derives gross from tax rate on store', function () { 'tax_rate_id' => (string) $tax->id, 'ordered_at' => '2026-06-01', 'ordered_quantity' => '1000', + 'ordered_unit' => 'gram', 'price_per_kg' => '10,00', ])->assertRedirect(route('admin.inventory.stock-entries.index')); @@ -82,6 +83,7 @@ test('gross price derives net from tax rate on store', function () { 'tax_rate_id' => (string) $tax->id, 'ordered_at' => '2026-06-01', 'ordered_quantity' => '1000', + 'ordered_unit' => 'gram', 'price_per_kg_gross' => '11,90', ])->assertRedirect(route('admin.inventory.stock-entries.index')); @@ -108,6 +110,7 @@ test('without tax rate net equals gross', function () { 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-06-01', 'ordered_quantity' => '500', + 'ordered_unit' => 'gram', 'price_per_kg' => '8,00', ])->assertRedirect(route('admin.inventory.stock-entries.index')); @@ -135,6 +138,7 @@ test('ingredient requires net or gross price', function () { 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-06-01', 'ordered_quantity' => '500', + 'ordered_unit' => 'gram', ])->assertSessionHasErrors('price_per_kg'); });