445 lines
22 KiB
PHP
445 lines
22 KiB
PHP
<?php
|
|
|
|
use App\Models\Country;
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
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\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);
|
|
});
|