Warenwirtschaft: F-04 — Rezeptur-Phase an Verbrauchszeilen, keine Vervielfachung beim Wiederöffnen

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 10:54:59 +00:00
parent 859b1f083d
commit 104671e929
8 changed files with 109 additions and 3 deletions

View file

@ -41,6 +41,7 @@ class StoreProductionRequest extends FormRequest
'ingredient_lines.*.ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'], 'ingredient_lines.*.ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
'ingredient_lines.*.batch_key' => ['nullable', 'string', 'max:120'], 'ingredient_lines.*.batch_key' => ['nullable', 'string', 'max:120'],
'ingredient_lines.*.stock_entry_id' => ['nullable', 'integer', 'exists:stock_entries,id'], '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'], 'ingredient_lines.*.quantity_used' => ['nullable', 'string'],
'outputs' => ['nullable', 'array'], 'outputs' => ['nullable', 'array'],
'outputs.*.product_id' => ['nullable', 'integer', 'exists:products,id'], '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. // 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.*.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'], '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 // Audit H-2: Vorzeichen-/Zahlprüfung — negative Zeilen konnten sich
// in der Summenprüfung gegenseitig aufheben und Bestand aufblähen. // in der Summenprüfung gegenseitig aufheben und Bestand aufblähen.
'ingredient_lines.*.quantity_used' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail) { 'ingredient_lines.*.quantity_used' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail) {
@ -120,6 +123,7 @@ class StoreProductionRequest extends FormRequest
$out = [ $out = [
'ingredient_id' => (int) $line['ingredient_id'], 'ingredient_id' => (int) $line['ingredient_id'],
'quantity_used' => $line['quantity_used'] ?? null, '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'])) { if (! empty($line['batch_key'])) {
$out['batch_key'] = (string) $line['batch_key']; $out['batch_key'] = (string) $line['batch_key'];

View file

@ -12,9 +12,19 @@ class ProductionIngredient extends Model
'ingredient_id', 'ingredient_id',
'stock_entry_id', 'stock_entry_id',
'batch_key', 'batch_key',
'recipe_phase_id',
'quantity_used', '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 * F-03: Chargen-Schlüssel für das Formular — im Entwurf der roh gespeicherte
* batch_key, sonst aus dem aufgelösten Wareneingang abgeleitet. * batch_key, sonst aus dem aufgelösten Wareneingang abgeleitet.

View file

@ -606,6 +606,8 @@ class ProductionService
'ingredient_id' => $ingredientId, 'ingredient_id' => $ingredientId,
'stock_entry_id' => (int) $entry->id, 'stock_entry_id' => (int) $entry->id,
'quantity_used' => $take, '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 // Audit H-3: Restmenge im Cache dekrementieren — sonst rechnet
// eine zweite Zeile derselben Charge erneut gegen die volle // eine zweite Zeile derselben Charge erneut gegen die volle
@ -763,6 +765,7 @@ class ProductionService
'ingredient_id' => (int) $line['ingredient_id'], 'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null, 'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null,
'batch_key' => $stockEntryId > 0 ? null : ($batchKey !== '' ? $batchKey : 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), 'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]; ];
} }

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Prüfung 08.09.2026 F-04: Verbrauchszeilen merken sich die Rezeptur-Phase, damit
* ein in mehreren Phasen verwendeter Rohstoff beim Wiederöffnen je Phase mit seinem
* eigenen Anteil vorbelegt wird (statt alle Zeilen in jeder Phase zu wiederholen).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('production_ingredients', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -5,6 +5,7 @@
var excludeProductionId = @json($excludeProductionId ?? null); var excludeProductionId = @json($excludeProductionId ?? null);
var lineIndex = 0; var lineIndex = 0;
var legacyLinesUsed = {};
var recipeIngredients = []; var recipeIngredients = [];
// AP-35: Umrechnung Anteil (%) → Gramm/Stück (Füllgewicht / 100; 1,0 ohne Basis). // AP-35: Umrechnung Anteil (%) → Gramm/Stück (Füllgewicht / 100; 1,0 ohne Basis).
var recipeGramScale = 1; var recipeGramScale = 1;
@ -83,6 +84,8 @@
var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>'); var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>');
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id)); $colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id));
// F-04: Phase mitsenden, damit die Zeile beim Wiederöffnen in derselben Phase landet.
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][recipe_phase_id]">').val(ing.phase_id != null ? ing.phase_id : ''));
$colSelect.append(buildStockSelect(idx, ing.batches, preselectedBatchKey)); $colSelect.append(buildStockSelect(idx, ing.batches, preselectedBatchKey));
$row.append($colSelect); $row.append($colSelect);
@ -264,7 +267,15 @@
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>'); $wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$container.append($wrap); $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) { if (existing.length > 0) {
existing.forEach(function (ex) { existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used); addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
@ -280,6 +291,7 @@
recipeGramScale = data.recipe_gram_scale != null ? data.recipe_gram_scale : 1; recipeGramScale = data.recipe_gram_scale != null ? data.recipe_gram_scale : 1;
$('#recipe-ingredient-lines').empty(); $('#recipe-ingredient-lines').empty();
lineIndex = 0; lineIndex = 0;
legacyLinesUsed = {};
if (data.recipe_required === false) { if (data.recipe_required === false) {
$('#recipe-warning').hide(); $('#recipe-warning').hide();

View file

@ -21,8 +21,9 @@
@section('scripts') @section('scripts')
@php @php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile. // 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 $existingLines = $model
? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) { ? $model->productionIngredients->groupBy(fn ($l) => $l->formGroupKey())->map(function ($lines) {
return $lines return $lines
->groupBy(fn ($l) => $l->formBatchKey()) ->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')]) ->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])

View file

@ -29,7 +29,8 @@
@section('scripts') @section('scripts')
@php @php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile. // 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 return $lines
->groupBy(fn ($l) => $l->formBatchKey()) ->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')]) ->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])

View file

@ -6,6 +6,7 @@ use App\Models\Location;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductIngredient; use App\Models\ProductIngredient;
use App\Models\Production; use App\Models\Production;
use App\Models\RecipePhase;
use App\Models\StockEntry; use App\Models\StockEntry;
use App\Models\Supplier; use App\Models\Supplier;
use App\Services\InventoryService; 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($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG')
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0); ->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);
});