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>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -0,0 +1,222 @@
<?php
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\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('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]);
});