gruene-seele/tests/Feature/ProductionScalingTest.php
Kevin Adametz 73512897ee Warenwirtschaft: F-02 — getrennter Produktkatalog für Ergebniszeilen
Planung oben bleibt auf produktionsrelevante Produkte gefiltert (AP-47);
Ergebniszeilen bieten alle aktiven Einzelprodukte plus bereits gespeicherte
Output-Produkte an (Bearbeiten/Kopieren verliert keine Zuordnung mehr).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:50:24 +00:00

264 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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\Production;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* AP-35: Rezeptur ist in Prozent; die Sollmenge skaliert auf das Füllgewicht.
* AP-39: Abwiegen darf ±5 % vom Soll abweichen (realer Ist-Wert wird gebucht).
* Helfer sind mit ps* präfixiert, um Kollisionen mit anderen Testdateien zu meiden.
*/
function psUser(): User
{
$user = User::query()->create([
'email' => uniqid('ps_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
function psIngredient(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> $attrs
*/
function psProduct(array $attrs, Ingredient $ing, float $gram = 100, float $factor = 1.0): Product
{
$product = Product::query()->create(array_merge(['name' => uniqid('PS-', true), 'title' => 'PS', 'active' => true], $attrs));
ProductIngredient::create([
'product_id' => $product->id, 'ingredient_id' => $ing->id,
'pos' => 0, 'gram' => $gram, 'factor' => $factor, 'recipe_type' => 'manufacturer',
]);
return $product->fresh('manufacturer_ingredients');
}
test('AP-35: 100%-rezeptur skaliert auf die füllmenge eines ml-produkts', function () {
$ing = psIngredient('PS-Sheabutter');
// 50 ml (unit=1) => 50 g Basis, 100 % Anteil, Faktor 1 => 50 g je Stück.
$product = psProduct(['contents_total' => 50, 'unit' => 1], $ing);
$required = app(ProductionService::class)->requiredGramsByIngredient($product, 1);
expect($required[$ing->id])->toBe(50.0);
// 3 Stück => 150 g
$required3 = app(ProductionService::class)->requiredGramsByIngredient($product, 3);
expect($required3[$ing->id])->toBe(150.0);
});
test('AP-35: explizites füllgewicht hat vorrang vor der ml-menge', function () {
$ing = psIngredient('PS-Balm');
// 50 ml aber real 60 g Füllgewicht (dichter als Wasser) => 60 g Basis.
$product = psProduct(['contents_total' => 50, 'unit' => 1, 'fill_weight' => 60], $ing);
$required = app(ProductionService::class)->requiredGramsByIngredient($product, 1);
expect($required[$ing->id])->toBe(60.0);
});
test('AP-35: ohne massenbasis (stück-produkt) gilt das altverhalten anteil=gramm', function () {
$ing = psIngredient('PS-Kapsel');
// unit=2 (Stück) ohne fill_weight => keine Massenbasis => Faktor 1,0.
$product = psProduct(['contents_total' => 54, 'unit' => 2], $ing, gram: 30, factor: 1.0);
$required = app(ProductionService::class)->requiredGramsByIngredient($product, 2);
expect($required[$ing->id])->toBe(60.0); // 30 × 1 × 2 (unskaliert)
});
test('AP-39: abwiegen darf bis 5 % vom soll abweichen und wird gebucht', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-Tol');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
StockEntry::query()->create([
'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' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15',
'received_quantity' => 1000, 'batch_number' => 'PS-B1', 'best_before' => '2030-12-31',
]);
// 100 ml, 100 % => Soll 100 g je Stück.
$product = psProduct(['contents_total' => 100, 'unit' => 1], $ing);
$stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id');
// Ist 103 g (±3 %) => darf gespeichert werden, realer Wert wird gebucht.
$this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1,
'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '103']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasNoErrors();
expect(Production::query()->count())->toBe(1)
->and((float) Production::query()->first()->productionIngredients()->sum('quantity_used'))->toBe(103.0);
});
test('AP-39: abweichung über 5 % wird abgelehnt (zahlendreher-schutz)', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-TolBad');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
StockEntry::query()->create([
'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' => 1000, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15',
'received_quantity' => 1000, 'batch_number' => 'PS-B2', 'best_before' => '2030-12-31',
]);
$product = psProduct(['contents_total' => 100, 'unit' => 1], $ing);
$stockId = StockEntry::query()->where('ingredient_id', $ing->id)->value('id');
// Ist 120 g (+20 %) => Ablehnung, keine Produktion.
$this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1,
'ingredient_lines' => [['ingredient_id' => $ing->id, 'stock_entry_id' => $stockId, 'quantity_used' => '120']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasErrors('ingredient_lines');
expect(Production::query()->count())->toBe(0);
});
test('AP-40: recipeJson-payload enthält aktuellen bestand je INCI (für differenz)', function () {
$user = psUser();
$ing = psIngredient('PS-Bestand');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
StockEntry::query()->create([
'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' => 800, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15',
'received_quantity' => 800, 'batch_number' => 'PS-BST', 'best_before' => '2030-12-31',
]);
// 100 ml, 100 % => Soll 100 g/Stück; 10 Stück => 1000 g Bedarf, Bestand 800 g => Differenz -200 g.
$product = psProduct(['contents_total' => 100, 'unit' => 1], $ing);
$payload = app(ProductionService::class)->buildRecipePayload($product, $location->id, 10);
$line = collect($payload['ingredients'])->firstWhere('id', $ing->id);
expect($line['current_stock'])->toBe(800.0)
->and($line['required_grams_total'])->toBe(1000.0);
});
test('AP-45: verpackung wird je ergebnisprodukt aus dem produktionsergebnis gebucht', function () {
$user = psUser();
$supplier = Supplier::factory()->create();
$location = Location::factory()->create();
$material = PackagingMaterial::factory()->create();
$pkA = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-A', 'category' => 'packaging']);
$pkB = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-B', 'category' => 'packaging']);
// Beide Ergebnisprodukte ohne Rezepturpflicht (kein Rohstoff-Abwiegen nötig).
$planned = Product::query()->create(['name' => uniqid('PS-Plan-', true), 'title' => 'x', 'active' => true, 'no_recipe_required' => true]);
$planned->packagings()->sync([$pkA->id => ['quantity' => 1, 'pos' => 0]]);
$other = Product::query()->create(['name' => uniqid('PS-Other-', true), 'title' => 'y', 'active' => true, 'no_recipe_required' => true]);
$other->packagings()->sync([$pkB->id => ['quantity' => 2, 'pos' => 0]]);
// Eine Charge, Ergebnis: 40× Plan (Tiegel A) + 50× Other (Tiegel B).
$production = app(ProductionService::class)->store([
'product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 40,
], [], $user->id, [
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'PS-A1'],
['product_id' => $other->id, 'quantity' => 50, 'batch_number' => 'PS-B1'],
]);
$byItem = $production->productionPackagings->keyBy('packaging_item_id');
expect($byItem[$pkA->id]->quantity_used)->toBe(40) // 1 × 40 (Ergebnis Plan)
->and($byItem[$pkB->id]->quantity_used)->toBe(100); // 2 × 50 (Ergebnis Other)
});
test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-Rel');
psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
$html = $this->get(route('admin.inventory.productions.create'))
->assertSuccessful()
->assertSee('PS47-Sichtbar')
->getContent();
// Planung oben: nur produktionsrelevante Produkte.
expect(psSelectHtml($html, 'id="product_id"'))->toContain('PS47-Sichtbar')->not->toContain('PS47-Versteckt')
// F-02: Ergebniszeilen: auch nicht-produktionsrelevante Produkte (5 ml aus der 50-ml-Masse).
->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS47-Sichtbar')->toContain('PS47-Versteckt');
});
test('F-02: bearbeiten behält ein nicht mehr produktionsrelevantes ergebnisprodukt und speichert unverändert', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-F02');
$big = psProduct(['name' => 'PS-F02-50ml', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
$small = psProduct(['name' => 'PS-F02-5ml', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
$location = Location::factory()->create();
$production = app(ProductionService::class)->store(
['product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[
['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'],
]
);
$html = $this->get(route('admin.inventory.productions.edit', $production))->assertSuccessful()->getContent();
expect(psSelectHtml($html, 'id="product_id"'))->not->toContain('PS-F02-5ml')
->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS-F02-5ml');
$this->put(route('admin.inventory.productions.update', $production), [
'product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50,
'outputs' => [
['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'],
],
])->assertSessionHasNoErrors();
expect($production->fresh()->outputs()->where('product_id', $small->id)->value('quantity'))->toBe(50);
});
/**
* Liefert das HTML eines <select>, identifiziert über ein eindeutiges Attribut im Start-Tag.
*/
function psSelectHtml(string $html, string $attribute): string
{
$start = strpos($html, $attribute);
expect($start)->not->toBeFalse();
$end = strpos($html, '</select>', $start);
return substr($html, $start, $end - $start);
}
test('AP-47: enthalten-in blendet nicht-produktionsrelevante produkte aus', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-EnthaltenRel');
psProduct(['name' => 'PS47-EnthaltenSichtbar', 'production_relevant' => true], $ing);
psProduct(['name' => 'PS47-EnthaltenVersteckt', 'production_relevant' => false], $ing);
$this->get(route('admin.inventory.raw-material-stock.show', $ing))
->assertSuccessful()
->assertSee('PS47-EnthaltenSichtbar')
->assertDontSee('PS47-EnthaltenVersteckt');
});