diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 532c179..dd7f776 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -591,6 +591,11 @@ class ProductionService $hasRecipe = $product->manufacturer_ingredients->isNotEmpty(); // AP-35: Umrechnungsfaktor Anteil (%) → Gramm je Stück (Füllgewicht / 100). $scale = $this->recipeGramScale($product); + // AP-40: aktueller Rohstoffbestand je INCI (für Bestands-/Differenz-Spalte). + // InventoryService wird lazy aufgelöst — es hängt selbst von ProductionService + // ab, eine Konstruktor-Injektion ergäbe eine zirkuläre Abhängigkeit. + $ingredientIds = $product->manufacturer_ingredients->pluck('id')->map(fn ($id) => (int) $id)->all(); + $stockByIngredient = app(InventoryService::class)->remainingByIngredient($ingredientIds); $ingredients = []; foreach ($product->manufacturer_ingredients as $ing) { @@ -609,6 +614,8 @@ class ProductionService 'gram' => $hasGram ? (float) $gram : null, 'factor' => $factor, 'required_grams_total' => $req, + // AP-40: aktueller Bestand des Rohstoffs (global, in Gramm). + 'current_stock' => round((float) ($stockByIngredient[(int) $ing->id] ?? 0.0), 2), 'stock_entries' => $available ->map(function (StockEntry $se) { return [ diff --git a/resources/views/admin/inventory/productions/_form_fields.blade.php b/resources/views/admin/inventory/productions/_form_fields.blade.php index 77cccd7..2e18294 100644 --- a/resources/views/admin/inventory/productions/_form_fields.blade.php +++ b/resources/views/admin/inventory/productions/_form_fields.blade.php @@ -77,7 +77,9 @@ {{ __('Qualität') }} {{ __('Anteil %') }} {{ __('Gramm (gesamt)') }} - {{ __('Regal') }} + {{ __('Ablage') }} + {{ __('Bestand') }} + {{ __('Differenz') }} @@ -86,20 +88,22 @@ + {{-- AP-41: Verpackungs-Vorschau direkt unter die INCI-Tabelle — man sieht auf + einen Blick, was zusätzlich zu den Rohstoffen gebraucht wird. --}} +
{{ __('Verpackung (Vorschau)') }}
+
+ + + +
{{ __('Artikel') }}{{ __('Stück gesamt') }}
+
+
{{ __('Chargen zuordnen') }}

@error('ingredient_lines')
{{ $message }}
@enderror - -
{{ __('Verpackung (Vorschau)') }}
-
- - - -
{{ __('Artikel') }}{{ __('Stück gesamt') }}
-
diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php index f97ce01..c71bb98 100644 --- a/resources/views/admin/inventory/productions/_scripts.blade.php +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -134,6 +134,24 @@ }); renderInciTable(); updateAllStatuses(); + updatePhaseTotals(); + } + + // AP-41: Summe der Soll-Gramm je Phase im Phasenkopf aktualisieren. + function updatePhaseTotals() { + var qty = parseInt($('#quantity').val(), 10) || 1; + $('.recipe-phase-block').each(function () { + var sum = 0; + var any = false; + $(this).find('[data-ingredient-id]').each(function () { + var ing = $(this).data('recipe-ing'); + if (ing && ing.gram != null) { + sum += ing.gram * recipeGramScale * (ing.factor || 1) * qty; + any = true; + } + }); + $(this).find('.recipe-phase-total').first().text(any ? ' (' + fmt(sum) + ' g)' : ''); + }); } // AP-28: INCI-Liste der Hersteller-Rezeptur (Alternativname, Qualität, Anteil, Gramm, Regal). @@ -155,20 +173,30 @@ if (grams != null) { totalGrams += grams; } - $body.append($('') + var $row = $('') .append($('').text(ing.name)) .append($('').text(ing.alt_name || '—')) .append($('').text(ing.quality || '—')) .append($('').text(ing.gram != null ? fmt(ing.gram) : '—')) .append($('').text(grams != null ? fmt(grams) : '—')) - .append($('').text(ing.storage_label || '—'))); + .append($('').text(ing.storage_label || '—')) + .append($('').text(ing.current_stock != null ? fmt(ing.current_stock) + ' g' : '—')); + // AP-40: Differenz = Bestand − Soll (negativ rot = Fehlmenge, sonst grün). + var $diff = $(''); + if (grams != null && ing.current_stock != null) { + var d = ing.current_stock - grams; + $diff.text((d >= 0 ? '+' : '') + fmt(d) + ' g').css('color', d < 0 ? '#dc3545' : '#28a745'); + } else { + $diff.text('—'); + } + $body.append($row.append($diff)); }); $foot.append($('') .append($('').text('{{ __('Summe') }}')) .append('') .append($('').text(fmt(totalPercent) + ' %')) .append($('').text(fmt(totalGrams) + ' g')) - .append('')); + .append('')); $('#recipe-inci').show(); } @@ -281,6 +309,9 @@ var $block = $('
'); var $header = $('
'); $header.append($('').text(group.name)); + // AP-41: Gesamtgramm je Phase hinter dem Namen — zeigt, wie groß + // das Rührgefäß für diese Phase sein muss. + $header.append($('')); if (group.note) { $header.append($('
').text(group.note)); } @@ -292,6 +323,7 @@ renderIngredientWrap(ing, $target); }); }); + updatePhaseTotals(); if (hintParts.length) { $('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger'); diff --git a/tests/Feature/ProductionScalingTest.php b/tests/Feature/ProductionScalingTest.php index 3c783cd..3c17a91 100644 --- a/tests/Feature/ProductionScalingTest.php +++ b/tests/Feature/ProductionScalingTest.php @@ -135,3 +135,24 @@ test('AP-39: abweichung über 5 % wird abgelehnt (zahlendreher-schutz)', functio expect(Production::query()->count())->toBe(0); }); + +test('AP-40: recipeJson-payload enthält aktuellen bestand je INCI (für differenz)', function () { + $user = psUser(); + $ing = psIngredient('PS-Bestand'); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, + 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', + 'ordered_quantity' => 800, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', + 'received_quantity' => 800, 'batch_number' => 'PS-BST', 'best_before' => '2030-12-31', + ]); + // 100 ml, 100 % => Soll 100 g/Stück; 10 Stück => 1000 g Bedarf, Bestand 800 g => Differenz -200 g. + $product = psProduct(['contents_total' => 100, 'unit' => 1], $ing); + + $payload = app(ProductionService::class)->buildRecipePayload($product, $location->id, 10); + $line = collect($payload['ingredients'])->firstWhere('id', $ing->id); + + expect($line['current_stock'])->toBe(800.0) + ->and($line['required_grams_total'])->toBe(1000.0); +});