gruene-seele/tests/Feature/ProductStockTest.php
Kevin Adametz 3ee2d756e9 Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)
- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:04:22 +00:00

191 lines
6.4 KiB
PHP

<?php
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap11MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap11_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
/**
* @param array<string, mixed> $overrides
*/
function ap11MakeProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
], $overrides));
}
test('bestand ist summe eingaenge minus ausgaenge', function () {
$product = ap11MakeProduct('AP11-Bestand');
$service = app(ProductStockService::class);
$service->recordMovement($product, 'in', 100, 'Initialbestand');
$service->recordMovement($product, 'out', 30, 'Verkauf');
$service->recordMovement($product, 'in', 5, 'Retoure');
expect($service->currentStock($product->id))->toBe(75);
});
test('status liefert kritisch, warnung und ok', function () {
$service = app(ProductStockService::class);
expect($service->productStatus(5, 20, 10))->toBe('critical')
->and($service->productStatus(15, 20, 10))->toBe('warning')
->and($service->productStatus(30, 20, 10))->toBe('ok')
->and($service->productStatus(0, null, null))->toBe('ok');
});
test('kritisch-zaehler beruecksichtigt nur hauptprodukte mit schwellwert', function () {
$service = app(ProductStockService::class);
$critical = ap11MakeProduct('AP11-Krit', ['critical_product_stock' => 10]);
$service->recordMovement($critical, 'in', 5, 'Initialbestand');
$ok = ap11MakeProduct('AP11-OK', ['critical_product_stock' => 10]);
$ok->stockMovements()->create(['direction' => 'in', 'quantity' => 50, 'reason' => 'Initialbestand', 'source' => 'manual']);
// ohne Schwellwert => nie kritisch
ap11MakeProduct('AP11-NoThreshold');
// Set wird ignoriert
ap11MakeProduct('AP11-Set', ['is_set' => true, 'critical_product_stock' => 10]);
expect($service->criticalProductCount())->toBe(1);
});
test('produktion bucht produktbestand als eingang', function () {
$user = ap11MakeUser();
$location = Location::factory()->create();
$product = ap11MakeProduct('AP11-Produktion', ['no_recipe_required' => true]);
app(ProductionService::class)->store([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 40,
'notes' => null,
], [], $user->id);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(40);
$movement = ProductStockMovement::query()->where('product_id', $product->id)->where('source', 'production')->first();
expect($movement)->not->toBeNull()
->and($movement->direction)->toBe('in')
->and((int) $movement->quantity)->toBe(40);
});
test('produktions-update korrigiert den produktbestand per differenz', function () {
$user = ap11MakeUser();
$location = Location::factory()->create();
$product = ap11MakeProduct('AP11-ProdUpdate', ['no_recipe_required' => true]);
$production = app(ProductionService::class)->store([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 40,
'notes' => null,
], [], $user->id);
app(ProductionService::class)->updateProduction($production, [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 25,
'notes' => null,
], [], $user->id);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(25);
// append-only: zwei Bewegungen (Produktion + Korrektur)
expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2);
});
test('uebersicht zeigt produkt und bestand', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-Liste', ['critical_product_stock' => 10]);
app(ProductStockService::class)->recordMovement($product, 'in', 13, 'Initialbestand');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-stock.index'));
$response->assertSuccessful();
$response->assertSee('Produktbestand');
$response->assertSee('AP11-Liste');
$response->assertSee('nur kritische anzeigen');
});
test('manuelle bewegung wird per http gebucht', function () {
$user = ap11MakeUser(7);
$product = ap11MakeProduct('AP11-HTTP');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
'direction' => 'in',
'quantity' => 12,
'reason' => 'Initialbestand',
'note' => 'Erstbefüllung',
]);
$response->assertRedirect();
$this->assertDatabaseHas('product_stock_movements', [
'product_id' => $product->id,
'direction' => 'in',
'quantity' => 12,
'reason' => 'Initialbestand',
]);
});
test('nicht-admin darf keine bewegung buchen', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-NoAdmin');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
'direction' => 'in',
'quantity' => 5,
'reason' => 'Initialbestand',
]);
$response->assertForbidden();
});
test('historie rendert und filtert nach richtung', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-Hist');
$service = app(ProductStockService::class);
$service->recordMovement($product, 'in', 50, 'Produktion', 'production');
$service->recordMovement($product, 'out', 2, 'Verkauf', 'sale');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-stock.history', ['direction' => 'out']));
$response->assertSuccessful();
$response->assertSee('Produktbestand Historie');
$response->assertSee('Verkauf');
});