Warenwirtschaft: AP-53 — Gramm-Mengen mit drei Nachkommastellen (Produktion, Wareneingang, Ausschuss)

Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g.

- Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert.
- InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController.
- formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail.
- Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125).
- Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001.
- Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378).

Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-09 15:54:29 +00:00
parent 021edbfcc7
commit b8180a06a1
25 changed files with 378 additions and 71 deletions

View file

@ -0,0 +1,164 @@
<?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\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);
});