create([ 'email' => uniqid('cdc_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function cdcIngredient(string $name): Ingredient { return Ingredient::query()->create([ 'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } function cdcStock(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' => 'CDC-B1', 'best_before' => '2030-12-31', ], $overrides)); } function cdcDisposal(Ingredient $ing, Location $location, float $qty, ?int $stockEntryId = null): StockDisposal { return StockDisposal::query()->create([ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $stockEntryId, 'location_id' => $location->id, 'quantity' => $qty, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-07-01', ]); } test('chargen-ausschuss reduziert chargen-restmenge in detail, produktion und batch-check', function () { $user = cdcUser(); $this->actingAs($user, 'user'); $ing = cdcIngredient('CDC-Voll'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $entry = cdcStock($ing, $location, $supplier, $user); cdcDisposal($ing, $location, 1000, $entry->id); // Detail: Charge vollständig entsorgt => keine verfügbare Charge mehr. expect(app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id))->toHaveCount(0); // Produktion: Charge erscheint nicht mehr im FEFO-Angebot. expect(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0); // Batch-Check (AP-27): remaining = 0. $res = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [ 'ingredient_id' => $ing->id, 'batch_number' => 'CDC-B1', ])); $res->assertOk(); expect((float) $res->json('remaining'))->toBe(0.0); }); test('teil-ausschuss je charge: rest sinkt entsprechend', function () { $user = cdcUser(); $ing = cdcIngredient('CDC-Teil'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $entry = cdcStock($ing, $location, $supplier, $user); cdcDisposal($ing, $location, 300, $entry->id); $charges = app(InventoryService::class)->chargesWithRemainingForIngredient($ing->id); expect($charges)->toHaveCount(1); expect((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(700.0); }); test('detail-kopfwert entspricht der index-aggregation auch bei ausschuss ohne chargenbezug', function () { $user = cdcUser(); $this->actingAs($user, 'user'); $ing = cdcIngredient('CDC-Kopf'); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); cdcStock($ing, $location, $supplier, $user); cdcDisposal($ing, $location, 400, null); // ohne Chargenbezug $indexRemaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id]; expect($indexRemaining)->toBe(600.0); $response = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); $response->assertOk(); expect($response->viewData('remaining'))->toBe(600.0); }); test('negativer bestand wird nicht mehr als 0 maskiert (key-vereinigung)', function () { $user = cdcUser(); $ing = cdcIngredient('CDC-Negativ'); $location = Location::factory()->create(); // Ausschuss ohne jeglichen Wareneingang. cdcDisposal($ing, $location, 250, null); $remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]); expect($remaining[$ing->id] ?? null)->toBe(-250.0); }); test('standort-aggregation rechnet chargen-ausschuss am standort der charge ab', function () { $user = cdcUser(); $ing = cdcIngredient('CDC-Standort'); $locationA = Location::factory()->create(['name' => 'A']); $locationB = Location::factory()->create(['name' => 'B']); $supplier = Supplier::factory()->create(); $entry = cdcStock($ing, $locationA, $supplier, $user); // Ausschuss der Charge (liegt an A), aber im Formular mit Standort B gebucht. cdcDisposal($ing, $locationB, 100, $entry->id); $byLocation = app(InventoryService::class)->remainingByLocationForIngredient($ing->id); expect($byLocation[$locationA->id])->toBe(900.0); expect($byLocation)->not->toHaveKey($locationB->id); }); test('F-09: ausschuss ohne charge prüft den gewählten lagerort, nicht den globalen bestand', function () { $user = cdcUser(); $supplier = Supplier::factory()->create(); $locA = Location::factory()->create(['name' => 'F09-A']); $locB = Location::factory()->create(['name' => 'F09-B']); $ing = cdcIngredient('F09-Standort'); cdcStock($ing, $locA, $supplier, $user, ['received_quantity' => 1000, 'batch_number' => 'F09-1']); $this->actingAs($user, 'user'); $this->from(route('admin.inventory.stock-disposals.create')) ->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $locB->id, 'quantity' => '500', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01', ])->assertSessionHasErrors('quantity'); expect(app(InventoryService::class)->remainingByLocationForIngredient($ing->id)[$locB->id] ?? 0.0)->toBe(0.0); }); test('F-09: ausschuss ohne charge wird FEFO auf die chargen des lagerorts verteilt und reduziert deren verfügbarkeit', function () { $user = cdcUser(); $supplier = Supplier::factory()->create(); $loc = Location::factory()->create(); $ing = cdcIngredient('F09-FEFO'); $old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'F09-ALT', 'best_before' => '2027-01-01']); $new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'F09-NEU', 'best_before' => '2029-01-01']); $this->actingAs($user, 'user'); $this->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id, 'quantity' => '400', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01', ])->assertRedirect()->assertSessionHasNoErrors(); // 300 g aus der älteren Charge (FEFO), 100 g aus der neueren — beides chargenbezogen. $this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $old->id, 'quantity' => 300.00]); $this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $new->id, 'quantity' => 100.00]); $this->assertDatabaseMissing('stock_disposals', ['ingredient_id' => $ing->id, 'stock_entry_id' => null]); $inventory = app(InventoryService::class); $charges = app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id); expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(600.0) ->and($charges)->toHaveCount(1) ->and($charges->first()->id)->toBe($new->id) ->and((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(600.0) ->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(600); // Gesamten Rest ohne Charge entsorgen: global 0 und keine Charge mehr verfügbar (D-07). $this->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id, 'quantity' => '600', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-02', ])->assertSessionHasNoErrors(); expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.0) ->and(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0); }); test('N-05: alt-ausschuss ohne chargenbezug wird in der chargenverfügbarkeit FEFO je standort angerechnet', function () { $user = cdcUser(); $supplier = Supplier::factory()->create(); $loc = Location::factory()->create(); $other = Location::factory()->create(); $ing = cdcIngredient('N05-Alt'); $old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'N05-ALT', 'best_before' => '2027-01-01']); $new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'N05-NEU', 'best_before' => '2029-01-01']); $elsewhere = cdcStock($ing, $other, $supplier, $user, ['received_quantity' => 500, 'batch_number' => 'N05-ANDERER']); // Altbuchung direkt (ohne Controller/FEFO-Verteilung): 400 g ohne Charge am Standort loc. cdcDisposal($ing, $loc, 400); $inventory = app(InventoryService::class); $service = app(ProductionService::class); $available = $service->availableStockEntriesForIngredient($ing->id, $loc->id); expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(1100.0) ->and($available)->toHaveCount(1) ->and($available->first()->id)->toBe($new->id) ->and((float) $available->first()->getAttribute('remaining_quantity'))->toBe(600.0) ->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(1100) ->and((float) $service->availableStockEntriesForIngredient($ing->id, $other->id)->first()->getAttribute('remaining_quantity'))->toBe(500.0); // D-07 des Erstberichts: gesamter Eingang ohne Charge entsorgt → keine Charge mehr verfügbar. cdcDisposal($ing, $loc, 600); expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(500.0) ->and($service->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0); // Chargen-Ausschuss über den Rest der Charge hinaus wird abgelehnt (nutzt dieselbe Verfügbarkeit). $this->actingAs($user, 'user'); $this->from(route('admin.inventory.stock-disposals.create')) ->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $new->id, 'location_id' => $loc->id, 'quantity' => '1', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01', ])->assertSessionHasErrors('quantity'); });