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:
parent
ea36067831
commit
97d420c98a
10 changed files with 592 additions and 100 deletions
|
|
@ -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">☰</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() {
|
||||
|
|
|
|||
|
|
@ -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') }}">☰</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') }}">☰</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') }}">☰</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') }}">☰</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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue