firstOrCreate(['code' => 'DE'], ['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]); $user = User::query()->create(['email' => uniqid('qp_', true).'@test.example', 'password' => bcrypt('password')]); $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); return $user->fresh(); } function qpIngredient(): Ingredient { return Ingredient::query()->create(['name' => 'QP-Konservierer', 'trans_name' => '', 'inci' => 'QP', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0]); } /** Rezeptur: 0,125 g je Stück (Soll bei 1 Stück = 0,125 g). */ function qpProduct(Ingredient $ing, float $gram = 0.125): Product { $product = Product::query()->create(['name' => 'QP-Produkt', 'title' => 'QP-Produkt', 'active' => true]); ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => $gram, 'factor' => 1.0, 'recipe_type' => 'manufacturer']); return $product; } function qpStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, float $qty = 10): 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' => $qty, 'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => $qty, 'batch_number' => 'QP-B', 'best_before' => '2030-12-31', 'quality_id' => null, ]); } test('AP-53: eingabe-parsing — komma mit drei stellen, „0.125" als dezimalzahl, „1.500" bleibt tausend', function () { expect(reFormatWawiNumber('0,125'))->toBe(0.125) ->and(reFormatWawiNumber('1,005'))->toBe(1.005) ->and(reFormatWawiNumber('0.125'))->toBe(0.125) ->and(reFormatWawiNumber('5.12'))->toBe(5.12) ->and(reFormatWawiNumber('1.500'))->toBe(1500.0) ->and(reFormatWawiNumber('1.234,567'))->toBe(1234.567) ->and(roundWawiQuantity(0.1234))->toBe(0.123) ->and(roundWawiQuantity(0.1235))->toBe(0.124); }); test('AP-53: anzeige — bis zu drei nachkommastellen, mindestens zwei, optional ganzzahlig', function () { expect(formatWawiQuantity(0.125))->toBe('0,125') ->and(formatWawiQuantity(12.5))->toBe('12,50') ->and(formatWawiQuantity(1500))->toBe('1.500,00') ->and(formatWawiQuantity(1.1204))->toBe('1,12') ->and(formatWawiQuantity(1500, 0))->toBe('1.500') ->and(formatWawiQuantity(0.125, 0))->toBe('0,125'); }); test('AP-53: produktion bucht 0,125 g exakt und der chargen-restbestand rechnet auf drei stellen', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); $product = qpProduct($ing); $stock = qpStock($ing, $location, $supplier, $user, 10); Passport::actingAs($user); $this->actingAs($user, 'user'); $this->post(route('admin.inventory.productions.store'), [ 'save_mode' => 'complete', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-09', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '0,125']], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'QP1']], ])->assertRedirect()->assertSessionHasNoErrors(); $production = Production::query()->where('product_id', $product->id)->sole(); expect((float) $production->productionIngredients()->sole()->quantity_used)->toBe(0.125) ->and(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(9.875) ->and(app(ProductionService::class)->consumedByStockEntry([$stock->id])[$stock->id])->toBe(0.125); // Detailansicht zeigt die dritte Stelle. $this->get(route('admin.inventory.productions.show', $production))->assertOk()->assertSee('0,125'); }); test('AP-53: entwurf mit 1,125 g wird beim wiederöffnen als zahl übergeben (JS formatiert deutsch, nicht „1.125")', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); $product = qpProduct($ing, 1.125); $stock = qpStock($ing, $location, $supplier, $user, 10); 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-09-09', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '1,125']], ])->assertRedirect()->assertSessionHasNoErrors(); $draft = Production::query()->where('product_id', $product->id)->sole(); $html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent(); // existingLines trägt den Wert als JSON-Zahl 1.125; fmtInput() macht daraus „1,125" im Feld. expect($html)->toContain('"quantity_used":1.125') ->toContain('function fmtInput('); }); test('AP-53: toleranz bei kleinmengen — 0,131 g auf 0,125 g soll geht durch, 0,132 g nicht', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); $product = qpProduct($ing, 0.125); $stock = qpStock($ing, $location, $supplier, $user, 10); $head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-09', 'quantity' => 1, 'notes' => null]; $outputs = [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]; // 5 % von 0,125 = 0,00625 > Sockel 0,005 → Toleranz 0,00625: 0,131 ist drin, 0,132 nicht. app(ProductionService::class)->store($head, [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '0,131']], $user->id, $outputs, false); expect(fn () => app(ProductionService::class)->store($head, [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '0,132']], $user->id, $outputs, false)) ->toThrow(ValidationException::class); }); test('AP-53: ausschuss akzeptiert milligramm und reduziert den restbestand auf drei stellen', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); qpStock($ing, $location, $supplier, $user, 1); $this->actingAs($user, 'user'); $this->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id, 'quantity' => '0,005', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-09-09', ])->assertRedirect()->assertSessionHasNoErrors(); expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.995); });