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); }); /* * Prüfung AP-53 vom 10.09.2026 (pruefung-ap53-mengenpraezision-2026-09-10.md): AP53-01…04 + Punkte 4.1/4.2. */ test('AP53-01: die produktions-ampel bezieht toleranz-sockel und vergleichsreserve aus dem ProductionService', function () { $this->actingAs(qpUser(), 'user'); $html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent(); expect($html)->toContain('var TOLERANCE_RATIO = 0.05;') ->toContain('var TOLERANCE_FLOOR = 0.005;') ->toContain('Math.max(soll * TOLERANCE_RATIO, TOLERANCE_FLOOR)') ->toContain('<= tolerance + COMPARE_EPSILON') ->not->toContain('Math.max(soll * 0.05, 0.05)'); }); test('AP53-02: der exakte 5-mg-sockelrand ist zulässig (0,025 auf 0,020 g), 0,026 g nicht', function () { expect(ProductionService::withinTolerance(0.025, 0.020))->toBeTrue() ->and(ProductionService::withinTolerance(0.015, 0.020))->toBeTrue() ->and(ProductionService::withinTolerance(0.026, 0.020))->toBeFalse() ->and(ProductionService::withinTolerance(0.014, 0.020))->toBeFalse() ->and(ProductionService::withinTolerance(0.131, 0.125))->toBeTrue() ->and(ProductionService::withinTolerance(0.132, 0.125))->toBeFalse(); $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); $product = qpProduct($ing, 0.020); $stock = qpStock($ing, $location, $supplier, $user, 10); Passport::actingAs($user); $this->actingAs($user, 'user'); $payload = fn (string $qty) => [ 'save_mode' => 'complete', 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-10', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => $qty]], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'QP2']], ]; $this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), $payload('0,026')) ->assertSessionHasErrors('ingredient_lines'); $this->post(route('admin.inventory.productions.store'), $payload('0,025'))->assertRedirect()->assertSessionHasNoErrors(); expect((float) Production::query()->sole()->productionIngredients()->sole()->quantity_used)->toBe(0.025); }); test('AP53-03: die chargen-hauptzeile im rohstoff-detail zeigt 0,125 g statt 0 g', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); qpStock($ing, $location, $supplier, $user, 0.125); $this->actingAs($user, 'user'); $html = $this->get(route('admin.inventory.raw-material-stock.show', $ing))->assertOk()->getContent(); $table = substr($html, strpos($html, 'Verfügbare Chargen')); expect($table)->toContain('0,125 g')->not->toContain('>0 g<'); }); test('AP53-04: ausschuss-fehlermeldung nennt die tatsächlich geprüften mengen (0,005 g gegen 0,004 g)', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); qpStock($ing, $location, $supplier, $user, 0.004); $this->actingAs($user, 'user'); $this->from(route('admin.inventory.stock-disposals.create'))->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-10', ])->assertSessionHasErrors('quantity'); $message = session('errors')->first('quantity'); expect($message)->toContain('0,005 g')->toContain('0,004 g')->not->toContain('0,01 g'); }); test('AP53-4.2: mehr als drei stellen werden gerundet, unter 0,001 g abgelehnt, obergrenze validiert', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $ing = qpIngredient(); $product = qpProduct($ing, 0.123); $stock = qpStock($ing, $location, $supplier, $user, 10); Passport::actingAs($user); $this->actingAs($user, 'user'); $payload = fn (string $qty, string $mode = 'complete') => [ 'save_mode' => $mode, 'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-10', 'quantity' => 1, 'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => $qty]], 'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'QP3']], ]; // 0,1234 → 0,123 (kanonisch), Toleranz und Speicherung sehen denselben Wert. $this->post(route('admin.inventory.productions.store'), $payload('0,1234'))->assertRedirect()->assertSessionHasNoErrors(); expect((float) Production::query()->sole()->productionIngredients()->sole()->quantity_used)->toBe(0.123); // 0,0004 g rundet auf 0 → abgelehnt; Obergrenze → abgelehnt. $this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), $payload('0,0004')) ->assertSessionHasErrors('ingredient_lines.0.quantity_used'); $this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), $payload('1.000.000.000')) ->assertSessionHasErrors('ingredient_lines.0.quantity_used'); // Ausschuss: 0,0004 g → unter Auflösung. $this->from(route('admin.inventory.stock-disposals.create'))->post(route('admin.inventory.stock-disposals.store'), [ 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id, 'quantity' => '0,0004', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-09-10', ])->assertSessionHasErrors('quantity'); }); test('AP53-4.2: ml-bestellung, die umgerechnet unter 0,001 g fällt, wird abgelehnt; 0,0015 ml × 0,79 wird 0,001 g', function () { $user = qpUser(); $location = Location::factory()->create(); $supplier = Supplier::factory()->create(); $quality = MaterialQuality::factory()->create(); $ing = Ingredient::query()->create(['name' => 'QP-Öl', 'trans_name' => '', 'inci' => 'QPO', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0, 'density' => 0.4]); $this->actingAs($user, 'user'); $base = [ 'entry_type' => 'ingredient', 'ingredient_id' => (string) $ing->id, 'supplier_id' => (string) $supplier->id, 'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-09-10', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00', ]; // 0,001 ml × 0,4 = 0,0004 g → 0,000 g → abgelehnt. $this->from(route('admin.inventory.stock-entries.create'))->post(route('admin.inventory.stock-entries.store'), $base + ['ordered_quantity' => '0,001']) ->assertSessionHasErrors('ordered_quantity'); // 0,002 ml × 0,4 = 0,0008 g → 0,001 g → angenommen. $this->post(route('admin.inventory.stock-entries.store'), $base + ['ordered_quantity' => '0,002'])->assertSessionHasNoErrors(); expect((float) StockEntry::query()->latest('id')->firstOrFail()->ordered_quantity)->toBe(0.001); });