firstOrCreate( ['code' => 'DE'], [ 'phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true, ] ); $user = User::query()->create([ 'email' => uniqid('ap28_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill([ 'admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, ])->save(); return $user->fresh(); } function ap28MakeProduct(string $name, array $overrides = []): Product { return Product::query()->create(array_merge([ 'name' => $name, 'title' => $name, 'active' => true, ], $overrides)); } /** * Produkt ohne Rezeptur-Pflicht — Produktionen brauchen dann keine Chargen-Zeilen. */ function ap28MakeSimpleProduct(string $name, array $overrides = []): Product { return ap28MakeProduct($name, array_merge(['no_recipe_required' => true], $overrides)); } function ap28Stock(int $productId): int { return app(ProductStockService::class)->currentStock($productId); } test('mehrere outputs buchen produktbestand je produkt, planung bucht nichts', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $planned = ap28MakeSimpleProduct('AP28-Plan-50ml'); $small = ap28MakeSimpleProduct('AP28-5ml'); $production = app(ProductionService::class)->store( [ 'product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, // Planung — darf nicht buchen 'notes' => null, ], [], $user->id, [ ['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'GS1010726'], ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'GS2010726'], ] ); expect($production->outputs)->toHaveCount(2); expect(ap28Stock($planned->id))->toBe(40); expect(ap28Stock($small->id))->toBe(50); }); test('service ohne outputs faellt auf planung zurueck (backfill-semantik)', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $product = ap28MakeSimpleProduct('AP28-Fallback', ['batch_prefix' => 'TP1']); $production = app(ProductionService::class)->store( [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-06-15', 'quantity' => 12, 'notes' => null, ], [], $user->id ); expect($production->outputs)->toHaveCount(1); expect($production->outputs->first()->quantity)->toBe(12); // Chargen-Nr. der Fallback-Zeile = Präfix + ttmmjj des Produktionsdatums (AP-24). expect($production->outputs->first()->batch_number)->toBe('TP1150626'); expect(ap28Stock($product->id))->toBe(12); }); test('bearbeiten korrigiert produktbestand per differenz je output-produkt', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $planned = ap28MakeSimpleProduct('AP28-Edit-50ml'); $small = ap28MakeSimpleProduct('AP28-Edit-5ml'); $service = app(ProductionService::class); $production = $service->store( ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], [], $user->id, [['product_id' => $planned->id, 'quantity' => 50, 'batch_number' => null]] ); expect(ap28Stock($planned->id))->toBe(50); // Ergebnis nachträglich: nur 40×50 ml, dafür 50×5 ml. $production = $service->updateProduction( $production, ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], [], $user->id, [ ['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null], ['product_id' => $small->id, 'quantity' => 50, 'batch_number' => null], ] ); expect(ap28Stock($planned->id))->toBe(40); expect(ap28Stock($small->id))->toBe(50); // Append-only: Korrektur als Gegenbuchung, keine Historie gelöscht. expect(ProductStockMovement::query()->where('product_id', $planned->id)->count())->toBe(2); // Output-Produkt komplett entfernen ⇒ Bestand wird auf null zurückgeführt. $service->updateProduction( $production->fresh(), ['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null], [], $user->id, [['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null]] ); expect(ap28Stock($small->id))->toBe(0); }); test('rohstoffverbrauch bleibt chargenbasiert und unabhaengig vom ergebnis', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'AP28-Rohstoff', 'trans_name' => '', 'inci' => 'AP28-Rohstoff', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); $stock = StockEntry::query()->create([ 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 5000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 5000, 'batch_number' => 'AP28-CHARGE', 'best_before' => '2030-12-31', ]); $product = ap28MakeProduct('AP28-Rezeptur-Produkt'); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); // Plan 2 Stück (Soll 20 g) — Ergebnis weicht ab (nur 1 Stück), Verbrauch bleibt bei den Chargen-Werten. $production = app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 2, 'notes' => null], [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '20']], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]] ); expect((float) $production->productionIngredients->first()->quantity_used)->toBe(20.0); expect(ap28Stock($product->id))->toBe(1); }); test('http-validierung verlangt mindestens eine ergebniszeile', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $product = ap28MakeSimpleProduct('AP28-HTTP-Pflicht'); $this->actingAs($user, 'user'); $response = $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '01.07.2026', 'quantity' => 10, ]); $response->assertSessionHasErrors('outputs'); expect(Production::query()->count())->toBe(0); }); test('outputs validieren produkt und stueckzahl im service', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $product = ap28MakeSimpleProduct('AP28-Validierung'); $set = ap28MakeProduct('AP28-Set', ['is_set' => true, 'no_recipe_required' => true]); $base = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 1, 'notes' => null]; expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [ ['product_id' => $set->id, 'quantity' => 5, 'batch_number' => null], ]))->toThrow(ValidationException::class); expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [ ['product_id' => $product->id, 'quantity' => 0, 'batch_number' => null], ]))->toThrow(ValidationException::class); }); test('recipe-payload liefert batch_prefix, phasen und inci-zusatzspalten', function () { $user = ap28MakeUser(); $location = Location::factory()->create(); $ing = Ingredient::query()->create([ 'name' => 'AP28-Payload-Rohstoff', 'trans_name' => '', 'inci' => 'AP28-Payload-Rohstoff', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, 'alt_name' => 'Phospholipon 80 H', ]); $product = ap28MakeProduct('AP28-Payload-Produkt', ['batch_prefix' => 'TP1']); $phase = $product->recipePhases()->create([ 'recipe_type' => 'manufacturer', 'name' => 'Phase A', 'note' => 'auf 70 °C erhitzen', 'pos' => 0, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 100, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phase->id, ]); $payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1); expect($payload['product']['batch_prefix'])->toBe('TP1'); expect($payload['phases'])->toHaveCount(1); expect($payload['phases'][0]['name'])->toBe('Phase A'); expect($payload['phases'][0]['note'])->toBe('auf 70 °C erhitzen'); $first = $payload['ingredients'][0]; expect($first['alt_name'])->toBe('Phospholipon 80 H'); expect($first['phase_id'])->toBe($phase->id); expect(array_key_exists('storage_label', $first))->toBeTrue(); expect(array_key_exists('quality', $first))->toBeTrue(); }); test('produktions-maske rendert ergebnis-block und planungs-hinweis', function () { $user = ap28MakeUser(); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.productions.create')); $response->assertOk(); $response->assertSee('Produktionsergebnis'); $response->assertSee('Geplante Stückzahl (Planung)'); $response->assertSee('Die Planung oben ist unverbindlich.', false); $response->assertSee('output-row-template', false); });