create([ 'email' => uniqid('rmb_', true).'@test.example', 'password' => bcrypt('password'), ]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function rmbIngredient(): Ingredient { return Ingredient::query()->create([ 'name' => 'RMB-Öl', 'trans_name' => '', 'inci' => 'RMB', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, ]); } function rmbStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, float $qty, string $batch, string $bestBefore): StockEntry { return 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' => $qty, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => $qty, 'batch_number' => $batch, 'best_before' => $bestBefore, ]); } // 17.09.2026: Live-Fall Sonnenblumenöl — Alt-Ausschuss ohne Chargenbezug „frisst" nach FEFO // die Eingänge auf; die Chargenansicht blendet Rest 0 aus. Die Bestandsrechnung zeigt alles. test('bestandsrechnung listet auch aufgebrauchte eingaenge und rechnet den alt-ausschuss nach fefo zu', function () { $user = rmbUser(); $this->actingAs($user, 'user'); $ing = rmbIngredient(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $old = rmbStock($ing, $location, $supplier, $user, 2900, 'ALT', '2028-09-30'); $short = rmbStock($ing, $location, $supplier, $user, 874, 'KURZ', '2026-12-04'); $big = rmbStock($ing, $location, $supplier, $user, 10164.16, 'NEU', '2028-09-30'); $last = rmbStock($ing, $location, $supplier, $user, 6841.12, 'NEU', '2028-09-30'); // Ausschuss mit Chargenbezug auf die letzte Charge, dazu Alt-Ausschuss ohne Bezug. StockDisposal::query()->create([ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $last->id, 'location_id' => $location->id, 'quantity' => 41.12, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-07-01', ]); StockDisposal::query()->create([ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => null, 'location_id' => $location->id, 'quantity' => 12100, 'unit' => 'gram', 'reason' => 'Korrektur', 'note' => 'Initialbestand', 'disposed_at' => '2026-07-28', ]); $svc = app(InventoryService::class); $bd = $svc->stockBreakdownForIngredient($ing->id); // Kopfzahl und Summenzeile stimmen überein. $header = $svc->remainingByIngredient([$ing->id])[$ing->id]; expect($bd['remaining_total'])->toBe($header) ->and($header)->toBe(roundWawiQuantity(20779.28 - 41.12 - 12100)) ->and($bd['received_total'])->toBe(20779.28) ->and($bd['disposed_bound_total'])->toBe(41.12) ->and($bd['disposed_unbound_total'])->toBe(12100.0) ->and($bd['disposed_unbound_unallocated'])->toBe(0.0) ->and($bd['unbound_disposals'])->toHaveCount(1); // Alle vier Eingänge gelistet (Chargenansicht zeigt nur die mit Rest > 0). expect($bd['entries'])->toHaveCount(4) ->and($svc->chargesWithRemainingForIngredient($ing->id))->toHaveCount(2); $byId = $bd['entries']->keyBy('id'); // FEFO: kurzes MHD zuerst, dann nach id. expect((float) $byId[$short->id]->getAttribute('unbound_disposal_quantity'))->toBe(874.0) ->and((float) $byId[$short->id]->getAttribute('remaining_quantity'))->toBe(0.0) ->and((float) $byId[$old->id]->getAttribute('unbound_disposal_quantity'))->toBe(2900.0) ->and((float) $byId[$old->id]->getAttribute('remaining_quantity'))->toBe(0.0) ->and((float) $byId[$big->id]->getAttribute('unbound_disposal_quantity'))->toBe(8326.0) ->and((float) $byId[$big->id]->getAttribute('remaining_quantity'))->toBe(1838.16) ->and((float) $byId[$last->id]->getAttribute('bound_disposal_quantity'))->toBe(41.12) ->and((float) $byId[$last->id]->getAttribute('unbound_disposal_quantity'))->toBe(0.0) ->and((float) $byId[$last->id]->getAttribute('remaining_quantity'))->toBe(6800.0); // Seite: Klappbereich mit Zusammenfassung, aufgebrauchte Eingänge und Alt-Ausschuss sichtbar. $res = $this->get(route('admin.inventory.raw-material-stock.show', $ing)); $res->assertOk() ->assertSee('Bestandsrechnung nachvollziehen') ->assertSee('4 Eingänge, 2 davon aufgebraucht, 1 Ausschuss ohne Chargenbezug') ->assertSee('KURZ') ->assertSee('Initialbestand') ->assertSee('= Gesamtbestand'); }); test('bestandsrechnung weist alt-ausschuss ueber dem eingang als nicht zurechenbar aus', function () { $user = rmbUser(); $this->actingAs($user, 'user'); $ing = rmbIngredient(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); rmbStock($ing, $location, $supplier, $user, 500, 'A', '2028-01-01'); StockDisposal::query()->create([ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => null, 'location_id' => $location->id, 'quantity' => 800, 'unit' => 'gram', 'reason' => 'Korrektur', 'disposed_at' => '2026-07-28', ]); $bd = app(InventoryService::class)->stockBreakdownForIngredient($ing->id); expect($bd['disposed_unbound_unallocated'])->toBe(300.0) ->and($bd['remaining_total'])->toBe(-300.0) ->and($bd['remaining_total'])->toBe(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id]); });