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 @@
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);
+});