Prüfbericht pruefung-ap53-mengenpraezision-2026-09-10.md, alle Punkte umgesetzt: - AP53-01: Toleranz nur noch in ProductionService (TOLERANCE_RATIO 0,05, TOLERANCE_FLOOR 0,005, COMPARE_EPSILON); das Produktions-JS rendert die Konstanten aus dem Service statt eigener Zahlen (Ampel hatte noch Sockel 0,05 g). - AP53-02: withinTolerance() vergleicht mit Float-Ausgleich 1e-9 — 0,025 g auf 0,020 g Soll ist wieder zulässig, 0,026 g nicht; Sollwerte werden nicht gerundet. - AP53-03: Chargen-Hauptzeile und Standortsumme im Rohstoff-Detail mit formatWawiQuantity(…, 0) (0,125 g statt 0 g). - AP53-04: Ausschuss-Fehlermeldungen (Request ohne/mit Charge, Controller-Wiederholung) mit formatWawiQuantity(). - 4.1: Obergrenze decimal(12,3) bewusst akzeptiert (Live max. 49.000 g) und als InventoryService::QUANTITY_MAX validiert; Bereichsprüfung + Rollback-Hinweis in der Deploy-Checkliste. - 4.2: normalizeWawiQuantity() (parsen + drei Stellen) in Bestell-/Wareneingangs-/Ausschuss-Requests, parseQuantity() rundet kanonisch für batch_key- und stock_entry_id-Zeilen; Untergrenze 0,001 g; ml-Eingaben, die umgerechnet 0,000 g ergeben, werden abgelehnt (Store/Update/Receive). - 4.3: Einkaufs-JS trennt formatQuantity (3), formatPrice (2, wie vor AP-53) und formatDensity (4). - 4.4: Hinweise präzisiert (Übersichten ganzzahlig, Rundungs-/Untergrenzregel), Leitfaden §26 mit Sockelrand-, Hauptzeilen- und Meldungs-Prüfschritten, Plan V7 und README aktualisiert, Prüfbericht Abschnitt 6 mit Nacharbeitsstand. Tests: QuantityPrecisionTest +6 (AP53-01…04, 4.2 Rundung/Untergrenze/Obergrenze, ml→0 g). Suite 384 grün / 1.592 Assertions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
297 lines
15 KiB
PHP
297 lines
15 KiB
PHP
<?php
|
||
|
||
use App\Models\Country;
|
||
use App\Models\Ingredient;
|
||
use App\Models\Location;
|
||
use App\Models\MaterialQuality;
|
||
use App\Models\Product;
|
||
use App\Models\ProductIngredient;
|
||
use App\Models\Production;
|
||
use App\Models\StockEntry;
|
||
use App\Models\Supplier;
|
||
use App\Services\InventoryService;
|
||
use App\Services\ProductionService;
|
||
use App\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Laravel\Passport\Passport;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class, RefreshDatabase::class);
|
||
|
||
/**
|
||
* AP-53 (Kundenwunsch 09.09.2026): Gramm-Mengen mit drei Nachkommastellen in
|
||
* Produktion, Wareneingang und Ausschuss; einheitliche Rundung und Anzeige.
|
||
*/
|
||
function qpUser(): User
|
||
{
|
||
Country::query()->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);
|
||
});
|