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:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
41
tests/Feature/InventoryNoticesTest.php
Normal file
41
tests/Feature/InventoryNoticesTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function noticeUser(int $adminLevel = 8): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('notice_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
test('hinweise-seite rendert das markdown gerendert als html', function () {
|
||||
$this->actingAs(noticeUser(8), 'user');
|
||||
|
||||
$response = $this->get(route('admin.inventory.notices'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Hinweise');
|
||||
$response->assertSee('Entwicklungsstand', false);
|
||||
$response->assertSee('<h2', false);
|
||||
});
|
||||
|
||||
test('nicht-superadmin hat keinen zugriff auf hinweise-seite', function () {
|
||||
$this->actingAs(noticeUser(7), 'user');
|
||||
|
||||
$this->get(route('admin.inventory.notices'))->assertRedirect('/home');
|
||||
});
|
||||
135
tests/Feature/ProductOutOfStockTest.php
Normal file
135
tests/Feature/ProductOutOfStockTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Repositories\ProductRepository;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function oosMakeUser(int $adminLevel = 1): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('oos_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function oosMakeProduct(string $name): Product
|
||||
{
|
||||
return Product::query()->create([
|
||||
'name' => $name,
|
||||
'title' => $name,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
test('repository berechnet out_of_stock_until aus tagesangabe', function () {
|
||||
$product = oosMakeProduct('OOS-Tage');
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '14',
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
||||
expect($product->out_of_stock_indefinite)->toBeFalse();
|
||||
expect($product->out_of_stock_until)->not->toBeNull();
|
||||
expect($product->out_of_stock_until->isSameDay(now()->addDays(14)))->toBeTrue();
|
||||
expect($product->isOutOfStock())->toBeTrue();
|
||||
});
|
||||
|
||||
test('unbestimmt hat vorrang und nullt das datum', function () {
|
||||
$product = oosMakeProduct('OOS-Unbestimmt');
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '14',
|
||||
'out_of_stock_indefinite' => '1',
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
||||
expect($product->out_of_stock_indefinite)->toBeTrue();
|
||||
expect($product->out_of_stock_until)->toBeNull();
|
||||
expect($product->isOutOfStock())->toBeTrue();
|
||||
expect($product->outOfStockNotice())->toBe('Zur Zeit nicht vorrätig');
|
||||
});
|
||||
|
||||
test('ohne aktivierung werden die felder geleert', function () {
|
||||
$product = oosMakeProduct('OOS-Aus');
|
||||
$product->update(['out_of_stock_until' => now()->addDays(5), 'out_of_stock_indefinite' => false]);
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
||||
expect($product->out_of_stock_until)->toBeNull();
|
||||
expect($product->out_of_stock_indefinite)->toBeFalse();
|
||||
expect($product->isOutOfStock())->toBeFalse();
|
||||
});
|
||||
|
||||
test('vergangenes datum gilt nicht als nicht vorrätig', function () {
|
||||
$product = oosMakeProduct('OOS-Vergangen');
|
||||
$product->update(['out_of_stock_until' => now()->subDays(2)]);
|
||||
$product->refresh();
|
||||
|
||||
expect($product->isOutOfStock())->toBeFalse();
|
||||
expect($product->outOfStockRemainingDays())->toBeNull();
|
||||
expect($product->outOfStockNotice())->toBeNull();
|
||||
});
|
||||
|
||||
test('hinweis zeigt verbleibende tage', function () {
|
||||
$product = oosMakeProduct('OOS-Hinweis');
|
||||
$product->update(['out_of_stock_until' => now()->addDays(5)]);
|
||||
$product->refresh();
|
||||
|
||||
expect($product->isOutOfStock())->toBeTrue();
|
||||
expect($product->outOfStockRemainingDays())->toBe(5);
|
||||
expect($product->outOfStockNotice())->toBe('In ca. 5 Tagen wieder da!');
|
||||
});
|
||||
|
||||
test('http-store speichert nicht-vorrätig mit tagen', function () {
|
||||
$this->actingAs(oosMakeUser(1), 'user');
|
||||
|
||||
$response = $this->post(route('admin_product_store'), [
|
||||
'id' => 'new',
|
||||
'name' => 'OOS-HTTP',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '7',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$product = Product::query()->where('name', 'OOS-HTTP')->firstOrFail();
|
||||
expect($product->out_of_stock_until)->not->toBeNull();
|
||||
expect($product->out_of_stock_until->isSameDay(now()->addDays(7)))->toBeTrue();
|
||||
expect($product->isOutOfStock())->toBeTrue();
|
||||
});
|
||||
|
|
@ -212,7 +212,7 @@ test('produktion kann bearbeitet und kopiert werden (views rendern)', function (
|
|||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'product',
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
$stock = StockEntry::query()->create([
|
||||
|
|
|
|||
272
tests/Feature/ProductSetTest.php
Normal file
272
tests/Feature/ProductSetTest.php
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\PackagingItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Repositories\ProductRepository;
|
||||
use App\Services\ProductionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function setMakeUser(int $adminLevel = 1): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('set_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function setMakeProduct(string $name, bool $active = true): Product
|
||||
{
|
||||
return Product::query()->create([
|
||||
'name' => $name,
|
||||
'title' => $name,
|
||||
'active' => $active,
|
||||
]);
|
||||
}
|
||||
|
||||
test('repository speichert ein set mit bestandteilen und mengen', function () {
|
||||
$set = setMakeProduct('SET-Bundle');
|
||||
$a = setMakeProduct('SET-Einzel-A');
|
||||
$b = setMakeProduct('SET-Einzel-B');
|
||||
|
||||
$repo = new ProductRepository($set);
|
||||
$repo->update([
|
||||
'id' => (string) $set->id,
|
||||
'name' => $set->name,
|
||||
'title' => $set->title,
|
||||
'active' => '1',
|
||||
'is_set' => '1',
|
||||
'set_component_id' => [(string) $a->id, (string) $b->id],
|
||||
'set_quantity' => ['2', '3'],
|
||||
]);
|
||||
|
||||
$set->refresh();
|
||||
|
||||
expect($set->is_set)->toBeTrue();
|
||||
expect($set->setItems)->toHaveCount(2);
|
||||
expect((int) $set->setItems->firstWhere('id', $a->id)->pivot->quantity)->toBe(2);
|
||||
expect((int) $set->setItems->firstWhere('id', $b->id)->pivot->quantity)->toBe(3);
|
||||
});
|
||||
|
||||
test('set leert rezeptur und verpackung', function () {
|
||||
$ing = Ingredient::query()->create([
|
||||
'name' => 'SET-Roh', 'trans_name' => '', 'inci' => 'SR', 'trans_inci' => '',
|
||||
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
|
||||
]);
|
||||
$packaging = PackagingItem::factory()->create();
|
||||
$component = setMakeProduct('SET-Komp');
|
||||
|
||||
$product = setMakeProduct('SET-Wandelbar');
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id, 'ingredient_id' => $ing->id,
|
||||
'pos' => 0, 'gram' => 50, 'factor' => 1.1, 'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
$product->packagings()->attach($packaging->id, ['quantity' => 1, 'pos' => 0]);
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'is_set' => '1',
|
||||
'set_component_id' => [(string) $component->id],
|
||||
'set_quantity' => ['1'],
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
||||
expect($product->is_set)->toBeTrue();
|
||||
expect($product->manufacturer_ingredients)->toHaveCount(0);
|
||||
expect($product->packagings)->toHaveCount(0);
|
||||
expect($product->setItems)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('hauptprodukt-zuordnung wird gespeichert und bei set genullt', function () {
|
||||
$main = setMakeProduct('SET-Haupt');
|
||||
$variant = setMakeProduct('SET-Variante');
|
||||
|
||||
$repo = new ProductRepository($variant);
|
||||
$repo->update([
|
||||
'id' => (string) $variant->id,
|
||||
'name' => $variant->name,
|
||||
'title' => $variant->title,
|
||||
'active' => '1',
|
||||
'main_product_id' => (string) $main->id,
|
||||
'main_product_quantity' => '50',
|
||||
]);
|
||||
|
||||
$variant->refresh();
|
||||
expect($variant->main_product_id)->toBe($main->id);
|
||||
expect($variant->main_product_quantity)->toBe(50);
|
||||
|
||||
// Wird das Produkt zum Set, ist es keine Variante mehr.
|
||||
$repo->update([
|
||||
'id' => (string) $variant->id,
|
||||
'name' => $variant->name,
|
||||
'title' => $variant->title,
|
||||
'active' => '1',
|
||||
'is_set' => '1',
|
||||
'main_product_id' => (string) $main->id,
|
||||
'set_component_id' => [(string) $main->id],
|
||||
'set_quantity' => ['1'],
|
||||
]);
|
||||
|
||||
$variant->refresh();
|
||||
expect($variant->main_product_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('scopes trennen einzelprodukte, sets und hauptprodukte', function () {
|
||||
$single = setMakeProduct('SCOPE-Einzel');
|
||||
$set = setMakeProduct('SCOPE-Set');
|
||||
$set->update(['is_set' => true]);
|
||||
$variant = setMakeProduct('SCOPE-Variante');
|
||||
$variant->update(['main_product_id' => $single->id]);
|
||||
|
||||
expect(Product::query()->singleProducts()->pluck('id')->all())
|
||||
->toContain($single->id, $variant->id)
|
||||
->not->toContain($set->id);
|
||||
|
||||
expect(Product::query()->sets()->pluck('id')->all())
|
||||
->toContain($set->id)
|
||||
->not->toContain($single->id);
|
||||
|
||||
expect(Product::query()->mainProducts()->pluck('id')->all())
|
||||
->toContain($single->id, $set->id)
|
||||
->not->toContain($variant->id);
|
||||
});
|
||||
|
||||
test('set kann nicht produziert werden', function () {
|
||||
$location = Location::factory()->create();
|
||||
$set = setMakeProduct('SET-NichtProduzierbar');
|
||||
$set->update(['is_set' => true]);
|
||||
|
||||
$service = app(ProductionService::class);
|
||||
|
||||
expect(fn () => $service->store(
|
||||
[
|
||||
'product_id' => $set->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-03-01',
|
||||
'quantity' => 1,
|
||||
'notes' => null,
|
||||
],
|
||||
[],
|
||||
setMakeUser()->id
|
||||
))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('produktions-formular zeigt keine sets', function () {
|
||||
p51EnsureCountryForSet();
|
||||
Location::factory()->create(['name' => 'Köln']);
|
||||
$user = setMakeUser(1);
|
||||
|
||||
setMakeProduct('SET-FORM-EINZEL');
|
||||
$set = setMakeProduct('SET-FORM-SET');
|
||||
$set->update(['is_set' => true]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.productions.create'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('SET-FORM-EINZEL');
|
||||
$response->assertDontSee('SET-FORM-SET');
|
||||
});
|
||||
|
||||
test('set ohne bestandteile wird abgelehnt', function () {
|
||||
$user = setMakeUser(1);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$response = $this->post(route('admin_product_store'), [
|
||||
'id' => 'new',
|
||||
'name' => 'SET-LEER',
|
||||
'is_set' => '1',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('mindestens ein Einzelprodukt', false);
|
||||
expect(Product::query()->where('name', 'SET-LEER')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('set mit set-bestandteil wird abgelehnt', function () {
|
||||
$user = setMakeUser(1);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$innerSet = setMakeProduct('SET-INNEN');
|
||||
$innerSet->update(['is_set' => true]);
|
||||
|
||||
$response = $this->post(route('admin_product_store'), [
|
||||
'id' => 'new',
|
||||
'name' => 'SET-AUSSEN',
|
||||
'is_set' => '1',
|
||||
'set_component_id' => [(string) $innerSet->id],
|
||||
'set_quantity' => ['1'],
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('dürfen selbst keine Sets sein', false);
|
||||
});
|
||||
|
||||
test('gueltiges set wird per http gespeichert', function () {
|
||||
$user = setMakeUser(1);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$component = setMakeProduct('SET-HTTP-KOMP');
|
||||
|
||||
$response = $this->post(route('admin_product_store'), [
|
||||
'id' => 'new',
|
||||
'name' => 'SET-HTTP',
|
||||
'is_set' => '1',
|
||||
'set_component_id' => [(string) $component->id],
|
||||
'set_quantity' => ['4'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$set = Product::query()->where('name', 'SET-HTTP')->firstOrFail();
|
||||
expect($set->is_set)->toBeTrue();
|
||||
expect($set->setItems)->toHaveCount(1);
|
||||
expect((int) $set->setItems->first()->pivot->quantity)->toBe(4);
|
||||
});
|
||||
|
||||
test('produkt-kopie uebernimmt set-bestandteile', function () {
|
||||
$component = setMakeProduct('COPY-KOMP');
|
||||
$set = setMakeProduct('COPY-SET');
|
||||
$set->update(['is_set' => true]);
|
||||
$set->setItems()->attach($component->id, ['quantity' => 5, 'pos' => 0]);
|
||||
|
||||
$repo = new ProductRepository(new Product);
|
||||
$copy = $repo->copy($set->fresh());
|
||||
|
||||
expect($copy->is_set)->toBeTrue();
|
||||
expect($copy->setItems()->count())->toBe(1);
|
||||
expect((int) $copy->setItems()->first()->pivot->quantity)->toBe(5);
|
||||
});
|
||||
|
||||
function p51EnsureCountryForSet(): Country
|
||||
{
|
||||
return Country::query()->firstOrCreate(
|
||||
['code' => 'DE'],
|
||||
[
|
||||
'phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany',
|
||||
'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
191
tests/Feature/ProductStockTest.php
Normal file
191
tests/Feature/ProductStockTest.php
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<?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');
|
||||
});
|
||||
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal file
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ProductionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function ap09MakeUser(int $adminLevel = 7): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('ap09_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function ap09EnsureCountry(): Country
|
||||
{
|
||||
return Country::query()->firstOrCreate(
|
||||
['code' => 'DE'],
|
||||
[
|
||||
'phone' => '00',
|
||||
'en' => 'Germany',
|
||||
'de' => 'Deutschland',
|
||||
'es' => 'Germany',
|
||||
'fr' => 'Germany',
|
||||
'it' => 'Germany',
|
||||
'ru' => 'Germany',
|
||||
'active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function ap09MakeIngredient(string $name): Ingredient
|
||||
{
|
||||
return Ingredient::query()->create([
|
||||
'name' => $name,
|
||||
'trans_name' => '',
|
||||
'inci' => $name,
|
||||
'trans_inci' => '',
|
||||
'effect' => '',
|
||||
'trans_effect' => '',
|
||||
'active' => true,
|
||||
'pos' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function ap09MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
|
||||
{
|
||||
return StockEntry::query()->create(array_merge([
|
||||
'entry_type' => 'ingredient',
|
||||
'ingredient_id' => $ing->id,
|
||||
'packaging_item_id' => null,
|
||||
'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' => 1,
|
||||
'status' => 'received',
|
||||
'received_by' => $user->id,
|
||||
'received_at' => '2026-01-15',
|
||||
'received_quantity' => 10000,
|
||||
'batch_number' => 'AP09-B',
|
||||
'best_before' => '2030-12-31',
|
||||
'quality_id' => null,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('production rechnet soll-verbrauch aus hersteller-rezeptur', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap09MakeIngredient('AP09-Soll');
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Soll-Produkt',
|
||||
'title' => 'AP09-Soll-Produkt',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Produkt-Rezeptur (darf NICHT verwendet werden)
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'ingredient_id' => $ing->id,
|
||||
'pos' => 0,
|
||||
'gram' => 999,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'product',
|
||||
]);
|
||||
// Hersteller-Rezeptur (maßgeblich): 10 g * 1.2 * 5 Stück = 60 g
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'ingredient_id' => $ing->id,
|
||||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.2,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
$stock = ap09MakeStock($ing, $location, $supplier, $user);
|
||||
|
||||
$production = app(ProductionService::class)->store(
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-03-01',
|
||||
'quantity' => 5,
|
||||
'notes' => null,
|
||||
],
|
||||
[
|
||||
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '60'],
|
||||
],
|
||||
$user->id
|
||||
);
|
||||
|
||||
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(60.0);
|
||||
});
|
||||
|
||||
test('production blockiert ohne hersteller-rezeptur trotz vorhandener produkt-rezeptur', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap09MakeIngredient('AP09-NoMfg');
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-NoMfg-Produkt',
|
||||
'title' => 'AP09-NoMfg-Produkt',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'ingredient_id' => $ing->id,
|
||||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'product',
|
||||
]);
|
||||
|
||||
$stock = ap09MakeStock($ing, $location, $supplier, $user);
|
||||
|
||||
expect(fn () => app(ProductionService::class)->store(
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-03-01',
|
||||
'quantity' => 1,
|
||||
'notes' => null,
|
||||
],
|
||||
[
|
||||
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10'],
|
||||
],
|
||||
$user->id
|
||||
))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('recipe json meldet fehlende hersteller-rezeptur', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser(1);
|
||||
$location = Location::factory()->create();
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Recipe-Empty',
|
||||
'title' => 'AP09-Recipe-Empty',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=1');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('has_recipe', false);
|
||||
expect($response->json('ingredients'))->toBe([]);
|
||||
});
|
||||
|
||||
test('chargen ohne restbestand erscheinen nicht im rezept', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap09MakeIngredient('AP09-Rest');
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Rest-Produkt',
|
||||
'title' => 'AP09-Rest-Produkt',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'ingredient_id' => $ing->id,
|
||||
'pos' => 0,
|
||||
'gram' => 100,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
// Charge komplett aufgebraucht
|
||||
$emptyStock = ap09MakeStock($ing, $location, $supplier, $user, [
|
||||
'batch_number' => 'AP09-LEER',
|
||||
'received_quantity' => 100,
|
||||
'best_before' => '2030-01-01',
|
||||
]);
|
||||
// Charge mit Restbestand
|
||||
$fullStock = ap09MakeStock($ing, $location, $supplier, $user, [
|
||||
'batch_number' => 'AP09-VOLL',
|
||||
'received_quantity' => 100,
|
||||
'best_before' => '2030-06-01',
|
||||
]);
|
||||
|
||||
// 1 Produktion verbraucht die erste Charge vollständig (100 g * 1.0 * 1 Stück)
|
||||
app(ProductionService::class)->store(
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-03-01',
|
||||
'quantity' => 1,
|
||||
'notes' => null,
|
||||
],
|
||||
[
|
||||
['ingredient_id' => $ing->id, 'stock_entry_id' => $emptyStock->id, 'quantity_used' => '100'],
|
||||
],
|
||||
$user->id
|
||||
);
|
||||
|
||||
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
|
||||
|
||||
$entryIds = collect($payload['ingredients'][0]['stock_entries'])->pluck('id')->all();
|
||||
expect($entryIds)->toContain($fullStock->id);
|
||||
expect($entryIds)->not->toContain($emptyStock->id);
|
||||
});
|
||||
|
||||
test('chargen-label zeigt lieferant, charge und datum ohne mhd-text', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create(['name' => 'AP09-Lieferant']);
|
||||
$ing = ap09MakeIngredient('AP09-Label');
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Label-Produkt',
|
||||
'title' => 'AP09-Label-Produkt',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductIngredient::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'ingredient_id' => $ing->id,
|
||||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
ap09MakeStock($ing, $location, $supplier, $user, [
|
||||
'batch_number' => 'CHARGE-123',
|
||||
'best_before' => '2030-12-31',
|
||||
]);
|
||||
|
||||
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
|
||||
$label = $payload['ingredients'][0]['stock_entries'][0]['label'];
|
||||
|
||||
expect($label)->toBe('AP09-Lieferant - CHARGE-123 - 31.12.2030');
|
||||
expect($label)->not->toContain('MHD');
|
||||
});
|
||||
|
||||
test('produkt ohne rezeptur kann ohne chargen produziert werden', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Broschuere',
|
||||
'title' => 'AP09-Broschuere',
|
||||
'active' => true,
|
||||
'no_recipe_required' => true,
|
||||
]);
|
||||
|
||||
$production = app(ProductionService::class)->store(
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '2026-03-01',
|
||||
'quantity' => 25,
|
||||
'notes' => null,
|
||||
],
|
||||
[],
|
||||
$user->id
|
||||
);
|
||||
|
||||
expect($production->productionIngredients)->toHaveCount(0);
|
||||
expect($production->quantity)->toBe(25);
|
||||
});
|
||||
|
||||
test('recipe json meldet recipe_required false bei eigenprodukt', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser(1);
|
||||
$location = Location::factory()->create();
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-Etikett',
|
||||
'title' => 'AP09-Etikett',
|
||||
'active' => true,
|
||||
'no_recipe_required' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=10');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('recipe_required', false);
|
||||
});
|
||||
|
||||
test('produktion per http speichert eigenprodukt ohne chargen', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP09-HTTP-Eigen',
|
||||
'title' => 'AP09-HTTP-Eigen',
|
||||
'active' => true,
|
||||
'no_recipe_required' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->post(route('admin.inventory.productions.store'), [
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_at' => '01.03.2026',
|
||||
'quantity' => 12,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseHas('productions', [
|
||||
'product_id' => $product->id,
|
||||
'quantity' => 12,
|
||||
]);
|
||||
});
|
||||
|
||||
test('produktentwicklung platzhalter rendert briefing-hinweis', function () {
|
||||
ap09EnsureCountry();
|
||||
$user = ap09MakeUser(1);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.product-development'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Briefing ausstehend');
|
||||
});
|
||||
|
|
@ -81,6 +81,7 @@ test('production service stores ingredients and packaging snapshot', function ()
|
|||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
$material = PackagingMaterial::factory()->create();
|
||||
|
|
@ -168,6 +169,7 @@ test('production service rejects wrong gram sum', function () {
|
|||
'pos' => 0,
|
||||
'gram' => 10,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
$stock = StockEntry::query()->create([
|
||||
|
|
@ -240,6 +242,7 @@ test('production api recipe json returns ingredients and packagings', function (
|
|||
'pos' => 0,
|
||||
'gram' => 2,
|
||||
'factor' => 1.5,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
StockEntry::query()->create([
|
||||
|
|
@ -300,6 +303,7 @@ test('production mhd warning when stock expires before product end', function ()
|
|||
'pos' => 0,
|
||||
'gram' => 1,
|
||||
'factor' => 1.0,
|
||||
'recipe_type' => 'manufacturer',
|
||||
]);
|
||||
|
||||
$stock = StockEntry::query()->create([
|
||||
|
|
|
|||
269
tests/Feature/RawMaterialStockTest.php
Normal file
269
tests/Feature/RawMaterialStockTest.php
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\Production;
|
||||
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 ap10MakeUser(int $adminLevel = 7): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('ap10_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function ap10MakeIngredient(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 ap10MakeStock(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' => 'AP10-B',
|
||||
'best_before' => '2030-12-31',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function ap10ConsumeViaProduction(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams, string $producedAt): void
|
||||
{
|
||||
$product = Product::query()->create([
|
||||
'name' => 'AP10-Produkt-'.uniqid(),
|
||||
'title' => 'AP10-Produkt',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$production = Production::query()->create([
|
||||
'product_id' => $product->id,
|
||||
'location_id' => $location->id,
|
||||
'produced_by' => $user->id,
|
||||
'produced_at' => $producedAt,
|
||||
'quantity' => 1,
|
||||
'mhd_warning' => false,
|
||||
]);
|
||||
|
||||
$production->productionIngredients()->create([
|
||||
'ingredient_id' => $ing->id,
|
||||
'stock_entry_id' => $stock->id,
|
||||
'quantity_used' => $grams,
|
||||
]);
|
||||
}
|
||||
|
||||
test('restbestand ist wareneingang abzueglich produktionsverbrauch', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap10MakeIngredient('AP10-Rest');
|
||||
|
||||
$stock = ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
|
||||
ap10ConsumeViaProduction($ing, $stock, $location, $user, 2500, now()->subDays(10)->toDateString());
|
||||
|
||||
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]);
|
||||
|
||||
expect($remaining[$ing->id])->toBe(7500.0);
|
||||
});
|
||||
|
||||
test('verbrauch pro tag wird aus produktionshistorie gemittelt', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap10MakeIngredient('AP10-Verbrauch');
|
||||
|
||||
$stock = ap10MakeStock($ing, $location, $supplier, $user);
|
||||
// 9000 g innerhalb des 90-Tage-Fensters => 100 g/Tag
|
||||
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(5)->toDateString());
|
||||
|
||||
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
|
||||
|
||||
expect($daily[$ing->id])->toBe(100.0);
|
||||
});
|
||||
|
||||
test('verbrauch ausserhalb des fensters zaehlt nicht', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap10MakeIngredient('AP10-Alt');
|
||||
|
||||
$stock = ap10MakeStock($ing, $location, $supplier, $user);
|
||||
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(200)->toDateString());
|
||||
|
||||
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
|
||||
|
||||
expect($daily[$ing->id] ?? 0.0)->toBe(0.0);
|
||||
});
|
||||
|
||||
test('status meldet kritisch bei unterschrittenem meldebestand', function () {
|
||||
$service = app(InventoryService::class);
|
||||
|
||||
expect($service->stockStatus(1000.0, 500.0, null, null))->toBe('critical')
|
||||
->and($service->stockStatus(1000.0, 1500.0, 5.0, 30))->toBe('ok');
|
||||
});
|
||||
|
||||
test('status meldet warnung wenn bestand vor lieferung leer ist', function () {
|
||||
$service = app(InventoryService::class);
|
||||
|
||||
// 200 g Rest, 20 g/Tag => 10 Tage Reichweite, Lieferzeit 14 Tage => Warnung
|
||||
expect($service->stockStatus(null, 200.0, 20.0, 14))->toBe('warning');
|
||||
});
|
||||
|
||||
test('kritisch-zaehler zaehlt nur unterschrittene meldebestaende', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
|
||||
$critical = ap10MakeIngredient('AP10-Kritisch', 5000);
|
||||
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
|
||||
|
||||
$ok = ap10MakeIngredient('AP10-OK', 500);
|
||||
ap10MakeStock($ok, $location, $supplier, $user, ['received_quantity' => 9000]);
|
||||
|
||||
// ohne Meldebestand => nie kritisch
|
||||
$noAlert = ap10MakeIngredient('AP10-OhneMelde');
|
||||
ap10MakeStock($noAlert, $location, $supplier, $user, ['received_quantity' => 0]);
|
||||
|
||||
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(1);
|
||||
});
|
||||
|
||||
test('offene bestellungen werden je rohstoff summiert', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap10MakeIngredient('AP10-Offen');
|
||||
|
||||
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 200, 'received_quantity' => null]);
|
||||
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 300, 'received_quantity' => null]);
|
||||
|
||||
$open = app(InventoryService::class)->openOrderQuantityByIngredient([$ing->id]);
|
||||
|
||||
expect($open[$ing->id])->toBe(500.0);
|
||||
});
|
||||
|
||||
test('kritischer rohstoff mit offener bestellung wird entschaerft', function () {
|
||||
$service = app(InventoryService::class);
|
||||
|
||||
expect($service->stockStatus(1000.0, 500.0, null, null, true))->toBe('critical_ordered')
|
||||
->and($service->stockStatus(1000.0, 500.0, null, null, false))->toBe('critical');
|
||||
});
|
||||
|
||||
test('kritisch-zaehler ignoriert rohstoffe mit offener bestellung', function () {
|
||||
$user = ap10MakeUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
|
||||
$critical = ap10MakeIngredient('AP10-KritischOffen', 5000);
|
||||
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
|
||||
// offene Nachbestellung => entschärft
|
||||
ap10MakeStock($critical, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 8000, 'received_quantity' => null]);
|
||||
|
||||
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(0);
|
||||
});
|
||||
|
||||
test('detailansicht zeigt offene bestellung', function () {
|
||||
$user = ap10MakeUser(1);
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create(['name' => 'AP10-OffenLieferant']);
|
||||
$ing = ap10MakeIngredient('AP10-OffenDetail');
|
||||
|
||||
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 750, 'received_quantity' => null]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Offene Bestellungen');
|
||||
$response->assertSee('AP10-OffenLieferant');
|
||||
});
|
||||
|
||||
test('uebersicht rendert rohstoff mit bestand', function () {
|
||||
$user = ap10MakeUser(1);
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = ap10MakeIngredient('AP10-Sheabutter', 5000);
|
||||
ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 2578]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.raw-material-stock.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Rohstoffbestand');
|
||||
$response->assertSee('AP10-Sheabutter');
|
||||
$response->assertSee('nur kritische anzeigen');
|
||||
});
|
||||
|
||||
test('einkauf-formular ist mit rohstoff und inhaltsstoff vorbelegt', function () {
|
||||
$user = ap10MakeUser(7);
|
||||
$ing = ap10MakeIngredient('AP10-Vorbelegt');
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.stock-entries.create', ['ingredient_id' => $ing->id]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('AP10-Vorbelegt');
|
||||
// Art "Rohstoff" ist als Standard ausgewählt
|
||||
$response->assertSee('value="ingredient" selected', false);
|
||||
});
|
||||
|
||||
test('detailansicht zeigt charge und lieferant', function () {
|
||||
$user = ap10MakeUser(1);
|
||||
$location = Location::factory()->create(['name' => 'AP10-Lager']);
|
||||
$supplier = Supplier::factory()->create(['name' => 'AP10-Lieferant']);
|
||||
$ing = ap10MakeIngredient('AP10-Detail');
|
||||
$ing->suppliers()->attach($supplier->id, ['preferred' => true]);
|
||||
|
||||
ap10MakeStock($ing, $location, $supplier, $user, [
|
||||
'batch_number' => 'AP10-CHARGE-7',
|
||||
'received_quantity' => 4000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('AP10-Detail');
|
||||
$response->assertSee('AP10-CHARGE-7');
|
||||
$response->assertSee('AP10-Lieferant');
|
||||
});
|
||||
222
tests/Feature/StockDisposalTest.php
Normal file
222
tests/Feature/StockDisposalTest.php
Normal 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]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue