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

@ -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">