Warenwirtschaft: F-03 — Entwurf sichert Chargenzeilen verlustfrei

Entwürfe speichern batch_key roh (stock_entry_id nullable), keine Auflösung
und keine Filterung unfertiger Zeilen mehr beim Zwischenspeichern. Eine nicht
mehr verfügbare Charge wird im Formular markiert statt alle Zeilen zu verwerfen.
Ergebniszeilen dürfen im Entwurf ohne Stückzahl bleiben. Auflösung, Prüfung und
Buchung erst beim Abschluss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 10:53:36 +00:00
parent 73512897ee
commit 859b1f083d
10 changed files with 200 additions and 41 deletions

View file

@ -85,24 +85,21 @@ class ProductionController extends Controller
}
/**
* AP-27/AP-43: Chargen-Zeilen auf Einzel-Wareneingänge auflösen. Im Entwurf
* darf das Auflösen scheitern (Charge nicht verfügbar) — dann bleiben nur die
* bereits konkret gewählten Wareneingänge erhalten, der Entwurf ist immer speicherbar.
* AP-27: Chargen-Zeilen auf Einzel-Wareneingänge auflösen (nur beim Abschluss).
* Prüfung 08.09.2026 F-03: Im Entwurf wird nicht aufgelöst — die Zeilen werden
* roh (batch_key) gesichert, damit eine nicht mehr verfügbare Charge nicht den
* gesamten Arbeitsstand verwirft. Aufgelöst und geprüft wird erst beim Abschluss.
*
* @param array<int, array<string, mixed>> $lines
* @return array<int, array<string, mixed>>
*/
private function expandLines(array $lines, int $locationId, ?int $excludeProductionId, bool $isDraft): array
{
if (! $isDraft) {
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
if ($isDraft) {
return $lines;
}
try {
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
} catch (ValidationException) {
return array_values(array_filter($lines, fn ($l) => (int) ($l['stock_entry_id'] ?? 0) > 0));
}
return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId);
}
/**

View file

@ -44,7 +44,8 @@ class StoreProductionRequest extends FormRequest
'ingredient_lines.*.quantity_used' => ['nullable', 'string'],
'outputs' => ['nullable', 'array'],
'outputs.*.product_id' => ['nullable', 'integer', 'exists:products,id'],
'outputs.*.quantity' => ['nullable', 'integer', 'min:1'],
// F-03: Im Entwurf darf die Stückzahl noch leer/0 sein.
'outputs.*.quantity' => ['nullable', 'integer', 'min:0'],
'outputs.*.batch_number' => ['nullable', 'string', 'max:100'],
];
}
@ -100,10 +101,12 @@ class StoreProductionRequest extends FormRequest
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0);
}));
$outputs = array_values(array_filter($data['outputs'] ?? [], function ($output): bool {
// F-03: Im Entwurf zählt ein gewähltes Ergebnisprodukt auch ohne Stückzahl als Arbeitsstand.
$isDraft = $this->isDraft();
$outputs = array_values(array_filter($data['outputs'] ?? [], function ($output) use ($isDraft): bool {
return is_array($output)
&& (int) ($output['product_id'] ?? 0) > 0
&& (int) ($output['quantity'] ?? 0) > 0;
&& ($isDraft || (int) ($output['quantity'] ?? 0) > 0);
}));
return [

View file

@ -11,9 +11,23 @@ class ProductionIngredient extends Model
'production_id',
'ingredient_id',
'stock_entry_id',
'batch_key',
'quantity_used',
];
/**
* F-03: Chargen-Schlüssel für das Formular — im Entwurf der roh gespeicherte
* batch_key, sonst aus dem aufgelösten Wareneingang abgeleitet.
*/
public function formBatchKey(): string
{
if (! empty($this->batch_key)) {
return (string) $this->batch_key;
}
return $this->stockEntry?->batchGroupKey() ?? 'e:'.$this->stock_entry_id;
}
/**
* @return array<string, string>
*/

View file

