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:
parent
021edbfcc7
commit
b8180a06a1
25 changed files with 378 additions and 71 deletions
164
tests/Feature/QuantityPrecisionTest.php
Normal file
164
tests/Feature/QuantityPrecisionTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue