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

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