gruene-seele/tests/Feature/ProductionScalingTest.php
Kevin Adametz 313c6277ce Warenwirtschaft: N-01/N-03/N-05 — Entwurfsmenge ohne Charge, Snapshot-Signatur je Produkt, Alt-Ausschuss in Chargenverfügbarkeit
- N-01: Ist-Menge ohne gewählte Charge bleibt im Entwurf erhalten (Filter in
  Request und Service, formBatchKey liefert '' für chargenlose Zeilen).
- N-03: outputSignature summiert Stückzahlen je Produkt — ein Chargensplit
  ersetzt den historischen Verpackungs-Snapshot nicht mehr.
- N-05: Ausschuss ohne Chargenbezug (Altbestand) wird für die Verfügbarkeit je
  Standort FEFO auf die Chargen angerechnet (applyUnboundDisposals, lesend);
  Chargen-Ausschussprüfung nutzt dieselbe Verfügbarkeit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:48:02 +00:00

304 lines
15 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('F-05: notizkorrektur behält den historischen verpackungs-snapshot, mengenkorrektur bildet ihn neu', function () {
$user = psUser();
$supplier = Supplier::factory()->create();
$location = Location::factory()->create();
$material = PackagingMaterial::factory()->create();
$pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-F05-Tiegel', 'category' => 'packaging']);
$product = Product::query()->create(['name' => uniqid('PS-F05-', true), 'title' => 'x', 'active' => true, 'no_recipe_required' => true]);
$product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]);
$service = app(ProductionService::class);
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
$production = $service->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05']]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(1);
// Stückliste nachträglich auf 2 Tiegel je Stück ändern.
$product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]);
// Nur die Notiz korrigieren: Snapshot bleibt bei 1.
$production = $service->updateProduction($production->fresh(), $head + ['notes' => 'Korrektur'], [], $user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05']]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(1)
->and($production->notes)->toBe('Korrektur');
// Echte Mengenkorrektur (1 → 3 Stück): Snapshot wird mit der aktuellen Stückliste neu gebildet (2 × 3).
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id,
[['product_id' => $product->id, 'quantity' => 3, 'batch_number' => 'F05']]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(6);
// N-03: Chargensplit bei gleicher Gesamtstückzahl (3 → 2 + 1) ist keine Mengenänderung — Snapshot bleibt,
// auch wenn die Stückliste inzwischen wieder anders ist.
$product->packagings()->sync([$pk->id => ['quantity' => 5, 'pos' => 0]]);
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id, [
['product_id' => $product->id, 'quantity' => 2, 'batch_number' => 'F05-A'],
['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05-B'],
]);
expect($production->productionPackagings->sum('quantity_used'))->toBe(6)
->and($production->outputs)->toHaveCount(2);
});
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');
});