diff --git a/app/Http/Controllers/Admin/Inventory/ProductionController.php b/app/Http/Controllers/Admin/Inventory/ProductionController.php index 7402e2b..33f6d5b 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductionController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductionController.php @@ -57,13 +57,11 @@ class ProductionController extends Controller public function store(StoreProductionRequest $request): RedirectResponse { $payload = $request->validatedPayload(); + $isDraft = (bool) $payload['is_draft']; try { // AP-27: Chargen-Zeilen (batch_key) FEFO auf die Einzel-Wareneingänge der Charge auflösen. - $ingredientLines = $this->productionService->expandBatchLines( - $payload['ingredient_lines'], - $payload['location_id'] - ); + $ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], null, $isDraft); $production = $this->productionService->store( [ @@ -75,12 +73,50 @@ class ProductionController extends Controller ], $ingredientLines, (int) $request->user()->id, - $payload['outputs'] + $payload['outputs'], + $isDraft ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); } + return $this->afterSave($production, $isDraft); + } + + /** + * 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. + * + * @param array> $lines + * @return array> + */ + private function expandLines(array $lines, int $locationId, ?int $excludeProductionId, bool $isDraft): array + { + if (! $isDraft) { + return $this->productionService->expandBatchLines($lines, $locationId, $excludeProductionId); + } + + 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)); + } + } + + /** + * AP-43: Entwurf zurück in die Bearbeitung (Weiterarbeiten), abgeschlossene + * Produktion in die Detailansicht. + */ + private function afterSave(Production $production, bool $isDraft): RedirectResponse + { + if ($isDraft) { + \Session::flash('alert-save', '1'); + + return redirect()->route('admin.inventory.productions.edit', $production) + ->with('alert-info', __('Entwurf zwischengespeichert. Die Produktion ist noch nicht gebucht.')); + } + if ($production->mhd_warning) { \Session::flash('alert-warning', __('Hinweis: Mindestens eine Rohstoff-Charge hat ein kürzeres MHD als das geplante Produkt-MHD.')); } @@ -135,14 +171,11 @@ class ProductionController extends Controller public function update(StoreProductionRequest $request, Production $production): RedirectResponse { $payload = $request->validatedPayload(); + $isDraft = (bool) $payload['is_draft']; try { // AP-27: Beim Bearbeiten die eigene Produktion aus der Rest-Berechnung ausnehmen. - $ingredientLines = $this->productionService->expandBatchLines( - $payload['ingredient_lines'], - $payload['location_id'], - $production->id - ); + $ingredientLines = $this->expandLines($payload['ingredient_lines'], $payload['location_id'], $production->id, $isDraft); $production = $this->productionService->updateProduction( $production, @@ -155,18 +188,14 @@ class ProductionController extends Controller ], $ingredientLines, (int) $request->user()->id, - $payload['outputs'] + $payload['outputs'], + $isDraft ); } catch (ValidationException $e) { return redirect()->back()->withInput()->withErrors($e->errors()); } - if ($production->mhd_warning) { - \Session::flash('alert-warning', __('Hinweis: Mindestens eine Rohstoff-Charge hat ein kürzeres MHD als das geplante Produkt-MHD.')); - } - \Session::flash('alert-save', '1'); - - return redirect()->route('admin.inventory.productions.show', $production); + return $this->afterSave($production, $isDraft); } public function copy(Production $production): View|RedirectResponse diff --git a/app/Http/Requests/Inventory/StoreProductionRequest.php b/app/Http/Requests/Inventory/StoreProductionRequest.php index 229f4e8..fcde945 100644 --- a/app/Http/Requests/Inventory/StoreProductionRequest.php +++ b/app/Http/Requests/Inventory/StoreProductionRequest.php @@ -14,11 +14,41 @@ class StoreProductionRequest extends FormRequest return (bool) $this->user()?->isAdmin(); } + /** + * AP-43: „Zwischenspeichern" schickt save_mode=draft und speichert einen + * Entwurf ohne Pflichtfeld-Validierung (Chargen/Ergebnis optional). + */ + public function isDraft(): bool + { + return $this->input('save_mode') === 'draft'; + } + /** * @return array> */ public function rules(): array { + // AP-43: Entwurf — nur die Kopfdaten (DB-Pflichtspalten) prüfen; Rezeptur- + // Zeilen und Produktionsergebnis dürfen unvollständig bleiben. + if ($this->isDraft()) { + return [ + 'product_id' => ['required', 'integer', 'exists:products,id'], + 'location_id' => ['required', 'integer', 'exists:locations,id'], + 'produced_at' => ['required', 'date'], + 'quantity' => ['required', 'integer', 'min:1'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'ingredient_lines' => ['nullable', 'array'], + '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.*.quantity_used' => ['nullable', 'string'], + 'outputs' => ['nullable', 'array'], + 'outputs.*.product_id' => ['nullable', 'integer', 'exists:products,id'], + 'outputs.*.quantity' => ['nullable', 'integer', 'min:1'], + 'outputs.*.batch_number' => ['nullable', 'string', 'max:100'], + ]; + } + $recipeRequired = $this->recipeRequired(); return [ @@ -62,7 +92,22 @@ class StoreProductionRequest extends FormRequest { $data = $this->validated(); + // AP-43: Im Entwurf dürfen Zeilen unvollständig sein — nur solche mit + // Rohstoff + (Charge oder Wareneingang) bzw. Ergebnis-Produkt übernehmen. + $ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line): bool { + return is_array($line) + && (int) ($line['ingredient_id'] ?? 0) > 0 + && (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0); + })); + + $outputs = array_values(array_filter($data['outputs'] ?? [], function ($output): bool { + return is_array($output) + && (int) ($output['product_id'] ?? 0) > 0 + && (int) ($output['quantity'] ?? 0) > 0; + })); + return [ + 'is_draft' => $this->isDraft(), 'product_id' => (int) $data['product_id'], 'location_id' => (int) $data['location_id'], 'produced_at' => $data['produced_at'], @@ -71,7 +116,7 @@ class StoreProductionRequest extends FormRequest 'ingredient_lines' => array_map(function (array $line): array { $out = [ 'ingredient_id' => (int) $line['ingredient_id'], - 'quantity_used' => $line['quantity_used'], + 'quantity_used' => $line['quantity_used'] ?? null, ]; if (! empty($line['batch_key'])) { $out['batch_key'] = (string) $line['batch_key']; @@ -80,14 +125,14 @@ class StoreProductionRequest extends FormRequest } return $out; - }, $data['ingredient_lines'] ?? []), + }, $ingredientLines), 'outputs' => array_map(function (array $output): array { return [ 'product_id' => (int) $output['product_id'], 'quantity' => (int) $output['quantity'], 'batch_number' => $output['batch_number'] ?? null, ]; - }, $data['outputs'] ?? []), + }, $outputs), ]; } } diff --git a/app/Models/Production.php b/app/Models/Production.php index c9e0bb9..adaf020 100644 --- a/app/Models/Production.php +++ b/app/Models/Production.php @@ -3,6 +3,7 @@ namespace App\Models; use App\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -12,6 +13,12 @@ class Production extends Model { use SoftDeletes; + /** AP-43: Entwurf — gespeichert, aber noch nicht gebucht. */ + public const STATUS_DRAFT = 'draft'; + + /** AP-43: Abgeschlossen — Bestand gebucht (Alt-Verhalten, Default). */ + public const STATUS_COMPLETED = 'completed'; + protected $fillable = [ 'product_id', 'location_id', @@ -20,8 +27,29 @@ class Production extends Model 'quantity', 'notes', 'mhd_warning', + 'status', ]; + /** + * AP-43: true, solange die Produktion ein Entwurf ist (kein Bestand gebucht). + */ + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * AP-43: Nur gebuchte (abgeschlossene) Produktionen — Entwürfe zählen nicht + * in Bestands-/Verbrauchsberechnungen. + * + * @param Builder $query + * @return Builder + */ + public function scopeBooked($query) + { + return $query->where('status', '!=', self::STATUS_DRAFT); + } + /** * @return array */ diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 8d4ac48..87df293 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Ingredient; +use App\Models\Production; use App\Models\ProductionIngredient; use App\Models\ProductionPackaging; use App\Models\StockDisposal; @@ -47,6 +48,8 @@ class InventoryService $consumed = ProductionIngredient::query() ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') ->whereNull('productions.deleted_at') + // AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand. + ->where('productions.status', '!=', Production::STATUS_DRAFT) ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') ->groupBy('production_ingredients.ingredient_id') @@ -114,6 +117,8 @@ class InventoryService ->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id') ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') ->whereNull('productions.deleted_at') + // AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand. + ->where('productions.status', '!=', Production::STATUS_DRAFT) ->where('production_ingredients.ingredient_id', $ingredientId) ->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty') ->groupBy('stock_entries.location_id') @@ -163,6 +168,8 @@ class InventoryService $consumed = ProductionPackaging::query() ->join('productions', 'productions.id', '=', 'production_packagings.production_id') ->whereNull('productions.deleted_at') + // AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand. + ->where('productions.status', '!=', Production::STATUS_DRAFT) ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('production_packagings.packaging_item_id', $packagingItemIds)) ->selectRaw('production_packagings.packaging_item_id as packaging_item_id, SUM(production_packagings.quantity_used) as qty') ->groupBy('production_packagings.packaging_item_id') @@ -249,6 +256,8 @@ class InventoryService $rows = ProductionIngredient::query() ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') ->whereNull('productions.deleted_at') + // AP-43: Entwürfe (nicht gebucht) zählen nicht in den Bestand. + ->where('productions.status', '!=', Production::STATUS_DRAFT) ->where('productions.produced_at', '>=', $since) ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index fec3bc6..16d73c4 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -23,9 +23,9 @@ class ProductionService * @param array $ingredientLines * @param array $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl */ - public function store(array $data, array $ingredientLines, int $userId, array $outputs = []): Production + public function store(array $data, array $ingredientLines, int $userId, array $outputs = [], bool $draft = false): Production { - return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) { + return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs, $draft) { $product = Product::query() ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); @@ -38,7 +38,11 @@ class ProductionService $this->assertNotASet($product); - if ($product->no_recipe_required) { + 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); + } elseif ($product->no_recipe_required) { $ingredientLines = []; } else { $this->assertManufacturerRecipe($product); @@ -52,7 +56,7 @@ class ProductionService } } - $mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); + $mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production = Production::query()->create([ 'product_id' => $product->id, @@ -62,6 +66,7 @@ class ProductionService 'quantity' => $producedQty, 'notes' => $data['notes'] ?? null, 'mhd_warning' => $mhdWarning, + 'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED, ]); foreach ($ingredientLines as $line) { @@ -72,14 +77,21 @@ class ProductionService ]); } - $this->persistOutputs($production, $product, $producedQty, $outputs); + // 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); + } - // AP-45: Verpackungsverbrauch aus dem tatsächlichen Produktionsergebnis - // buchen (je Ergebnisprodukt), daher nach persistOutputs. - $this->syncPackagingSnapshot($production); + // AP-43: Entwürfe buchen keinen Bestand (Roh-/Produkt-/Verpackung). + if (! $draft) { + // AP-45: Verpackungsverbrauch aus dem tatsächlichen Produktionsergebnis + // buchen (je Ergebnisprodukt), daher nach persistOutputs. + $this->syncPackagingSnapshot($production); - $production->setRelation('product', $product); - $this->productStockService->recordProductionStock($production, $userId); + $production->setRelation('product', $product); + $this->productStockService->recordProductionStock($production, $userId); + } return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']); }); @@ -90,12 +102,20 @@ class ProductionService * @param array $ingredientLines * @param array $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl */ - public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = []): Production + public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = [], bool $draft = false): Production { - return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) { + return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs, $draft) { // Audit M-9: Produktionszeile sperren — parallele Updates derselben // Produktion lesen sonst denselben Buchungs-Snapshot und buchen doppelt. - Production::query()->lockForUpdate()->findOrFail($production->id); + $locked = Production::query()->lockForUpdate()->findOrFail($production->id); + + // AP-43: Eine bereits abgeschlossene (gebuchte) Produktion darf nicht + // zurück in den Entwurf — die Buchungen würden sonst verwaisen. + if ($draft && ! $locked->isDraft()) { + throw ValidationException::withMessages([ + 'status' => __('Eine abgeschlossene Produktion kann nicht mehr als Entwurf gespeichert werden.'), + ]); + } $product = Product::query() ->with(['manufacturer_ingredients', 'packagings']) @@ -109,7 +129,10 @@ class ProductionService $this->assertNotASet($product); - if ($product->no_recipe_required) { + if ($draft) { + // AP-43: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung. + $ingredientLines = $this->filterCompleteLines($ingredientLines); + } elseif ($product->no_recipe_required) { $ingredientLines = []; } else { $this->assertManufacturerRecipe($product); @@ -123,7 +146,7 @@ class ProductionService } } - $mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); + $mhdWarning = $draft ? false : $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production->update([ 'product_id' => $product->id, @@ -132,6 +155,7 @@ class ProductionService 'quantity' => $producedQty, 'notes' => $data['notes'] ?? null, 'mhd_warning' => $mhdWarning, + 'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED, ]); $production->productionIngredients()->delete(); @@ -144,14 +168,20 @@ class ProductionService } $production->outputs()->delete(); - $this->persistOutputs($production, $product, $producedQty, $outputs); + if (! $draft || $outputs !== []) { + $this->persistOutputs($production, $product, $producedQty, $outputs); + } - // AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs. + // AP-43: Entwürfe buchen keinen Bestand. Ein zuvor gelöschter Verpackungs- + // Snapshot bleibt für den Entwurf leer. $production->productionPackagings()->delete(); - $this->syncPackagingSnapshot($production); + if (! $draft) { + // AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs. + $this->syncPackagingSnapshot($production); - $production->setRelation('product', $product); - $this->productStockService->recordProductionStock($production, $userId); + $production->setRelation('product', $product); + $this->productStockService->recordProductionStock($production, $userId); + } return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']); }); @@ -706,4 +736,20 @@ class ProductionService return $n !== null && $n !== '' && is_numeric($n) ? (float) $n : 0.0; } + + /** + * 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. + * + * @param array> $ingredientLines + * @return array> + */ + private function filterCompleteLines(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; + })); + } } diff --git a/database/migrations/2026_08_28_130000_add_status_to_productions_table.php b/database/migrations/2026_08_28_130000_add_status_to_productions_table.php new file mode 100644 index 0000000..64b8a04 --- /dev/null +++ b/database/migrations/2026_08_28_130000_add_status_to_productions_table.php @@ -0,0 +1,28 @@ +string('status', 20)->default('completed')->after('mhd_warning'); + }); + } + + public function down(): void + { + Schema::table('productions', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/resources/views/admin/inventory/productions/create.blade.php b/resources/views/admin/inventory/productions/create.blade.php index 1cf73ae..1744397 100644 --- a/resources/views/admin/inventory/productions/create.blade.php +++ b/resources/views/admin/inventory/productions/create.blade.php @@ -9,7 +9,9 @@ @csrf @include('admin.inventory.productions._form_fields', ['isEdit' => false]) - + + {{-- AP-43: Zwischenspeichern als Entwurf — ohne Pflichtfeld-/Bestandsprüfung, bucht keinen Bestand. --}} + {{ __('Abbrechen') }} diff --git a/resources/views/admin/inventory/productions/edit.blade.php b/resources/views/admin/inventory/productions/edit.blade.php index 6fdfd47..cc6d0b2 100644 --- a/resources/views/admin/inventory/productions/edit.blade.php +++ b/resources/views/admin/inventory/productions/edit.blade.php @@ -1,7 +1,12 @@ @extends('layouts.layout-2') @section('content') -

{{ __('Produktion bearbeiten') }} #{{ $model->id }}

+

+ {{ __('Produktion bearbeiten') }} #{{ $model->id }} + @if ($model->isDraft()) + {{ __('Entwurf') }} + @endif +

@@ -10,7 +15,11 @@ @method('PUT') @include('admin.inventory.productions._form_fields', ['isEdit' => true]) - + + {{-- AP-43: „Zwischenspeichern" nur solange die Produktion noch nicht gebucht ist (Entwurf). --}} + @if ($model->isDraft()) + + @endif {{ __('Abbrechen') }}
diff --git a/resources/views/admin/inventory/productions/index.blade.php b/resources/views/admin/inventory/productions/index.blade.php index dc6e131..b63de9a 100644 --- a/resources/views/admin/inventory/productions/index.blade.php +++ b/resources/views/admin/inventory/productions/index.blade.php @@ -54,7 +54,12 @@ @endif {{ $row->produced_at?->format('d.m.Y') }} - {{ $row->product?->name ?? '—' }} + + {{ $row->product?->name ?? '—' }} + @if($row->isDraft()) + {{ __('Entwurf') }} + @endif + {{-- AP-28: reale Outputs statt Planungs-Stückzahl --}} @if($row->outputs->isNotEmpty()) diff --git a/tests/Feature/ProductionDraftTest.php b/tests/Feature/ProductionDraftTest.php new file mode 100644 index 0000000..3180e8f --- /dev/null +++ b/tests/Feature/ProductionDraftTest.php @@ -0,0 +1,200 @@ +firstOrCreate( + ['code' => 'DE'], + ['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true] + ); +} + +function draftUser(int $adminLevel = 7): User +{ + draftCountry(); + $user = User::query()->create(['email' => uniqid('draft_', true).'@test.example', 'password' => bcrypt('password')]); + $user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +function draftRecipeProduct(Ingredient $ing): Product +{ + $product = Product::query()->create(['name' => 'Draft-Produkt', 'title' => 'Draft-Produkt', 'active' => true]); + // Hersteller-Rezeptur: 10 g * 1.0 * 1 Stück = 10 g Soll + ProductIngredient::query()->create([ + 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, + 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', + ]); + + return $product; +} + +function draftIngredient(): Ingredient +{ + return Ingredient::query()->create([ + 'name' => 'Draft-Roh', 'trans_name' => '', 'inci' => 'DR', 'trans_inci' => '', + 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, + ]); +} + +function draftStock(Ingredient $ing, Location $location, Supplier $supplier, User $user): StockEntry +{ + return StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'packaging_item_id' => null, + 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram', + 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000, + 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, + 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-B', + 'best_before' => '2030-12-31', 'quality_id' => null, + ]); +} + +test('AP-43: entwurf bucht keinen produktbestand und bleibt status draft', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $product = Product::query()->create(['name' => 'Draft-Simpel', 'title' => 'Draft-Simpel', 'active' => true, 'no_recipe_required' => true]); + + $production = app(ProductionService::class)->store( + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20, 'notes' => 'WIP'], + [], + $user->id, + [['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]], + true // draft + ); + + expect($production->isDraft())->toBeTrue() + ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0); +}); + +test('AP-43: entwurf reduziert den rohstoffbestand nicht', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $ing = draftIngredient(); + $product = draftRecipeProduct($ing); + $stock = draftStock($ing, $location, $supplier, $user); + + $before = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; + + app(ProductionService::class)->store( + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], + $user->id, + [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + true // draft + ); + + $after = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; + + expect((float) $after)->toBe((float) $before); // Entwurf verbraucht nichts +}); + +test('AP-43: entwurf abschließen bucht bestand und wird completed', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $ing = draftIngredient(); + $product = draftRecipeProduct($ing); + $stock = draftStock($ing, $location, $supplier, $user); + + $service = app(ProductionService::class); + + $draft = $service->store( + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], + $user->id, + [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + true + ); + + expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0); + + $completed = $service->updateProduction( + $draft->fresh(), + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null], + [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']], + $user->id, + [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], + false // abschließen + ); + + expect($completed->isDraft())->toBeFalse() + ->and($completed->status)->toBe(Production::STATUS_COMPLETED) + ->and(app(ProductStockService::class)->currentStock($product->id))->toBe(1); + + // Jetzt zählt der Rohstoffverbrauch: 1000 - 10 = 990 + $remaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0; + expect((float) $remaining)->toBe(990.0); +}); + +test('AP-43: abgeschlossene produktion kann nicht zum entwurf zurück', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $product = Product::query()->create(['name' => 'Draft-Done', 'title' => 'Draft-Done', 'active' => true, 'no_recipe_required' => true]); + + $completed = app(ProductionService::class)->store( + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null], + [], + $user->id, + [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]], + false + ); + + expect(fn () => app(ProductionService::class)->updateProduction( + $completed->fresh(), + ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null], + [], + $user->id, + [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]], + true // Rück-Entwurf verboten + ))->toThrow(ValidationException::class); +}); + +test('AP-43: HTTP zwischenspeichern ohne ergebnis speichert entwurf; abschließen erzwingt ergebnis', function () { + $user = draftUser(); + $location = Location::factory()->create(); + $product = Product::query()->create(['name' => 'Draft-HTTP', 'title' => 'Draft-HTTP', 'active' => true, 'no_recipe_required' => true]); + + Passport::actingAs($user); + $this->actingAs($user, 'user'); + + // Entwurf: kein Produktionsergebnis nötig → wird gespeichert (Redirect zur Bearbeitung) + $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, + ])->assertRedirect(); + + $draft = Production::query()->where('product_id', $product->id)->sole(); + expect($draft->isDraft())->toBeTrue(); + + // Abschließen ohne outputs → Validierungsfehler (outputs required) + $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' => 3, + ])->assertSessionHasErrors('outputs'); +});