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('ap27_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill([ 'admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, ])->save(); return $user->fresh(); } function ap27MakeIngredient(string $name): Ingredient { return Ingredient::query()->create([ 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } /** * @param array $overrides */ function ap27MakeStock(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' => 5000, 'price_per_kg' => 15, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 5000, 'batch_number' => 'AP27-CHARGE', 'best_before' => '2030-12-31', ], $overrides)); } function ap27Consume(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams): void { $product = Product::query()->create([ 'name' => 'AP27-Verbrauch-'.uniqid(), 'title' => 'AP27-Verbrauch', 'active' => true, ]); $production = Production::query()->create([ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_by' => $user->id, 'produced_at' => '2026-06-01', 'quantity' => 1, 'mhd_warning' => false, ]); $production->productionIngredients()->create([ 'ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => $grams, ]); } test('chargeGroups fuehrt gleiche charge zusammen, einkaeufe bleiben separat', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Gruppe'); $first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']); ap27MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'AP27-ANDERE', 'received_quantity' => 1000]); ap27Consume($ing, $first, $location, $user, 1000); $groups = app(InventoryService::class)->chargeGroupsForIngredient($ing->id); expect($groups)->toHaveCount(2); $merged = $groups->firstWhere('batch_number', 'AP27-CHARGE'); expect($merged['remaining'])->toBe(7000.0); expect($merged['entries'])->toHaveCount(2); $other = $groups->firstWhere('batch_number', 'AP27-ANDERE'); expect($other['remaining'])->toBe(1000.0); expect($other['entries'])->toHaveCount(1); // Beide Einkäufe existieren weiterhin als eigene stock_entries. expect(StockEntry::query()->where('batch_number', 'AP27-CHARGE')->count())->toBe(2); }); test('batch-check endpoint erkennt vorhandene charge und liefert vorbelegung', function () { $user = ap27MakeUser(); $location = Location::factory()->create(['name' => 'AP27-Lager']); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Check'); $first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']); ap27Consume($ing, $first, $location, $user, 1000); $this->actingAs($user, 'user'); $response = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [ 'ingredient_id' => $ing->id, 'batch_number' => 'AP27-CHARGE', ])); $response->assertOk(); $response->assertJson([ 'exists' => true, 'purchases' => 2, 'remaining' => 7000.0, 'best_before' => '31.12.2030', 'location' => 'AP27-Lager', ]); $miss = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [ 'ingredient_id' => $ing->id, 'batch_number' => 'GIBT-ES-NICHT', ])); $miss->assertOk(); $miss->assertJson(['exists' => false]); }); test('rohstoff-detailansicht zeigt charge summiert mit einkaufs-unterzeilen', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Detail'); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']); $this->actingAs($user, 'user'); $response = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); $response->assertOk(); $response->assertSee('AP27-CHARGE'); $response->assertSee('2 Einkäufe'); $response->assertSee('8.000 g'); $response->assertSee('Einkauf vom 15.01.2026'); $response->assertSee('Einkauf vom 15.02.2026'); }); test('recipe-payload gruppiert chargen mit summen-rest', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Payload'); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]); ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']); $product = Product::query()->create([ 'name' => 'AP27-Payload-Produkt', 'title' => 'AP27-Payload-Produkt', 'active' => true, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 100, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1); $batches = $payload['ingredients'][0]['batches']; expect($batches)->toHaveCount(1); expect($batches[0]['key'])->toBe('b:'.$supplier->id.':AP27-CHARGE'); expect($batches[0]['remaining'])->toBe(8000.0); expect($batches[0]['purchases'])->toBe(2); // Einzel-Einträge bleiben im Payload erhalten (Audit/Abwärtskompatibilität). expect($payload['ingredients'][0]['stock_entries'])->toHaveCount(2); }); test('expandBatchLines verteilt verbrauch fefo auf die einkaeufe der charge', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-FEFO'); // Früheres MHD zuerst (FEFO): Eintrag A 100 g Rest, Eintrag B 200 g Rest. $entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']); $entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']); $lines = app(ProductionService::class)->expandBatchLines([ ['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':AP27-CHARGE', 'quantity_used' => '150'], ], $location->id); expect($lines)->toHaveCount(2); expect($lines[0]['stock_entry_id'])->toBe($entryA->id); expect((float) $lines[0]['quantity_used'])->toBe(100.0); expect($lines[1]['stock_entry_id'])->toBe($entryB->id); expect((float) $lines[1]['quantity_used'])->toBe(50.0); }); test('expandBatchLines reicht stock_entry_id-zeilen unveraendert durch und wirft bei unbekannter charge', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Passthrough'); $stock = ap27MakeStock($ing, $location, $supplier, $user); $passthrough = [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '50']]; expect(app(ProductionService::class)->expandBatchLines($passthrough, $location->id))->toBe($passthrough); expect(fn () => app(ProductionService::class)->expandBatchLines([ ['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':UNBEKANNT', 'quantity_used' => '10'], ], $location->id))->toThrow(ValidationException::class); }); test('produktion per formular bucht charge fefo auf beide einkaeufe', function () { $user = ap27MakeUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = ap27MakeIngredient('AP27-Buchung'); $entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']); $entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']); $product = Product::query()->create([ 'name' => 'AP27-Buchung-Produkt', 'title' => 'AP27-Buchung-Produkt', 'active' => true, ]); ProductIngredient::query()->create([ 'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 150, 'factor' => 1.0, 'recipe_type' => 'manufacturer', ]); $this->actingAs($user, 'user'); $response = $this->post(route('admin.inventory.productions.store'), [ 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 1, 'ingredient_lines' => [ ['ingredient_id' => $ing->id, 'batch_key' => 'b:'.$supplier->id.':AP27-CHARGE', 'quantity_used' => '150'], ], 'outputs' => [ ['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null], ], ]); $response->assertSessionHasNoErrors(); $response->assertRedirect(); $production = Production::query()->where('product_id', $product->id)->firstOrFail(); $lines = $production->productionIngredients()->orderBy('stock_entry_id')->get(); expect($lines)->toHaveCount(2); expect($lines[0]->stock_entry_id)->toBe($entryA->id); expect((float) $lines[0]->quantity_used)->toBe(100.0); expect($lines[1]->stock_entry_id)->toBe($entryB->id); expect((float) $lines[1]->quantity_used)->toBe(50.0); });