diff --git a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php index c4d6a56..af50c0c 100644 --- a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php +++ b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php @@ -110,7 +110,7 @@ class RawMaterialStockController extends Controller $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); $openOrders = $this->inventoryService->openOrdersForIngredient($ingredient->id); - $openTotal = round((float) $openOrders->sum('ordered_quantity'), 2); + $openTotal = roundWawiQuantity((float) $openOrders->sum('ordered_quantity')); $daily = ($this->inventoryService->dailyConsumptionByIngredient([$ingredient->id]))[$ingredient->id] ?? null; $minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null; @@ -128,7 +128,7 @@ class RawMaterialStockController extends Controller 'ingredient' => $ingredient, 'productStock' => $productStock, 'chargeGroups' => $chargeGroups, - 'remaining' => round($remaining, 2), + 'remaining' => roundWawiQuantity($remaining), 'remainingByLocation' => $remainingByLocation, 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'openOrders' => $openOrders, @@ -169,8 +169,8 @@ class RawMaterialStockController extends Controller $usage[(int) $productId] = [ 'percent' => round($percent, 3), - 'grams_per_piece' => $hasMassBasis ? round($percent * $scale, 2) : null, - 'consumption_per_piece' => $hasMassBasis ? round($withFactor * $scale, 2) : null, + 'grams_per_piece' => $hasMassBasis ? roundWawiQuantity($percent * $scale) : null, + 'consumption_per_piece' => $hasMassBasis ? roundWawiQuantity($withFactor * $scale) : null, ]; } diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php index bf9f14d..a49c1fc 100644 --- a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -109,19 +109,19 @@ class StockDisposalController extends Controller } $ingredientId = (int) $data['ingredient_id']; - $quantity = round((float) $data['quantity'], 2); + $quantity = roundWawiQuantity((float) $data['quantity']); if (! empty($data['stock_entry_id'])) { $entry = StockEntry::query()->lockForUpdate()->findOrFail((int) $data['stock_entry_id']); $available = $productionService->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)->firstWhere('id', $entry->id); - $remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0; + $remaining = $available !== null ? roundWawiQuantity((float) $available->getAttribute('remaining_quantity')) : 0.0; } else { // Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen. StockEntry::query()->lockForUpdate() ->where('entry_type', 'ingredient')->where('ingredient_id', $ingredientId) ->where('location_id', (int) $data['location_id']) ->get(); - $remaining = round((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0), 2); + $remaining = roundWawiQuantity((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0)); } if ($quantity > $remaining) { @@ -156,15 +156,15 @@ class StockDisposalController extends Controller } $rows = []; - $needed = round((float) $data['quantity'], 2); + $needed = roundWawiQuantity((float) $data['quantity']); $lastIndex = $entries->count() - 1; foreach ($entries as $index => $entry) { $capacity = (float) $entry->getAttribute('remaining_quantity'); - $take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2); + $take = $index === $lastIndex ? $needed : roundWawiQuantity(min($needed, $capacity)); if ($take > 0) { $rows[] = array_merge($data, ['stock_entry_id' => (int) $entry->id, 'quantity' => $take]); } - $needed = round($needed - $take, 2); + $needed = roundWawiQuantity($needed - $take); if ($needed <= 0) { break; } diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index dfcc420..cc2ddf4 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -165,7 +165,7 @@ class StockEntryController extends Controller // die Kopie zur Gramm-Bestellung und ein Wareneingang „100“ bucht 100 g statt 79 g. if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && (float) $stockEntry->density_used > 0) { $data['ordered_unit'] = 'ml'; - $data['ordered_quantity'] = round((float) $stockEntry->ordered_quantity / (float) $stockEntry->density_used, 2); + $data['ordered_quantity'] = roundWawiQuantity((float) $stockEntry->ordered_quantity / (float) $stockEntry->density_used); $data['density_used'] = (float) $stockEntry->density_used; } @@ -270,7 +270,7 @@ class StockEntryController extends Controller return response()->json([ 'exists' => true, 'purchases' => $entries->count(), - 'remaining' => round((float) $remaining, 2), + 'remaining' => roundWawiQuantity((float) $remaining), 'best_before' => $latest->best_before?->format('d.m.Y'), 'location' => $latest->location?->name, 'quality_id' => $latest->quality_id, diff --git a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php index 5429935..f5b56b6 100644 --- a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php +++ b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php @@ -28,7 +28,7 @@ class StoreStockDisposalRequest extends FormRequest 'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'], 'stock_entry_id' => ['nullable', 'integer', 'exists:stock_entries,id'], 'location_id' => ['required', 'integer', 'exists:locations,id'], - 'quantity' => ['required', 'numeric', 'min:0.01'], + 'quantity' => ['required', 'numeric', 'min:0.001'], 'reason' => ['required', 'string', 'max:100'], 'note' => ['nullable', 'string', 'max:255'], 'disposed_at' => ['required', 'date'], @@ -116,7 +116,7 @@ class StoreStockDisposalRequest extends FormRequest // N-05: Verfügbarkeit der Charge wie in der Produktion (inkl. FEFO-Anrechnung von Alt-Ausschuss ohne Charge). $available = app(ProductionService::class)->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id) ->firstWhere('id', $entry->id); - $remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0; + $remaining = $available !== null ? roundWawiQuantity((float) $available->getAttribute('remaining_quantity')) : 0.0; if ($quantity > $remaining) { $validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand der Charge (:rest g).', [ 'qty' => number_format($quantity, 2, ',', '.'), diff --git a/app/Models/ProductionIngredient.php b/app/Models/ProductionIngredient.php index 8988eb9..e3aab23 100644 --- a/app/Models/ProductionIngredient.php +++ b/app/Models/ProductionIngredient.php @@ -48,7 +48,7 @@ class ProductionIngredient extends Model protected function casts(): array { return [ - 'quantity_used' => 'decimal:2', + 'quantity_used' => 'decimal:3', ]; } diff --git a/app/Models/StockDisposal.php b/app/Models/StockDisposal.php index b46147e..0972348 100644 --- a/app/Models/StockDisposal.php +++ b/app/Models/StockDisposal.php @@ -32,7 +32,7 @@ class StockDisposal extends Model { return [ 'disposed_at' => 'date', - 'quantity' => 'decimal:2', + 'quantity' => 'decimal:3', ]; } diff --git a/app/Models/StockEntry.php b/app/Models/StockEntry.php index 61be1c3..0550b4a 100644 --- a/app/Models/StockEntry.php +++ b/app/Models/StockEntry.php @@ -51,8 +51,8 @@ class StockEntry extends Model 'ordered_at' => 'date', 'received_at' => 'date', 'best_before' => 'date', - 'ordered_quantity' => 'decimal:2', - 'received_quantity' => 'decimal:2', + 'ordered_quantity' => 'decimal:3', + 'received_quantity' => 'decimal:3', 'density_used' => 'decimal:4', 'price_per_kg' => 'decimal:4', 'price_per_kg_gross' => 'decimal:4', diff --git a/app/Repositories/StockEntryRepository.php b/app/Repositories/StockEntryRepository.php index 35565b9..784d231 100644 --- a/app/Repositories/StockEntryRepository.php +++ b/app/Repositories/StockEntryRepository.php @@ -80,7 +80,7 @@ class StockEntryRepository $data['purchase_unit'] = 'ml'; $data['density_used'] = (float) $density; if (isset($data['ordered_quantity'])) { - $data['ordered_quantity'] = round((float) $data['ordered_quantity'] * (float) $density, 2); + $data['ordered_quantity'] = roundWawiQuantity((float) $data['ordered_quantity'] * (float) $density); } } } @@ -157,7 +157,7 @@ class StockEntryRepository // AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst // und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet. if ($locked->purchase_unit === 'ml' && $locked->density_used !== null && isset($data['received_quantity'])) { - $data['received_quantity'] = round((float) $data['received_quantity'] * (float) $locked->density_used, 2); + $data['received_quantity'] = roundWawiQuantity((float) $data['received_quantity'] * (float) $locked->density_used); } $locked->update($data); diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php index 501e694..1fc6450 100644 --- a/app/Services/InventoryService.php +++ b/app/Services/InventoryService.php @@ -13,6 +13,12 @@ use Illuminate\Database\Eloquent\Collection; class InventoryService { + /** + * AP-53 (09.09.2026): Gramm-Mengen (Produktion, Wareneingang, Ausschuss, + * Restbestände) werden einheitlich auf drei Nachkommastellen geführt. + */ + public const QUANTITY_DECIMALS = 3; + public function __construct( protected ProductionService $productionService, ) {} @@ -68,7 +74,7 @@ class InventoryService $result = []; foreach ($ids as $id) { - $result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2); + $result[$id] = roundWawiQuantity((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0)); } return $result; @@ -92,7 +98,7 @@ class InventoryService $result = []; foreach ($rows as $id => $qty) { - $result[(int) $id] = round((float) $qty, 2); + $result[(int) $id] = roundWawiQuantity((float) $qty); } return $result; @@ -142,7 +148,7 @@ class InventoryService $result = []; foreach ($ids as $locationId) { - $result[$locationId] = round((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2); + $result[$locationId] = roundWawiQuantity((float) ($received[$locationId] ?? 0) - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0)); } return $result; @@ -185,7 +191,7 @@ class InventoryService $result = []; foreach ($ids as $id) { - $result[$id] = round((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0), 2); + $result[$id] = roundWawiQuantity((float) ($received[$id] ?? 0) - (float) ($consumed[$id] ?? 0) - (float) ($disposed[$id] ?? 0)); } return $result; @@ -209,7 +215,7 @@ class InventoryService $result = []; foreach ($rows as $id => $qty) { - $result[(int) $id] = round((float) $qty, 2); + $result[(int) $id] = roundWawiQuantity((float) $qty); } return $result; @@ -234,7 +240,7 @@ class InventoryService $result = []; foreach ($rows as $id => $qty) { - $result[(int) $id] = round((float) $qty, 2); + $result[(int) $id] = roundWawiQuantity((float) $qty); } return $result; @@ -345,7 +351,7 @@ class InventoryService $entries = $entries->map(function (StockEntry $entry) use ($consumed) { $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; - $entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2)); + $entry->setAttribute('remaining_quantity', roundWawiQuantity($received - ($consumed[(int) $entry->id] ?? 0.0))); return $entry; }); @@ -375,7 +381,7 @@ class InventoryService 'key' => $key, 'batch_number' => $first->batch_number, 'entries' => $entries->values(), - 'remaining' => round($entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2), + 'remaining' => roundWawiQuantity($entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'))), 'best_before' => $entries->pluck('best_before')->filter()->min(), 'suppliers' => $entries->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique()->values(), 'locations' => $entries->map(fn (StockEntry $e) => $e->location?->name)->filter()->unique()->values(), diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 45eba38..147b96d 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -291,13 +291,13 @@ class ProductionService // passen — gebucht wird der reale Ist-Wert. Toleranz ±5 % (mit kleinem // absolutem Sockel gegen Float-/Kleinmengen-Effekte); größere // Abweichungen fängt der Zahlendreher-Schutz weiterhin ab. - $tolerance = max((float) $req * 0.05, 0.05); + $tolerance = max((float) $req * 0.05, 0.005); // AP-53: Sockel 5 mg if (abs($sum - $req) > $tolerance) { throw ValidationException::withMessages([ 'ingredient_lines' => __('Die Ist-Menge je INCI darf höchstens 5 % vom Soll abweichen (INCI :id: Soll :req g, Ist :sum g).', [ 'id' => $iid, - 'req' => number_format($req, 2, ',', '.'), - 'sum' => number_format($sum, 2, ',', '.'), + 'req' => formatWawiQuantity($req), + 'sum' => formatWawiQuantity($sum), ]), ]); } @@ -541,7 +541,7 @@ class ProductionService $entries = $entries->map(function (StockEntry $entry) use ($consumed) { $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; - $entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2)); + $entry->setAttribute('remaining_quantity', roundWawiQuantity($received - ($consumed[(int) $entry->id] ?? 0.0))); return $entry; }); @@ -577,7 +577,7 @@ class ProductionService ->pluck('qty', 'location_id'); foreach ($unbound as $locationId => $qty) { - $needed = round((float) $qty, 2); + $needed = roundWawiQuantity((float) $qty); foreach ($entries as $entry) { if ($needed <= 0) { break; @@ -589,9 +589,9 @@ class ProductionService if ($remaining <= 0) { continue; } - $take = round(min($remaining, $needed), 2); - $entry->setAttribute('remaining_quantity', round($remaining - $take, 2)); - $needed = round($needed - $take, 2); + $take = roundWawiQuantity(min($remaining, $needed)); + $entry->setAttribute('remaining_quantity', roundWawiQuantity($remaining - $take)); + $needed = roundWawiQuantity($needed - $take); } } @@ -639,7 +639,7 @@ class ProductionService 'label' => implode(' - ', $labelParts), 'batch_number' => $first->batch_number, 'best_before' => $bestBefore?->format('Y-m-d'), - 'remaining' => round($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2), + 'remaining' => roundWawiQuantity($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'))), 'purchases' => $group->count(), ]; }) @@ -685,12 +685,12 @@ class ProductionService ]); } - $needed = round($this->parseQuantity($line['quantity_used'] ?? null), 2); + $needed = roundWawiQuantity($this->parseQuantity($line['quantity_used'] ?? null)); $lastIndex = $batchEntries->count() - 1; foreach ($batchEntries as $index => $entry) { $capacity = (float) $entry->getAttribute('remaining_quantity'); - $take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2); + $take = $index === $lastIndex ? $needed : roundWawiQuantity(min($needed, $capacity)); if ($take > 0) { $expanded[] = [ 'ingredient_id' => $ingredientId, @@ -702,9 +702,9 @@ class ProductionService // Audit H-3: Restmenge im Cache dekrementieren — sonst rechnet // eine zweite Zeile derselben Charge erneut gegen die volle // Kapazität und belastet denselben Einkauf doppelt. - $entry->setAttribute('remaining_quantity', round($capacity - $take, 2)); + $entry->setAttribute('remaining_quantity', roundWawiQuantity($capacity - $take)); } - $needed = round($needed - $take, 2); + $needed = roundWawiQuantity($needed - $take); if ($needed <= 0) { break; } @@ -752,7 +752,7 @@ class ProductionService 'factor' => $factor, 'required_grams_total' => $req, // AP-40: aktueller Bestand des Rohstoffs (global, in Gramm). - 'current_stock' => round((float) ($stockByIngredient[(int) $ing->id] ?? 0.0), 2), + 'current_stock' => roundWawiQuantity((float) ($stockByIngredient[(int) $ing->id] ?? 0.0)), 'stock_entries' => $available ->map(function (StockEntry $se) { return [ diff --git a/app/helpers.php b/app/helpers.php index 790d45e..ccea3c5 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,5 +1,7 @@ $minDecimals && str_ends_with($asString, '0')) { + $asString = substr($asString, 0, -1); + $decimals--; + } + + return number_format($rounded, $decimals, ',', '.'); + } +} + if (! function_exists('formatPlural')) { function formatPlural($count, $singular, $plural) { diff --git a/database/migrations/2026_09_09_100000_change_quantities_to_three_decimals.php b/database/migrations/2026_09_09_100000_change_quantities_to_three_decimals.php new file mode 100644 index 0000000..7c43a65 --- /dev/null +++ b/database/migrations/2026_09_09_100000_change_quantities_to_three_decimals.php @@ -0,0 +1,43 @@ +decimal('quantity_used', 12, 3)->change(); + }); + Schema::table('stock_entries', function (Blueprint $table) { + $table->decimal('ordered_quantity', 12, 3)->change(); + $table->decimal('received_quantity', 12, 3)->nullable()->change(); + }); + Schema::table('stock_disposals', function (Blueprint $table) { + $table->decimal('quantity', 12, 3)->change(); + }); + } + + public function down(): void + { + Schema::table('production_ingredients', function (Blueprint $table) { + $table->decimal('quantity_used', 12, 2)->change(); + }); + Schema::table('stock_entries', function (Blueprint $table) { + $table->decimal('ordered_quantity', 12, 2)->change(); + $table->decimal('received_quantity', 12, 2)->nullable()->change(); + }); + Schema::table('stock_disposals', function (Blueprint $table) { + $table->decimal('quantity', 12, 2)->change(); + }); + } +}; diff --git a/dev/product management /01-entwicklungsplaene/entwicklungsplan-V7-2026-08-26.md b/dev/product management /01-entwicklungsplaene/entwicklungsplan-V7-2026-08-26.md index 4911736..9bd2447 100644 --- a/dev/product management /01-entwicklungsplaene/entwicklungsplan-V7-2026-08-26.md +++ b/dev/product management /01-entwicklungsplaene/entwicklungsplan-V7-2026-08-26.md @@ -32,9 +32,10 @@ Die mit **`#`** markierten Punkte hat der Kunde als **buggy/dringend** priorisie | **AP-44** Rezeptur-Darstellung (Optik/iPad) | ✅ erledigt (2a6daf2) | | **AP-38** WooCommerce-Ampel (Cron/Auth) | ✅ erledigt (0c8d321 ERP+Connector, b6ca332 Cron-Selbstheilung) — Live-Ursache behoben | | **AP-52** Verpackung: Bestand/Differenz/Lagerort in der Produktion (Nachtrag 09.09.) | ✅ erledigt (3abb9e6) — Spalte einheitlich „Lagerort" | +| **AP-53** Mengen mit drei Nachkommastellen (Nachtrag 09.09.) | ✅ erledigt — Migration `2026_09_09_100000` auf Dev-DB ausgeführt, Live ausstehend | | übrige APs | — | -Suite (26.08.2026): **338 grün**; nach Nacharbeit, Nachprüfung und Gegenprüfung (08./09.09.2026): **370 grün**. Erledigt: **AP-34…AP-51 vollständig** (AP-34/35/36/37/38/39/40/41/42/43/44/45/46/47/48/49/50/51). **Keine offenen APs mehr.** Verbleibend nur der **Connector-Rollout v1.6.0** in den Shop (AP-34 + AP-38). +Suite (26.08.2026): **338 grün**; nach Nacharbeit, Nachprüfung und Gegenprüfung (08./09.09.2026): **370 grün**; mit AP-52/AP-53 und AP-47-Nachtrag (09.09.2026): **378 grün**. Erledigt: **AP-34…AP-51 vollständig** (AP-34/35/36/37/38/39/40/41/42/43/44/45/46/47/48/49/50/51). **Keine offenen APs mehr.** Verbleibend nur der **Connector-Rollout v1.6.0** in den Shop (AP-34 + AP-38). > **AP-43-Hinweis:** „Zwischenspeichern" legt die Produktion als **Entwurf** ab (kein Bestand gebucht); erst **„Produktion abschließen"** bucht Roh-/Produkt-/Verpackungsbestand. Entwürfe zählen nirgends in den Bestand — auch nicht in die Chargenverfügbarkeit (F-01). Pflicht bleiben im Entwurf nur Produkt, Standort, Datum und Planmenge; Chargen- und Ergebniszeilen dürfen unvollständig sein und werden verlustfrei gesichert (F-03). Eine bereits abgeschlossene Produktion kann nicht zurück in den Entwurf. @@ -186,6 +187,18 @@ Aktuell nach `pos` sortiert (`Product::manufacturer_ingredients() ->orderByPivot **Lösung:** Verpackungs-Vorschau mit Spalten Artikel · Stück gesamt · Lagerort · Bestand · Differenz (rot = Fehlmenge, grün = ausreichend, gleiche Darstellung wie AP-40). Bestand aus `InventoryService::remainingByPackagingItem()`, Lagerort aus den AP-51-Feldern. Die INCI-Spalte „Ablage" heißt jetzt ebenfalls „Lagerort". Bei bereits gebuchten Produktionen (Snapshot, F-05/N-04) wird keine Differenz angezeigt, da der Bestand den Abzug bereits enthält. **Umsetzung:** ✅ 09.09.2026, Commit 3abb9e6. +### AP-53 — Mengen mit drei Nachkommastellen (Nachtrag 09.09.2026) +**Kundenwunsch:** Die Eingabe bei der Produktion soll drei Nachkommastellen zulassen (Milligramm, z. B. 0,125 g Konservierer). +**Befund (Prüfung 09.09.2026):** Chargenverbrauch, Wareneingang und Ausschuss waren als `decimal(12,2)` angelegt, sämtliche Bestandsrechnungen (Chargen-Rest, FEFO, Verfügbarkeit) rundeten auf zwei Stellen, die Anzeige ebenso; die Soll-Ist-Toleranz hatte einen Sockel von 0,05 g. Eine Eingabe „0,125" wurde still auf 0,13 gerundet; „0.125" (Punkt) wurde nach der Regel vom 15.07.2026 als 125 g gelesen. +**Lösung:** +- Migration `2026_09_09_100000_change_quantities_to_three_decimals`: `production_ingredients.quantity_used`, `stock_entries.ordered_quantity`/`received_quantity`, `stock_disposals.quantity` → `decimal(12,3)`; Model-Casts `decimal:3`. Altdaten unverändert. +- Zentrale Konstante `InventoryService::QUANTITY_DECIMALS = 3`; Helfer `roundWawiQuantity()` ersetzt alle `round(…, 2)` der Bestandslogik (InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController). Helfer `formatWawiQuantity($v, $min = 2)` für die Anzeige (bis 3 Stellen, mind. 2, optional 0). +- Eingaberegel `reFormatWawiNumber` + JS (`parseGerman`, Wareneingang `parseNumber`): zusätzlich `0.xxx` als Dezimalzahl; `1.500` bleibt Tausend. Produktions-JS formatiert gespeicherte Zahlen beim Wiederöffnen deutsch ins Feld (`fmtInput`), damit 1,125 nicht als 1.125 = 1125 gelesen wird; `fmt()` zeigt bis 3 Stellen. +- Toleranzsockel 0,05 → 0,005 g; Ausschuss-Validierung `min:0.001`. +- Anzeige umgestellt: Produktionsdetail, Einkaufs-Liste/-Detail/-Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail. Übersichten (Rohstoffbestand gesamt, Verbrauch/Tag) bleiben ganzzahlig. +**Tests:** `tests/Feature/QuantityPrecisionTest.php` (6): Parsing-Regeln, Anzeigeformat, Produktion 0,125 g exakt inkl. Rest 9,875 g, Entwurf 1,125 als JSON-Zahl + fmtInput, Toleranz bei Kleinmengen, Ausschuss 0,005 g. +**Umsetzung:** ✅ 09.09.2026. + --- ## D. Klärungspunkte — beantwortet (Kundenentscheide 26.08.2026) diff --git a/dev/product management /README.md b/dev/product management /README.md index 14969db..bb2c401 100644 --- a/dev/product management /README.md +++ b/dev/product management /README.md @@ -2,7 +2,7 @@ > **Stand:** 09.09.2026 > **Status:** Modul ist **live** und in der **Kundentestphase** (Dev-Container mit eingespielter Live-Datenbank inkl. Testdaten). -> **Testsuite:** 371 Tests grün (1.512 Assertions, 0 Failures). +> **Testsuite:** 378 Tests grün (1.554 Assertions, 0 Failures). Dieses Dokument ist der Einstieg in den Ordner. Es skizziert den Gesamtstand, listet die erledigten und die noch offenen Arbeitspakete und verweist auf die @@ -101,7 +101,7 @@ WooCommerce-Ampel ist voraussichtlich **kein** Partnercenter-Fehler, sondern shopseitig (Status-Cron/Auth). Rohfassung des Feedbacks: [entwicklungsplan-aktualisiert-2026-08-26.md](01-entwicklungsplaene/entwicklungsplan-aktualisiert-2026-08-26.md). Offene Klärungspunkte (D-1…D-6) im Plan blockieren den Start einzelner Pakete. -**Stand 09.09.2026:** AP-34…AP-51 sind umgesetzt (siehe Umsetzungsstand in V7.0); Kundennachtrag **AP-52** (Verpackungs-Vorschau in der Produktion mit Bestand, Differenz und „Lagerort", einheitlich mit der INCI-Tabelle) ebenfalls umgesetzt. +**Stand 09.09.2026:** AP-34…AP-51 sind umgesetzt (siehe Umsetzungsstand in V7.0); Kundennachträge **AP-52** (Verpackungs-Vorschau in der Produktion mit Bestand, Differenz und „Lagerort", einheitlich mit der INCI-Tabelle) und **AP-53** (Gramm-Mengen mit drei Nachkommastellen in Produktion, Wareneingang und Ausschuss) ebenfalls umgesetzt; AP-47-Häkchen in die Karte „Warenwirtschaft" verschoben und ausformuliert („In der Produktion anzeigen"). ## Umsetzungsprüfung 08.09.2026 → Nacharbeit @@ -145,11 +145,11 @@ Offene Kundenfragen im Dokument. Details: --- -## Deployment-Checkliste (Stand 09.09.2026, Paket V7.0 + Nachbesserungen + AP-52) +## Deployment-Checkliste (Stand 09.09.2026, Paket V7.0 + Nachbesserungen + AP-52/AP-53) 1. **Code:** `master` bis Commit der Doku-Aktualisierung 09.09.2026 (siehe `git log`). -2. **Migrationen:** `php artisan migrate` — drei neue Migrationen `2026_09_08_100000` (batch_key, stock_entry_id nullable), `2026_09_08_110000` (recipe_phase_id), `2026_09_08_120000` (fill_weight decimal). Auf der Dev-DB (Live-Kopie, MySQL) am 09.09.2026 erfolgreich ausgeführt; bestehende Produktionszeilen bleiben unverändert (neue Spalten NULL). -3. **In-App-Doku:** `resources/docs/hinweise.md` (Stand 09.09.2026) und `resources/docs/testleitfaden.md` (Version 3, Teil C) sind aktualisiert und werden ohne Build ausgeliefert (Einstellungen → Hinweise / Leitfaden). +2. **Migrationen:** `php artisan migrate` — vier neue Migrationen `2026_09_08_100000` (batch_key, stock_entry_id nullable), `2026_09_08_110000` (recipe_phase_id), `2026_09_08_120000` (fill_weight decimal), `2026_09_09_100000` (AP-53: Chargenverbrauch, Bestell-/Eingangsmenge, Ausschussmenge → decimal(12,3)). Alle auf der Dev-DB (Live-Kopie, MySQL) am 09.09.2026 erfolgreich ausgeführt; bestehende Zeilen bleiben unverändert (neue Spalten NULL, dritte Nachkommastelle 0). +3. **In-App-Doku:** `resources/docs/hinweise.md` (Stand 09.09.2026) und `resources/docs/testleitfaden.md` (Version 3.1, Teil C) sind aktualisiert und werden ohne Build ausgeliefert (Einstellungen → Hinweise / Leitfaden). 4. **Kein Frontend-Build nötig:** alle Änderungen liegen in Blade/Inline-JS; Laravel Mix ist nicht betroffen. 5. **Nach dem Deploy (Pflicht):** `php artisan optimize:clear` und anschließend `php artisan route:cache` / `config:cache` / `view:cache` neu aufbauen. Live cached Routen — ein veralteter Route-Cache führte am 09.09.2026 zu einem 500er unter `/admin/inventory/general` (`Route [admin.inventory.stock-movement-reasons.create] not defined`, Route aus AP-48 seit 28.08.2026 im Code). Danach Testleitfaden Teil C mit dem Kunden durchgehen (insb. Abschnitte 19–21 auf dem iPad). 6. **Getrennt davon:** Connector-Plugin **v1.6.0** im WooCommerce-Shop einspielen (AP-34/AP-38), siehe `dev/partnercenter-api/README.md`. diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index b4bbff3..c805813 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -5,8 +5,8 @@ > später noch ausgebaut werden können. > > **Stand:** 09.09.2026 — ein geführter Rundgang durch die neuen Funktionen -> steht unter **Einstellungen → Leitfaden** (aktuell **Version 3**: Runde V7.0 -> plus Nachbesserungen und Nachtrag vom 08./09.09.2026). +> steht unter **Einstellungen → Leitfaden** (aktuell **Version 3.1**: Runde V7.0 +> plus Nachbesserungen und Nachträge AP-52/AP-53 vom 08./09.09.2026). --- @@ -220,11 +220,25 @@ Aus der Testphasen-Rückmeldung 26.08.2026 umgesetzt: Status-Mapping ist vollständig (Wartestellung, Abholung, Storno). *Details im Connector-README; benötigt v1.6.0.* -### Neu / geändert 08./09.09.2026 (Nachbesserungen + Nachtrag AP-52) +### Neu / geändert 08./09.09.2026 (Nachbesserungen + Nachträge AP-52/AP-53) Nach der unabhängigen Prüfung der V7-Umsetzung wurden alle Befunde behoben. Was sich dadurch für die Bedienung ändert: +- **Mengen mit drei Nachkommastellen (AP-53, Kundennachtrag 09.09.2026):** Die + Ist-Mengen je Charge in der **Produktion** lassen sich jetzt auf das Milligramm + eingeben, z. B. **0,125 g** Konservierer. Damit Restbestände ohne Rundungsdifferenz + aufgehen, gilt das genauso für die **Bestell-/Eingangsmenge im Wareneingang** und + die **Ausschussmenge**. Gespeichert, gerechnet (Chargen-Rest, FEFO-Verteilung, + Verfügbarkeit) und angezeigt wird einheitlich mit drei Stellen; Übersichten zeigen + weiterhin mindestens zwei Stellen („12,50 g") und die dritte nur, wenn sie belegt + ist („0,125 g"). Bestehende Buchungen bleiben unverändert (dritte Stelle 0). + **Eingaberegel:** Dezimaltrenner ist das **Komma** („0,125"). Ein **Punkt** mit + 1–2 Stellen („5.1", „5.12") oder „0.125" wird ebenfalls als Komma gelesen; ein Punkt + mit drei Stellen hinter einer anderen Ziffer bleibt der Tausenderpunkt + („1.500" = 1500 g). Die Soll-Ist-Toleranz von 5 % hat jetzt einen Sockel von 0,005 g + (vorher 0,05 g), damit auch Kleinstmengen sauber geprüft werden. + - **Häkchen „In der Produktion anzeigen" (AP-47, 09.09.2026 verschoben und ausformuliert):** Das bisherige Kästchen „Produktionsrelevant" oben im Produktformular war schwer zu finden. Es sitzt jetzt in der Karte **Warenwirtschaft** unter „Im Produktbestand diff --git a/resources/docs/testleitfaden.md b/resources/docs/testleitfaden.md index 3d94ff0..7820762 100644 --- a/resources/docs/testleitfaden.md +++ b/resources/docs/testleitfaden.md @@ -1,4 +1,4 @@ -# Testleitfaden Warenwirtschaft — Version 3 +# Testleitfaden Warenwirtschaft — Version 3.1 > **Stand:** 09.09.2026 > Dieser Leitfaden führt einmal durch die Funktionen der Warenwirtschaft — in der Reihenfolge eines typischen Arbeitsablaufs, sodass jede Stelle direkt im Browser nachgeschaut und getestet werden kann. @@ -6,7 +6,7 @@ > > - **Teil A** (Abschnitte 1–11) — die Basis-Funktionen der Warenwirtschaft (Runde 12.06.2026, Stand 16.07.2026). Unverändert gültig. > - **Teil B** (Abschnitte 12–18) — Runde V7.0 (Testphasen-Rückmeldung 26.–28.08.2026). -> - **Teil C** (Abschnitte 19–25) — **neu: Nachbesserungen aus der Umsetzungsprüfung und Nachtrag AP-52 (08./09.09.2026)**. Wer nur die neuen Punkte prüfen will, springt direkt zu **Teil C**. +> - **Teil C** (Abschnitte 19–26) — **neu: Nachbesserungen aus der Umsetzungsprüfung und Nachträge AP-52/AP-53 (08./09.09.2026)**. Wer nur die neuen Punkte prüfen will, springt direkt zu **Teil C**. --- @@ -401,6 +401,24 @@ --- +## 26. Mengen mit drei Nachkommastellen (AP-53) + +**Wo:** **[Produktion](/admin/inventory/productions)** (Ist-Menge je Charge), **[Einkauf & Wareneingang](/admin/inventory/stock-entries)** (Bestell-/Eingangsmenge), **[Ausschuss](/admin/inventory/stock-disposals)** (Menge) + +**Neu:** +- Gramm-Mengen werden auf **drei Nachkommastellen** (Milligramm) eingegeben, gespeichert, gerechnet und angezeigt — z. B. **0,125 g**. Bisher wurde auf zwei Stellen gerundet (0,125 → 0,13). +- Anzeige: mindestens zwei Stellen („12,50 g"), die dritte nur wenn belegt („0,125 g"); Chargen-Restbestände im Rohstoff-Detail zeigen sie ebenfalls. +- Eingabe: **Komma** als Dezimaltrenner. Punkt mit 1–2 Stellen („5.12") oder „0.125" wird als Komma gelesen; „1.500" bleibt 1500 g (Tausenderpunkt). +- Soll-Ist-Toleranz: 5 % mit Sockel 0,005 g (vorher 0,05 g). + +**So testen:** +1. Rezeptur mit einem Rohstoff zu 0,125 % (bzw. 0,125 g je Stück) anlegen, Produktion für 1 Stück: Ist-Menge **0,125** eintragen → Ampel grün, abschließen → Produktionsdetail zeigt **0,125 g**, Rohstoff-Detail: Chargen-Rest um genau 0,125 g kleiner. +2. Denselben Entwurf mit **1,125** speichern und wieder öffnen → im Feld steht **1,125** (nicht 1.125 oder 1,13). +3. Wareneingang mit **250,375 g** buchen → Detail zeigt 250,375 g; Ausschuss **0,005 g** buchen → geht durch, Restbestand rechnet auf drei Stellen. +4. Gegenprobe Tausenderpunkt: Ist-Menge **1.500** eingeben → Summe der Phase zeigt 1.500,00 g (nicht 1,5). + +--- + ## Hinweis am Rande Die Listen **Einkauf & Wareneingang**, **Produktionen** und die **Produktbestand-Historie** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet. diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php index 64b6319..6afa058 100644 --- a/resources/views/admin/inventory/productions/_scripts.blade.php +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -29,8 +29,18 @@ return url; } + // AP-53: Gramm-Mengen mit bis zu drei Nachkommastellen (0,125), mindestens zwei (12,50). function fmt(value) { - return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}); + return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 3}); + } + + // AP-53: Zahl → Eingabefeld (deutsches Komma, ohne Tausenderpunkt, max. 3 Stellen), + // damit gespeicherte Werte wie 1,125 beim Wiederöffnen nicht als 1.125 (= 1125) gelesen werden. + function fmtInput(value) { + if (typeof value !== 'number') { + return value == null ? '' : String(value); + } + return Number(value).toLocaleString('de-DE', {maximumFractionDigits: 3, useGrouping: false}); } // AP-52: Stückzahlen (Verpackung) ohne Nachkommastellen. @@ -44,8 +54,9 @@ return 0; } // Audit N-6: einzelner Punkt mit 1–2 Nachkommastellen = Dezimaltrenner - // ("5.1" = 5,1) — identische Regel wie serverseitig (reFormatWawiNumber). - if (/^-?\d+\.\d{1,2}$/.test(value)) { + // ("5.1" = 5,1); AP-53: zusätzlich "0.125" — identische Regel wie + // serverseitig (reFormatWawiNumber). "1.500" bleibt 1500. + if (/^-?(\d+\.\d{1,2}|0\.\d{3})$/.test(value)) { return parseFloat(value) || 0; } return parseFloat(value.replace(/\./g, '').replace(',', '.')) || 0; @@ -96,7 +107,7 @@ var $colQty = $('
'); var $group = $('
'); - $group.append($('').val(preselectedQty || '')); + $group.append($('').val(fmtInput(preselectedQty))); $group.append('
g
'); $colQty.append($group); $row.append($colQty); diff --git a/resources/views/admin/inventory/productions/show.blade.php b/resources/views/admin/inventory/productions/show.blade.php index 019cb24..1344dc8 100644 --- a/resources/views/admin/inventory/productions/show.blade.php +++ b/resources/views/admin/inventory/productions/show.blade.php @@ -124,7 +124,7 @@ (MHD {{ $line->stockEntry->best_before->format('d.m.Y') }}) @endif - {{ \App\Services\Util::formatNumber($line->quantity_used) }} + {{ formatWawiQuantity($line->quantity_used) }} @endforeach diff --git a/resources/views/admin/inventory/raw-material-stock/show.blade.php b/resources/views/admin/inventory/raw-material-stock/show.blade.php index cc95a23..617dd3e 100644 --- a/resources/views/admin/inventory/raw-material-stock/show.blade.php +++ b/resources/views/admin/inventory/raw-material-stock/show.blade.php @@ -276,7 +276,7 @@ {{ $charge->supplier?->name ?? '—' }} {{ $charge->location?->name ?? '—' }} {{ $charge->best_before?->format('d.m.Y') ?? '—' }} - {{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g + {{ formatWawiQuantity($charge->getAttribute('remaining_quantity'), 0) }} g @endforeach @endif diff --git a/resources/views/admin/inventory/stock-disposals/index.blade.php b/resources/views/admin/inventory/stock-disposals/index.blade.php index 0d9e215..a3c8287 100644 --- a/resources/views/admin/inventory/stock-disposals/index.blade.php +++ b/resources/views/admin/inventory/stock-disposals/index.blade.php @@ -65,7 +65,7 @@ {{ $disposal->location?->name ?? '—' }} - −{{ \App\Services\Util::formatNumber($disposal->quantity, $disposal->unit === 'piece' ? 0 : 2) }} + −{{ $disposal->unit === 'piece' ? \App\Services\Util::formatNumber($disposal->quantity, 0) : formatWawiQuantity($disposal->quantity) }} {{ $disposal->unit === 'piece' ? __('Stück') : 'g' }} {{ $disposal->reason }} diff --git a/resources/views/admin/inventory/stock-entries/_form.blade.php b/resources/views/admin/inventory/stock-entries/_form.blade.php index 7499b37..9dccbb1 100644 --- a/resources/views/admin/inventory/stock-entries/_form.blade.php +++ b/resources/views/admin/inventory/stock-entries/_form.blade.php @@ -109,7 +109,7 @@ // über die gespeicherte Dichte); Bestand bleibt intern in Gramm. $orderedIsMl = $model->purchase_unit === 'ml' && $model->density_used; $orderedDisplay = $model->ordered_quantity !== null - ? \App\Services\Util::formatNumber($orderedIsMl ? $model->ordered_quantity / $model->density_used : $model->ordered_quantity) + ? formatWawiQuantity($orderedIsMl ? $model->ordered_quantity / $model->density_used : $model->ordered_quantity) : ''; @endphp
diff --git a/resources/views/admin/inventory/stock-entries/_scripts.blade.php b/resources/views/admin/inventory/stock-entries/_scripts.blade.php index 983ca98..ddc105a 100644 --- a/resources/views/admin/inventory/stock-entries/_scripts.blade.php +++ b/resources/views/admin/inventory/stock-entries/_scripts.blade.php @@ -107,7 +107,9 @@ if (value === null || value === undefined) { return null; } - var normalized = String(value).trim().replace(/\./g, '').replace(',', '.'); + // AP-53: gleiche Regel wie reFormatWawiNumber — Punkt mit 1–2 Stellen oder "0.xxx" = Komma. + var raw = String(value).trim(); + var normalized = /^-?(\d+\.\d{1,2}|0\.\d{3})$/.test(raw) ? raw : raw.replace(/\./g, '').replace(',', '.'); if (normalized === '' || isNaN(normalized)) { return null; } @@ -115,7 +117,8 @@ } function formatNumber(value) { - return value.toFixed(2).replace('.', ','); + // AP-53: bis zu drei Nachkommastellen, mindestens zwei. + return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: false}); } function currentFactor() { diff --git a/resources/views/admin/inventory/stock-entries/index.blade.php b/resources/views/admin/inventory/stock-entries/index.blade.php index 41bd3e9..05624fc 100644 --- a/resources/views/admin/inventory/stock-entries/index.blade.php +++ b/resources/views/admin/inventory/stock-entries/index.blade.php @@ -73,7 +73,7 @@ {{ $row->supplier?->name ?? '—' }} @if($row->unit === 'gram') - {{ \App\Services\Util::formatNumber($row->ordered_quantity) }} g + {{ formatWawiQuantity($row->ordered_quantity) }} g @else {{ \App\Services\Util::formatNumber($row->ordered_quantity, 0) }} {{ __('Stk.') }} @endif diff --git a/resources/views/admin/inventory/stock-entries/show.blade.php b/resources/views/admin/inventory/stock-entries/show.blade.php index bae4051..ef5cc7f 100644 --- a/resources/views/admin/inventory/stock-entries/show.blade.php +++ b/resources/views/admin/inventory/stock-entries/show.blade.php @@ -84,9 +84,9 @@ {{ __('Bestellte Menge') }} @if($model->unit === 'gram') - {{ \App\Services\Util::formatNumber($model->ordered_quantity) }} g + {{ formatWawiQuantity($model->ordered_quantity) }} g @if($model->purchase_unit === 'ml' && $model->density_used) - (= {{ \App\Services\Util::formatNumber($model->ordered_quantity / $model->density_used) }} ml) + (= {{ formatWawiQuantity($model->ordered_quantity / $model->density_used) }} ml) @endif @else {{ \App\Services\Util::formatNumber($model->ordered_quantity, 0) }} {{ __('Stk.') }} @@ -136,9 +136,9 @@ {{ __('Eingegangene Menge') }} @if($model->unit === 'gram') - {{ \App\Services\Util::formatNumber($model->received_quantity) }} g + {{ formatWawiQuantity($model->received_quantity) }} g @if($model->purchase_unit === 'ml' && $model->density_used && $model->received_quantity !== null) - (= {{ \App\Services\Util::formatNumber($model->received_quantity / $model->density_used) }} ml) + (= {{ formatWawiQuantity($model->received_quantity / $model->density_used) }} ml) @endif @else {{ \App\Services\Util::formatNumber($model->received_quantity, 0) }} {{ __('Stk.') }} @@ -195,8 +195,8 @@ // und über die gespeicherte Dichte in Gramm umgerechnet. $receiveIsMl = $model->purchase_unit === 'ml' && $model->density_used; $receiveDefault = $receiveIsMl - ? \App\Services\Util::formatNumber($model->ordered_quantity / $model->density_used) - : \App\Services\Util::formatNumber($model->ordered_quantity); + ? formatWawiQuantity($model->ordered_quantity / $model->density_used) + : formatWawiQuantity($model->ordered_quantity); @endphp
diff --git a/tests/Feature/QuantityPrecisionTest.php b/tests/Feature/QuantityPrecisionTest.php new file mode 100644 index 0000000..0c15b3a --- /dev/null +++ b/tests/Feature/QuantityPrecisionTest.php @@ -0,0 +1,164 @@ +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); +});