April 2026 waren Wirtschaft Feedback

This commit is contained in:
Kevin Adametz 2026-04-10 17:14:38 +02:00
parent 02f2a4c23e
commit 9ce711d6b2
167 changed files with 25278 additions and 8518 deletions

View file

@ -0,0 +1,294 @@
<?php
use App\Models\Country;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Product;
use App\Models\Supplier;
use App\Models\SupplierCategory;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function makeUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('inv_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ensureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
test('locations full CRUD as superadmin', function () {
$user = makeUser(8);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.locations.index'))->assertSuccessful();
$this->get(route('admin.inventory.locations.create'))->assertSuccessful();
$this->post(route('admin.inventory.locations.store'), [
'name' => 'CRUD-Lager-A',
'active' => '1',
])->assertRedirect(route('admin.inventory.locations.index'));
$location = Location::query()->where('name', 'CRUD-Lager-A')->firstOrFail();
$this->get(route('admin.inventory.locations.edit', $location))->assertSuccessful();
$this->put(route('admin.inventory.locations.update', $location), [
'name' => 'CRUD-Lager-B',
'active' => '1',
])->assertRedirect(route('admin.inventory.locations.index'));
$location->refresh();
expect($location->name)->toBe('CRUD-Lager-B');
$this->delete(route('admin.inventory.locations.destroy', $location))
->assertRedirect(route('admin.inventory.locations.index'));
expect(Location::query()->whereKey($location->id)->exists())->toBeFalse();
});
test('material qualities full CRUD as superadmin', function () {
$user = makeUser(8);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.material-qualities.index'))->assertSuccessful();
$this->get(route('admin.inventory.material-qualities.create'))->assertSuccessful();
$this->post(route('admin.inventory.material-qualities.store'), [
'name' => 'CRUD-Qualität',
'pos' => 7,
])->assertRedirect(route('admin.inventory.material-qualities.index'));
$model = MaterialQuality::query()->where('name', 'CRUD-Qualität')->firstOrFail();
expect($model->pos)->toBe(7);
$this->get(route('admin.inventory.material-qualities.edit', $model))->assertSuccessful();
$this->put(route('admin.inventory.material-qualities.update', $model), [
'name' => 'CRUD-Qualität-upd',
'pos' => 8,
])->assertRedirect(route('admin.inventory.material-qualities.index'));
$model->refresh();
expect($model->name)->toBe('CRUD-Qualität-upd');
expect($model->pos)->toBe(8);
$this->delete(route('admin.inventory.material-qualities.destroy', $model))
->assertRedirect(route('admin.inventory.material-qualities.index'));
expect(MaterialQuality::query()->whereKey($model->id)->exists())->toBeFalse();
});
test('packaging materials full CRUD as superadmin', function () {
$user = makeUser(8);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.packaging-materials.index'))->assertSuccessful();
$this->get(route('admin.inventory.packaging-materials.create'))->assertSuccessful();
$this->post(route('admin.inventory.packaging-materials.store'), [
'name' => 'CRUD-Verpackungsmat',
'pos' => 3,
])->assertRedirect(route('admin.inventory.packaging-materials.index'));
$model = PackagingMaterial::query()->where('name', 'CRUD-Verpackungsmat')->firstOrFail();
$this->get(route('admin.inventory.packaging-materials.edit', $model))->assertSuccessful();
$this->put(route('admin.inventory.packaging-materials.update', $model), [
'name' => 'CRUD-Verpackungsmat-upd',
'pos' => 4,
])->assertRedirect(route('admin.inventory.packaging-materials.index'));
$model->refresh();
expect($model->name)->toBe('CRUD-Verpackungsmat-upd');
$this->delete(route('admin.inventory.packaging-materials.destroy', $model))
->assertRedirect(route('admin.inventory.packaging-materials.index'));
expect(PackagingMaterial::query()->whereKey($model->id)->exists())->toBeFalse();
});
test('supplier categories full CRUD as admin', function () {
$user = makeUser(7);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.supplier-categories.index'))->assertSuccessful();
$this->get(route('admin.inventory.supplier-categories.create'))->assertSuccessful();
$this->post(route('admin.inventory.supplier-categories.store'), [
'name' => 'CRUD-Lief-Kat',
'pos' => 2,
])->assertRedirect(route('admin.inventory.supplier-categories.index'));
$model = SupplierCategory::query()->where('name', 'CRUD-Lief-Kat')->firstOrFail();
$this->get(route('admin.inventory.supplier-categories.edit', $model))->assertSuccessful();
$this->put(route('admin.inventory.supplier-categories.update', $model), [
'name' => 'CRUD-Lief-Kat-upd',
'pos' => 3,
])->assertRedirect(route('admin.inventory.supplier-categories.index'));
$model->refresh();
expect($model->name)->toBe('CRUD-Lief-Kat-upd');
$this->delete(route('admin.inventory.supplier-categories.destroy', $model))
->assertRedirect(route('admin.inventory.supplier-categories.index'));
expect(SupplierCategory::query()->whereKey($model->id)->exists())->toBeFalse();
});
test('suppliers full CRUD with category sync as admin', function () {
$country = ensureCountry();
$cat = SupplierCategory::query()->create(['name' => 'CRUD-Kat-Sup', 'pos' => 1]);
$user = makeUser(7);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.suppliers.index'))->assertSuccessful();
$this->get(route('admin.inventory.suppliers.create'))->assertSuccessful();
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'CRUD-Lieferant',
'country_id' => (string) $country->id,
'active' => '1',
'supplier_category_ids' => [(string) $cat->id],
'email' => 'crud@example.test',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier = Supplier::query()->where('name', 'CRUD-Lieferant')->firstOrFail();
expect($supplier->supplierCategories()->pluck('supplier_categories.id')->all())->toEqual([$cat->id]);
$this->get(route('admin.inventory.suppliers.edit', $supplier))->assertSuccessful();
$this->put(route('admin.inventory.suppliers.update', $supplier), [
'name' => 'CRUD-Lieferant-upd',
'country_id' => (string) $country->id,
'active' => '1',
'supplier_category_ids' => [(string) $cat->id],
'email' => 'crud-upd@example.test',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier->refresh();
expect($supplier->name)->toBe('CRUD-Lieferant-upd');
$this->delete(route('admin.inventory.suppliers.destroy', $supplier))
->assertRedirect(route('admin.inventory.suppliers.index'));
expect(Supplier::query()->whereKey($supplier->id)->exists())->toBeFalse();
expect(Supplier::withTrashed()->whereKey($supplier->id)->exists())->toBeTrue();
});
test('packaging items full CRUD as admin', function () {
$material = PackagingMaterial::factory()->create(['name' => 'CRUD-PM-'.uniqid('', true)]);
$supplier = Supplier::factory()->create();
$product = Product::query()->create([
'name' => 'CRUD-Produkt',
'title' => 'CRUD-Produkt',
'active' => true,
]);
$user = makeUser(7);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.packaging-items.index'))->assertSuccessful();
$this->get(route('admin.inventory.packaging-items.create'))->assertSuccessful();
$this->post(route('admin.inventory.packaging-items.store'), [
'packaging_material_id' => (string) $material->id,
'supplier_id' => (string) $supplier->id,
'name' => 'CRUD-Verpack-Art',
'category' => 'packaging',
'weight_grams' => '12.5',
'min_stock_alert' => '100',
'product_id' => (string) $product->id,
'active' => '1',
])->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'packaging']));
$item = PackagingItem::query()->where('name', 'CRUD-Verpack-Art')->firstOrFail();
expect($item->packaging_material_id)->toBe($material->id);
expect($item->product_id)->toBe($product->id);
$this->get(route('admin.inventory.packaging-items.edit', $item))->assertSuccessful();
$this->put(route('admin.inventory.packaging-items.update', $item), [
'packaging_material_id' => (string) $material->id,
'supplier_id' => '',
'name' => 'CRUD-Verpack-Art-upd',
'category' => 'label',
'weight_grams' => '5',
'min_stock_alert' => '',
'product_id' => '',
'active' => '1',
])->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'shipping']));
$item->refresh();
expect($item->name)->toBe('CRUD-Verpack-Art-upd');
expect($item->category)->toBe('label');
expect($item->supplier_id)->toBeNull();
expect($item->product_id)->toBeNull();
$this->delete(route('admin.inventory.packaging-items.destroy', $item))
->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'shipping']));
expect(PackagingItem::query()->whereKey($item->id)->exists())->toBeFalse();
expect(PackagingItem::withTrashed()->whereKey($item->id)->exists())->toBeTrue();
});
test('admin cannot access superadmin inventory routes', function () {
$user = makeUser(7);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.locations.index'))->assertRedirect(route('home'));
});
test('admin cannot store locations', function () {
$user = makeUser(7);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.locations.store'), [
'name' => 'Forbidden-Lager',
'active' => '1',
])->assertRedirect(route('home'));
expect(Location::query()->where('name', 'Forbidden-Lager')->exists())->toBeFalse();
});
test('copyreader cannot access inventory suppliers', function () {
$user = makeUser(1);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.suppliers.index'))->assertRedirect(route('home'));
});

View file

@ -0,0 +1,228 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Repositories\StockEntryRepository;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function phase3MakeUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('inv3_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function phase3EnsureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
function phase3MakeIngredient(): Ingredient
{
return Ingredient::query()->create([
'name' => 'Test-INCI-'.uniqid('', true),
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
}
test('copyreader can list stock entries and receive but not create form', function () {
$user = phase3MakeUser(1);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.stock-entries.index'))->assertSuccessful();
$this->get(route('admin.inventory.stock-entries.create'))->assertRedirect(route('home'));
});
test('non-copyreader cannot access stock entries', function () {
$user = phase3MakeUser(0);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.stock-entries.index'))->assertRedirect(route('home'));
});
test('admin creates pending stock entry with unit gram', function () {
phase3EnsureCountry();
$admin = phase3MakeUser(7);
$ingredient = phase3MakeIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'ordered_at' => '2026-03-27',
'ordered_quantity' => '1000',
'price_per_kg' => '12,50',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect($entry->status)->toBe('pending');
expect($entry->unit)->toBe('gram');
expect((float) $entry->ordered_quantity)->toBe(1000.0);
});
test('copyreader can book receive for pending entry', function () {
phase3EnsureCountry();
$admin = phase3MakeUser(7);
$copy = phase3MakeUser(1);
$ingredient = phase3MakeIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$mq = MaterialQuality::query()->create(['name' => 'MQ-Test-'.uniqid('', true), 'pos' => 1]);
$entry = StockEntry::query()->create([
'entry_type' => 'ingredient',
'ingredient_id' => $ingredient->id,
'packaging_item_id' => null,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $admin->id,
'ordered_at' => '2026-03-01',
'ordered_quantity' => 500,
'price_per_kg' => 10,
'price_total' => null,
'status' => 'pending',
]);
$this->actingAs($copy, 'user');
$this->put(route('admin.inventory.stock-entries.receive', $entry), [
'received_at' => '2026-03-15',
'received_quantity' => '500',
'batch_number' => 'B-001',
'best_before' => '2027-12-31',
'quality_id' => (string) $mq->id,
])->assertRedirect(route('admin.inventory.stock-entries.show', $entry));
$entry->refresh();
expect($entry->status)->toBe('received');
expect((float) $entry->received_quantity)->toBe(500.0);
expect($entry->received_by)->toBe($copy->id);
});
test('ingredient search returns select2 results shape', function () {
$user = phase3MakeUser(1);
$ingredient = phase3MakeIngredient();
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.ingredients.search', ['q' => $ingredient->name]));
$response->assertSuccessful();
$response->assertJsonStructure(['results' => [['id', 'text']]]);
expect($response->json('results.0.id'))->toBe($ingredient->id);
});
test('stock entry factory creates valid pending ingredient entry', function () {
phase3EnsureCountry();
$e = StockEntry::factory()->create();
expect($e->status)->toBe('pending');
expect($e->unit)->toBe('gram');
expect($e->ingredient_id)->toBeGreaterThan(0);
});
test('stock entry factory packaging and received states', function () {
phase3EnsureCountry();
MaterialQuality::query()->create(['name' => 'MQ-Factory-'.uniqid('', true), 'pos' => 99]);
$p = StockEntry::factory()->packaging()->create();
expect($p->entry_type)->toBe('packaging');
expect($p->unit)->toBe('piece');
expect($p->price_total)->not->toBeNull();
$r = StockEntry::factory()->received()->create();
expect($r->status)->toBe('received');
expect($r->received_quantity)->not->toBeNull();
});
test('listForIndex sorts pending before received', function () {
phase3EnsureCountry();
$admin = phase3MakeUser(7);
$ingredient = phase3MakeIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => '100',
'price_per_kg' => '1',
]);
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'ordered_at' => '2026-06-01',
'ordered_quantity' => '200',
'price_per_kg' => '1',
]);
$entries = StockEntry::query()->orderBy('id')->get();
expect($entries)->toHaveCount(2);
$older = $entries[0];
$newer = $entries[1];
$this->actingAs($admin, 'user');
$this->put(route('admin.inventory.stock-entries.receive', $older), [
'received_at' => '2026-03-10',
'received_quantity' => '100',
'batch_number' => 'X',
'best_before' => '2028-01-01',
'quality_id' => '',
]);
$repo = app(StockEntryRepository::class);
$list = $repo->listForIndex();
expect($list)->toHaveCount(2);
expect($list[0]->id)->toBe($newer->id);
expect($list[0]->status)->toBe('pending');
expect($list[1]->id)->toBe($older->id);
expect($list[1]->status)->toBe('received');
});

View file

@ -0,0 +1,186 @@
<?php
use App\Models\Attribute;
use App\Models\AttributeType;
use App\Models\Ingredient;
use App\Models\Product;
use App\Models\ProductAttribute;
use App\Models\ProductIngredient;
use App\Repositories\ProductRepository;
use App\Services\HTMLHelper;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('product_ingredients relation uses product_id foreign key', function () {
$product = Product::create([
'name' => 'Relation Test',
'title' => 'Relation Test',
'active' => true,
]);
$ingredient = Ingredient::create([
'name' => 'Test INCI',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
ProductIngredient::create([
'product_id' => $product->id,
'ingredient_id' => $ingredient->id,
]);
$product->refresh();
expect($product->product_ingredients)->toHaveCount(1);
expect($product->product_ingredients->first()->ingredient_id)->toBe($ingredient->id);
});
test('ingredient product_ingredients relation uses ingredient_id foreign key', function () {
$product = Product::create([
'name' => 'Relation Test 2',
'title' => 'Relation Test 2',
'active' => true,
]);
$ingredient = Ingredient::create([
'name' => 'Test INCI 2',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
ProductIngredient::create([
'product_id' => $product->id,
'ingredient_id' => $ingredient->id,
]);
$ingredient->refresh();
expect($ingredient->product_ingredients)->toHaveCount(1);
expect($ingredient->product_ingredients->first()->product_id)->toBe($product->id);
});
test('updateIngredients removes deselected ingredients', function () {
$product = Product::create([
'name' => 'Sync Test',
'title' => 'Sync Test',
'active' => true,
]);
$a = Ingredient::create([
'name' => 'Alpha',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$b = Ingredient::create([
'name' => 'Beta',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$repo = new ProductRepository($product);
$repo->updateIngredients(['product_ingredients' => [(string) $a->id, (string) $b->id]]);
expect($product->fresh()->p_ingredients)->toHaveCount(2);
$repo->updateIngredients(['product_ingredients' => [(string) $a->id]]);
expect($product->fresh()->p_ingredients)->toHaveCount(1);
expect($product->fresh()->p_ingredients->first()->id)->toBe($a->id);
});
test('getProductIngredientsOptions lists ingredients alphabetically by name', function () {
Ingredient::create([
'name' => 'Zebra',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
Ingredient::create([
'name' => 'Alpha',
'trans_name' => '',
'inci' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$html = HTMLHelper::getProductIngredientsOptions([], false);
expect(strpos($html, 'Alpha'))->toBeLessThan(strpos($html, 'Zebra'));
});
test('copy preserves product attribute type_id', function () {
$type = AttributeType::create([
'name' => 'Type A',
'slug' => 'type-a-'.uniqid(),
'pos' => 0,
'active' => true,
]);
$typeB = AttributeType::create([
'name' => 'Type B',
'slug' => 'type-b-'.uniqid(),
'pos' => 1,
'active' => true,
]);
$attr = Attribute::create([
'attribute_type_id' => $type->id,
'name' => 'Attr',
'trans_name' => '',
'pos' => 0,
'active' => true,
'slug' => 'attr-'.uniqid(),
]);
$source = Product::create([
'name' => 'Source',
'title' => 'Source',
'active' => true,
]);
ProductAttribute::create([
'product_id' => $source->id,
'type_id' => $typeB->id,
'attribute_id' => $attr->id,
]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($source->fresh());
$copied = $copy->attributes()->first();
expect($copied)->not->toBeNull();
expect($copied->type_id)->toBe($typeB->id);
expect($copied->attribute_id)->toBe($attr->id);
});

View file

@ -0,0 +1,92 @@
<?php
use App\Models\Ingredient;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Repositories\ProductRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('product saves inci pivot pos gram factor and shelf life', function () {
$product = Product::create([
'name' => 'Salbe',
'title' => 'Salbe',
'active' => true,
]);
$a = Ingredient::create([
'name' => 'Shea',
'trans_name' => '',
'inci' => 'Butyrospermum',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'default_factor' => 1.15,
]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'product_inci_sync_sent' => '1',
'pi_ingredient_id' => [(string) $a->id],
'pi_gram' => ['10,5'],
'pi_factor' => ['1,20'],
'shelf_life_type' => 'fixed',
'shelf_life_months' => '12',
]);
$product->refresh();
expect($product->shelf_life_type)->toBe('fixed');
expect($product->shelf_life_months)->toBe(12);
$pivot = $product->p_ingredients()->where('ingredients.id', $a->id)->first();
expect($pivot)->not->toBeNull();
expect((float) $pivot->pivot->gram)->toBe(10.5);
expect((float) $pivot->pivot->factor)->toBe(1.20);
expect((int) $pivot->pivot->pos)->toBe(0);
});
test('product copy preserves inci pivot fields', function () {
$source = Product::create([
'name' => 'Original',
'title' => 'Original',
'active' => true,
'shelf_life_type' => 'pao',
]);
$ing = Ingredient::create([
'name' => 'Oel',
'trans_name' => '',
'inci' => 'Oil',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
ProductIngredient::create([
'product_id' => $source->id,
'ingredient_id' => $ing->id,
'pos' => 2,
'gram' => 3.25,
'factor' => 1.08,
]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($source->fresh());
$row = ProductIngredient::where('product_id', $copy->id)->where('ingredient_id', $ing->id)->first();
expect($row)->not->toBeNull();
expect((float) $row->gram)->toBe(3.25);
expect((float) $row->factor)->toBe(1.08);
expect($row->pos)->toBe(2);
expect($copy->shelf_life_type)->toBe('pao');
});

View file

@ -0,0 +1,88 @@
<?php
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Product;
use App\Models\Supplier;
use App\Repositories\ProductRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
test('product saves packaging bom quantities and order', function () {
$product = Product::create([
'name' => 'Creme',
'title' => 'Creme',
'active' => true,
]);
$material = PackagingMaterial::factory()->create(['name' => 'Glas-P4']);
$supplier = Supplier::factory()->create();
$a = PackagingItem::factory()->create([
'packaging_material_id' => $material->id,
'supplier_id' => $supplier->id,
'name' => 'Flasche 30 ml',
'category' => 'packaging',
'weight_grams' => 40,
]);
$b = PackagingItem::factory()->create([
'packaging_material_id' => $material->id,
'supplier_id' => $supplier->id,
'name' => 'Etikett',
'category' => 'label',
'weight_grams' => 1.5,
]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'product_inci_sync_sent' => '1',
'pp_packaging_item_id' => [(string) $b->id, (string) $a->id],
'pp_quantity' => ['2', '1,5'],
]);
$product->refresh();
$rows = $product->packagings()->orderByPivot('pos')->get();
expect($rows)->toHaveCount(2);
expect($rows[0]->id)->toBe($b->id);
expect((float) $rows[0]->pivot->quantity)->toBe(2.0);
expect((int) $rows[0]->pivot->pos)->toBe(0);
expect($rows[1]->id)->toBe($a->id);
expect((float) $rows[1]->pivot->quantity)->toBe(1.5);
expect((int) $rows[1]->pivot->pos)->toBe(1);
});
test('product copy preserves packaging bom', function () {
$product = Product::create([
'name' => 'Serum',
'title' => 'Serum',
'active' => true,
]);
$material = PackagingMaterial::factory()->create();
$supplier = Supplier::factory()->create();
$pk = PackagingItem::factory()->create([
'packaging_material_id' => $material->id,
'supplier_id' => $supplier->id,
'name' => 'Pumpflasche',
'category' => 'packaging',
]);
$product->packagings()->sync([
$pk->id => ['quantity' => 3,
'pos' => 0,
],
]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($product->fresh());
$copy->load('packagings');
expect($copy->packagings)->toHaveCount(1);
expect($copy->packagings->first()->id)->toBe($pk->id);
expect((float) $copy->packagings->first()->pivot->quantity)->toBe(3.0);
});

View file

@ -0,0 +1,345 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
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 phase5MakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('p5_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function phase5EnsureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
test('production service stores ingredients and packaging snapshot', function () {
phase5EnsureCountry();
$user = phase5MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'P5-Rohstoff',
'trans_name' => '',
'inci' => 'INCI',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$product = Product::query()->create([
'name' => 'P5-Produkt',
'title' => 'P5-Produkt',
'active' => true,
'shelf_life_type' => null,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
]);
$material = PackagingMaterial::factory()->create();
$pk = PackagingItem::factory()->create([
'packaging_material_id' => $material->id,
'supplier_id' => $supplier->id,
'name' => 'P5-Flasche',
'category' => 'packaging',
]);
$product->packagings()->sync([
$pk->id => ['quantity' => 2, 'pos' => 0],
]);
$stock = StockEntry::query()->create([
'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' => 'B-P5',
'best_before' => '2030-12-31',
'quality_id' => null,
]);
$service = app(ProductionService::class);
$production = $service->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 5,
'notes' => 'test',
],
[
[
'ingredient_id' => $ing->id,
'stock_entry_id' => $stock->id,
'quantity_used' => '50',
],
],
$user->id
);
expect($production->productionIngredients)->toHaveCount(1);
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(50.0);
expect($production->productionPackagings)->toHaveCount(1);
expect($production->productionPackagings->first()->quantity_used)->toBe(10);
});
test('production service rejects wrong gram sum', function () {
phase5EnsureCountry();
$user = phase5MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'P5-R2',
'trans_name' => '',
'inci' => 'X',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$product = Product::query()->create([
'name' => 'P5-P2',
'title' => 'P5-P2',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
]);
$stock = StockEntry::query()->create([
'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' => 'B2',
'best_before' => '2030-12-31',
'quality_id' => null,
]);
$service = app(ProductionService::class);
expect(fn () => $service->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' => '100',
],
],
$user->id
))->toThrow(ValidationException::class);
});
test('production api recipe json returns ingredients and packagings', function () {
phase5EnsureCountry();
$user = phase5MakeUser(1);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'P5-API',
'trans_name' => '',
'inci' => 'Y',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$product = Product::query()->create([
'name' => 'P5-API-P',
'title' => 'P5-API-P',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 2,
'factor' => 1.5,
]);
StockEntry::query()->create([
'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' => 5000,
'price_per_kg' => 1,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-02-01',
'received_quantity' => 5000,
'batch_number' => 'API-1',
'best_before' => '2028-06-01',
'quality_id' => null,
]);
$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('product.id', $product->id);
expect((float) $response->json('ingredients.0.required_grams_total'))->toBe(30.0);
});
test('production mhd warning when stock expires before product end', function () {
phase5EnsureCountry();
$user = phase5MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'P5-MHD',
'trans_name' => '',
'inci' => 'Z',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$product = Product::query()->create([
'name' => 'P5-MHD-P',
'title' => 'P5-MHD-P',
'active' => true,
'shelf_life_type' => 'fixed',
'shelf_life_months' => 24,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 1,
'factor' => 1.0,
]);
$stock = StockEntry::query()->create([
'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' => 5000,
'price_per_kg' => 1,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-02-01',
'received_quantity' => 5000,
'batch_number' => 'MHD-B',
'best_before' => '2026-06-01',
'quality_id' => null,
]);
$service = app(ProductionService::class);
$production = $service->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' => '1',
],
],
$user->id
);
expect($production->mhd_warning)->toBeTrue();
});