gruene-seele/tests/Feature/RawMaterialStockBreakdownTest.php
Kevin Adametz 761ea35047 Warenwirtschaft: Bestandsrechnung auf der Rohstoff-Detailseite nachvollziehbar
Neuer Klappbereich „Bestandsrechnung nachvollziehen" zeigt alle gebuchten
Eingänge – auch aufgebrauchte mit Rest 0 – mit Produktionsverbrauch,
Ausschuss mit Chargenbezug und dem nach FEFO zugerechneten Ausschuss ohne
Chargenbezug; Summenzeile bis zum Gesamtbestand identisch zur Kopfzahl.
Alt-Ausschuss ohne Chargenbezug wird einzeln mit Grund/Notiz/Bearbeiter
gelistet. Anlass: Live-Fall Sonnenblumenöl (doppelte Korrektur vom 28.07.).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:34:04 +00:00

127 lines
6 KiB
PHP

<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function rmbUser(): User
{
$user = User::query()->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]);
});