From 104671e9293fb63cf30c80d8467bb1708c2bee04 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 8 Sep 2026 10:54:59 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20F-04=20=E2=80=94=20Rezeptur-?= =?UTF-8?q?Phase=20an=20Verbrauchszeilen,=20keine=20Vervielfachung=20beim?= =?UTF-8?q?=20Wieder=C3=B6ffnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit production_ingredients.recipe_phase_id; Formular sendet die Phase je Zeile, Vorbelegung je Rohstoff|Phase. Altbestand ohne Phase wird nur einmal eingesetzt. Co-Authored-By: Claude Fable 5.1 --- .../Inventory/StoreProductionRequest.php | 4 ++ app/Models/ProductionIngredient.php | 10 ++++ app/Services/ProductionService.php | 3 ++ ...ase_id_to_production_ingredients_table.php | 28 +++++++++++ .../inventory/productions/_scripts.blade.php | 14 +++++- .../inventory/productions/create.blade.php | 3 +- .../inventory/productions/edit.blade.php | 3 +- tests/Feature/ProductionDraftTest.php | 47 +++++++++++++++++++ 8 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_09_08_110000_add_recipe_phase_id_to_production_ingredients_table.php diff --git a/app/Http/Requests/Inventory/StoreProductionRequest.php b/app/Http/Requests/Inventory/StoreProductionRequest.php index c0914f0..f60a1cd 100644 --- a/app/Http/Requests/Inventory/StoreProductionRequest.php +++ b/app/Http/Requests/Inventory/StoreProductionRequest.php @@ -41,6 +41,7 @@ class StoreProductionRequest extends FormRequest 'ingredient_lines.*.ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'], 'ingredient_lines.*.batch_key' => ['nullable', 'string', 'max:120'], 'ingredient_lines.*.stock_entry_id' => ['nullable', 'integer', 'exists:stock_entries,id'], + 'ingredient_lines.*.recipe_phase_id' => ['nullable', 'integer', 'exists:recipe_phases,id'], 'ingredient_lines.*.quantity_used' => ['nullable', 'string'], 'outputs' => ['nullable', 'array'], 'outputs.*.product_id' => ['nullable', 'integer', 'exists:products,id'], @@ -63,6 +64,8 @@ class StoreProductionRequest extends FormRequest // AP-27: Das Formular übergibt Chargen (batch_key); Einzel-Einträge (stock_entry_id) bleiben unterstützt. 'ingredient_lines.*.batch_key' => ['required_without:ingredient_lines.*.stock_entry_id', 'nullable', 'string', 'max:120'], 'ingredient_lines.*.stock_entry_id' => ['required_without:ingredient_lines.*.batch_key', 'nullable', 'integer', 'exists:stock_entries,id'], + // F-04: Phase der Rezepturzeile — nur zur Vorbelegung beim Wiederöffnen. + 'ingredient_lines.*.recipe_phase_id' => ['nullable', 'integer', 'exists:recipe_phases,id'], // Audit H-2: Vorzeichen-/Zahlprüfung — negative Zeilen konnten sich // in der Summenprüfung gegenseitig aufheben und Bestand aufblähen. 'ingredient_lines.*.quantity_used' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail) { @@ -120,6 +123,7 @@ class StoreProductionRequest extends FormRequest $out = [ 'ingredient_id' => (int) $line['ingredient_id'], 'quantity_used' => $line['quantity_used'] ?? null, + 'recipe_phase_id' => ! empty($line['recipe_phase_id']) ? (int) $line['recipe_phase_id'] : null, ]; if (! empty($line['batch_key'])) { $out['batch_key'] = (string) $line['batch_key']; diff --git a/app/Models/ProductionIngredient.php b/app/Models/ProductionIngredient.php index 2d8981f..dfcdfa3 100644 --- a/app/Models/ProductionIngredient.php +++ b/app/Models/ProductionIngredient.php @@ -12,9 +12,19 @@ class ProductionIngredient extends Model 'ingredient_id', 'stock_entry_id', 'batch_key', + 'recipe_phase_id', 'quantity_used', ]; + /** + * F-04: Schlüssel für die Formular-Vorbelegung: Rohstoff + Rezeptur-Phase. + * Zeilen ohne Phase (Altbestand/Rezeptur ohne Phasen) enden auf „|“. + */ + public function formGroupKey(): string + { + return (int) $this->ingredient_id.'|'.($this->recipe_phase_id !== null ? (int) $this->recipe_phase_id : ''); + } + /** * F-03: Chargen-Schlüssel für das Formular — im Entwurf der roh gespeicherte * batch_key, sonst aus dem aufgelösten Wareneingang abgeleitet. diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index b45b485..918e6b0 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -606,6 +606,8 @@ class ProductionService 'ingredient_id' => $ingredientId, 'stock_entry_id' => (int) $entry->id, 'quantity_used' => $take, + // F-04: Phase der Formularzeile für die spätere Vorbelegung mitführen. + 'recipe_phase_id' => ! empty($line['recipe_phase_id']) ? (int) $line['recipe_phase_id'] : null, ]; // Audit H-3: Restmenge im Cache dekrementieren — sonst rechnet // eine zweite Zeile derselben Charge erneut gegen die volle @@ -763,6 +765,7 @@ class ProductionService 'ingredient_id' => (int) $line['ingredient_id'], 'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null, 'batch_key' => $stockEntryId > 0 ? null : ($batchKey !== '' ? $batchKey : null), + 'recipe_phase_id' => ! empty($line['recipe_phase_id']) ? (int) $line['recipe_phase_id'] : null, 'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null), ]; } diff --git a/database/migrations/2026_09_08_110000_add_recipe_phase_id_to_production_ingredients_table.php b/database/migrations/2026_09_08_110000_add_recipe_phase_id_to_production_ingredients_table.php new file mode 100644 index 0000000..96c4447 --- /dev/null +++ b/database/migrations/2026_09_08_110000_add_recipe_phase_id_to_production_ingredients_table.php @@ -0,0 +1,28 @@ +foreignId('recipe_phase_id')->nullable()->after('batch_key') + ->constrained('recipe_phases')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('production_ingredients', function (Blueprint $table) { + $table->dropConstrainedForeignId('recipe_phase_id'); + }); + } +}; diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php index f8a3316..45324a9 100644 --- a/resources/views/admin/inventory/productions/_scripts.blade.php +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -5,6 +5,7 @@ var excludeProductionId = @json($excludeProductionId ?? null); var lineIndex = 0; + var legacyLinesUsed = {}; var recipeIngredients = []; // AP-35: Umrechnung Anteil (%) → Gramm/Stück (Füllgewicht / 100; 1,0 ohne Basis). var recipeGramScale = 1; @@ -83,6 +84,8 @@ var $colSelect = $('
'); $colSelect.append($('').val(ing.id)); + // F-04: Phase mitsenden, damit die Zeile beim Wiederöffnen in derselben Phase landet. + $colSelect.append($('').val(ing.phase_id != null ? ing.phase_id : '')); $colSelect.append(buildStockSelect(idx, ing.batches, preselectedBatchKey)); $row.append($colSelect); @@ -264,7 +267,15 @@ $wrap.append(''); $container.append($wrap); - var existing = existingLines[String(ing.id)] || []; + // F-04: Vorbelegung je Rohstoff|Phase. Altbestand ohne Phase (Schlüssel „id|“) + // wird nur einmal — beim ersten Vorkommen des Rohstoffs — eingesetzt. + var phaseKey = String(ing.id) + '|' + (ing.phase_id != null ? String(ing.phase_id) : ''); + var legacyKey = String(ing.id) + '|'; + var existing = existingLines[phaseKey] || []; + if (!existing.length && phaseKey !== legacyKey && existingLines[legacyKey] && !legacyLinesUsed[legacyKey]) { + existing = existingLines[legacyKey]; + legacyLinesUsed[legacyKey] = true; + } if (existing.length > 0) { existing.forEach(function (ex) { addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used); @@ -280,6 +291,7 @@ recipeGramScale = data.recipe_gram_scale != null ? data.recipe_gram_scale : 1; $('#recipe-ingredient-lines').empty(); lineIndex = 0; + legacyLinesUsed = {}; if (data.recipe_required === false) { $('#recipe-warning').hide(); diff --git a/resources/views/admin/inventory/productions/create.blade.php b/resources/views/admin/inventory/productions/create.blade.php index 03068a9..b8d49e8 100644 --- a/resources/views/admin/inventory/productions/create.blade.php +++ b/resources/views/admin/inventory/productions/create.blade.php @@ -21,8 +21,9 @@ @section('scripts') @php // AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile. + // F-04: Schlüssel = Rohstoff|Phase, damit ein Rohstoff in zwei Phasen je Phase seinen Anteil behält. $existingLines = $model - ? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) { + ? $model->productionIngredients->groupBy(fn ($l) => $l->formGroupKey())->map(function ($lines) { return $lines ->groupBy(fn ($l) => $l->formBatchKey()) ->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')]) diff --git a/resources/views/admin/inventory/productions/edit.blade.php b/resources/views/admin/inventory/productions/edit.blade.php index 456884a..04f3f25 100644 --- a/resources/views/admin/inventory/productions/edit.blade.php +++ b/resources/views/admin/inventory/productions/edit.blade.php @@ -29,7 +29,8 @@ @section('scripts') @php // AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile. - $existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) { + // F-04: Schlüssel = Rohstoff|Phase, damit ein Rohstoff in zwei Phasen je Phase seinen Anteil behält. + $existingLines = $model->productionIngredients->groupBy(fn ($l) => $l->formGroupKey())->map(function ($lines) { return $lines ->groupBy(fn ($l) => $l->formBatchKey()) ->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')]) diff --git a/tests/Feature/ProductionDraftTest.php b/tests/Feature/ProductionDraftTest.php index 6369b84..ef448c3 100644 --- a/tests/Feature/ProductionDraftTest.php +++ b/tests/Feature/ProductionDraftTest.php @@ -6,6 +6,7 @@ use App\Models\Location; use App\Models\Product; use App\Models\ProductIngredient; use App\Models\Production; +use App\Models\RecipePhase; use App\Models\StockEntry; use App\Models\Supplier; use App\Services\InventoryService; @@ -346,3 +347,49 @@ test('F-03: entwurf mit gewähltem ergebnisprodukt ohne stückzahl wird gespeich ->and($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG') ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0); }); + +test('F-04: derselbe rohstoff in zwei phasen behält beim wiederöffnen je phase seinen anteil und lässt sich unverändert abschließen', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $ing = draftIngredient(); + $stock = draftStock($ing, $location, $supplier, $user); + + $product = Product::query()->create(['name' => 'Draft-Phasen', 'title' => 'Draft-Phasen', 'active' => true]); + $phaseA = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase A', 'pos' => 0]); + $phaseB = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase B', 'pos' => 1]); + ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseA->id]); + ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseB->id]); + + Passport::actingAs($user); + $this->actingAs($user, 'user'); + + $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1]; + $lines = [ + ['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'], + ['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'], + ]; + + $this->post(route('admin.inventory.productions.store'), $head + ['save_mode' => 'draft', 'ingredient_lines' => $lines]) + ->assertRedirect()->assertSessionHasNoErrors(); + + $draft = Production::query()->where('product_id', $product->id)->sole(); + $html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent(); + $existing = json_decode(substr($html, strpos($html, 'var existingLines = ') + strlen('var existingLines = '), strpos($html, ';', strpos($html, 'var existingLines = ')) - strpos($html, 'var existingLines = ') - strlen('var existingLines = ')), true); + + expect($existing)->toHaveKeys([$ing->id.'|'.$phaseA->id, $ing->id.'|'.$phaseB->id]) + ->and($existing[$ing->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6) + ->and($existing[$ing->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4); + + // Unverändert abschließen: Summe 10 g = Soll 10 g, je Phase bleibt der Anteil erhalten. + $this->put(route('admin.inventory.productions.update', $draft), $head + [ + 'save_mode' => 'complete', 'ingredient_lines' => $lines, + 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + ])->assertSessionHasNoErrors(); + + $done = $draft->fresh(); + expect($done->isDraft())->toBeFalse() + ->and($done->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6) + ->and($done->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4) + ->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0); +});