284 lines
10 KiB
PHP
284 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Models\Country;
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
use App\Models\PackagingItem;
|
|
use App\Models\StockDisposal;
|
|
use App\Models\StockEntry;
|
|
use App\Models\Supplier;
|
|
use App\Models\UserAccount;
|
|
use App\Services\InventoryService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function ap12MakeUser(int $adminLevel = 7): User
|
|
{
|
|
$user = User::query()->create([
|
|
'email' => uniqid('ap12_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$user->forceFill([
|
|
'admin' => $adminLevel,
|
|
'confirmed' => true,
|
|
'active' => true,
|
|
'wizard' => 100,
|
|
'blocked' => false,
|
|
])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
function ap12MakeIngredient(string $name, ?float $minStockAlert = null): Ingredient
|
|
{
|
|
return Ingredient::query()->create([
|
|
'name' => $name,
|
|
'trans_name' => '',
|
|
'inci' => $name,
|
|
'trans_inci' => '',
|
|
'effect' => '',
|
|
'trans_effect' => '',
|
|
'active' => true,
|
|
'pos' => 0,
|
|
'min_stock_alert' => $minStockAlert,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function ap12MakeStock(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' => 10000,
|
|
'price_per_kg' => 15,
|
|
'status' => 'received',
|
|
'received_by' => $user->id,
|
|
'received_at' => '2026-01-15',
|
|
'received_quantity' => 10000,
|
|
'batch_number' => 'AP12-B',
|
|
'best_before' => '2030-12-31',
|
|
], $overrides));
|
|
}
|
|
|
|
test('ausschuss wird je rohstoff summiert', function () {
|
|
$location = Location::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-Sum');
|
|
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
|
'quantity' => 300, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
|
|
]);
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
|
'quantity' => 200, 'unit' => 'gram', 'reason' => 'Verfall', 'disposed_at' => '2026-03-02',
|
|
]);
|
|
|
|
expect(app(InventoryService::class)->disposedByIngredient([$ing->id])[$ing->id])->toBe(500.0);
|
|
});
|
|
|
|
test('restbestand zieht ausschuss ab', function () {
|
|
$user = ap12MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-Rest');
|
|
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
|
|
|
|
$service = app(InventoryService::class);
|
|
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(10000.0);
|
|
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
|
'quantity' => 1500, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
|
|
]);
|
|
|
|
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(8500.0)
|
|
->and($service->remainingByLocationForIngredient($ing->id)[$location->id])->toBe(8500.0);
|
|
});
|
|
|
|
test('restbestand verpackung zieht ausschuss ab', function () {
|
|
$user = ap12MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$item = PackagingItem::factory()->create();
|
|
|
|
StockEntry::query()->create([
|
|
'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id,
|
|
'location_id' => $location->id, 'unit' => 'piece', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
|
|
'ordered_quantity' => 500, 'price_total' => 100, 'status' => 'received', 'received_by' => $user->id,
|
|
'received_at' => '2026-01-15', 'received_quantity' => 500,
|
|
]);
|
|
|
|
$service = app(InventoryService::class);
|
|
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(500.0);
|
|
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id,
|
|
'quantity' => 40, 'unit' => 'piece', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
|
|
]);
|
|
|
|
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(460.0);
|
|
});
|
|
|
|
test('admin bucht ausschuss per http und bestand sinkt', function () {
|
|
$user = ap12MakeUser(7);
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-HTTP');
|
|
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
|
|
'disposal_type' => 'ingredient',
|
|
'ingredient_id' => $ing->id,
|
|
'location_id' => $location->id,
|
|
'quantity' => '750',
|
|
'reason' => 'Verfall / MHD überschritten',
|
|
'note' => 'Charge verdorben',
|
|
'disposed_at' => '2026-03-10',
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertDatabaseHas('stock_disposals', [
|
|
'ingredient_id' => $ing->id,
|
|
'quantity' => 750.00,
|
|
'reason' => 'Verfall / MHD überschritten',
|
|
'unit' => 'gram',
|
|
]);
|
|
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(4250.0);
|
|
});
|
|
|
|
test('grund ist pflicht', function () {
|
|
$user = ap12MakeUser(7);
|
|
$location = Location::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-NoReason');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
|
|
'disposal_type' => 'ingredient',
|
|
'ingredient_id' => $ing->id,
|
|
'location_id' => $location->id,
|
|
'quantity' => '100',
|
|
'disposed_at' => '2026-03-10',
|
|
]);
|
|
|
|
$response->assertSessionHasErrors('reason');
|
|
});
|
|
|
|
test('nicht-admin darf keinen ausschuss buchen', function () {
|
|
$user = ap12MakeUser(1);
|
|
$location = Location::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-NoAdmin');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
|
|
'disposal_type' => 'ingredient',
|
|
'ingredient_id' => $ing->id,
|
|
'location_id' => $location->id,
|
|
'quantity' => '100',
|
|
'reason' => 'Bruch / Beschädigung',
|
|
'disposed_at' => '2026-03-10',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('liste und formular rendern', function () {
|
|
$user = ap12MakeUser(7);
|
|
$location = Location::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-Render');
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
|
'quantity' => 50, 'unit' => 'gram', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-03-01',
|
|
]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$this->get(route('admin.inventory.stock-disposals.index'))->assertSuccessful()->assertSee('Ausgang / Ausschuss');
|
|
$this->get(route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ing->id]))
|
|
->assertSuccessful()
|
|
->assertSee('AP12-Render');
|
|
});
|
|
|
|
test('ausschuss-liste laedt benutzerkonten ohne n+1', function () {
|
|
$admin = ap12MakeUser(7);
|
|
$location = Location::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-N1');
|
|
|
|
$country = Country::query()->firstOrCreate(
|
|
['code' => 'DE'],
|
|
['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]
|
|
);
|
|
|
|
$makeDisposal = function () use ($ing, $location, $country) {
|
|
$account = UserAccount::query()->create(['first_name' => 'Vera', 'last_name' => 'Sucher', 'country_id' => $country->id]);
|
|
$user = ap12MakeUser(1);
|
|
$user->forceFill(['account_id' => $account->id])->save();
|
|
|
|
StockDisposal::query()->create([
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
|
'quantity' => 10, 'unit' => 'gram', 'reason' => 'Bruch / Beschädigung',
|
|
'disposed_at' => '2026-03-01', 'user_id' => $user->id,
|
|
]);
|
|
};
|
|
|
|
$this->actingAs($admin, 'user');
|
|
$countQueries = function (): int {
|
|
DB::flushQueryLog();
|
|
DB::enableQueryLog();
|
|
$this->get(route('admin.inventory.stock-disposals.index'))->assertSuccessful();
|
|
$count = count(DB::getQueryLog());
|
|
DB::disableQueryLog();
|
|
|
|
return $count;
|
|
};
|
|
|
|
$makeDisposal();
|
|
$this->get(route('admin.inventory.stock-disposals.index'))->assertSuccessful(); // Warm-up
|
|
$baseline = $countQueries();
|
|
|
|
foreach (range(1, 5) as $i) {
|
|
$makeDisposal();
|
|
}
|
|
|
|
expect($countQueries())->toBeLessThanOrEqual($baseline);
|
|
});
|
|
|
|
test('chargen-endpoint liefert eingegangene chargen', function () {
|
|
$user = ap12MakeUser(7);
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap12MakeIngredient('AP12-Charges');
|
|
ap12MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'CHG-1']);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->getJson(route('admin.inventory.api.disposals.ingredient-charges', $ing));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonFragment(['location_id' => $location->id]);
|
|
});
|
|
|
|
test('F-17: ausschuss-formular belegt köln als lagerort vor, inaktives köln nicht', function () {
|
|
$user = ap12MakeUser(7);
|
|
$other = Location::factory()->create(['name' => 'Aachen', 'active' => true]);
|
|
$koeln = Location::factory()->create(['name' => 'Köln', 'active' => true]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$html = $this->get(route('admin.inventory.stock-disposals.create'))->assertOk()->getContent();
|
|
expect($html)->toContain('value="'.$koeln->id.'" selected')
|
|
->and(Location::defaultId())->toBe($koeln->id);
|
|
|
|
$koeln->forceFill(['active' => false])->save();
|
|
expect(Location::defaultId())->toBe($other->id);
|
|
$html = $this->get(route('admin.inventory.stock-disposals.create'))->assertOk()->getContent();
|
|
expect($html)->toContain('value="'.$other->id.'" selected')->not->toContain('Köln');
|
|
});
|