create(['email' => uniqid('fw_', true).'@test.example', 'password' => bcrypt('password')]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } test('F-08: füllgewicht 39,5 wird mit nachkommastellen gespeichert und skaliert die rezeptur exakt', function () { $this->actingAs(fwUser(), 'user'); $this->post(route('admin_product_store'), ['id' => 'new', 'name' => 'FW-Dezimal', 'fill_weight' => '39,5']) ->assertRedirect()->assertSessionHasNoErrors(); $product = Product::query()->where('name', 'FW-Dezimal')->firstOrFail(); expect($product->fill_weight)->toBe(39.5) ->and(app(ProductionService::class)->recipeGramScale($product))->toBe(0.395); // Punkt mit 1–2 Nachkommastellen gilt als Dezimaltrenner (WaWi-Konvention), leer = null. $this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Dezimal', 'fill_weight' => '4.5'])->assertSessionHasNoErrors(); expect($product->fresh()->fill_weight)->toBe(4.5); $this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Dezimal', 'fill_weight' => ''])->assertSessionHasNoErrors(); expect($product->fresh()->fill_weight)->toBeNull(); }); test('F-08: ungültiges oder negatives füllgewicht wird abgelehnt', function () { $this->actingAs(fwUser(), 'user'); $product = Product::query()->create(['name' => 'FW-Invalid', 'title' => 'x', 'active' => true, 'fill_weight' => 10]); // Der Produkt-Controller rendert bei Fehlern das Formular direkt (kein Redirect). foreach (['abc', '-5', '0'] as $bad) { $this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Invalid', 'fill_weight' => $bad]) ->assertOk() ->assertSee('is-invalid', false); } expect($product->fresh()->fill_weight)->toBe(10.0); }); test('F-07: produktionsformular liefert die verpackungs-stückliste je ergebnisprodukt für die vorschau', function () { $this->actingAs(fwUser(), 'user'); $supplier = Supplier::factory()->create(); $material = PackagingMaterial::factory()->create(); $pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'F07-Tiegel', 'category' => 'packaging']); $small = Product::query()->create(['name' => 'F07-5ml', 'title' => 'x', 'active' => true, 'no_recipe_required' => true, 'production_relevant' => false]); $small->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]); Location::factory()->create(); $html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent(); $start = strpos($html, "data-packaging-bom='") + strlen("data-packaging-bom='"); $bom = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true); expect($bom)->toHaveKey((string) $small->id) ->and($bom[$small->id][0]['name'])->toBe('F07-Tiegel') ->and($bom[$small->id][0]['per_unit'])->toEqual(2) ->and($html)->toContain('renderPackagingPreview'); }); test('AP-52: verpackungsvorschau liefert bestand und lagerort je verpackungsartikel; spalten heißen einheitlich Lagerort', function () { $user = fwUser(); $this->actingAs($user, 'user'); $supplier = Supplier::factory()->create(); $material = PackagingMaterial::factory()->create(); $room = StorageRoom::query()->create(['label' => 'Lager 1', 'pos' => 0, 'active' => true]); $shelf = StorageShelf::query()->create(['label' => 'Regal B', 'pos' => 0, 'active' => true]); $pk = PackagingItem::factory()->create([ 'packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'AP52-Tiegel', 'category' => 'packaging', 'storage_room_id' => $room->id, 'storage_shelf_id' => $shelf->id, ]); $location = Location::factory()->create(); // 120 Stück Wareneingang, 20 bereits verbraucht → Bestand 100. StockEntry::query()->create([ 'entry_type' => 'packaging', 'packaging_item_id' => $pk->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'piece', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 120, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 120, ]); $product = Product::query()->create(['name' => 'AP52-Produkt', 'title' => 'x', 'active' => true, 'no_recipe_required' => true]); $product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]); app(ProductionService::class)->store( ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20], [], $user->id, [['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]] ); $html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent(); $start = strpos($html, "data-packaging-info='") + strlen("data-packaging-info='"); $info = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true); expect($info[$pk->id]['stock'])->toEqual(100) ->and($info[$pk->id]['storage_label'])->toBe('Lager 1 | Regal B') ->and($html)->toContain('packagingRow(') ->and(substr_count($html, 'Lagerort'))->toBe(2) ->and($html)->not->toContain('Ablage'); }); test('AP-47: checkbox „produktionsrelevant" im produktformular wird gespeichert und steuert die produktions-auswahl', function () { $this->actingAs(fwUser(), 'user'); // Neuanlage ohne Häkchen (Hidden-Fallback 0) => Zukaufartikel wie Aloe verschwindet aus der Planung. $this->post(route('admin_product_store'), ['id' => 'new', 'name' => 'AP47-Aloe-Zukauf', 'active' => 1, 'production_relevant' => '0']) ->assertRedirect()->assertSessionHasNoErrors(); $product = Product::query()->where('name', 'AP47-Aloe-Zukauf')->firstOrFail(); expect($product->production_relevant)->toBeFalse(); // 09.09.2026: Häkchen sitzt in der Karte „Warenwirtschaft" und ist ausformuliert. $edit = $this->get(route('admin_product_edit', $product->id))->assertOk()->getContent(); expect($edit)->toContain('In der Produktion anzeigen (Produkt wird selbst hergestellt)') ->and(strpos($edit, 'id="production_relevant"'))->toBeGreaterThan(strpos($edit, 'id="show_in_product_stock"')); $html = $this->get(route('admin.inventory.productions.create'))->assertSuccessful()->getContent(); $planningSelect = substr($html, strpos($html, 'id="product_id"')); $planningSelect = substr($planningSelect, 0, strpos($planningSelect, '')); expect($planningSelect)->not->toContain('AP47-Aloe-Zukauf'); // Häkchen wieder setzen => wieder wählbar. $this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'AP47-Aloe-Zukauf', 'active' => 1, 'production_relevant' => '1']) ->assertSessionHasNoErrors(); expect($product->fresh()->production_relevant)->toBeTrue(); $html = $this->get(route('admin.inventory.productions.create'))->assertSuccessful()->getContent(); $planningSelect = substr($html, strpos($html, 'id="product_id"')); $planningSelect = substr($planningSelect, 0, strpos($planningSelect, '')); expect($planningSelect)->toContain('AP47-Aloe-Zukauf'); });