gruene-seele/tests/Feature/ProductionScalingTest.php
Kevin Adametz 744b36cdcb Warenwirtschaft: AP-35 + AP-39 — Produktionsmengen auf Füllgewicht + ±5 % Abwiegen
AP-35 (Mengenberechnung): Die Hersteller-Rezeptur ist in Prozent hinterlegt; die
Sollmengen skalieren jetzt auf das Füllgewicht statt fest auf ~100 g. Neuer
Faktor recipeGramScale = Füllgewicht/100. Basis = neues, optionales Produkt-Feld
"fill_weight" (g); ist es leer, gilt bei ml-Produkten (unit=1) die Füllmenge
(contents_total) als Gramm (1 g/ml). Ohne Massenbasis (z. B. Stück-Produkte)
bleibt es beim Altverhalten (Anteil = Gramm), sodass Bestandsprodukte/Tests
unverändert rechnen. Skala wird an die Maske durchgereicht (recipe_gram_scale),
damit Client und Server identisch rechnen (Service + 4 JS-Stellen).

AP-39 (Abwiegen): Die Ist-Menge je INCI muss nicht mehr aufs Gramm passen —
assertLinesMatchRecipe erlaubt ±5 % (kleiner absoluter Sockel), gebucht wird der
reale Ist-Wert. Status-Ampel in der Maske: Orange = nichts eingetragen,
Grün = eingetragen und ±5 %, Rot = >5 % daneben (Zahlendreher-Schutz).

Migration add_fill_weight_to_products auf Dev-/Live-DB ausgeführt (nullable).
Product fillable/cast + Produktformular-Feld ergänzt. 5 Tests, Suite 310 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-27 14:36:52 +00:00

137 lines
6.2 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);
});