diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 1aa4e70..a8e9cd0 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -274,6 +274,14 @@ class ProductRepository extends BaseRepository // Audit H-4: Delete-then-Insert atomar — ein Fehler beim Neuaufbau darf // die bestehende Rezeptur nicht vernichten. return \DB::transaction(function () use ($data): bool { + // R-01: bisherige Rohstoff→Phase-Zuordnung sichern (für die Phasenzuordnung ohne IDs). + $previousRowsByPhase = ProductIngredient::where('product_id', $this->model->id) + ->where('recipe_type', 'manufacturer') + ->get(['ingredient_id', 'recipe_phase_id']) + ->groupBy(fn ($r) => (int) ($r->recipe_phase_id ?? 0)) + ->map(fn ($rows) => $rows->pluck('ingredient_id')->map(fn ($id) => (int) $id)->unique()->values()->all()) + ->all(); + ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'manufacturer') ->delete(); @@ -284,7 +292,7 @@ class ProductRepository extends BaseRepository // 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); + $phaseIds = $this->persistRecipePhases($data, $previousRowsByPhase); if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) { return true; @@ -338,16 +346,28 @@ class ProductRepository extends BaseRepository } /** - * Legt die Rezeptur-Phasen (AP-23) an und liefert Block-Index => Phase-ID. + * Legt/aktualisiert die Rezeptur-Phasen (AP-23) und liefert Block-Index => Phase-ID. * Ein einzelner Block ohne Namen und Notiz bleibt „phasenlos" (Altverhalten, * Zeilen behalten recipe_phase_id NULL). * + * N-02/R-01: Bestehende Phasen werden aktualisiert statt neu angelegt, damit + * Produktions-Verbrauchszeilen (recipe_phase_id) ihre Zuordnung behalten. + * Zuordnung Formularblock → bestehende Phase: + * 1. übermittelte Phasen-ID (mfg_phase_id[], Formular) — geprüft gegen Produkt/Typ, + * 2. gleicher (effektiver) Name, + * 3. gemeinsame Rohstoffe (Block enthält Rohstoffe, die die Phase bisher hatte). + * Bewusst **keine** Zuordnung nach Reihenfolge: Ein vorangestellter neuer Block + * würde sonst die ID einer umbenannten Phase übernehmen (Gegenprüfung R-01). + * Nicht zugeordnete bestehende Phasen werden gelöscht (Verbrauchszeilen: nullOnDelete). + * + * @param array> $previousRowsByPhase [phase_id => ingredient_ids] vor dem Neuaufbau * @return array */ - private function persistRecipePhases(array $data): array + private function persistRecipePhases(array $data, array $previousRowsByPhase = []): array { $names = array_values($data['mfg_phase_name'] ?? []); $notes = array_values($data['mfg_phase_note'] ?? []); + $submittedIds = array_values($data['mfg_phase_id'] ?? []); $implicitSingle = count($names) <= 1 && trim((string) ($names[0] ?? '')) === '' @@ -365,8 +385,6 @@ class ProductRepository extends BaseRepository return []; } - // 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); @@ -378,23 +396,54 @@ class ProductRepository extends BaseRepository ]; } + // Rohstoffe je Formularblock (für Zuordnung 3). + $rowsByBlock = []; + foreach (($data['mfg_ingredient_id'] ?? []) as $rowIndex => $ingredientId) { + $blockIndex = (int) (($data['mfg_row_phase'] ?? [])[$rowIndex] ?? 0); + if ((int) $ingredientId > 0) { + $rowsByBlock[$blockIndex][] = (int) $ingredientId; + } + } + $unmatched = $existing->keyBy('id'); $assigned = []; + + // 1. übermittelte IDs foreach ($blocks as $index => $block) { + $id = (int) ($submittedIds[$index] ?? 0); + if ($id > 0 && $unmatched->has($id)) { + $assigned[$index] = $unmatched->get($id); + $unmatched->forget($id); + } + } + // 2. gleicher Name + foreach ($blocks as $index => $block) { + if (isset($assigned[$index])) { + continue; + } $match = $unmatched->first(fn (RecipePhase $p) => $p->name === $block['name']); if ($match !== null) { $assigned[$index] = $match; $unmatched->forget($match->id); } } + // 3. gemeinsame Rohstoffe (größte Überschneidung gewinnt) foreach ($blocks as $index => $block) { - if (isset($assigned[$index])) { + if (isset($assigned[$index]) || empty($rowsByBlock[$index])) { continue; } - $next = $unmatched->first(); - if ($next !== null) { - $assigned[$index] = $next; - $unmatched->forget($next->id); + $best = null; + $bestOverlap = 0; + foreach ($unmatched as $phase) { + $overlap = count(array_intersect($rowsByBlock[$index], $previousRowsByPhase[(int) $phase->id] ?? [])); + if ($overlap > $bestOverlap) { + $best = $phase; + $bestOverlap = $overlap; + } + } + if ($best !== null) { + $assigned[$index] = $best; + $unmatched->forget($best->id); } } $unmatched->each->delete(); diff --git a/resources/views/admin/product/form.blade.php b/resources/views/admin/product/form.blade.php index 4edf9ef..e0b4540 100755 --- a/resources/views/admin/product/form.blade.php +++ b/resources/views/admin/product/form.blade.php @@ -778,10 +778,10 @@ $mfgBlocks = []; if (($mfgRowsByPhase[0] ?? collect())->isNotEmpty() || $mfgPhaseList->isEmpty()) { // Altbestand ohne Phase bzw. leere Rezeptur => impliziter erster Block - $mfgBlocks[] = ['name' => '', 'note' => '', 'rows' => $mfgRowsByPhase[0] ?? collect()]; + $mfgBlocks[] = ['id' => null, 'name' => '', 'note' => '', 'rows' => $mfgRowsByPhase[0] ?? collect()]; } foreach ($mfgPhaseList as $mfgPhase) { - $mfgBlocks[] = ['name' => $mfgPhase->name, 'note' => $mfgPhase->note, 'rows' => $mfgRowsByPhase[$mfgPhase->id] ?? collect()]; + $mfgBlocks[] = ['id' => $mfgPhase->id, 'name' => $mfgPhase->name, 'note' => $mfgPhase->note, 'rows' => $mfgRowsByPhase[$mfgPhase->id] ?? collect()]; } @endphp @@ -790,6 +790,8 @@
☰ + {{-- R-01: stabile Phasen-ID — Umbenennen/Umordnen/Einfügen wechselt die Identität nicht --}} + @@ -920,6 +922,7 @@
☰ +