From fa44af0a1f3c5b27c437b2b7930fd028719eb2ab Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 15 Jul 2026 12:48:55 +0000 Subject: [PATCH] Warenwirtschaft: OPT-8 Rezeptur-Robustheit (Audit H-4, H-5, M-8, N-11/12/14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H-4: Rezeptur-Neuaufbau (Delete-then-Insert) in DB::transaction; unbekannte Rohstoff-IDs brechen validiert ab (kein FK-Crash nach dem Delete mehr), Phasennamen auf varchar(100) gekappt. - H-5: Phasen werden auch ohne Rohstoffzeilen persistiert — Struktur mit Namen/Notizen geht beim Speichern nicht mehr verloren. - M-8: requiredGramsByIngredient() summiert doppelte Rohstoffe statt zu überschreiben. - N-11: Set-Umstellung löscht Rezeptur-Phasen mit. - N-12: Auto-Phasenname nach Z: AA, AB, … - N-14: Legacy-Zweig updateIngredients() nicht mehr destruktiv — bestehende gram/factor-Werte bleiben beim Listen-Sync erhalten (Verhalten lt. ProductPhase0Test unverändert). Tests: neuer RecipeRobustnessTest (6). Suite komplett grün. Co-Authored-By: Claude Fable 5 --- app/Repositories/ProductRepository.php | 114 +++++++++++++++------- app/Services/ProductionService.php | 4 +- tests/Feature/RecipeRobustnessTest.php | 126 +++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 tests/Feature/RecipeRobustnessTest.php diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index 87fdab7..6e82559 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -14,6 +14,7 @@ use App\Models\RecipePhase; use App\Services\Slim; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Validation\ValidationException; class ProductRepository extends BaseRepository { @@ -108,6 +109,8 @@ class ProductRepository extends BaseRepository if ($this->model->is_set) { // Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert. ProductIngredient::where('product_id', $this->model->id)->delete(); + // Audit N-11: Phasen mitlöschen, sonst tauchen sie nach Rück-Umstellung wieder auf. + RecipePhase::where('product_id', $this->model->id)->delete(); $this->model->packagings()->detach(); $this->updateSetItems($data); } else { @@ -192,18 +195,25 @@ class ProductRepository extends BaseRepository $ids = array_values(array_unique(array_filter(array_map('intval', $data['product_ingredients']), fn (int $id) => $id > 0))); $defaults = Ingredient::whereIn('id', $ids)->pluck('default_factor', 'id'); + // Audit N-14: bestehende Werte sichern — der Neuaufbau setzte + // gram bisher pauschal auf null (stiller Datenverlust). + $existing = ProductIngredient::where('product_id', $this->model->id) + ->where('recipe_type', 'product') + ->get() + ->keyBy('ingredient_id'); + ProductIngredient::where('product_id', $this->model->id) ->where('recipe_type', 'product') ->delete(); foreach ($ids as $index => $ingredientId) { - $factor = $defaults[$ingredientId] ?? 1.10; + $prev = $existing[$ingredientId] ?? null; ProductIngredient::create([ 'product_id' => $this->model->id, 'ingredient_id' => $ingredientId, 'pos' => $index, - 'gram' => null, - 'factor' => $factor, + 'gram' => $prev?->gram, + 'factor' => $prev?->factor ?? ($defaults[$ingredientId] ?? 1.10), 'recipe_type' => 'product', ]); } @@ -252,41 +262,58 @@ class ProductRepository extends BaseRepository return true; } - 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-4: Delete-then-Insert atomar — ein Fehler beim Neuaufbau darf + // die bestehende Rezeptur nicht vernichten. + return \DB::transaction(function () use ($data): bool { + ProductIngredient::where('product_id', $this->model->id) + ->where('recipe_type', 'manufacturer') + ->delete(); + RecipePhase::where('product_id', $this->model->id) + ->where('recipe_type', 'manufacturer') + ->delete(); - if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) { - return true; - } + // Audit H-5: Phasen auch ohne Rohstoffzeilen persistieren — vorher + // gingen Namen/Notizen verloren, wenn (noch) keine Zeile existierte. + $phaseIds = $this->persistRecipePhases($data); - $phaseIds = $this->persistRecipePhases($data); - - $ids = $data['mfg_ingredient_id']; - $grams = $data['mfg_gram'] ?? []; - $factors = $data['mfg_factor'] ?? []; - $rowPhases = $data['mfg_row_phase'] ?? []; - - foreach ($ids as $index => $ingredientId) { - $ingredientId = (int) $ingredientId; - if ($ingredientId <= 0) { - continue; + if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) { + return true; } - ProductIngredient::create([ - 'product_id' => $this->model->id, - 'ingredient_id' => $ingredientId, - 'pos' => $index, - 'gram' => $this->parseNullableDecimal($grams[$index] ?? null), - 'factor' => $this->parseFactor($factors[$index] ?? null, $ingredientId), - 'recipe_type' => 'manufacturer', - 'recipe_phase_id' => $phaseIds[(int) ($rowPhases[$index] ?? 0)] ?? null, - ]); - } - return true; + $ids = $data['mfg_ingredient_id']; + $grams = $data['mfg_gram'] ?? []; + $factors = $data['mfg_factor'] ?? []; + $rowPhases = $data['mfg_row_phase'] ?? []; + + // Audit H-4: unbekannte Rohstoff-IDs vorab prüfen statt am FK zu scheitern. + $requestedIds = array_values(array_unique(array_filter(array_map('intval', $ids), fn (int $id) => $id > 0))); + $knownIds = Ingredient::query()->whereIn('id', $requestedIds)->pluck('id')->map(fn ($id) => (int) $id)->all(); + if ($missing = array_diff($requestedIds, $knownIds)) { + throw ValidationException::withMessages([ + 'mfg_ingredient_id' => __('Unbekannter Rohstoff in der Hersteller-Rezeptur (ID :ids). Bitte Formular neu laden.', [ + 'ids' => implode(', ', $missing), + ]), + ]); + } + + foreach ($ids as $index => $ingredientId) { + $ingredientId = (int) $ingredientId; + if ($ingredientId <= 0) { + continue; + } + ProductIngredient::create([ + 'product_id' => $this->model->id, + 'ingredient_id' => $ingredientId, + 'pos' => $index, + 'gram' => $this->parseNullableDecimal($grams[$index] ?? null), + 'factor' => $this->parseFactor($factors[$index] ?? null, $ingredientId), + 'recipe_type' => 'manufacturer', + 'recipe_phase_id' => $phaseIds[(int) ($rowPhases[$index] ?? 0)] ?? null, + ]); + } + + return true; + }); } /** @@ -317,7 +344,8 @@ class ProductRepository extends BaseRepository $phase = RecipePhase::create([ 'product_id' => $this->model->id, 'recipe_type' => 'manufacturer', - 'name' => $name !== '' ? $name : 'Phase '.chr(65 + $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, ]); @@ -327,6 +355,22 @@ class ProductRepository extends BaseRepository return $phaseIds; } + /** + * Auto-Name für unbenannte Phasen: A…Z, danach AA, AB … (Audit N-12 — + * chr(65+index) lief nach „Z" in Sonderzeichen). + */ + private function autoPhaseName(int $index): string + { + $label = ''; + $n = $index; + do { + $label = chr(65 + ($n % 26)).$label; + $n = intdiv($n, 26) - 1; + } while ($n >= 0); + + return 'Phase '.$label; + } + private function parseNullableDecimal(mixed $value): ?float { if ($value === null || $value === '') { diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 0e61b9f..c17a22c 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -295,7 +295,9 @@ class ProductionService ]); } $factor = (float) ($ing->pivot->factor ?? 1.1); - $required[(int) $ing->id] = (float) $gram * $factor * $producedQuantity; + // Audit M-8: aufsummieren statt überschreiben — kommt derselbe Rohstoff + // mehrfach vor (z. B. in zwei Phasen), zählt die Gesamtmenge. + $required[(int) $ing->id] = ($required[(int) $ing->id] ?? 0.0) + (float) $gram * $factor * $producedQuantity; } return $required; diff --git a/tests/Feature/RecipeRobustnessTest.php b/tests/Feature/RecipeRobustnessTest.php new file mode 100644 index 0000000..c23f01d --- /dev/null +++ b/tests/Feature/RecipeRobustnessTest.php @@ -0,0 +1,126 @@ +create([ + 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', + 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, + ]); +} + +function rrProduct(string $name): Product +{ + return Product::query()->create(['name' => $name, 'title' => $name, 'active' => true]); +} + +test('phasen ohne rohstoffzeilen werden gespeichert (H-5)', function () { + $product = rrProduct('RR-PhasenOhneZeilen'); + $repo = new ProductRepository($product); + + $repo->updateManufacturerIngredients([ + 'manufacturer_inci_sync_sent' => '1', + 'mfg_phase_name' => ['Fettphase', 'Wasserphase'], + 'mfg_phase_note' => ['auf 70 °C erhitzen', ''], + ]); + + $phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get(); + expect($phases)->toHaveCount(2); + expect($phases[0]->name)->toBe('Fettphase'); + expect($phases[0]->note)->toBe('auf 70 °C erhitzen'); +}); + +test('unbekannte rohstoff-id bricht ab und laesst die alte rezeptur unangetastet (H-4)', function () { + $product = rrProduct('RR-Rollback'); + $ing = rrIngredient('RR-Alt'); + ProductIngredient::create([ + 'product_id' => $product->id, 'ingredient_id' => $ing->id, + 'pos' => 0, 'gram' => 50, 'factor' => 1, 'recipe_type' => 'manufacturer', + ]); + + $repo = new ProductRepository($product); + + expect(fn () => $repo->updateManufacturerIngredients([ + 'manufacturer_inci_sync_sent' => '1', + 'mfg_phase_name' => [''], + 'mfg_phase_note' => [''], + 'mfg_ingredient_id' => [999999], + 'mfg_gram' => ['10'], + 'mfg_factor' => ['1'], + 'mfg_row_phase' => [0], + ]))->toThrow(ValidationException::class); + + // Transaktion zurückgerollt: alte Zeile existiert weiter. + $rows = ProductIngredient::query()->where('product_id', $product->id)->where('recipe_type', 'manufacturer')->get(); + expect($rows)->toHaveCount(1); + expect((float) $rows[0]->gram)->toBe(50.0); +}); + +test('ueberlanger phasenname wird auf spaltenlaenge gekappt (H-4)', function () { + $product = rrProduct('RR-Lang'); + $repo = new ProductRepository($product); + + $repo->updateManufacturerIngredients([ + 'manufacturer_inci_sync_sent' => '1', + 'mfg_phase_name' => [str_repeat('X', 150), 'B'], + 'mfg_phase_note' => ['', ''], + ]); + + $phase = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->first(); + expect(mb_strlen($phase->name))->toBe(100); +}); + +test('doppelter rohstoff summiert die sollmenge statt zu ueberschreiben (M-8)', function () { + $product = rrProduct('RR-Doppel'); + $ing = rrIngredient('RR-Zweifach'); + foreach ([10, 5] as $i => $gram) { + ProductIngredient::create([ + 'product_id' => $product->id, 'ingredient_id' => $ing->id, + 'pos' => $i, 'gram' => $gram, 'factor' => 1, 'recipe_type' => 'manufacturer', + ]); + } + + $required = app(ProductionService::class)->requiredGramsByIngredient($product->fresh('manufacturer_ingredients'), 10); + + expect($required[$ing->id])->toBe(150.0); // (10 + 5) × 1 × 10 +}); + +test('auto-phasenname laeuft nach Z sauber weiter (N-12)', function () { + $product = rrProduct('RR-27Phasen'); + $repo = new ProductRepository($product); + + $repo->updateManufacturerIngredients([ + 'manufacturer_inci_sync_sent' => '1', + 'mfg_phase_name' => array_fill(0, 27, ''), + 'mfg_phase_note' => array_merge([''], array_fill(1, 26, '')), + ]); + + $phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get(); + expect($phases)->toHaveCount(27); + expect($phases[25]->name)->toBe('Phase Z'); + expect($phases[26]->name)->toBe('Phase AA'); +}); + +test('set-umstellung entfernt auch die rezeptur-phasen (N-11)', function () { + $product = rrProduct('RR-Set'); + RecipePhase::create([ + 'product_id' => $product->id, 'recipe_type' => 'manufacturer', + 'name' => 'Phase A', 'note' => null, 'pos' => 0, + ]); + + $repo = new ProductRepository($product); + $repo->update(['id' => $product->id, 'name' => $product->name, 'is_set' => 1]); + + expect(RecipePhase::query()->where('product_id', $product->id)->count())->toBe(0); +});