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

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

375 lines
11 KiB
PHP

<?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');
});