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

@ -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,
];
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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, ',', '.'),

View file

@ -48,7 +48,7 @@ class ProductionIngredient extends Model
protected function casts(): array
{
return [
'quantity_used' => 'decimal:2',
'quantity_used' => 'decimal:3',
];
}

View file

@ -32,7 +32,7 @@ class StockDisposal extends Model
{
return [
'disposed_at' => 'date',
'quantity' => 'decimal:2',
'quantity' => 'decimal:3',
];
}

View file

@ -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',

View file

@ -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);

View file

@ -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(),

View file

@ -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 [

View file

@ -1,5 +1,7 @@
<?php
use App\Services\InventoryService;
if (! function_exists('make_old_url')) {
function make_old_url($path)
{
@ -89,7 +91,9 @@ if (! function_exists('reFormatWawiNumber')) {
*/
function reFormatWawiNumber($number)
{
if (is_string($number) && preg_match('/^-?\d+\.\d{1,2}$/', trim($number))) {
// AP-53 (09.09.2026): zusätzlich „0.xxx" mit drei Stellen als Dezimalzahl —
// ein Tausenderpunkt hinter einer 0 ergibt keinen Sinn. „1.500" bleibt 1500.
if (is_string($number) && preg_match('/^-?(\d+\.\d{1,2}|0\.\d{3})$/', trim($number))) {
return (float) trim($number);
}
@ -97,6 +101,37 @@ if (! function_exists('reFormatWawiNumber')) {
}
}
if (! function_exists('roundWawiQuantity')) {
/**
* AP-53: Mengenrundung der Warenwirtschaft (Gramm) — einheitlich drei
* Nachkommastellen (Milligramm), siehe InventoryService::QUANTITY_DECIMALS.
*/
function roundWawiQuantity($value): float
{
return round((float) $value, InventoryService::QUANTITY_DECIMALS);
}
}
if (! function_exists('formatWawiQuantity')) {
/**
* AP-53: Mengenanzeige (deutsch) mit bis zu drei Nachkommastellen; mindestens
* $minDecimals werden gezeigt (Standard 2: „12,50", „0,125", „1.500,00").
*/
function formatWawiQuantity($value, int $minDecimals = 2): string
{
$max = InventoryService::QUANTITY_DECIMALS;
$rounded = round((float) $value, $max);
$decimals = $max;
$asString = number_format($rounded, $max, '.', '');
while ($decimals > $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)
{

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* AP-53 (Kundenwunsch 09.09.2026): Mengen in Gramm mit drei Nachkommastellen
* (Milligramm-Genauigkeit, z. B. 0,125 g Konservierer). Betrifft den Chargen-
* verbrauch der Produktion sowie — damit Restbestände ohne Rundungsdifferenz
* aufgehen — Bestell-/Eingangsmenge des Wareneingangs und die Ausschussmenge.
* Bestehende Werte bleiben unverändert (dritte Stelle = 0).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('production_ingredients', function (Blueprint $table) {
$table->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();
});
}
};

View file

@ -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)

View file

@ -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`.

View file

@ -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

View file

@ -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.

View file

@ -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 = $('<div class="col-12 col-sm-5 mb-1"></div>');
var $group = $('<div class="input-group input-group-sm"></div>');
$group.append($('<input type="text" class="form-control recipe-qty-input" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append($('<input type="text" class="form-control recipe-qty-input" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]" inputmode="decimal">').val(fmtInput(preselectedQty)));
$group.append('<div class="input-group-append"><span class="input-group-text">g</span></div>');
$colQty.append($group);
$row.append($colQty);

View file

@ -124,7 +124,7 @@
(MHD {{ $line->stockEntry->best_before->format('d.m.Y') }})
@endif
</td>
<td>{{ \App\Services\Util::formatNumber($line->quantity_used) }}</td>
<td>{{ formatWawiQuantity($line->quantity_used) }}</td>
</tr>
@endforeach
</tbody>

View file

@ -276,7 +276,7 @@
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
<td class="text-right">{{ formatWawiQuantity($charge->getAttribute('remaining_quantity'), 0) }} g</td>
</tr>
@endforeach
@endif

View file

@ -65,7 +65,7 @@
</td>
<td>{{ $disposal->location?->name ?? '—' }}</td>
<td class="text-right text-danger font-weight-bold">
−{{ \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' }}
</td>
<td>{{ $disposal->reason }}</td>

View file

@ -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
<div class="form-group">

View file

@ -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() {

View file

@ -73,7 +73,7 @@
<td>{{ $row->supplier?->name ?? '—' }}</td>
<td>
@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

View file

@ -84,9 +84,9 @@
<span class="wawi-deflist__label">{{ __('Bestellte Menge') }}</span>
<span class="wawi-deflist__value">
@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)
<span class="text-muted">(= {{ \App\Services\Util::formatNumber($model->ordered_quantity / $model->density_used) }} ml)</span>
<span class="text-muted">(= {{ formatWawiQuantity($model->ordered_quantity / $model->density_used) }} ml)</span>
@endif
@else
{{ \App\Services\Util::formatNumber($model->ordered_quantity, 0) }} {{ __('Stk.') }}
@ -136,9 +136,9 @@
<span class="wawi-deflist__label">{{ __('Eingegangene Menge') }}</span>
<span class="wawi-deflist__value">
@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)
<span class="text-muted">(= {{ \App\Services\Util::formatNumber($model->received_quantity / $model->density_used) }} ml)</span>
<span class="text-muted">(= {{ formatWawiQuantity($model->received_quantity / $model->density_used) }} ml)</span>
@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
<div class="form-group">
<label for="received_quantity">{{ __('Eingegangene Menge') }} <span class="text-danger">*</span></label>

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);
});