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