Warenwirtschaft: F-06 — Chargenzeilen nach Validierungsfehler aus old() wiederherstellen

Vorbelegung der Chargen-Zeilen zentral in ProductionController::existingLines();
old('ingredient_lines') hat Vorrang vor dem gespeicherten Stand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 10:57:07 +00:00
parent a803e956e1
commit 122d28bc4f
5 changed files with 78 additions and 25 deletions

View file

@ -52,6 +52,7 @@ class ProductionController extends Controller
'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
'model' => null,
'existingLines' => $this->existingLines(null),
]);
}
@ -160,6 +161,7 @@ class ProductionController extends Controller
...$this->productCatalogs($production),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'existingLines' => $this->existingLines($production),
]);
}
@ -217,9 +219,48 @@ class ProductionController extends Controller
...$this->productCatalogs($production),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'existingLines' => $this->existingLines($production),
]);
}
/**
* Vorbelegung der Chargen-Zeilen im Formular, Schlüssel „Rohstoff|Phase“ (F-04).
* Prüfung 08.09.2026 F-06: Nach einem Validierungsfehler haben die zuletzt
* eingegebenen Zeilen (old()) Vorrang vor dem gespeicherten Stand — wie bei
* Kopfdaten und Ergebniszeilen. Sonst AP-27: Zeilen je Charge (batch_key)
* zusammenfassen, mehrere Einkäufe derselben Charge = eine Formularzeile.
*
* @return array<string, array<int, array{batch_key: string, quantity_used: float|string}>>
*/
private function existingLines(?Production $production): array
{
$old = old('ingredient_lines');
if (is_array($old) && $old !== []) {
return collect($old)
->filter(fn ($l) => is_array($l)
&& (int) ($l['ingredient_id'] ?? 0) > 0
&& (trim((string) ($l['batch_key'] ?? '')) !== '' || trim((string) ($l['quantity_used'] ?? '')) !== ''))
->groupBy(fn ($l) => (int) $l['ingredient_id'].'|'.(! empty($l['recipe_phase_id']) ? (int) $l['recipe_phase_id'] : ''))
->map(fn ($group) => $group->map(fn ($l) => [
'batch_key' => trim((string) ($l['batch_key'] ?? '')),
'quantity_used' => (string) ($l['quantity_used'] ?? ''),
])->values()->all())
->all();
}
if ($production === null) {
return [];
}
return $production->productionIngredients
->groupBy(fn ($l) => $l->formGroupKey())
->map(fn ($lines) => $lines
->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => (string) $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values()->all())
->all();
}
/**
* Prüfung 08.09.2026 F-02: Zwei getrennte Kataloge.
* - `products`: Planungsprodukt oben — nur produktionsrelevante Produkte (AP-47),

View file

@ -1,7 +1,7 @@
<script>
(function ($) {
var recipeBase = @json(url('/admin/inventory/api/products'));
var existingLines = @json($existingLines ?? (object) []);
var existingLines = @json(($existingLines ?? []) === [] ? (object) [] : $existingLines);
var excludeProductionId = @json($excludeProductionId ?? null);
var lineIndex = 0;

View file

@ -19,17 +19,6 @@
@endsection
@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(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')])
->values();
})
: [];
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => null])
{{-- Vorbelegung der Chargen-Zeilen: ProductionController::existingLines() (AP-27/F-04/F-06). --}}
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines ?? [], 'excludeProductionId' => null])
@endsection

View file

@ -27,15 +27,6 @@
@endsection
@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->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')])
->values();
});
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])
{{-- Vorbelegung der Chargen-Zeilen: ProductionController::existingLines() (AP-27/F-04/F-06). --}}
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines ?? [], 'excludeProductionId' => $model->id])
@endsection

View file

@ -393,3 +393,35 @@ test('F-04: derselbe rohstoff in zwei phasen behält beim wiederöffnen je phase
->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);
});
test('F-06: nach einem validierungsfehler bleiben die eingegebenen chargenzeilen im formular erhalten', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing); // Soll 10 g
$stock = draftStock($ing, $location, $supplier, $user);
$key = $stock->batchGroupKey();
Passport::actingAs($user);
$this->actingAs($user, 'user');
// 20 g statt 10 g → Toleranzfehler beim Abschluss, Redirect zurück mit old().
$this->from(route('admin.inventory.productions.create'))
->post(route('admin.inventory.productions.store'), [
'save_mode' => 'complete',
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1,
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $key, 'quantity_used' => '20,0']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])
->assertRedirect(route('admin.inventory.productions.create'))
->assertSessionHasErrors('ingredient_lines');
$html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
$start = strpos($html, 'var existingLines = ') + strlen('var existingLines = ');
$existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true);
expect($existing)->toHaveKey($ing->id.'|')
->and($existing[$ing->id.'|'][0]['batch_key'])->toBe($key)
->and($existing[$ing->id.'|'][0]['quantity_used'])->toBe('20,0');
});