@ -39,9 +39,9 @@ class ProductionService
$this->assertNotASet($product);
if ($draft) {
// AP-43: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung. Es werden
// nur bereits vollständig ausgefüllte Chargen-Zeilen gesichert.
$ingredientLines = $this->filterCompleteLines($ingredientLines);
// AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung; alle
// Zeilen mit Rohstoff + Charge werden roh gesichert (auch ohne Menge).
$ingredientLines = $this->draftLines($ingredientLines);
} elseif ($product->no_recipe_required) {
$ingredientLines = [];
} else {
@ -70,17 +70,13 @@ class ProductionService
]);
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
$production->productionIngredients()->create($this->ingredientLineAttributes($line));
}
// AP-43: Für den Entwurf nur die tatsächlich angegebenen Ergebnis-Zeilen
// sichern (kein Planungs-Fallback), sonst wie gehabt.
if (! $draft || $outputs !== []) {
$this->persistOutputs($production, $product, $producedQty, $outputs);
$this->persistOutputs($production, $product, $producedQty, $outputs, $draft);
}
// AP-43: Entwürfe buchen keinen Bestand (Roh-/Produkt-/Verpackung).
@ -130,8 +126,8 @@ class ProductionService
$this->assertNotASet($product);
if ($draft) {
// AP-43: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung.
$ingredientLines = $this->filterCompleteLines($ingredientLines);
// AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung, Zeilen roh sichern.
$ingredientLines = $this->draftLines($ingredientLines);
} elseif ($product->no_recipe_required) {
$ingredientLines = [];
} else {
@ -160,16 +156,12 @@ class ProductionService
$production->productionIngredients()->delete();
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
$production->productionIngredients()->create($this->ingredientLineAttributes($line));
}
$production->outputs()->delete();
if (! $draft || $outputs !== []) {
$this->persistOutputs($production, $product, $producedQty, $outputs);
$this->persistOutputs($production, $product, $producedQty, $outputs, $draft);
}
// AP-43: Entwürfe buchen keinen Bestand. Ein zuvor gelöschter Verpackungs-
@ -194,7 +186,7 @@ class ProductionService
*
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs
*/
private function persistOutputs(Production $production, Product $plannedProduct, int $plannedQty, array $outputs): void
private function persistOutputs(Production $production, Product $plannedProduct, int $plannedQty, array $outputs, bool $draft = false): void
{
if ($outputs === []) {
$producedAt = $production->produced_at instanceof Carbon
@ -223,9 +215,11 @@ class ProductionService
if ($outputProduct->is_set) {
throw ValidationException::withMessages(['outputs' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt im Produktionsergebnis wählen.')]);
}
if ($quantity < 1) {
// F-03: Im Entwurf darf die Stückzahl noch fehlen (0); beim Abschluss ist sie Pflicht.
if ($quantity < 1 && ! $draft) {
throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]);
}
$quantity = max(0, $quantity);
$batchNumber = trim((string) ($output['batch_number'] ?? ''));
@ -741,18 +735,35 @@ class ProductionService
}
/**
* AP-43: Für den Entwurf nur vollständige Chargen-Zeilen sichern — eine Zeile
* ist vollständig, wenn ein konkreter Wareneingang (stock_entry_id) gewählt ist
* und eine Menge > 0 eingetragen wurde. Unfertige Zeilen werden verworfen.
* AP-43/F-03: Entwurfszeilen verlustfrei sichern — jede Zeile mit Rohstoff und
* Charge (batch_key) bzw. Wareneingang bleibt erhalten, auch ohne Menge.
* Die Auflösung auf Wareneingänge und die fachliche Prüfung folgen beim Abschluss.
*
* @param array<int, array<string, mixed>> $ingredientLines
* @return array<int, array<string, mixed>>
*/
private function filterCompleteLines(array $ingredientLines): array
private function draftLines(array $ingredientLines): array
{
return array_values(array_filter($ingredientLines, function (array $line): bool {
return (int) ($line['stock_entry_id'] ?? 0) > 0
&& $this->parseQuantity($line['quantity_used'] ?? null) > 0;
return (int) ($line['ingredient_id'] ?? 0) > 0
&& ((int) ($line['stock_entry_id'] ?? 0) > 0 || trim((string) ($line['batch_key'] ?? '')) !== '');
}));
}
/**
* @param array<string, mixed> $line
* @return array<string, mixed>
*/
private function ingredientLineAttributes(array $line): array
{
$stockEntryId = (int) ($line['stock_entry_id'] ?? 0);
$batchKey = trim((string) ($line['batch_key'] ?? ''));
return [
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null,
'batch_key' => $stockEntryId > 0 ? null : ($batchKey !== '' ? $batchKey : null),
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Prüfung 08.09.2026 F-03: Entwürfe speichern ihre Chargen-Zeilen roh (batch_key),
* ohne sie beim Zwischenspeichern auf Wareneingänge aufzulösen. Erst der Abschluss
* löst auf und bucht. Dafür darf stock_entry_id im Entwurf leer sein.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('production_ingredients', function (Blueprint $table) {
$table->string('batch_key', 120)->nullable()->after('stock_entry_id');
});
Schema::table('production_ingredients', function (Blueprint $table) {
$table->unsignedBigInteger('stock_entry_id')->nullable()->change();
});
}
public function down(): void
{
Schema::table('production_ingredients', function (Blueprint $table) {
$table->dropColumn('batch_key');
});
Schema::table('production_ingredients', function (Blueprint $table) {
$table->unsignedBigInteger('stock_entry_id')->nullable(false)->change();
});
}
};

View file

@ -50,6 +50,7 @@
function buildStockSelect(idx, batches, preselectedBatchKey) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][batch_key]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
var found = false;
(batches || []).forEach(function (batch) {
var label = batch.label;
if (batch.remaining != null) {
@ -61,9 +62,18 @@
var opt = $('<option></option>').attr('value', batch.key).text(label);
if (preselectedBatchKey && String(batch.key) === String(preselectedBatchKey)) {
opt.prop('selected', true);
found = true;
}
select.append(opt);
});
// F-03: Eine im Entwurf gesicherte, inzwischen nicht mehr verfügbare Charge
// sichtbar markieren statt still zu verwerfen — der Abschluss lehnt sie ab.
if (preselectedBatchKey && !found) {
var parts = String(preselectedBatchKey).split(':');
var label = '{{ __('Charge') }} ' + (parts.length > 2 ? parts.slice(2).join(':') : preselectedBatchKey) + ' — {{ __('nicht (mehr) verfügbar') }}';
select.append($('<option></option>').attr('value', preselectedBatchKey).text(label).prop('selected', true));
select.addClass('is-invalid');
}
return select;
}

View file

@ -24,7 +24,7 @@
$existingLines = $model
? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
})

