Warenwirtschaft: R-01 — stabile Phasen-IDs im Artikelformular, keine Zuordnung nach Reihenfolge

mfg_phase_id[] je Phasenblock; Zuordnung bestehender Phasen über ID, dann Name,
dann gemeinsame Rohstoffe. Ein vorangestellter neuer Block übernimmt nicht mehr
die ID einer umbenannten Phase; Produktionszeilen behalten ihre Phase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-09 07:28:06 +00:00
parent a439d05a6a
commit fda37d5599
2 changed files with 64 additions and 12 deletions

View file

@ -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<int, array<int, int>> $previousRowsByPhase [phase_id => ingredient_ids] vor dem Neuaufbau
* @return array<int, int>
*/
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();

View file

@ -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 @@
<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>
{{-- R-01: stabile Phasen-ID — Umbenennen/Umordnen/Einfügen wechselt die Identität nicht --}}
<input type="hidden" name="mfg_phase_id[]" class="mfg-phase-id" value="{{ $block['id'] ?? '' }}">
<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">
@ -920,6 +922,7 @@
<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="hidden" name="mfg_phase_id[]" class="mfg-phase-id" value="">
<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>