Warenwirtschaft: AP-40 + AP-41 — Bestand/Differenz-Spalten, Ablage, Verpackung oben, Phasen-Gramm

AP-40: INCI-Tabelle der Produktion um zwei Spalten ergänzt — „Bestand" (aktueller
Rohstoffbestand je INCI, global) und „Differenz" (Bestand − Soll; negativ rot =
Fehlmenge, sonst grün). Spaltenkopf „Regal" → „Ablage". Bestand kommt aus
InventoryService::remainingByIngredient und wird als current_stock in den
recipeJson-Payload gehängt (lazy aufgelöst — InventoryService hängt selbst von
ProductionService ab, Konstruktor-Injektion wäre zirkulär).

AP-41: „Verpackung (Vorschau)" direkt unter die INCI-Tabelle verschoben (man sieht
auf einen Blick den Gesamtbedarf). Je Phase die Gesamt-Gramm hinter dem Namen
(„Phase A (345 g)"), aktualisiert sich mit Stückzahl und Füllgewicht-Skala.

Test für den current_stock-Payload ergänzt. Suite 311 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 08:27:43 +00:00
parent 744b36cdcb
commit 4c6a57b0f1
4 changed files with 76 additions and 12 deletions

View file

@ -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 [

View file

@ -77,7 +77,9 @@
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Anteil %') }}</th>
<th class="text-right">{{ __('Gramm (gesamt)') }}</th>
<th>{{ __('Regal') }}</th>
<th>{{ __('Ablage') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right">{{ __('Differenz') }}</th>
</tr>
</thead>
<tbody id="recipe-inci-body"></tbody>
@ -86,20 +88,22 @@
</div>
</div>
{{-- AP-41: Verpackungs-Vorschau direkt unter die INCI-Tabelle — man sieht auf
einen Blick, was zusätzlich zu den Rohstoffen gebraucht wird. --}}
<h6>{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded mb-3">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">

View file

@ -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($('<tr></tr>')
var $row = $('<tr></tr>')
.append($('<td></td>').text(ing.name))
.append($('<td></td>').text(ing.alt_name || '—'))
.append($('<td></td>').text(ing.quality || '—'))
.append($('<td class="text-right"></td>').text(ing.gram != null ? fmt(ing.gram) : '—'))
.append($('<td class="text-right"></td>').text(grams != null ? fmt(grams) : '—'))
.append($('<td></td>').text(ing.storage_label || '—')));
.append($('<td></td>').text(ing.storage_label || '—'))
.append($('<td class="text-right"></td>').text(ing.current_stock != null ? fmt(ing.current_stock) + ' g' : '—'));
// AP-40: Differenz = Bestand − Soll (negativ rot = Fehlmenge, sonst grün).
var $diff = $('<td class="text-right"></td>');
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($('<tr class="font-weight-bold"></tr>')
.append($('<td></td>').text('{{ __('Summe') }}'))
.append('<td></td><td></td>')
.append($('<td class="text-right"></td>').text(fmt(totalPercent) + ' %'))
.append($('<td class="text-right"></td>').text(fmt(totalGrams) + ' g'))
.append('<td></td>'));
.append('<td></td><td></td><td></td>'));
$('#recipe-inci').show();
}
@ -281,6 +309,9 @@
var $block = $('<div class="border rounded p-2 mb-3 recipe-phase-block"></div>');
var $header = $('<div class="mb-2"></div>');
$header.append($('<strong></strong>').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($('<span class="recipe-phase-total text-muted"></span>'));
if (group.note) {
$header.append($('<div class="text-muted small"></div>').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');

View file

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