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>
127 lines
6 KiB
PHP
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]);
|
|
});
|