Warenwirtschaft: N-02 — Rezeptur-Phasen beim Artikelspeichern aktualisieren statt neu anlegen

Phasen werden über Name/Reihenfolge den bestehenden zugeordnet und aktualisiert;
nur entfernte Phasen werden gelöscht. Produktions-Verbrauchszeilen behalten so
ihre recipe_phase_id. Derselbe Rohstoff je Phase nur einmal (Validierung).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 11:48:02 +00:00
parent dd93a99265
commit 520d4e2deb

View file

@ -277,12 +277,13 @@ class ProductRepository extends BaseRepository
ProductIngredient::where('product_id', $this->model->id)
->where('recipe_type', 'manufacturer')
->delete();
RecipePhase::where('product_id', $this->model->id)
->where('recipe_type', 'manufacturer')
->delete();
// Audit H-5: Phasen auch ohne Rohstoffzeilen persistieren — vorher
// gingen Namen/Notizen verloren, wenn (noch) keine Zeile existierte.
// N-02 (Nachprüfung 08.09.2026): bestehende Phasen aktualisieren statt
// löschen/neu anlegen — Produktions-Verbrauchszeilen referenzieren die Phase
// (production_ingredients.recipe_phase_id, nullOnDelete) und verlören sonst
// bei jedem Artikelspeichern ihre Zuordnung.
$phaseIds = $this->persistRecipePhases($data);
if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) {
@ -305,11 +306,22 @@ class ProductRepository extends BaseRepository
]);
}
// N-02: Derselbe Rohstoff darf je Phase nur einmal vorkommen (der Picker verhindert
// das; Datenbestand/Direktaufrufe müssen es ebenfalls einhalten, sonst ist die
// Zeilenidentität „Rohstoff + Phase“ im Produktionsformular nicht eindeutig).
$seen = [];
foreach ($ids as $index => $ingredientId) {
$ingredientId = (int) $ingredientId;
if ($ingredientId <= 0) {
continue;
}
$phaseKey = $ingredientId.'|'.(int) ($rowPhases[$index] ?? 0);
if (isset($seen[$phaseKey])) {
throw ValidationException::withMessages([
'mfg_ingredient_id' => __('Rohstoff-ID :id kommt in derselben Phase mehrfach vor — bitte die Mengen in einer Zeile zusammenfassen.', ['id' => $ingredientId]),
]);
}
$seen[$phaseKey] = true;
ProductIngredient::create([
'product_id' => $this->model->id,
'ingredient_id' => $ingredientId,
@ -341,24 +353,62 @@ class ProductRepository extends BaseRepository
&& trim((string) ($names[0] ?? '')) === ''
&& trim((string) ($notes[0] ?? '')) === '';
$existing = RecipePhase::query()
->where('product_id', $this->model->id)
->where('recipe_type', 'manufacturer')
->orderBy('pos')->orderBy('id')
->get();
if ($implicitSingle) {
$existing->each->delete();
return [];
}
$phaseIds = [];
// N-02: Zuordnung neue → bestehende Phase zuerst über den (effektiven) Namen,
// danach in Reihenfolge; nur wirklich entfernte Phasen werden gelöscht.
$blocks = [];
foreach ($names as $index => $name) {
$name = trim((string) $name);
$note = trim((string) ($notes[$index] ?? ''));
$phase = RecipePhase::create([
'product_id' => $this->model->id,
'recipe_type' => 'manufacturer',
$blocks[$index] = [
// Audit H-4: Spaltenlänge varchar(100) absichern (strict mode).
'name' => $name !== '' ? mb_substr($name, 0, 100) : $this->autoPhaseName($index),
'note' => $note !== '' ? $note : null,
'pos' => $index,
]);
$phaseIds[$index] = $phase->id;
];
}
$unmatched = $existing->keyBy('id');
$assigned = [];
foreach ($blocks as $index => $block) {
$match = $unmatched->first(fn (RecipePhase $p) => $p->name === $block['name']);
if ($match !== null) {
$assigned[$index] = $match;
$unmatched->forget($match->id);
}
}
foreach ($blocks as $index => $block) {
if (isset($assigned[$index])) {
continue;
}
$next = $unmatched->first();
if ($next !== null) {
$assigned[$index] = $next;
$unmatched->forget($next->id);
}
}
$unmatched->each->delete();
$phaseIds = [];
foreach ($blocks as $index => $block) {
$attributes = $block + ['pos' => $index];
if (isset($assigned[$index])) {
$assigned[$index]->fill($attributes)->save();
$phaseIds[$index] = (int) $assigned[$index]->id;
} else {
$phase = RecipePhase::create($attributes + ['product_id' => $this->model->id, 'recipe_type' => 'manufacturer']);
$phaseIds[$index] = (int) $phase->id;
}
}
return $phaseIds;