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:
parent
7a5b5e0f7b
commit
99a6c92e6c
17 changed files with 1009 additions and 74 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue