Warenwirtschaft: OPT-8 Rezeptur-Robustheit (Audit H-4, H-5, M-8, N-11/12/14)

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 12:48:55 +00:00
parent c69721d174
commit fa44af0a1f
3 changed files with 208 additions and 36 deletions

View file

@ -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 === '') {

View file

@ -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;

View file

@ -0,0 +1,126 @@
<?php
use App\Models\Ingredient;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\RecipePhase;
use App\Repositories\ProductRepository;
use App\Services\ProductionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function rrIngredient(string $name): Ingredient
{
return Ingredient::query()->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);
});