create([ 'email' => uniqid('ph_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function phIngredient(string $name): Ingredient { return Ingredient::query()->create([ 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } function phStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry { return StockEntry::query()->create(array_merge([ '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' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'PH-B1', 'best_before' => '2030-12-31', ], $overrides)); } function phRecipeProduct(Ingredient $ing): Product { $product = Product::query()->create(['name' => 'PH-Prod', 'title' => 'PH-Prod', 'active' => true]); ProductIngredient::create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 10, 'factor' => 1, 'recipe_type' => 'manufacturer', ]); return $product; } test('negative chargen-menge wird abgelehnt, auch wenn die summe stimmt (H-2)', function () { $user = phUser(); $this->actingAs($user, 'user'); $ing = phIngredient('PH-Neg'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $a = phStock($ing, $location, $supplier, $user); $b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-B2']); $product = phRecipeProduct($ing); // Soll = 10 g × 10 Stück = 100 g; +150 und −50 ergäben in Summe 100. $response = $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-15', 'quantity' => 10, 'ingredient_lines' => [ ['ingredient_id' => $ing->id, 'stock_entry_id' => $a->id, 'quantity_used' => '150'], ['ingredient_id' => $ing->id, 'stock_entry_id' => $b->id, 'quantity_used' => '-50'], ], 'outputs' => [ ['product_id' => $product->id, 'quantity' => 10, 'batch_number' => null], ], ]); $response->assertSessionHasErrors('ingredient_lines.1.quantity_used'); expect(Production::query()->count())->toBe(0); }); test('zwei zeilen derselben charge belasten nicht denselben einkauf doppelt (H-3)', function () { $user = phUser(); $ing = phIngredient('PH-Doppel'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); // Gleiche Chargennummer, zwei Einkäufe: A = 100 g Rest, B = 200 g Rest. $a = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 100, 'received_at' => '2026-01-10']); $b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 200, 'received_at' => '2026-01-12']); $expanded = app(ProductionService::class)->expandBatchLines([ ['ingredient_id' => $ing->id, 'batch_key' => $a->batchGroupKey(), 'quantity_used' => 100], ['ingredient_id' => $ing->id, 'batch_key' => $a->batchGroupKey(), 'quantity_used' => 100], ], $location->id); $byEntry = []; foreach ($expanded as $line) { $byEntry[$line['stock_entry_id']] = ($byEntry[$line['stock_entry_id']] ?? 0) + (float) $line['quantity_used']; } // Vorher: beide Zeilen landeten auf A (−100 g Überzug). Jetzt: A 100, B 100. expect($byEntry[$a->id])->toBe(100.0); expect($byEntry[$b->id])->toBe(100.0); }); test('ausschuss mit fremder charge wird abgelehnt (M-1)', function () { $user = phUser(); $this->actingAs($user, 'user'); $ingA = phIngredient('PH-M1-A'); $ingB = phIngredient('PH-M1-B'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); phStock($ingA, $location, $supplier, $user); $entryB = phStock($ingB, $location, $supplier, $user, ['batch_number' => 'PH-FREMD']); $response = $this->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ingA->id, 'stock_entry_id' => $entryB->id, 'location_id' => $location->id, 'quantity' => '100', 'reason' => 'Test', 'disposed_at' => '15.07.2026', ]); $response->assertSessionHasErrors('stock_entry_id'); expect(StockDisposal::query()->count())->toBe(0); }); test('ausschuss ueber restbestand wird abgelehnt (M-1)', function () { $user = phUser(); $this->actingAs($user, 'user'); $ing = phIngredient('PH-M1-Rest'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); phStock($ing, $location, $supplier, $user); // 1000 g $response = $this->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id, 'quantity' => '1500', 'reason' => 'Test', 'disposed_at' => '15.07.2026', ]); $response->assertSessionHasErrors('quantity'); expect(StockDisposal::query()->count())->toBe(0); }); test('copyreader darf keine produktion buchen (M-2)', function () { $viewer = User::query()->create([ 'email' => uniqid('ph_', true).'@test.example', 'password' => bcrypt('password'), ]); $viewer->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); $this->actingAs($viewer->fresh(), 'user'); $product = Product::query()->create(['name' => 'PH-M2', 'title' => 'PH-M2', 'active' => true, 'no_recipe_required' => true]); $location = Location::factory()->create(); $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-15', 'quantity' => 5, 'outputs' => [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]], ])->assertForbidden(); expect(Production::query()->count())->toBe(0); }); test('javascript-url wird bei lieferanten abgelehnt (M-10)', function () { $user = phUser(); $this->actingAs($user, 'user'); $this->post(route('admin.inventory.suppliers.store'), [ 'name' => 'PH-XSS-Lieferant', 'url' => 'javascript:alert(1)', ])->assertSessionHasErrors('url'); expect(Supplier::query()->where('name', 'PH-XSS-Lieferant')->exists())->toBeFalse(); }); test('gleiche lot-nummer verschiedener lieferanten bleibt getrennt (N-1)', function () { $user = phUser(); $ing = phIngredient('PH-N1'); $location = Location::factory()->create(); $supplierA = Supplier::factory()->create(['name' => 'Lieferant A']); $supplierB = Supplier::factory()->create(['name' => 'Lieferant B']); // Beide extern mit Lot "0107", aber unterschiedliches MHD. $a = phStock($ing, $location, $supplierA, $user, ['batch_number' => '0107', 'received_quantity' => 100, 'best_before' => '2027-01-01']); $b = phStock($ing, $location, $supplierB, $user, ['batch_number' => '0107', 'received_quantity' => 200, 'best_before' => '2030-01-01']); expect($a->batchGroupKey())->not->toBe($b->batchGroupKey()); $available = app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $location->id); $options = app(ProductionService::class)->batchOptionsForEntries($available); // Zwei getrennte Chargen-Optionen, keine MHD-/Rest-Verschmelzung. expect($options)->toHaveCount(2); expect(collect($options)->pluck('remaining')->sort()->values()->all())->toBe([100.0, 200.0]); }); test('punkt mit ein bis zwei nachkommastellen gilt als dezimaltrenner (N-6)', function () { expect((float) reFormatWawiNumber('5.1'))->toBe(5.1); expect((float) reFormatWawiNumber('5.12'))->toBe(5.12); expect((float) reFormatWawiNumber('1.500'))->toBe(1500.0); // 3 Stellen = Tausender expect((float) reFormatWawiNumber('1.234,56'))->toBe(1234.56); expect((float) reFormatWawiNumber('100'))->toBe(100.0); expect((float) reFormatWawiNumber('3,5'))->toBe(3.5); });