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:
parent
c69721d174
commit
fa44af0a1f
3 changed files with 208 additions and 36 deletions
|
|
@ -14,6 +14,7 @@ use App\Models\RecipePhase;
|
||||||
use App\Services\Slim;
|
use App\Services\Slim;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProductRepository extends BaseRepository
|
class ProductRepository extends BaseRepository
|
||||||
{
|
{
|
||||||
|
|
@ -108,6 +109,8 @@ class ProductRepository extends BaseRepository
|
||||||
if ($this->model->is_set) {
|
if ($this->model->is_set) {
|
||||||
// Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert.
|
// Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert.
|
||||||
ProductIngredient::where('product_id', $this->model->id)->delete();
|
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->model->packagings()->detach();
|
||||||
$this->updateSetItems($data);
|
$this->updateSetItems($data);
|
||||||
} else {
|
} 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)));
|
$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');
|
$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)
|
ProductIngredient::where('product_id', $this->model->id)
|
||||||
->where('recipe_type', 'product')
|
->where('recipe_type', 'product')
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
foreach ($ids as $index => $ingredientId) {
|
foreach ($ids as $index => $ingredientId) {
|
||||||
$factor = $defaults[$ingredientId] ?? 1.10;
|
$prev = $existing[$ingredientId] ?? null;
|
||||||
ProductIngredient::create([
|
ProductIngredient::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
'ingredient_id' => $ingredientId,
|
'ingredient_id' => $ingredientId,
|
||||||
'pos' => $index,
|
'pos' => $index,
|
||||||
'gram' => null,
|
'gram' => $prev?->gram,
|
||||||
'factor' => $factor,
|
'factor' => $prev?->factor ?? ($defaults[$ingredientId] ?? 1.10),
|
||||||
'recipe_type' => 'product',
|
'recipe_type' => 'product',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -252,41 +262,58 @@ class ProductRepository extends BaseRepository
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProductIngredient::where('product_id', $this->model->id)
|
// Audit H-4: Delete-then-Insert atomar — ein Fehler beim Neuaufbau darf
|
||||||
->where('recipe_type', 'manufacturer')
|
// die bestehende Rezeptur nicht vernichten.
|
||||||
->delete();
|
return \DB::transaction(function () use ($data): bool {
|
||||||
RecipePhase::where('product_id', $this->model->id)
|
ProductIngredient::where('product_id', $this->model->id)
|
||||||
->where('recipe_type', 'manufacturer')
|
->where('recipe_type', 'manufacturer')
|
||||||
->delete();
|
->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'])) {
|
// Audit H-5: Phasen auch ohne Rohstoffzeilen persistieren — vorher
|
||||||
return true;
|
// gingen Namen/Notizen verloren, wenn (noch) keine Zeile existierte.
|
||||||
}
|
$phaseIds = $this->persistRecipePhases($data);
|
||||||
|
|
||||||
$phaseIds = $this->persistRecipePhases($data);
|
if (! isset($data['mfg_ingredient_id']) || ! is_array($data['mfg_ingredient_id'])) {
|
||||||
|
return true;
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
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([
|
$phase = RecipePhase::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
'recipe_type' => 'manufacturer',
|
'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,
|
'note' => $note !== '' ? $note : null,
|
||||||
'pos' => $index,
|
'pos' => $index,
|
||||||
]);
|
]);
|
||||||
|
|
@ -327,6 +355,22 @@ class ProductRepository extends BaseRepository
|
||||||
return $phaseIds;
|
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
|
private function parseNullableDecimal(mixed $value): ?float
|
||||||
{
|
{
|
||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,9 @@ class ProductionService
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$factor = (float) ($ing->pivot->factor ?? 1.1);
|
$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;
|
return $required;
|
||||||
|
|
|
||||||
126
tests/Feature/RecipeRobustnessTest.php
Normal file
126
tests/Feature/RecipeRobustnessTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue