Warenwirtschaft: AP-00 bis AP-08 + aktualisierter Entwicklungsplan

Umsetzung der Warenwirtschafts-/Produktmanagement-Erweiterung gemaess
Entwicklungsplan V4.0:

- AP-00: Regressionsbasis fuer 5.1-Features (ProductPhase51Test)
- AP-01: URL-Bugfixes B1/B2 (suppliers/packaging-items, breitere url-Spalten)
- AP-04/04.1: iPad-taugliche, vereinheitlichte Tabellen-Aktionen
- AP-05: Einstellungen "Allgemein" mit UST-Saetzen (tax_rates) und
  Lieferzeit-Vorlagen (delivery_times, inkl. Tage-Feld)
- AP-06: Lieferanten um Bestellweg, Bestell-Mail/-URL und Lieferzeit erweitert
- AP-07/07.1: INCI um Lieferanten-Mehrfachwahl, UST und Lieferzeit erweitert;
  Lieferanten-Detailansicht im Modal mit pflegbaren INCI-/Verpackungslisten
- AP-08: Einkauf um UST-Snapshot, Netto/Brutto-Automatik und Duplizieren erweitert

Entwicklungsplan aktualisiert: alle Klaerungspunkte (§5) vom Kunden beantwortet
und in die jeweiligen APs eingearbeitet (AP-02/03/09/13/15), neues AP-18
(Hinweise-Doku unter Einstellungen) ergaenzt. Naechster Schritt eindeutig
markiert: AP-09 (Produktion auf Hersteller-Rezeptur, kein Fallback, Warnung).
This commit is contained in:
Kevin Adametz 2026-06-02 16:30:42 +00:00
parent ca3eb663fe
commit 78679e0c55
67 changed files with 3523 additions and 101 deletions

View file

@ -0,0 +1,126 @@
<?php
use App\Models\DeliveryTime;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function deliveryTimeUser(int $adminLevel = 8): User
{
$user = User::query()->create([
'email' => uniqid('dt_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
test('allgemein-seite zeigt lieferzeit-vorlagen', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
DeliveryTime::create(['label' => '35 Werktage', 'active' => true, 'pos' => 0]);
$response = $this->get(route('admin.inventory.general'));
$response->assertSuccessful();
$response->assertSee('Lieferzeit-Vorlagen', false);
$response->assertSee('35 Werktage', false);
$response->assertSee(route('admin.inventory.delivery-times.create'), false);
});
test('superadmin legt lieferzeit-vorlage an', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$this->get(route('admin.inventory.delivery-times.create'))->assertSuccessful();
$this->post(route('admin.inventory.delivery-times.store'), [
'label' => '12 Wochen',
'days' => 14,
'active' => '1',
'pos' => 0,
])->assertRedirect(route('admin.inventory.general'));
$deliveryTime = DeliveryTime::query()->where('label', '12 Wochen')->firstOrFail();
expect($deliveryTime->active)->toBeTrue()
->and($deliveryTime->days)->toBe(14);
});
test('lieferzeit-vorlage ohne tage bleibt erlaubt', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$this->post(route('admin.inventory.delivery-times.store'), [
'label' => 'Auf Anfrage',
'days' => '',
'active' => '1',
])->assertRedirect(route('admin.inventory.general'));
$deliveryTime = DeliveryTime::query()->where('label', 'Auf Anfrage')->firstOrFail();
expect($deliveryTime->days)->toBeNull();
});
test('tage muss eine ganze zahl sein', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$this->post(route('admin.inventory.delivery-times.store'), [
'label' => 'Falsche Tage',
'days' => 'abc',
])->assertSessionHasErrors('days');
});
test('lieferzeit-vorlage kann bearbeitet und deaktiviert werden', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$deliveryTime = DeliveryTime::create(['label' => '35 Werktage', 'active' => true, 'pos' => 1]);
$this->get(route('admin.inventory.delivery-times.edit', $deliveryTime))->assertSuccessful();
$this->put(route('admin.inventory.delivery-times.update', $deliveryTime), [
'label' => '57 Werktage',
'pos' => 1,
])->assertRedirect(route('admin.inventory.general'));
$deliveryTime->refresh();
expect($deliveryTime->label)->toBe('57 Werktage');
expect($deliveryTime->active)->toBeFalse();
});
test('lieferzeit-vorlage kann geloescht werden', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$deliveryTime = DeliveryTime::create(['label' => 'Express', 'active' => true, 'pos' => 2]);
$this->delete(route('admin.inventory.delivery-times.destroy', $deliveryTime))
->assertRedirect(route('admin.inventory.general'));
expect(DeliveryTime::query()->whereKey($deliveryTime->id)->exists())->toBeFalse();
});
test('bezeichnung ist pflicht', function () {
$this->actingAs(deliveryTimeUser(8), 'user');
$this->post(route('admin.inventory.delivery-times.store'), [
'label' => '',
])->assertSessionHasErrors('label');
});
test('nur aktive vorlagen ueber active-scope', function () {
DeliveryTime::create(['label' => 'Aktiv', 'active' => true, 'pos' => 0]);
DeliveryTime::create(['label' => 'Inaktiv', 'active' => false, 'pos' => 1]);
expect(DeliveryTime::query()->active()->pluck('label')->all())->toBe(['Aktiv']);
});
test('nicht-superadmin hat keinen zugriff auf lieferzeit-anlage', function () {
$this->actingAs(deliveryTimeUser(7), 'user');
$this->get(route('admin.inventory.delivery-times.create'))->assertRedirect('/home');
});

View file

@ -0,0 +1,135 @@
<?php
use App\Models\DeliveryTime;
use App\Models\Ingredient;
use App\Models\Supplier;
use App\Models\TaxRate;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ingredientOrderUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('ing_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ingredientOrderSupplier(string $name): Supplier
{
return Supplier::factory()->create([
'name' => $name,
'active' => true,
]);
}
test('ingredient form shows suppliers, tax rates and delivery time templates', function () {
$supplier = ingredientOrderSupplier('Aktiv-Lieferant');
TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
DeliveryTime::query()->create(['label' => '35 Werktage', 'days' => 5, 'active' => true, 'pos' => 0]);
DeliveryTime::query()->create(['label' => 'Inaktiv-Vorlage', 'active' => false, 'pos' => 1]);
$this->actingAs(ingredientOrderUser(7), 'user');
$this->get(route('admin_product_ingredient_edit', 'new'))
->assertSuccessful()
->assertSee('supplier_ids')
->assertSee('tax_rate_id')
->assertSee('Aktiv-Lieferant')
->assertSee('35 Werktage')
->assertSee('data-days="5"', false)
->assertDontSee('Inaktiv-Vorlage');
});
test('ingredient can be stored with tax rate, delivery time and suppliers', function () {
$taxRate = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
$supplierA = ingredientOrderSupplier('Lieferant A');
$supplierB = ingredientOrderSupplier('Lieferant B');
$this->actingAs(ingredientOrderUser(7), 'user');
$this->post(route('admin_product_ingredient_store'), [
'id' => 'new',
'name' => 'Sheabutter',
'active' => '1',
'tax_rate_id' => (string) $taxRate->id,
'delivery_time' => '35 Werktage',
'delivery_time_days' => '5',
'supplier_ids' => [(string) $supplierA->id, (string) $supplierB->id],
])->assertRedirect(route('admin_product_ingredients'));
$ingredient = Ingredient::query()->where('name', 'Sheabutter')->firstOrFail();
expect($ingredient->tax_rate_id)->toBe($taxRate->id)
->and($ingredient->delivery_time)->toBe('35 Werktage')
->and($ingredient->delivery_time_days)->toBe(5)
->and($ingredient->suppliers()->pluck('suppliers.id')->all())
->toEqualCanonicalizing([$supplierA->id, $supplierB->id]);
});
test('ingredient suppliers are synced on update', function () {
$supplierA = ingredientOrderSupplier('Lieferant A');
$supplierB = ingredientOrderSupplier('Lieferant B');
$ingredient = Ingredient::query()->create([
'name' => 'Lavendelöl',
'active' => true,
]);
$ingredient->suppliers()->sync([$supplierA->id]);
$this->actingAs(ingredientOrderUser(7), 'user');
$this->post(route('admin_product_ingredient_store'), [
'id' => (string) $ingredient->id,
'name' => 'Lavendelöl',
'active' => '1',
'supplier_ids' => [(string) $supplierB->id],
])->assertRedirect(route('admin_product_ingredients'));
expect($ingredient->fresh()->suppliers()->pluck('suppliers.id')->all())
->toEqualCanonicalizing([$supplierB->id]);
});
test('ingredient delivery time days must be an integer', function () {
$this->actingAs(ingredientOrderUser(7), 'user');
$this->post(route('admin_product_ingredient_store'), [
'id' => 'new',
'name' => 'Falsche-Tage',
'active' => '1',
'delivery_time_days' => 'viele',
])->assertSessionHasErrors('delivery_time_days');
});
test('ingredient tax rate must exist', function () {
$this->actingAs(ingredientOrderUser(7), 'user');
$this->post(route('admin_product_ingredient_store'), [
'id' => 'new',
'name' => 'Falscher-Steuersatz',
'active' => '1',
'tax_rate_id' => '999999',
])->assertSessionHasErrors('tax_rate_id');
});
test('ingredient name is required', function () {
$this->actingAs(ingredientOrderUser(7), 'user');
$this->post(route('admin_product_ingredient_store'), [
'id' => 'new',
'name' => '',
'active' => '1',
])->assertSessionHasErrors('name');
});

View file

@ -0,0 +1,100 @@
<?php
use App\Models\Country;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Supplier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Konfigurator-URL mit kodierten Parametern (Versandverpackung), siehe docs/Todos.md.
*/
function configuratorUrl(): string
{
return 'https://www.kartonsaufmass.de/bestellen?bom_configuration=%7B%2522length%2522:125,%2522width%2522:125,%2522height%2522:30,%2522amount%2522:100%7D';
}
function urlTestUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('urlfield_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function urlTestCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
test('supplier speichert URL mit Parametern (B1/B2)', function () {
$country = urlTestCountry();
$user = urlTestUser(7);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Kartons-auf-Mass',
'country_id' => (string) $country->id,
'url' => configuratorUrl(),
'active' => '1',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier = Supplier::query()->where('name', 'Kartons-auf-Mass')->firstOrFail();
expect($supplier->url)->toBe(configuratorUrl());
});
test('supplier ohne URL bleibt speicherbar', function () {
$country = urlTestCountry();
$user = urlTestUser(7);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Ohne-URL',
'country_id' => (string) $country->id,
'active' => '1',
])->assertRedirect(route('admin.inventory.suppliers.index'));
expect(Supplier::query()->where('name', 'Ohne-URL')->value('url'))->toBeNull();
});
test('packaging item speichert URL mit Parametern (B2)', function () {
$material = PackagingMaterial::factory()->create();
$user = urlTestUser(7);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.packaging-items.store'), [
'packaging_material_id' => (string) $material->id,
'name' => 'Versandkarton Konfigurator',
'category' => 'shipping',
'url' => configuratorUrl(),
'active' => '1',
])->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'shipping']));
$item = PackagingItem::query()->where('name', 'Versandkarton Konfigurator')->firstOrFail();
expect($item->url)->toBe(configuratorUrl());
});

