gruene-seele/tests/Feature/ProductionDraftTest.php
Kevin Adametz bdaac3b8fb Warenwirtschaft: R-02 — leerer historischer Verpackungs-Snapshot gilt in der Vorschau
Ob der Snapshot gilt, entscheidet der Buchungsstatus (nicht die Zeilenzahl);
ohne Verpackung gebuchte Produktionen zeigen „Kein Verpackungsverbrauch gebucht“.
Tests R-01/R-02.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 07:28:06 +00:00

668 lines
36 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\Country;
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\RecipePhase;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Repositories\ProductRepository;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Laravel\Passport\Passport;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function draftCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]
);
}
function draftUser(int $adminLevel = 7): User
{
draftCountry();
$user = User::query()->create(['email' => uniqid('draft_', true).'@test.example', 'password' => bcrypt('password')]);
$user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
function draftRecipeProduct(Ingredient $ing): Product
{
$product = Product::query()->create(['name' => 'Draft-Produkt', 'title' => 'Draft-Produkt', 'active' => true]);
// Hersteller-Rezeptur: 10 g * 1.0 * 1 Stück = 10 g Soll
ProductIngredient::query()->create([
'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0,
'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer',
]);
return $product;
}
function draftIngredient(): Ingredient
{
return Ingredient::query()->create([
'name' => 'Draft-Roh', 'trans_name' => '', 'inci' => 'DR', 'trans_inci' => '',
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
]);
}
function draftStock(Ingredient $ing, Location $location, Supplier $supplier, User $user): StockEntry
{
return StockEntry::query()->create([
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'packaging_item_id' => null,
'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram',
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000,
'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id,
'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-B',
'best_before' => '2030-12-31', 'quality_id' => null,
]);
}
test('AP-43: entwurf bucht keinen produktbestand und bleibt status draft', function () {
$user = draftUser();
$location = Location::factory()->create();
$product = Product::query()->create(['name' => 'Draft-Simpel', 'title' => 'Draft-Simpel', 'active' => true, 'no_recipe_required' => true]);
$production = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20, 'notes' => 'WIP'],
[],
$user->id,
[['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]],
true // draft
);
expect($production->isDraft())->toBeTrue()
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
});
test('AP-43: entwurf reduziert den rohstoffbestand nicht', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
$before = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
true // draft
);
$after = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
expect((float) $after)->toBe((float) $before); // Entwurf verbraucht nichts
});
test('AP-43: entwurf abschließen bucht bestand und wird completed', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
$service = app(ProductionService::class);
$draft = $service->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
true
);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
$completed = $service->updateProduction(
$draft->fresh(),
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
false // abschließen
);
expect($completed->isDraft())->toBeFalse()
->and($completed->status)->toBe(Production::STATUS_COMPLETED)
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(1);
// Jetzt zählt der Rohstoffverbrauch: 1000 - 10 = 990
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
expect((float) $remaining)->toBe(990.0);
});
test('AP-43: abgeschlossene produktion kann nicht zum entwurf zurück', function () {
$user = draftUser();
$location = Location::factory()->create();
$product = Product::query()->create(['name' => 'Draft-Done', 'title' => 'Draft-Done', 'active' => true, 'no_recipe_required' => true]);
$completed = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
[],
$user->id,
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
false
);
expect(fn () => app(ProductionService::class)->updateProduction(
$completed->fresh(),
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
[],
$user->id,
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
true // Rück-Entwurf verboten
))->toThrow(ValidationException::class);
});
test('AP-43/44: neue-produktion-maske zeigt abschließen + zwischenspeichern und die aufgehübschte rezeptur-optik', function () {
$user = draftUser();
Location::factory()->create();
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.productions.create'))
->assertOk()
// AP-43: umbenannter Abschluss-Button + Entwurf-Button
->assertSee('Produktion abschließen')
->assertSee('Zwischenspeichern (Entwurf)')
->assertSee('name="save_mode"', false)
// AP-44: Optik-Hooks (Phasen-Beschreibung formatiert, kein innerer Kasten, iPad-Felder)
->assertSee('recipe-phase-note', false)
->assertSee('recipe-item', false)
->assertSee('pointer: coarse', false);
});
test('AP-43: HTTP zwischenspeichern ohne ergebnis speichert entwurf; abschließen erzwingt ergebnis', function () {
$user = draftUser();
$location = Location::factory()->create();
$product = Product::query()->create(['name' => 'Draft-HTTP', 'title' => 'Draft-HTTP', 'active' => true, 'no_recipe_required' => true]);
Passport::actingAs($user);
$this->actingAs($user, 'user');
// Entwurf: kein Produktionsergebnis nötig → wird gespeichert (Redirect zur Bearbeitung)
$this->post(route('admin.inventory.productions.store'), [
'save_mode' => 'draft',
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-08-01',
'quantity' => 3,
])->assertRedirect();
$draft = Production::query()->where('product_id', $product->id)->sole();
expect($draft->isDraft())->toBeTrue();
// Abschließen ohne outputs → Validierungsfehler (outputs required)
$this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), [
'save_mode' => 'complete',
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-08-01',
'quantity' => 3,
])->assertSessionHasErrors('outputs');
});
test('F-01: entwurf reduziert weder chargen- noch standortbestand und blockiert keine andere produktion', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
$draft = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
true // draft
);
$productionService = app(ProductionService::class);
$inventory = app(InventoryService::class);
// Chargenverbrauch ohne Ausschluss der eigenen Produktion — wie ihn andere Produktionen/Ausschuss sehen
expect($productionService->consumedByStockEntry([$stock->id]))->toBe([])
->and((float) $productionService->availableStockEntriesForIngredient($ing->id, $location->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0)
->and((float) $inventory->chargesWithRemainingForIngredient($ing->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0)
->and((float) ($inventory->remainingByLocationForIngredient($ing->id)[$location->id] ?? 0.0))->toBe(1000.0);
// Eine zweite Produktion kann die komplette Charge trotz Entwurf buchen
$completed = $productionService->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-02', 'quantity' => 100, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '1000']],
$user->id,
[['product_id' => $product->id, 'quantity' => 100, 'batch_number' => null]]
);
expect($completed->isDraft())->toBeFalse()
->and($productionService->consumedByStockEntry([$stock->id])[$stock->id])->toBe(1000.0)
->and($productionService->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0);
// Ausschuss mit Charge: Entwurf darf die zulässige Menge nicht verkleinern (vor Abschluss der zweiten Produktion wäre 1000 g erlaubt)
$completed->delete();
$this->actingAs($user, 'user')
->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id,
'location_id' => $location->id, 'quantity' => '1000', 'disposed_at' => '2026-08-03', 'reason' => 'Test',
])
->assertSessionHasNoErrors();
});
test('F-03: entwurf sichert alle chargenzeilen roh, auch eine nicht verfügbare, und löst erst beim abschluss auf', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
$validKey = $stock->batchGroupKey();
$missingKey = 'b:'.$supplier->id.':GIBT-ES-NICHT';
Passport::actingAs($user);
$this->actingAs($user, 'user');
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
$this->post(route('admin.inventory.productions.store'), $head + [
'save_mode' => 'draft',
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10'],
['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => ''],
],
])->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
$lines = $draft->productionIngredients()->orderBy('id')->get();
expect($draft->isDraft())->toBeTrue()
->and($lines)->toHaveCount(2)
->and($lines[0]->stock_entry_id)->toBeNull()
->and($lines[0]->batch_key)->toBe($validKey)
->and((float) $lines[0]->quantity_used)->toBe(10.0)
->and($lines[1]->batch_key)->toBe($missingKey)
->and((float) $lines[1]->quantity_used)->toBe(0.0);
// Wiederöffnen: beide Chargen-Schlüssel werden ins Formular gegeben.
$this->get(route('admin.inventory.productions.edit', $draft))
->assertOk()
->assertSee($validKey)
->assertSee($missingKey);
// Abschluss mit der nicht verfügbaren Charge wird abgelehnt — nichts geht verloren.
$this->from(route('admin.inventory.productions.edit', $draft))
->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => '10']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasErrors('ingredient_lines');
expect($draft->fresh()->productionIngredients()->count())->toBe(2);
// Abschluss mit der gültigen Charge löst auf den Wareneingang auf und bucht.
$this->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasNoErrors();
$done = $draft->fresh();
expect($done->isDraft())->toBeFalse()
->and($done->productionIngredients()->count())->toBe(1)
->and($done->productionIngredients()->first()->stock_entry_id)->toBe($stock->id)
->and($done->productionIngredients()->first()->batch_key)->toBeNull()
->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0);
});
test('F-03: entwurf mit gewähltem ergebnisprodukt ohne stückzahl wird gespeichert', function () {
$user = draftUser();
$location = Location::factory()->create();
$product = Product::query()->create(['name' => 'Draft-Out0', 'title' => 'Draft-Out0', 'active' => true, 'no_recipe_required' => true]);
Passport::actingAs($user);
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.productions.store'), [
'save_mode' => 'draft',
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3,
'outputs' => [['product_id' => $product->id, 'quantity' => 0, 'batch_number' => 'VORLAEUFIG']],
])->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
expect($draft->outputs()->count())->toBe(1)
->and($draft->outputs()->first()->quantity)->toBe(0)
->and($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG')
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
});
test('F-04: derselbe rohstoff in zwei phasen behält beim wiederöffnen je phase seinen anteil und lässt sich unverändert abschließen', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$stock = draftStock($ing, $location, $supplier, $user);
$product = Product::query()->create(['name' => 'Draft-Phasen', 'title' => 'Draft-Phasen', 'active' => true]);
$phaseA = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase A', 'pos' => 0]);
$phaseB = RecipePhase::query()->create(['product_id' => $product->id, 'name' => 'Phase B', 'pos' => 1]);
ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseA->id]);
ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer', 'recipe_phase_id' => $phaseB->id]);
Passport::actingAs($user);
$this->actingAs($user, 'user');
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
$lines = [
['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'],
['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'],
];
$this->post(route('admin.inventory.productions.store'), $head + ['save_mode' => 'draft', 'ingredient_lines' => $lines])
->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
$html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent();
$existing = json_decode(substr($html, strpos($html, 'var existingLines = ') + strlen('var existingLines = '), strpos($html, ';', strpos($html, 'var existingLines = ')) - strpos($html, 'var existingLines = ') - strlen('var existingLines = ')), true);
expect($existing)->toHaveKeys([$ing->id.'|'.$phaseA->id, $ing->id.'|'.$phaseB->id])
->and($existing[$ing->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6)
->and($existing[$ing->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4);
// Unverändert abschließen: Summe 10 g = Soll 10 g, je Phase bleibt der Anteil erhalten.
$this->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete', 'ingredient_lines' => $lines,
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasNoErrors();
$done = $draft->fresh();
expect($done->isDraft())->toBeFalse()
->and($done->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6)
->and($done->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4)
->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0);
});
test('F-06: nach einem validierungsfehler bleiben die eingegebenen chargenzeilen im formular erhalten', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing); // Soll 10 g
$stock = draftStock($ing, $location, $supplier, $user);
$key = $stock->batchGroupKey();
Passport::actingAs($user);
$this->actingAs($user, 'user');
// 20 g statt 10 g → Toleranzfehler beim Abschluss, Redirect zurück mit old().
$this->from(route('admin.inventory.productions.create'))
->post(route('admin.inventory.productions.store'), [
'save_mode' => 'complete',
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1,
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $key, 'quantity_used' => '20,0']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])
->assertRedirect(route('admin.inventory.productions.create'))
->assertSessionHasErrors('ingredient_lines');
$html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
$start = strpos($html, 'var existingLines = ') + strlen('var existingLines = ');
$existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true);
expect($existing)->toHaveKey($ing->id.'|')
->and($existing[$ing->id.'|'][0]['batch_key'])->toBe($key)
->and($existing[$ing->id.'|'][0]['quantity_used'])->toBe('20,0');
});
test('F-18: mehrere zwischenspeichern-buttons und touch-regeln für das notizfeld; keine entwurfs-buttons bei gebuchter produktion', function () {
$user = draftUser();
$location = Location::factory()->create();
$this->actingAs($user, 'user');
$create = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
expect(substr_count($create, 'btn-save-draft'))->toBeGreaterThanOrEqual(3)
->and($create)->toContain('#notes {')->toContain('autoGrowNotes');
$product = Product::query()->create(['name' => 'Draft-F18', 'title' => 'Draft-F18', 'active' => true, 'no_recipe_required' => true]);
$done = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]
);
$edit = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent();
expect(substr_count($edit, 'btn-save-draft'))->toBe(0);
});
test('N-01: ist-menge ohne gewählte charge bleibt im entwurf erhalten und wird beim wiederöffnen vorbelegt', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$product = draftRecipeProduct($ing);
$stock = draftStock($ing, $location, $supplier, $user);
Passport::actingAs($user);
$this->actingAs($user, 'user');
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
$this->post(route('admin.inventory.productions.store'), $head + [
'save_mode' => 'draft',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => '', 'quantity_used' => '9,8']],
])->assertRedirect()->assertSessionHasNoErrors();
$draft = Production::query()->where('product_id', $product->id)->sole();
$line = $draft->productionIngredients()->sole();
expect((float) $line->quantity_used)->toBe(9.8)
->and($line->stock_entry_id)->toBeNull()
->and($line->batch_key)->toBeNull();
$html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent();
$start = strpos($html, 'var existingLines = ') + strlen('var existingLines = ');
$existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true);
expect($existing[$ing->id.'|'][0]['batch_key'])->toBe('')
->and((float) $existing[$ing->id.'|'][0]['quantity_used'])->toBe(9.8);
// Abschluss ohne Charge scheitert (Charge ist Pflicht), mit Charge klappt es.
$this->from(route('admin.inventory.productions.edit', $draft))->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => '', 'quantity_used' => '9,8']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasErrors();
$this->put(route('admin.inventory.productions.update', $draft), $head + [
'save_mode' => 'complete',
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '9,8']],
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
])->assertSessionHasNoErrors();
expect($draft->fresh()->isDraft())->toBeFalse();
});
test('N-02: unverändertes artikelspeichern erhält die phasenzuordnung der produktionszeilen; doppelter rohstoff je phase wird abgelehnt', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = draftIngredient();
$stock = draftStock($ing, $location, $supplier, $user);
$product = Product::query()->create(['name' => 'Draft-N02', 'title' => 'Draft-N02', 'active' => true]);
$repo = new ProductRepository($product);
$recipe = [
'id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1',
'manufacturer_inci_sync_sent' => '1',
'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''],
'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '1'],
];
$repo->update($recipe);
$phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get();
expect($phases)->toHaveCount(2);
[$phaseA, $phaseB] = [$phases[0], $phases[1]];
$draft = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'],
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'],
],
$user->id, [], true
);
// Artikel unverändert erneut speichern (wie das Produktformular) → Phasen-IDs bleiben.
(new ProductRepository($product->fresh()))->update($recipe);
$after = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->pluck('id')->all();
expect($after)->toBe([$phaseA->id, $phaseB->id])
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6)
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4);
// Umbenennen + zusätzliche Phase: bestehende Phasen bleiben, neue kommt dazu.
(new ProductRepository($product->fresh()))->update(array_merge($recipe, [
'mfg_phase_name' => ['Phase A', 'Phase B neu', 'Phase C'], 'mfg_phase_note' => ['', '', ''],
'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_row_phase' => ['0', '1'],
]));
expect(RecipePhase::query()->where('product_id', $product->id)->count())->toBe(3)
->and($phaseB->fresh()->name)->toBe('Phase B neu')
->and($draft->productionIngredients()->whereNull('recipe_phase_id')->count())->toBe(0);
// Derselbe Rohstoff zweimal in derselben Phase wird abgelehnt.
expect(fn () => (new ProductRepository($product->fresh()))->update(array_merge($recipe, [
'mfg_ingredient_id' => [$ing->id, $ing->id], 'mfg_row_phase' => ['0', '0'],
])))->toThrow(ValidationException::class);
});
test('N-04: bearbeitungsformular einer gebuchten produktion liefert den gespeicherten verpackungs-snapshot und die ergebnissummen', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$material = PackagingMaterial::factory()->create();
$pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'N04-Tiegel', 'category' => 'packaging']);
$product = Product::query()->create(['name' => 'Draft-N04', 'title' => 'Draft-N04', 'active' => true, 'no_recipe_required' => true]);
$product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]);
$done = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]
);
$product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]);
$this->actingAs($user, 'user');
$html = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent();
$start = strpos($html, "data-packaging-snapshot='") + strlen("data-packaging-snapshot='");
$snapshot = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
$start = strpos($html, "data-initial-output-totals='") + strlen("data-initial-output-totals='");
$totals = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
expect($snapshot)->toBe([['name' => 'N04-Tiegel', 'pieces' => 1]])
->and($totals)->toBe([(string) $product->id => 1])
->and($html)->toContain('Gespeicherter Verpackungsverbrauch');
});
test('R-01: neue textphase voranstellen und bestehende phase umbenennen erhält die phasenzuordnung (mit und ohne formular-IDs)', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$x = draftIngredient();
$y = Ingredient::query()->create(['name' => 'Draft-Roh-Y', 'trans_name' => '', 'inci' => 'DRY', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0]);
$stockX = draftStock($x, $location, $supplier, $user);
$stockY = StockEntry::query()->create([
'entry_type' => 'ingredient', 'ingredient_id' => $y->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram',
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000, 'price_per_kg' => 1, 'status' => 'received',
'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-Y', 'best_before' => '2030-12-31',
]);
$product = Product::query()->create(['name' => 'Draft-R01', 'title' => 'Draft-R01', 'active' => true]);
$base = ['id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1', 'manufacturer_inci_sync_sent' => '1'];
(new ProductRepository($product))->update($base + [
'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''],
'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '1'],
]);
[$phaseA, $phaseB] = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get()->all();
$draft = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[
['ingredient_id' => $x->id, 'stock_entry_id' => $stockX->id, 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'],
['ingredient_id' => $y->id, 'stock_entry_id' => $stockY->id, 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'],
],
$user->id, [], true
);
// Formular-Weg: Textphase „Vorbereitung“ voranstellen, B umbenennen — mit stabilen IDs.
(new ProductRepository($product->fresh()))->update($base + [
'mfg_phase_id' => ['', (string) $phaseA->id, (string) $phaseB->id],
'mfg_phase_name' => ['Vorbereitung', 'Phase A', 'Phase B neu'], 'mfg_phase_note' => ['Alles bereitlegen', '', ''],
'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['1', '2'],
]);
$phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get();
expect($phases->pluck('name')->all())->toBe(['Vorbereitung', 'Phase A', 'Phase B neu'])
->and($phases[1]->id)->toBe($phaseA->id)
->and($phases[2]->id)->toBe($phaseB->id)
->and($phases[0]->id)->not->toBe($phaseB->id)
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6)
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4);
// Ohne IDs (Direktaufruf): Umordnen + erneutes Umbenennen → Zuordnung über gemeinsame Rohstoffe.
(new ProductRepository($product->fresh()))->update($base + [
'mfg_phase_name' => ['Phase B final', 'Vorbereitung', 'Phase A'], 'mfg_phase_note' => ['', 'Alles bereitlegen', ''],
'mfg_ingredient_id' => [$y->id, $x->id], 'mfg_gram' => ['4', '6'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '2'],
]);
$phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get();
expect($phases->pluck('name')->all())->toBe(['Phase B final', 'Vorbereitung', 'Phase A'])
->and($phases[0]->id)->toBe($phaseB->id)
->and($phases[2]->id)->toBe($phaseA->id)
->and($draft->productionIngredients()->whereNull('recipe_phase_id')->count())->toBe(0);
// Wiederöffnen: beide Ist-Mengen stehen in ihrer Phase.
$this->actingAs($user, 'user');
$html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent();
$start = strpos($html, 'var existingLines = ') + strlen('var existingLines = ');
$existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true);
expect($existing[$x->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6)
->and($existing[$y->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4);
// Produktformular trägt die Phasen-IDs.
$form = $this->get(route('admin_product_edit', $product->id))->assertOk()->getContent();
expect($form)->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseA->id.'"')
->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseB->id.'"');
});
test('R-02: ohne verpackung gebuchte produktion zeigt nach stücklistenänderung den leeren snapshot statt der neuen stückliste', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$material = PackagingMaterial::factory()->create();
$pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'R02-Tiegel', 'category' => 'packaging']);
$product = Product::query()->create(['name' => 'Draft-R02', 'title' => 'Draft-R02', 'active' => true, 'no_recipe_required' => true]);
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null];
$done = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]);
expect($done->productionPackagings)->toHaveCount(0);
$product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]);
$this->actingAs($user, 'user');
$html = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent();
expect($html)->toContain("data-packaging-snapshot='[]'")
->toContain("data-packaging-snapshot-applies='1'")
->toContain('Kein Verpackungsverbrauch gebucht.');
// Entwurf: kein gültiger Snapshot → Vorschau rechnet mit der Stückliste.
$draft = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true);
expect($this->get(route('admin.inventory.productions.edit', $draft))->getContent())->toContain("data-packaging-snapshot-applies='0'");
// Notiz speichern → Verbrauch bleibt 0; Mengenänderung → Neuberechnung 2 × 3.
$done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['notes' => 'x'], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]);
expect($done->productionPackagings)->toHaveCount(0);
$done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['quantity' => 3], [], $user->id, [['product_id' => $product->id, 'quantity' => 3, 'batch_number' => null]]);
expect($done->productionPackagings->sum('quantity_used'))->toBe(6);
});