From 520d4e2deb673a35bc51b349ced630f91f8b55c9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 8 Sep 2026 11:48:02 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20N-02=20=E2=80=94=20Rezeptur-?= =?UTF-8?q?Phasen=20beim=20Artikelspeichern=20aktualisieren=20statt=20neu?= =?UTF-8?q?=20anlegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Repositories/ProductRepository.php | 72 ++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index d0dfad9..1aa4e70 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -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;