View file

@ -0,0 +1,263 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Repositories\ProductRepository;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function p51MakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('p51_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function p51EnsureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
/**
* @return array{0: Ingredient, 1: Ingredient}
*/
function p51MakeIngredients(): array
{
$base = [
'trans_name' => '',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
];
$a = Ingredient::query()->create($base + ['name' => 'P51-Rohstoff-A', 'inci' => 'INCI-A', 'default_factor' => 1.10]);
$b = Ingredient::query()->create($base + ['name' => 'P51-Rohstoff-B', 'inci' => 'INCI-B', 'default_factor' => 1.10]);
return [$a, $b];
}
test('ingredient speichert und liefert rohstoffqualitaet', function () {
$quality = MaterialQuality::query()->create(['name' => 'bio kaltgepresst', 'pos' => 1]);
$ing = Ingredient::query()->create([
'name' => 'Sonnenblumenöl',
'trans_name' => '',
'inci' => 'Helianthus Annuus Seed Oil',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'material_quality_id' => $quality->id,
]);
expect($ing->materialQuality)->not->toBeNull();
expect($ing->materialQuality->name)->toBe('bio kaltgepresst');
});
test('produkt speichert hersteller-rezeptur getrennt von produkt-rezeptur', function () {
[$a, $b] = p51MakeIngredients();
$product = Product::query()->create([
'name' => 'P51-Produkt',
'title' => 'P51-Produkt',
'active' => true,
]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
// Produkt-Rezeptur
'product_inci_sync_sent' => '1',
'pi_ingredient_id' => [(string) $a->id],
'pi_gram' => ['80,000'],
'pi_factor' => ['1,10'],
// Hersteller-Rezeptur
'manufacturer_inci_sync_sent' => '1',
'mfg_ingredient_id' => [(string) $b->id],
'mfg_gram' => ['20,000'],
'mfg_factor' => ['1,20'],
]);
$product->refresh();
expect($product->p_ingredients)->toHaveCount(1);
expect($product->p_ingredients->first()->id)->toBe($a->id);
expect($product->manufacturer_ingredients)->toHaveCount(1);
$mfg = $product->manufacturer_ingredients->first();
expect($mfg->id)->toBe($b->id);
expect((float) $mfg->pivot->gram)->toBe(20.0);
expect((float) $mfg->pivot->factor)->toBe(1.20);
});
test('produkt-kopie uebernimmt beide rezepturen', function () {
[$a, $b] = p51MakeIngredients();
$source = Product::query()->create([
'name' => 'P51-Quelle',
'title' => 'P51-Quelle',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $source->id,
'ingredient_id' => $a->id,
'pos' => 0,
'gram' => 70,
'factor' => 1.10,
'recipe_type' => 'product',
]);
ProductIngredient::query()->create([
'product_id' => $source->id,
'ingredient_id' => $b->id,
'pos' => 0,
'gram' => 30,
'factor' => 1.15,
'recipe_type' => 'manufacturer',
]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($source->fresh());
expect($copy->p_ingredients()->count())->toBe(1);
expect($copy->manufacturer_ingredients()->count())->toBe(1);
$mfg = $copy->manufacturer_ingredients()->first();
expect($mfg->id)->toBe($b->id);
expect((float) $mfg->pivot->gram)->toBe(30.0);
expect((float) $mfg->pivot->factor)->toBe(1.15);
});
test('produktions-formular zeigt nur aktive produkte', function () {
p51EnsureCountry();
Location::factory()->create(['name' => 'Köln']);
$user = p51MakeUser(1);
Product::query()->create(['name' => 'P51-AKTIV-PROD', 'title' => 'P51-AKTIV-PROD', 'active' => true]);
Product::query()->create(['name' => 'P51-INAKTIV-PROD', 'title' => 'P51-INAKTIV-PROD', 'active' => false]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.productions.create'));
$response->assertSuccessful();
$response->assertSee('P51-AKTIV-PROD');
$response->assertDontSee('P51-INAKTIV-PROD');
});
test('produktion kann bearbeitet und kopiert werden (views rendern)', function () {
p51EnsureCountry();
$user = p51MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'P51-Prod-Roh',
'trans_name' => '',
'inci' => 'PR',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$product = Product::query()->create([
'name' => 'P51-Prod',
'title' => 'P51-Prod',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'product',
]);
$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' => 'P51-B',
'best_before' => '2030-12-31',
'quality_id' => null,
]);
$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' => '50'],
],
$user->id
);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.productions.edit', $production))->assertSuccessful();
$this->get(route('admin.inventory.productions.copy', $production))->assertSuccessful();
// Übersicht rendert mit Aktionsspalte (Ansicht, Bearbeiten, Kopieren)
$index = $this->get(route('admin.inventory.productions.index'));
$index->assertSuccessful();
$index->assertSee(route('admin.inventory.productions.show', $production), false);
$index->assertSee(route('admin.inventory.productions.edit', $production), false);
$index->assertSee(route('admin.inventory.productions.copy', $production), false);
});

View file

@ -0,0 +1,203 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Models\TaxRate;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function stockPriceAdmin(): User
{
$user = User::query()->create([
'email' => uniqid('sep_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => 7,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function stockPriceIngredient(): Ingredient
{
return Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0]);
}
test('net price derives gross from tax rate on store', function () {
$admin = stockPriceAdmin();
$ingredient = stockPriceIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::factory()->create();
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
$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,
'quality_id' => (string) $quality->id,
'tax_rate_id' => (string) $tax->id,
'ordered_at' => '2026-06-01',
'ordered_quantity' => '1000',
'price_per_kg' => '10,00',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->price_per_kg)->toBe(10.0)
->and((float) $entry->price_per_kg_gross)->toBe(11.9)
->and((float) $entry->tax_rate_percent)->toBe(19.0);
});
test('gross price derives net from tax rate on store', function () {
$admin = stockPriceAdmin();
$ingredient = stockPriceIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::factory()->create();
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
$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,
'quality_id' => (string) $quality->id,
'tax_rate_id' => (string) $tax->id,
'ordered_at' => '2026-06-01',
'ordered_quantity' => '1000',
'price_per_kg_gross' => '11,90',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->price_per_kg)->toBe(10.0)
->and((float) $entry->price_per_kg_gross)->toBe(11.9);
});
test('without tax rate net equals gross', function () {
$admin = stockPriceAdmin();
$ingredient = stockPriceIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::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,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-06-01',
'ordered_quantity' => '500',
'price_per_kg' => '8,00',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->price_per_kg)->toBe(8.0)
->and((float) $entry->price_per_kg_gross)->toBe(8.0)
->and($entry->tax_rate_percent)->toBeNull();
});
test('ingredient requires net or gross price', function () {
$admin = stockPriceAdmin();
$ingredient = stockPriceIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::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,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-06-01',
'ordered_quantity' => '500',
])->assertSessionHasErrors('price_per_kg');
});
test('copy creates pending duplicate without receiving data', function () {
$admin = stockPriceAdmin();
$ingredient = stockPriceIngredient();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::factory()->create();
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
$source = StockEntry::query()->create([
'entry_type' => 'ingredient',
'ingredient_id' => $ingredient->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $admin->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 1000,
'price_per_kg' => 10,
'price_per_kg_gross' => 11.9,
'tax_rate_id' => $tax->id,
'tax_rate_percent' => 19,
'quality_id' => $quality->id,
'received_at' => '2026-01-10',
'received_quantity' => 1000,
'batch_number' => 'CHARGE-1',
'best_before' => '2027-01-01',
'status' => 'received',
]);
$this->actingAs($admin, 'user');
$this->get(route('admin.inventory.stock-entries.copy', $source))
->assertRedirect();
$copy = StockEntry::query()->where('status', 'pending')->latest('id')->firstOrFail();
expect($copy->id)->not->toBe($source->id)
->and($copy->ingredient_id)->toBe($ingredient->id)
->and($copy->supplier_id)->toBe($supplier->id)
->and((float) $copy->price_per_kg)->toBe(10.0)
->and((float) $copy->price_per_kg_gross)->toBe(11.9)
->and($copy->quality_id)->toBe($quality->id)
->and($copy->batch_number)->toBeNull()
->and($copy->best_before)->toBeNull()
->and($copy->received_quantity)->toBeNull()
->and($copy->ordered_by)->toBe($admin->id);
});
test('copy is blocked for non admin', function () {
$admin = stockPriceAdmin();
$source = StockEntry::factory()->create(['ordered_by' => $admin->id]);
$copyreader = User::query()->create([
'email' => uniqid('cr_', true).'@test.example',
'password' => bcrypt('password'),
]);
$copyreader->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
$this->actingAs($copyreader->fresh(), 'user');
$this->get(route('admin.inventory.stock-entries.copy', $source))
->assertRedirect(route('home'));
});

