Warenwirtschaft: AP-28 Neue Produktions-Maske + Produktionsergebnis

- production_outputs (Migration inkl. idempotentem Backfill): eine Produktion
  kann mehrere Output-Produkte erzeugen (z. B. 40x50 ml + 50x5 ml)
- ProductStockService bucht je Output-Produkt (idempotenter Diff, append-only);
  die geplante Stückzahl bucht nichts mehr, entfernte Outputs werden rückgebucht
- ProductionService store/update mit outputs[] (leer = Fallback auf Planung
  inkl. suggestedBatchNumber — Backfill-Semantik, Alt-Aufrufer stabil)
- Maske: INCI-Liste (Alternativname/Qualität/Anteil/Gramm/Regal), Chargen-
  Erfassung nach Rezeptur-Phasen mit Notiz + Status-Ampel, Produktionsergebnis-
  Repeater mit Chargen-Nr.-Vorbelegung (Präfix + ttmmjj), Planungs-Hinweis
- Show: Produktionsergebnis-Karte; Index: Ergebnis-Spalte
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben (nächster Schritt: AP-13)

Tests: ProductionOutputTest (8 neu); Suite 201 grün, 731 Assertionen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 14:02:54 +00:00
parent 7a5b5e0f7b
commit 99a6c92e6c
17 changed files with 1009 additions and 74 deletions

View file

@ -1,4 +1,19 @@
@php($isEdit = $isEdit ?? false)
@php
// Achtung: in diesem Template nur die Block-Form von php-Direktiven verwenden (Blade-Compiler-Falle, siehe AP-23).
$isEdit = $isEdit ?? false;
// AP-28: Vorbelegung der Produktionsergebnis-Zeilen (old() > bestehende Outputs > leer = Default aus Planung per JS).
$initialOutputs = old('outputs');
if (! $initialOutputs) {
$initialOutputs = ($model && $model->relationLoaded('outputs') && $model->outputs->isNotEmpty())
? $model->outputs->map(fn ($o) => [
'product_id' => $o->product_id,
'quantity' => $o->quantity,
'batch_number' => $o->batch_number,
])->values()->all()
: [];
}
@endphp
<div class="form-row">
<div class="form-group col-12 col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
@ -36,9 +51,10 @@
@enderror
</div>
<div class="form-group col-12 col-sm-6">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<label for="quantity">{{ __('Geplante Stückzahl (Planung)') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
<small class="text-muted">{{ __('Nur Planung — bucht keinen Bestand. Der Produktbestand entsteht aus dem Produktionsergebnis unten.') }}</small>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@ -49,6 +65,27 @@
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
{{-- AP-28: INCI-Liste der Hersteller-Rezeptur mit Alternativname, Qualität und Regal --}}
<div id="recipe-inci" style="display:none;">
<h6>{{ __('INCI-Liste (Hersteller-Rezeptur)') }}</h6>
<div class="table-responsive border rounded mb-3">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>{{ __('Rohstoff') }}</th>
<th>{{ __('Alternativer Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Anteil %') }}</th>
<th class="text-right">{{ __('Gramm (gesamt)') }}</th>
<th>{{ __('Regal') }}</th>
</tr>
</thead>
<tbody id="recipe-inci-body"></tbody>
<tfoot id="recipe-inci-foot"></tfoot>
</table>
</div>
</div>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@ -69,3 +106,42 @@
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model?->notes) }}</textarea>
</div>
{{-- AP-28: Produktionsergebnis — treibt den Produktbestand (mehrere Output-Produkte möglich) --}}
<hr>
<h6>{{ __('Produktionsergebnis') }}</h6>
<div class="alert alert-info py-2 px-3 small">
{{ __('Die Planung oben ist unverbindlich. Der Produktbestand entsteht ausschließlich aus dem Produktionsergebnis — auch mehrere Produkte möglich (z. B. 40×50 ml + 50×5 ml).') }}
</div>
@error('outputs')
<div class="text-danger small mb-2">{{ $message }}</div>
@enderror
<div class="form-row text-muted small mb-1 d-none d-sm-flex">
<div class="col-sm-5">{{ __('Produkt') }}</div>
<div class="col-sm-3">{{ __('Produzierte Stückzahl') }}</div>
<div class="col-sm-3">{{ __('Chargen-Nr.') }}</div>
<div class="col-sm-1"></div>
</div>
<div id="production-outputs" data-initial='@json($initialOutputs)'></div>
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" id="btn-add-output">{{ __('+ weitere Ergebniszeile') }}</button>
<script type="text/template" id="output-row-template">
<div class="form-row align-items-center mb-1 output-row">
<div class="col-12 col-sm-5 mb-1">
<select name="outputs[__INDEX__][product_id]" class="form-control form-control-sm output-product" required>
@foreach($products as $p)
<option value="{{ $p->id }}" data-batch-prefix="{{ $p->batch_prefix }}">{{ $p->name }}</option>
@endforeach
</select>
</div>
<div class="col-6 col-sm-3 mb-1">
<input type="number" name="outputs[__INDEX__][quantity]" min="1" step="1" class="form-control form-control-sm output-quantity" required>
</div>
<div class="col-6 col-sm-3 mb-1">
<input type="text" name="outputs[__INDEX__][batch_number]" maxlength="100" class="form-control form-control-sm output-batch" placeholder="{{ __('Chargen-Nr.') }}">
</div>
<div class="col-12 col-sm-1 mb-1 text-right">
<a href="#" class="text-danger output-remove" title="{{ __('Zeile entfernen') }}"><span class="fas fa-times"></span></a>
</div>
</div>
</script>

