Warenwirtschaft: AP-23 Rezeptur-Phasen (Hersteller-Rezeptur)

- Tabelle recipe_phases + product_ingredients.recipe_phase_id (nullable,
  rueckwaertskompatibel: Bestandsrezepturen bleiben phasenlos)
- Produktformular: Hersteller-Rezeptur in Phasen-Bloecke gegliedert
  (Name, Notiz, Rohstoffe je Phase, Drag&Drop auch zwischen Phasen,
  "+ Neue Phase", 100%-Summe global ueber alle Phasen)
- Repository: Phasen-Persistierung (Auto-Name Phase A/B..., einzelner
  unbenannter Block bleibt phasenlos), copy() uebernimmt Phasenstruktur
- Produktions-Sollmengen rechnen unveraendert ueber alle Zeilen
- Blade-Fix: inline @php() + @php-Block im selben Template paarten sich
  falsch (Raw-Block verschluckte halbes Formular) - auf Block-Form vereinheitlicht
- Hinweise-Doku fortgeschrieben

Tests: RecipePhaseTest (6 gruen), volle Suite 178 gruen, 649 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 12:02:39 +00:00
parent ea36067831
commit 97d420c98a
10 changed files with 592 additions and 100 deletions

View file

@ -327,12 +327,34 @@
syncPackagingModalRows();
});
// === Hersteller-Rezeptur (Manufacturer) ===
var $mfgTbody = document.getElementById('mfg-ingredient-sortable-rows');
if ($mfgTbody && typeof Sortable !== 'undefined') {
new Sortable($mfgTbody, {
// === Hersteller-Rezeptur (Manufacturer, mit Phasen — AP-23) ===
var $mfgContainer = document.getElementById('mfg-phases-container');
var mfgModalTargetTbody = null; // Ziel-Phase für das Rohstoff-Modal
function initMfgPhaseRowSortable(tbody) {
if (typeof Sortable === 'undefined') return;
new Sortable(tbody, {
handle: '.mfg-ingredient-drag-handle',
animation: 150,
group: 'mfg-phase-rows', // Zeilen zwischen Phasen verschiebbar
onEnd: syncMfgPhaseIndexes,
});
}
if ($mfgContainer && typeof Sortable !== 'undefined') {
new Sortable($mfgContainer, {
handle: '.mfg-phase-drag-handle',
animation: 150,
onEnd: syncMfgPhaseIndexes,
});
$('#mfg-phases-container .mfg-phase-rows').each(function () { initMfgPhaseRowSortable(this); });
}
// Hält mfg_row_phase[] (Zeile -> Block-Index) und die Phasen-Platzhalter aktuell.
function syncMfgPhaseIndexes() {
$('#mfg-phases-container .mfg-phase-block').each(function (blockIndex) {
$(this).find('.mfg-phase-name').attr('placeholder', 'Phase ' + String.fromCharCode(65 + blockIndex));
$(this).find('.mfg-phase-rows tr .mfg-row-phase').val(blockIndex);
});
}
@ -349,7 +371,7 @@
function updateMfgRecipeTotal() {
var total = 0, hasValue = false;
$('#mfg-ingredient-sortable-rows tr').each(function () {
$('#mfg-phases-container .mfg-phase-rows tr').each(function () {
var v = parseDeNumber($(this).find('.mfg-gram').val());
if (!isNaN(v)) { total += v; hasValue = true; }
});
@ -359,24 +381,27 @@
if (Math.abs(total - 100) < 0.000001) {
$cell.removeClass('text-danger').addClass('text-success').attr('title', '');
} else {
$cell.removeClass('text-success').addClass('text-danger').attr('title', 'Die Gesamtrezeptur ergibt nicht 100 %!');
$cell.removeClass('text-success').addClass('text-danger').attr('title', 'Die Gesamtrezeptur (über alle Phasen) ergibt nicht 100 %!');
}
}
function refreshAllMfgEffectives() {
$('#mfg-ingredient-sortable-rows tr').each(function () { updateMfgEffective($(this)); });
$('#mfg-phases-container .mfg-phase-rows tr').each(function () { updateMfgEffective($(this)); });
updateMfgRecipeTotal();
}
function appendMfgIngredientRow(id) {
function appendMfgIngredientRow(id, targetTbody) {
id = String(id);
var ing = catalog[id];
if (!ing) return;
var $tbody = targetTbody && targetTbody.length ? targetTbody : $('#mfg-phases-container .mfg-phase-rows').first();
if (!$tbody.length) return;
var factor = ing.default_factor != null ? String(ing.default_factor).replace('.', ',') : '1,10';
var row = $('<tr data-ingredient-id="' + id + '">' +
'<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
'<td><input type="hidden" name="mfg_ingredient_id[]" value="' + id + '">' +
'<input type="hidden" name="mfg_row_phase[]" class="mfg-row-phase" value="0">' +
'<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram" value="" autocomplete="off" step="0.000001"></td>' +
'<td><input type="text" name="mfg_factor[]" class="form-control form-control-sm mfg-factor" value="' + factor + '" autocomplete="off"></td>' +
'<td class="align-middle mfg-effective text-right small text-muted">—</td>' +
@ -384,14 +409,44 @@
row.find('td').eq(1).text(ing.name || '');
row.find('td').eq(2).text(ing.quality_name || '—');
row.find('td').eq(3).text(ing.inci || '');
$('#mfg-ingredient-sortable-rows').append(row);
$tbody.append(row);
syncMfgPhaseIndexes();
updateMfgEffective(row);
updateMfgRecipeTotal();
}
$('#btn-mfg-add-phase').on('click', function () {
var html = $('#mfg-phase-block-template').html();
var $block = $(html);
$('#mfg-phases-container').append($block);
initMfgPhaseRowSortable($block.find('.mfg-phase-rows')[0]);
syncMfgPhaseIndexes();
});
$(document).on('click', '.mfg-phase-remove', function (ev) {
ev.preventDefault();
var $block = $(this).closest('.mfg-phase-block');
var rowCount = $block.find('.mfg-phase-rows tr').length;
if ($('#mfg-phases-container .mfg-phase-block').length <= 1) {
alert('Die letzte Phase kann nicht entfernt werden.');
return;
}
if (rowCount > 0 && !confirm('Diese Phase enthält ' + rowCount + ' Rohstoff-Zeile(n), die mit entfernt werden. Fortfahren?')) {
return;
}
$block.remove();
syncMfgPhaseIndexes();
syncMfgIngredientModalRows();
updateMfgRecipeTotal();
});
$(document).on('click', '.mfg-phase-add-ingredients', function () {
mfgModalTargetTbody = $(this).closest('.mfg-phase-block').find('.mfg-phase-rows');
});
function syncMfgIngredientModalRows() {
var usedIds = {};
$('#mfg-ingredient-sortable-rows tr').each(function () {
$('#mfg-phases-container .mfg-phase-rows tr').each(function () {
var id = $(this).data('ingredient-id');
if (id) usedIds[String(id)] = true;
});
@ -427,7 +482,7 @@
$('#btn-mfg-ingredients-modal-add').on('click', function () {
var added = 0;
$('#modal-mfg-ingredients-pick .js-mfg-ingredient-modal-cb:checked:not(:disabled)').each(function () {
appendMfgIngredientRow(String($(this).val()));
appendMfgIngredientRow(String($(this).val()), mfgModalTargetTbody);
$(this).prop('checked', false);
added++;
});
@ -446,6 +501,12 @@
updateMfgRecipeTotal();
});
// Vor dem Absenden Block-Indizes final synchronisieren (Zeile -> Phase)
$('form').on('submit', function () {
syncMfgPhaseIndexes();
});
syncMfgPhaseIndexes();
refreshAllMfgEffectives();
function toggleShelfMonths() {

View file

@ -103,7 +103,9 @@
<div class="card mb-2" id="product-section-verfuegbarkeit">
<h5 class="card-header">{{ __('Verfügbarkeit') }}</h5>
<div class="card-body">
@php($outOfStockActive = $product->out_of_stock_until !== null && $product->out_of_stock_until->endOfDay()->isFuture())
@php
$outOfStockActive = $product->out_of_stock_until !== null && $product->out_of_stock_until->endOfDay()->isFuture();
@endphp
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('out_of_stock_active', 1, old('out_of_stock_active', $outOfStockActive), ['class' => 'custom-control-input', 'id' => 'out_of_stock_active']) !!}
@ -748,63 +750,93 @@
<div class="card-body">
<input type="hidden" name="manufacturer_inci_sync_sent" value="1">
<div class="js-recipe-fields">
<p class="text-muted small mb-2">{{ __('Eigene Hersteller-Rezeptur (separate INCI-Liste). Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p>
<div class="table-responsive">
<table class="table table-striped table-bordered mb-3" id="recipe-table-manufacturer">
<thead>
<tr>
<th style="width:2rem"></th>
<th>{{ __('Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th>{{ __('INCI') }}</th>
<th style="width:8rem">{{ __('Anteil (%)') }}</th>
<th style="width:6rem">{{ __('Faktor') }}</th>
<th style="width:7rem">{{ __('Effektiv') }}</th>
<th style="width:3rem"></th>
</tr>
</thead>
<tbody id="mfg-ingredient-sortable-rows">
@foreach ($product->manufacturer_ingredients as $ingredient)
<tr data-ingredient-id="{{ $ingredient->id }}">
<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab"
title="{{ __('Verschieben') }}">&#9776;</td>
<td class="align-middle">{{ $ingredient->name }}</td>
<td class="align-middle small text-muted">{{ $ingredient->materialQuality?->name ?? '—' }}</td>
<td class="align-middle">{{ $ingredient->inci }}</td>
<td>
<input type="hidden" name="mfg_ingredient_id[]" value="{{ $ingredient->id }}">
<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram"
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 6) : '' }}"
autocomplete="off" step="0.000001">
</td>
<td>
<input type="text" name="mfg_factor[]"
class="form-control form-control-sm mfg-factor"
value="{{ formatNumber($ingredient->pivot->factor ?? ($ingredient->default_factor ?? 1.1)) }}"
autocomplete="off">
</td>
<td class="align-middle mfg-effective text-right small text-muted">—</td>
<td class="align-middle">
<a class="text-danger mfg-ingredient-row-remove" href="#"
title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a>
</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-weight-bold" id="mfg-recipe-total-row">
<td colspan="4" class="text-right">{{ __('Gesamt:') }}</td>
<td class="text-right" id="mfg-recipe-total-percent">—</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
<p class="text-muted small mb-2">{{ __('Eigene Hersteller-Rezeptur (separate INCI-Liste), gegliedert in Phasen (Phase A, B …). Rohstoffe und Phasen per Drag & Drop sortieren, danach speichern. Die Gesamtsumme über alle Phasen muss 100 % ergeben.') }}</p>
@php
$mfgPhaseList = $product->exists ? $product->recipePhases()->get() : collect();
$mfgRowsByPhase = $product->manufacturer_ingredients->groupBy(fn ($i) => (int) ($i->pivot->recipe_phase_id ?? 0));
$mfgBlocks = [];
if (($mfgRowsByPhase[0] ?? collect())->isNotEmpty() || $mfgPhaseList->isEmpty()) {
// Altbestand ohne Phase bzw. leere Rezeptur => impliziter erster Block
$mfgBlocks[] = ['name' => '', 'note' => '', 'rows' => $mfgRowsByPhase[0] ?? collect()];
}
foreach ($mfgPhaseList as $mfgPhase) {
$mfgBlocks[] = ['name' => $mfgPhase->name, 'note' => $mfgPhase->note, 'rows' => $mfgRowsByPhase[$mfgPhase->id] ?? collect()];
}
@endphp
<div id="mfg-phases-container">
@foreach ($mfgBlocks as $blockIndex => $block)
<div class="card mb-3 mfg-phase-block">
<div class="card-header d-flex align-items-center py-2">
<span class="text-muted mfg-phase-drag-handle mr-2" style="cursor:grab" title="{{ __('Phase verschieben') }}">&#9776;</span>
<input type="text" name="mfg_phase_name[]" class="form-control form-control-sm mfg-phase-name"
style="max-width: 16rem" placeholder="{{ __('Phase') }} {{ chr(65 + $blockIndex) }}"
value="{{ $block['name'] }}" autocomplete="off">
<a class="text-danger ml-auto mfg-phase-remove" href="#" title="{{ __('Phase entfernen') }}"><i class="far fa-trash-alt"></i></a>
</div>
<div class="card-body p-2">
<div class="table-responsive">
<table class="table table-striped table-bordered mb-2 mfg-phase-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th>{{ __('Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th>{{ __('INCI') }}</th>
<th style="width:8rem">{{ __('Anteil (%)') }}</th>
<th style="width:6rem">{{ __('Faktor') }}</th>
<th style="width:7rem">{{ __('Effektiv') }}</th>
<th style="width:3rem"></th>
</tr>
</thead>
<tbody class="mfg-phase-rows">
@foreach ($block['rows'] as $ingredient)
<tr data-ingredient-id="{{ $ingredient->id }}">
<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab"
title="{{ __('Verschieben') }}">&#9776;</td>
<td class="align-middle">{{ $ingredient->name }}</td>
<td class="align-middle small text-muted">{{ $ingredient->materialQuality?->name ?? '—' }}</td>
<td class="align-middle">{{ $ingredient->inci }}</td>
<td>
<input type="hidden" name="mfg_ingredient_id[]" value="{{ $ingredient->id }}">
<input type="hidden" name="mfg_row_phase[]" class="mfg-row-phase" value="{{ $blockIndex }}">
<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram"
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 6) : '' }}"
autocomplete="off" step="0.000001">
</td>
<td>
<input type="text" name="mfg_factor[]"
class="form-control form-control-sm mfg-factor"
value="{{ formatNumber($ingredient->pivot->factor ?? ($ingredient->default_factor ?? 1.1)) }}"
autocomplete="off">
</td>
<td class="align-middle mfg-effective text-right small text-muted">—</td>
<td class="align-middle">
<a class="text-danger mfg-ingredient-row-remove" href="#"
title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-default mfg-phase-add-ingredients" data-toggle="modal"
data-target="#modal-mfg-ingredients-pick">
+ {{ __('Rohstoffe hinzufügen') }}
</button>
<textarea name="mfg_phase_note[]" class="form-control mt-2 mfg-phase-note" rows="2"
placeholder="{{ __('Notiz zur Phase (optional) …') }}">{{ $block['note'] }}</textarea>
</div>
</div>
@endforeach
</div>
<div class="form-group">
<button type="button" class="btn btn-default" data-toggle="modal"
data-target="#modal-mfg-ingredients-pick">
{{ __('Inhaltsstoffe hinzufügen') }}
</button>
<div class="d-flex align-items-center mb-3">
<button type="button" class="btn btn-sm btn-primary" id="btn-mfg-add-phase">+ {{ __('Neue Phase') }}</button>
<div class="ml-auto font-weight-bold">
{{ __('Gesamt:') }} <span id="mfg-recipe-total-percent">—</span>
</div>
</div>
<div class="modal fade" id="modal-mfg-ingredients-pick" tabindex="-1" role="dialog"
@ -863,6 +895,42 @@
</div>
</div>
</div>
<script type="text/template" id="mfg-phase-block-template">
<div class="card mb-3 mfg-phase-block">
<div class="card-header d-flex align-items-center py-2">
<span class="text-muted mfg-phase-drag-handle mr-2" style="cursor:grab" title="{{ __('Phase verschieben') }}">&#9776;</span>
<input type="text" name="mfg_phase_name[]" class="form-control form-control-sm mfg-phase-name"
style="max-width: 16rem" placeholder="{{ __('Phase') }}" value="" autocomplete="off">
<a class="text-danger ml-auto mfg-phase-remove" href="#" title="{{ __('Phase entfernen') }}"><i class="far fa-trash-alt"></i></a>
</div>
<div class="card-body p-2">
<div class="table-responsive">
<table class="table table-striped table-bordered mb-2 mfg-phase-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th>{{ __('Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th>{{ __('INCI') }}</th>
<th style="width:8rem">{{ __('Anteil (%)') }}</th>
<th style="width:6rem">{{ __('Faktor') }}</th>
<th style="width:7rem">{{ __('Effektiv') }}</th>
<th style="width:3rem"></th>
</tr>
</thead>
<tbody class="mfg-phase-rows"></tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-default mfg-phase-add-ingredients" data-toggle="modal"
data-target="#modal-mfg-ingredients-pick">
+ {{ __('Rohstoffe hinzufügen') }}
</button>
<textarea name="mfg_phase_note[]" class="form-control mt-2 mfg-phase-note" rows="2"
placeholder="{{ __('Notiz zur Phase (optional) …') }}"></textarea>
</div>
</div>
</script>
</div>{{-- /.js-recipe-fields --}}
<div class="form-row">
<div class="form-group col-md-12">