View file

@ -0,0 +1,114 @@
<?php
use App\Models\Ingredient;
use App\Models\PackagingItem;
use App\Models\Supplier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function supplierDetailsUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('det_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
test('supplier show displays assigned ingredients and packaging items', function () {
$supplier = Supplier::factory()->create(['name' => 'Detail-Lieferant']);
$ingredient = Ingredient::query()->create(['name' => 'Sheabutter', 'active' => true]);
$supplier->ingredients()->attach($ingredient->id);
$item = PackagingItem::factory()->create(['name' => 'Braunglas 50ml', 'supplier_id' => $supplier->id]);
$this->actingAs(supplierDetailsUser(7), 'user');
$this->get(route('admin.inventory.suppliers.show', $supplier))
->assertSuccessful()
->assertSee('Detail-Lieferant')
->assertSee('Sheabutter')
->assertSee('Braunglas 50ml')
->assertSee('Zugeordnete INCIs')
->assertSee('Zugeordnete Verpackungsartikel');
});
test('ingredient can be attached to supplier from details', function () {
$supplier = Supplier::factory()->create();
$ingredient = Ingredient::query()->create(['name' => 'Lavendelöl', 'active' => true]);
$this->actingAs(supplierDetailsUser(7), 'user');
$this->post(route('admin.inventory.suppliers.ingredients.attach', $supplier), [
'ingredient_id' => $ingredient->id,
])->assertSuccessful()->assertSee('Lavendelöl');
expect($supplier->ingredients()->pluck('ingredients.id')->all())->toContain($ingredient->id);
});
test('ingredient can be detached from supplier', function () {
$supplier = Supplier::factory()->create();
$ingredient = Ingredient::query()->create(['name' => 'Jojobaöl', 'active' => true]);
$supplier->ingredients()->attach($ingredient->id);
$this->actingAs(supplierDetailsUser(7), 'user');
$this->delete(route('admin.inventory.suppliers.ingredients.detach', [$supplier, $ingredient]))
->assertSuccessful();
expect($supplier->ingredients()->count())->toBe(0);
});
test('packaging item can be attached to supplier from details', function () {
$supplier = Supplier::factory()->create();
$item = PackagingItem::factory()->create(['name' => 'Pumpspender', 'supplier_id' => null]);
$this->actingAs(supplierDetailsUser(7), 'user');
$this->post(route('admin.inventory.suppliers.packaging-items.attach', $supplier), [
'packaging_item_id' => $item->id,
])->assertSuccessful()->assertSee('Pumpspender');
expect($item->fresh()->supplier_id)->toBe($supplier->id);
});
test('packaging item can be detached from supplier', function () {
$supplier = Supplier::factory()->create();
$item = PackagingItem::factory()->create(['name' => 'Faltschachtel', 'supplier_id' => $supplier->id]);
$this->actingAs(supplierDetailsUser(7), 'user');
$this->delete(route('admin.inventory.suppliers.packaging-items.detach', [$supplier, $item]))
->assertSuccessful();
expect($item->fresh()->supplier_id)->toBeNull();
});
test('attach ingredient validates ingredient exists', function () {
$supplier = Supplier::factory()->create();
$this->actingAs(supplierDetailsUser(7), 'user');
$this->post(route('admin.inventory.suppliers.ingredients.attach', $supplier), [
'ingredient_id' => 999999,
])->assertSessionHasErrors('ingredient_id');
});
test('supplier details require admin level', function () {
$supplier = Supplier::factory()->create();
$this->actingAs(supplierDetailsUser(1), 'user');
$this->get(route('admin.inventory.suppliers.show', $supplier))
->assertRedirect();
});

View file

@ -0,0 +1,176 @@
<?php
use App\Models\Country;
use App\Models\DeliveryTime;
use App\Models\Supplier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function supplierOrderUser(int $adminLevel): User
{
$user = User::query()->create([
'email' => uniqid('sup_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function supplierOrderCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
test('supplier form shows order fields and active delivery time templates', function () {
supplierOrderCountry();
DeliveryTime::query()->create(['label' => '35 Werktage', 'active' => true, 'pos' => 0]);
DeliveryTime::query()->create(['label' => 'Inaktiv-Vorlage', 'active' => false, 'pos' => 1]);
$this->actingAs(supplierOrderUser(7), 'user');
$this->get(route('admin.inventory.suppliers.create'))
->assertSuccessful()
->assertSee('order_method')
->assertSee('delivery_time')
->assertSee('35 Werktage')
->assertDontSee('Inaktiv-Vorlage');
});
test('supplier can be stored with email order method', function () {
$country = supplierOrderCountry();
$this->actingAs(supplierOrderUser(7), 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Mail-Lieferant',
'country_id' => (string) $country->id,
'active' => '1',
'order_method' => 'email',
'order_email' => 'bestellung@example.test',
'delivery_time' => '35 Werktage',
'delivery_time_days' => '5',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier = Supplier::query()->where('name', 'Mail-Lieferant')->firstOrFail();
expect($supplier->order_method)->toBe('email')
->and($supplier->order_email)->toBe('bestellung@example.test')
->and($supplier->delivery_time)->toBe('35 Werktage')
->and($supplier->delivery_time_days)->toBe(5);
});
test('supplier delivery time days must be an integer', function () {
$country = supplierOrderCountry();
$this->actingAs(supplierOrderUser(7), 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Falsche-Tage',
'country_id' => (string) $country->id,
'active' => '1',
'delivery_time_days' => 'viele',
])->assertSessionHasErrors('delivery_time_days');
});
test('supplier form exposes delivery time template days for autofill', function () {
supplierOrderCountry();
DeliveryTime::query()->create(['label' => '35 Werktage', 'days' => 5, 'active' => true, 'pos' => 0]);
$this->actingAs(supplierOrderUser(7), 'user');
$this->get(route('admin.inventory.suppliers.create'))
->assertSuccessful()
->assertSee('data-days="5"', false)
->assertSee('delivery_time_days');
});
test('supplier can be stored with online shop order method', function () {
$country = supplierOrderCountry();
$this->actingAs(supplierOrderUser(7), 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Shop-Lieferant',
'country_id' => (string) $country->id,
'active' => '1',
'order_method' => 'online_shop',
'order_url' => 'https://shop.example.test/order?id=5&ref=abc',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier = Supplier::query()->where('name', 'Shop-Lieferant')->firstOrFail();
expect($supplier->order_method)->toBe('online_shop')
->and($supplier->order_url)->toBe('https://shop.example.test/order?id=5&ref=abc');
});
test('supplier order fields can be updated', function () {
$country = supplierOrderCountry();
$supplier = Supplier::query()->create([
'name' => 'Update-Lieferant',
'country_id' => $country->id,
'active' => true,
'order_method' => 'email',
'order_email' => 'alt@example.test',
]);
$this->actingAs(supplierOrderUser(7), 'user');
$this->put(route('admin.inventory.suppliers.update', $supplier), [
'name' => 'Update-Lieferant',
'country_id' => (string) $country->id,
'active' => '1',
'order_method' => 'online_shop',
'order_url' => 'https://neu.example.test',
'delivery_time' => '12 Wochen',
])->assertRedirect(route('admin.inventory.suppliers.index'));
$supplier->refresh();
expect($supplier->order_method)->toBe('online_shop')
->and($supplier->order_url)->toBe('https://neu.example.test')
->and($supplier->delivery_time)->toBe('12 Wochen');
});
test('supplier order method rejects invalid value', function () {
$country = supplierOrderCountry();
$this->actingAs(supplierOrderUser(7), 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Falscher-Bestellweg',
'country_id' => (string) $country->id,
'active' => '1',
'order_method' => 'fax',
])->assertSessionHasErrors('order_method');
});
test('supplier order email must be valid email', function () {
$country = supplierOrderCountry();
$this->actingAs(supplierOrderUser(7), 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'Falsche-Mail',
'country_id' => (string) $country->id,
'active' => '1',
'order_method' => 'email',
'order_email' => 'keine-mail',
])->assertSessionHasErrors('order_email');
});

View file

@ -0,0 +1,111 @@
<?php
use App\Models\TaxRate;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function taxRateUser(int $adminLevel = 8): User
{
$user = User::query()->create([
'email' => uniqid('tax_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
test('allgemein-seite rendert und zeigt umsatzsteuersaetze', function () {
$this->actingAs(taxRateUser(8), 'user');
TaxRate::create(['name' => 'Standard', 'percent' => 19.00, 'active' => true, 'pos' => 0]);
$response = $this->get(route('admin.inventory.general'));
$response->assertSuccessful();
$response->assertSee('Umsatzsteuersätze', false);
$response->assertSee('Standard');
$response->assertSee(route('admin.inventory.tax-rates.create'), false);
});
test('superadmin legt umsatzsteuersatz an', function () {
$this->actingAs(taxRateUser(8), 'user');
$this->get(route('admin.inventory.tax-rates.create'))->assertSuccessful();
$this->post(route('admin.inventory.tax-rates.store'), [
'name' => 'Standard',
'percent' => '19',
'active' => '1',
'pos' => 0,
])->assertRedirect(route('admin.inventory.general'));
$taxRate = TaxRate::query()->where('name', 'Standard')->firstOrFail();
expect((float) $taxRate->percent)->toBe(19.00);
expect($taxRate->active)->toBeTrue();
});
test('umsatzsteuersatz kann bearbeitet und deaktiviert werden', function () {
$this->actingAs(taxRateUser(8), 'user');
$taxRate = TaxRate::create(['name' => 'Ermäßigt', 'percent' => 7.00, 'active' => true, 'pos' => 1]);
$this->get(route('admin.inventory.tax-rates.edit', $taxRate))->assertSuccessful();
$this->put(route('admin.inventory.tax-rates.update', $taxRate), [
'name' => 'Ermäßigt (alt)',
'percent' => '7',
'pos' => 1,
])->assertRedirect(route('admin.inventory.general'));
$taxRate->refresh();
expect($taxRate->name)->toBe('Ermäßigt (alt)');
expect($taxRate->active)->toBeFalse();
});
test('umsatzsteuersatz kann geloescht werden', function () {
$this->actingAs(taxRateUser(8), 'user');
$taxRate = TaxRate::create(['name' => 'Steuerfrei', 'percent' => 0.00, 'active' => true, 'pos' => 2]);
$this->delete(route('admin.inventory.tax-rates.destroy', $taxRate))
->assertRedirect(route('admin.inventory.general'));
expect(TaxRate::query()->whereKey($taxRate->id)->exists())->toBeFalse();
});
test('prozentsatz ist pflicht und muss numerisch sein', function () {
$this->actingAs(taxRateUser(8), 'user');
$this->post(route('admin.inventory.tax-rates.store'), [
'name' => 'Ohne Satz',
'percent' => '',
])->assertSessionHasErrors('percent');
$this->post(route('admin.inventory.tax-rates.store'), [
'name' => 'Zu hoch',
'percent' => '150',
])->assertSessionHasErrors('percent');
});
test('nur aktive saetze ueber active-scope', function () {
TaxRate::create(['name' => 'Aktiv', 'percent' => 19.00, 'active' => true, 'pos' => 0]);
TaxRate::create(['name' => 'Inaktiv', 'percent' => 16.00, 'active' => false, 'pos' => 1]);
expect(TaxRate::query()->active()->pluck('name')->all())->toBe(['Aktiv']);
});
test('nicht-superadmin hat keinen zugriff auf allgemein-seite', function () {
$this->actingAs(taxRateUser(7), 'user');
$this->get(route('admin.inventory.general'))->assertRedirect('/home');
});