View file

@ -31,7 +31,7 @@
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile.
$existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->groupBy(fn ($l) => $l->formBatchKey())
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
});

View file

@ -111,7 +111,12 @@
<tr>
<td>{{ $line->ingredient?->name ?? '—' }}</td>
<td>
#{{ $line->stock_entry_id }}
@if($line->stock_entry_id === null)
{{ __('Charge') }} {{ explode(':', (string) $line->batch_key, 3)[2] ?? $line->batch_key }}
<span class="text-muted">({{ __('Entwurf — noch nicht aufgelöst') }})</span>
@else
#{{ $line->stock_entry_id }}
@endif
@if($line->stockEntry?->batch_number)
— {{ $line->stockEntry->batch_number }}
@endif

View file

@ -262,3 +262,87 @@ test('F-01: entwurf reduziert weder chargen- noch standortbestand und blockiert
])
->assertSessionHasNoErrors();
});
test('F-03: entwurf sichert alle chargenzeilen roh, auch eine nicht verfügbare, und löst erst beim abschluss auf', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
$validKey = $stock->batchGroupKey();
$missingKey = 'b:'.$supplier->id.':GIBT-ES-NICHT';
Passport::actingAs($user);
$this->actingAs($user, 'user');
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
$this->post(route('admin.inventory.productions.store'), $head + [
'save_mode' => 'draft',
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10'],
['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => ''],
],
])->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
$lines = $draft->productionIngredients()->orderBy('id')->get();
expect($draft->isDraft())->toBeTrue()
->and($lines)->toHaveCount(2)
->and($lines[0]->stock_entry_id)->toBeNull()
->and($lines[0]->batch_key)->toBe($validKey)
->and((float) $lines[0]->quantity_used)->toBe(10.0)
->and($lines[1]->batch_key)->toBe($missingKey)
->and((float) $lines[1]->quantity_used)->toBe(0.0);
// Wiederöffnen: beide Chargen-Schlüssel werden ins Formular gegeben.
$this->get(route('admin.inventory.productions.edit', $draft))
->assertOk()
->assertSee($validKey)
->assertSee($missingKey);
// Abschluss mit der nicht verfügbaren Charge wird abgelehnt — nichts geht verloren.
$this->from(route('admin.inventory.productions.edit', $draft))
->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => '10']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasErrors('ingredient_lines');
expect($draft->fresh()->productionIngredients()->count())->toBe(2);
// Abschluss mit der gültigen Charge löst auf den Wareneingang auf und bucht.
$this->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasNoErrors();
$done = $draft->fresh();
expect($done->isDraft())->toBeFalse()
->and($done->productionIngredients()->count())->toBe(1)
->and($done->productionIngredients()->first()->stock_entry_id)->toBe($stock->id)
->and($done->productionIngredients()->first()->batch_key)->toBeNull()
->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0);
});
test('F-03: entwurf mit gewähltem ergebnisprodukt ohne stückzahl wird gespeichert', function () {
$user = draftUser();
$location = Location::factory()->create();
$product = Product::query()->create(['name' => 'Draft-Out0', 'title' => 'Draft-Out0', 'active' => true, 'no_recipe_required' => true]);
Passport::actingAs($user);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.productions.store'), [
'save_mode' => 'draft',
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3,
'outputs' => [['product_id' => $product->id, 'quantity' => 0, 'batch_number' => 'VORLAEUFIG']],
])->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
expect($draft->outputs()->count())->toBe(1)
->and($draft->outputs()->first()->quantity)->toBe(0)
->and($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG')
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
});