View file

@ -30,6 +30,14 @@
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function parseGerman(value) {
value = String(value || '').trim();
if (!value) {
return 0;
}
return parseFloat(value.replace(/\./g, '').replace(',', '.')) || 0;
}
// AP-27: Dropdown je Charge (batch_number) zusammengeführt — Restbestand ist die Summe
// über alle Einkäufe der Charge; die FEFO-Verteilung übernimmt der Server.
function buildStockSelect(idx, batches, preselectedBatchKey) {
@ -63,7 +71,7 @@
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
var $group = $('<div class="input-group input-group-sm"></div>');
$group.append($('<input type="text" class="form-control" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append($('<input type="text" class="form-control recipe-qty-input" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append('<div class="input-group-append"><span class="input-group-text">g</span></div>');
$colQty.append($group);
$row.append($colQty);
@ -71,6 +79,30 @@
$wrap.find('.recipe-rows').first().append($row);
}
// AP-28: Status-Ampel je Rohstoff — grün, wenn Summe der Chargen-Mengen dem Soll entspricht.
function updateIngredientStatus($wrap) {
var ing = $wrap.data('recipe-ing');
var $dot = $wrap.find('.recipe-status').first();
if (!ing || ing.gram == null || !$dot.length) {
return;
}
var qty = parseInt($('#quantity').val(), 10) || 1;
var soll = ing.gram * (ing.factor || 1) * qty;
var sum = 0;
$wrap.find('.recipe-qty-input').each(function () {
sum += parseGerman($(this).val());
});
var ok = Math.abs(sum - soll) <= 0.02;
$dot.css('color', ok ? '#28a745' : '#fd7e14')
.attr('title', ok ? '{{ __('Soll erfüllt') }}' : '{{ __('Summe weicht vom Soll ab') }}');
}
function updateAllStatuses() {
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
updateIngredientStatus($(this));
});
}
function recomputeSoll() {
var qty = parseInt($('#quantity').val(), 10) || 1;
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
@ -82,6 +114,98 @@
var soll = ing.gram * (ing.factor || 1) * qty;
$wrap.find('.recipe-soll').text(fmt(soll));
});
renderInciTable();
updateAllStatuses();
}
// AP-28: INCI-Liste der Hersteller-Rezeptur (Alternativname, Qualität, Anteil, Gramm, Regal).
function renderInciTable() {
var $body = $('#recipe-inci-body').empty();
var $foot = $('#recipe-inci-foot').empty();
if (!recipeIngredients.length) {
$('#recipe-inci').hide();
return;
}
var qty = parseInt($('#quantity').val(), 10) || 1;
var totalPercent = 0;
var totalGrams = 0;
recipeIngredients.forEach(function (ing) {
var grams = ing.gram != null ? ing.gram * (ing.factor || 1) * qty : null;
if (ing.gram != null) {
totalPercent += ing.gram;
}
if (grams != null) {
totalGrams += grams;
}
$body.append($('<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 || '—')));
});
$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>'));
$('#recipe-inci').show();
}
// AP-28: Chargen-Erfassung nach Rezeptur-Phasen (AP-23) gruppiert.
function buildPhaseGroups(data) {
var phases = data.phases || [];
var ingredients = (data.ingredients || []).filter(function (ing) { return ing.gram != null; });
if (!phases.length) {
return ingredients.length ? [{name: null, note: null, ingredients: ingredients}] : [];
}
var groups = [];
var byPhase = {};
phases.forEach(function (ph) {
var group = {name: ph.name, note: ph.note, ingredients: []};
byPhase[String(ph.id)] = group;
groups.push(group);
});
var loose = {name: null, note: null, ingredients: []};
ingredients.forEach(function (ing) {
var group = ing.phase_id != null ? byPhase[String(ing.phase_id)] : null;
(group || loose).ingredients.push(ing);
});
if (loose.ingredients.length) {
groups.push(loose);
}
return groups.filter(function (g) { return g.ingredients.length > 0; });
}
function renderIngredientWrap(ing, $container) {
var qty = parseInt($('#quantity').val(), 10) || 1;
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
var $title = $('<strong></strong>').text(ing.name);
if (ing.alt_name) {
$title.append($('<span class="text-muted font-weight-normal"></span>').text(' · ' + ing.alt_name));
}
$head.append($title);
$head.append($('<span class="text-muted small"></span>')
.html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g <span class="recipe-status" style="font-size:1rem;">●</span>'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$container.append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
updateIngredientStatus($wrap);
}
function renderRecipe(data) {
@ -93,6 +217,7 @@
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
$('#recipe-inci').hide();
$('#recipe-hint')
.text('{{ __('Dieses Produkt benötigt keine Rezeptur. Es wird ohne Rohstoffverbrauch produziert.') }}')
.removeClass('text-danger').addClass('text-muted');
@ -118,38 +243,35 @@
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
var qty = parseInt($('#quantity').val(), 10) || 1;
var hintParts = [];
var hasMissingGram = false;
renderInciTable();
var hintParts = [];
recipeIngredients.forEach(function (ing) {
if (ing.gram == null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Anteil in der Hersteller-Rezeptur fehlt') }}');
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
$head.append($('<strong></strong>').text(ing.name));
$head.append($('<span class="text-muted small"></span>').html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
});
if (hasMissingGram) {
var groups = buildPhaseGroups(data);
groups.forEach(function (group) {
var $target = $('#recipe-ingredient-lines');
if (group.name != null) {
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));
if (group.note) {
$header.append($('<div class="text-muted small"></div>').text(group.note));
}
$block.append($header);
$('#recipe-ingredient-lines').append($block);
$target = $block;
}
group.ingredients.forEach(function (ing) {
renderIngredientWrap(ing, $target);
});
});
if (hintParts.length) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint')
@ -173,6 +295,11 @@
return;
}
addIngredientRow($wrap, ing);
updateIngredientStatus($wrap);
});
$(document).on('input change', '.recipe-qty-input', function () {
updateIngredientStatus($(this).closest('[data-ingredient-id]'));
});
function loadRecipe() {
@ -209,10 +336,139 @@
}
});
// ------------------------------------------------------------------
// AP-28: Produktionsergebnis-Repeater — treibt den Produktbestand.
// Standard: eine Zeile, die der Planung folgt, bis sie manuell geändert wird.
// Chargen-Nr. wird aus Produkt-Präfix + Produktionsdatum (ttmmjj) vorbelegt.
// ------------------------------------------------------------------
var outputIndex = 0;
function batchDateSuffix() {
var raw = String($('#produced_at').val() || '').trim();
var m = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!m) {
return null;
}
return m[1] + m[2] + m[3].slice(2);
}
function suggestedBatch($select) {
var prefix = String($select.find('option:selected').data('batch-prefix') || '').trim();
var suffix = batchDateSuffix();
if (!prefix || !suffix) {
return '';
}
return prefix + suffix;
}
function refreshBatch($row) {
var $batch = $row.find('.output-batch');
if ($batch.data('manual') && String($batch.val() || '').trim() !== '') {
return;
}
$batch.val(suggestedBatch($row.find('.output-product'))).data('manual', false);
}
function addOutputRow(data, isAuto) {
var idx = outputIndex++;
var html = $('#output-row-template').html().replace(/__INDEX__/g, idx);
var $row = $(html);
$row.data('auto', !!isAuto);
if (data && data.product_id != null) {
$row.find('.output-product').val(String(data.product_id));
} else {
var planned = $('#product_id').val();
if (planned) {
$row.find('.output-product').val(String(planned));
}
}
if (data && data.quantity != null) {
$row.find('.output-quantity').val(data.quantity);
} else {
$row.find('.output-quantity').val($('#quantity').val() || 1);
}
if (data && data.batch_number) {
$row.find('.output-batch').val(data.batch_number).data('manual', true);
}
$('#production-outputs').append($row);
if (!data || !data.batch_number) {
refreshBatch($row);
}
}
$('#btn-add-output').on('click', function (e) {
e.preventDefault();
addOutputRow(null, false);
});
$(document).on('click', '.output-remove', function (e) {
e.preventDefault();
if ($('#production-outputs .output-row').length <= 1) {
return;
}
$(this).closest('.output-row').remove();
});
$(document).on('change', '.output-product', function () {
var $row = $(this).closest('.output-row');
$row.data('auto', false);
refreshBatch($row);
});
$(document).on('input change', '.output-quantity', function () {
$(this).closest('.output-row').data('auto', false);
});
$(document).on('input', '.output-batch', function () {
$(this).data('manual', String($(this).val() || '').trim() !== '');
});
// Planung oben spiegelt sich in der Auto-Zeile, solange diese unberührt ist.
$('#product_id').on('change', function () {
var val = $(this).val();
$('#production-outputs .output-row').each(function () {
var $row = $(this);
if ($row.data('auto') && val) {
$row.find('.output-product').val(String(val));
refreshBatch($row);
}
});
});
$('#quantity').on('change input', function () {
var val = $(this).val();
$('#production-outputs .output-row').each(function () {
var $row = $(this);
if ($row.data('auto')) {
$row.find('.output-quantity').val(val || 1);
}
});
});
$('#produced_at').on('change', function () {
$('#production-outputs .output-row').each(function () {
refreshBatch($(this));
});
});
function initOutputs() {
var initial = $('#production-outputs').data('initial') || [];
if (Array.isArray(initial) && initial.length > 0) {
initial.forEach(function (row) {
addOutputRow(row, false);
});
} else {
addOutputRow(null, true);
}
}
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
initOutputs();
});
})(jQuery);
</script>

