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:
parent
744b36cdcb
commit
4c6a57b0f1
4 changed files with 76 additions and 12 deletions
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue