gruene-seele/tests/Feature/ProductionScalingTest.php
Kevin Adametz 4c6a57b0f1 Warenwirtschaft: AP-40 + AP-41 — Bestand/Differenz-Spalten, Ablage, Verpackung oben, Phasen-Gramm
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>
2026-08-28 08:27:43 +00:00

158 lines
7.3 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\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);
});