AP-40: INCI-Tabelle der Produktion um zwei Spalten ergänzt — „Bestand" (aktueller Rohstoffbestand je INCI, global) und „Differenz" (Bestand − Soll; negativ rot = Fehlmenge, sonst grün). Spaltenkopf „Regal" → „Ablage". Bestand kommt aus InventoryService::remainingByIngredient und wird als current_stock in den recipeJson-Payload gehängt (lazy aufgelöst — InventoryService hängt selbst von ProductionService ab, Konstruktor-Injektion wäre zirkulär). AP-41: „Verpackung (Vorschau)" direkt unter die INCI-Tabelle verschoben (man sieht auf einen Blick den Gesamtbedarf). Je Phase die Gesamt-Gramm hinter dem Namen („Phase A (345 g)"), aktualisiert sich mit Stückzahl und Füllgewicht-Skala. Test für den current_stock-Payload ergänzt. Suite 311 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
7.3 KiB
PHP
158 lines
7.3 KiB
PHP
<?php
|
||
|
||
use App\Models\Ingredient;
|
||
use App\Models\Location;
|
||
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);
|
||
});
|