gruene-seele/tests/Feature/QuantityPrecisionTest.php
Kevin Adametz 6bd867cbe7 Warenwirtschaft: AP-53 — Nacharbeit zur Prüfung 10.09.2026 (Toleranzregel zentral, Sockelrand, Chargenzeile, Ausschuss-Meldung, Auflösung/Obergrenze)
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>
2026-09-10 07:25:11 +00:00

297 lines
15 KiB
PHP
Raw Permalink 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\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);
});