View file

@ -24,7 +24,7 @@
<th style="max-width: 130px;"></th>
<th>{{ __('Datum') }}</th>
<th>{{ __('Produkt') }}</th>
<th>{{ __('Stück') }}</th>
<th>{{ __('Ergebnis') }}</th>
<th>{{ __('Standort') }}</th>
<th>{{ __('MHD-Hinweis') }}</th>
</tr>
@ -45,7 +45,14 @@
</td>
<td data-sort="{{ $row->produced_at?->timestamp ?? 0 }}">{{ $row->produced_at?->format('d.m.Y') }}</td>
<td><span class="wawi-item-name">{{ $row->product?->name ?? '—' }}</span></td>
<td>{{ $row->quantity }}</td>
<td>
{{-- AP-28: reale Outputs statt Planungs-Stückzahl --}}
@if($row->outputs->isNotEmpty())
{{ $row->outputs->map(fn ($o) => $o->quantity . '× ' . ($o->product?->name ?? '—'))->implode(', ') }}
@else
{{ $row->quantity }} <span class="text-muted small">({{ __('Planung') }})</span>
@endif
</td>
<td>{{ $row->location?->name ?? '—' }}</td>
<td>
@if($row->mhd_warning)

View file

@ -32,8 +32,8 @@
<span class="wawi-deflist__value">{{ $model->produced_at?->format('d.m.Y') }}</span>
</div>
<div class="wawi-deflist__item">
<span class="wawi-deflist__label">{{ __('Stückzahl') }}</span>
<span class="wawi-deflist__value">{{ $model->quantity }}</span>
<span class="wawi-deflist__label">{{ __('Geplante Stückzahl') }}</span>
<span class="wawi-deflist__value">{{ $model->quantity }} <span class="text-muted small">({{ __('Planung — bucht keinen Bestand') }})</span></span>
</div>
<div class="wawi-deflist__item">
<span class="wawi-deflist__label">{{ __('Standort') }}</span>
@ -63,6 +63,37 @@
</div>
</div>
{{-- AP-28: Produktionsergebnis — diese Zeilen buchen den Produktbestand --}}
<div class="card wawi-card mb-3">
<div class="wawi-card__header">{{ __('Produktionsergebnis') }}</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt') }}</th>
<th>{{ __('Produzierte Stückzahl') }}</th>
<th>{{ __('Chargen-Nr.') }}</th>
</tr>
</thead>
<tbody>
@forelse($model->outputs as $output)
<tr>
<td>{{ $output->product?->name ?? '—' }}</td>
<td>{{ $output->quantity }}</td>
<td>{{ $output->batch_number ?: '—' }}</td>
</tr>
@empty
<tr>
<td colspan="3"><div class="wawi-empty">{{ __('Kein Produktionsergebnis erfasst.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="card wawi-card mb-3">
<div class="wawi-card__header">{{ __('Rohstoff-Verbrauch (Chargen)') }}</div>
<div class="card-body p-0">