Ohne Charge wird gegen den Restbestand des gewählten Lagerorts geprüft und die Menge FEFO auf die dort verfügbaren Chargen verteilt (je Charge gebucht, in einer Transaktion) — wie der Produktionsverbrauch. Gesamt-, Standort- und Chargenbestand stimmen damit überein. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
192 lines
8.8 KiB
PHP
192 lines
8.8 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\Services\ProductionService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function cdcUser(): User
|
|
{
|
|
$user = User::query()->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);
|
|
});
|