Warenwirtschaft: AP-53 — Gramm-Mengen mit drei Nachkommastellen (Produktion, Wareneingang, Ausschuss)
Kundennachtrag 09.09.2026: Ist-Mengen je Charge in der Produktion sollen auf das Milligramm eingegeben werden können (z. B. 0,125 g). Bisher rundeten Datenbank (decimal 12,2), Bestandslogik, Anzeige und Toleranzsockel auf zwei Stellen; „0,125" wurde still zu 0,13, „0.125" nach der Punkt-Regel zu 125 g. - Migration 2026_09_09_100000: production_ingredients.quantity_used, stock_entries.ordered_quantity/received_quantity, stock_disposals.quantity → decimal(12,3); Casts decimal:3. Auf der Dev-DB (Live-Kopie) ausgeführt, 53/78/7 Zeilen unverändert. - InventoryService::QUANTITY_DECIMALS = 3; roundWawiQuantity() ersetzt alle round(…, 2) in InventoryService, ProductionService, StockDisposalController/-Request, StockEntryRepository/-Controller, RawMaterialStockController. - formatWawiQuantity($v, $min = 2): bis drei Stellen, mindestens zwei — Produktionsdetail, Einkauf Liste/Detail/Formular, Ausschuss-Liste, Chargen-Rest im Rohstoff-Detail. - Eingaberegel (reFormatWawiNumber, JS parseGerman/parseNumber): zusätzlich „0.xxx" als Dezimalzahl; „1.500" bleibt 1500. Produktions-JS: fmt() bis drei Stellen, fmtInput() schreibt gespeicherte Zahlen deutsch ins Feld (1,125 statt 1.125 = 1125). - Soll-Ist-Toleranz: Sockel 0,05 → 0,005 g; Ausschuss min 0,001. - Doku: Hinweise (Neu/geändert), Leitfaden Version 3.1 mit Abschnitt 26, Plan V7 AP-53, README (Deploy-Checkliste, Suite 378). Tests: QuantityPrecisionTest (6) — Parsing, Anzeige, Produktion 0,125 g exakt + Rest 9,875 g, Entwurf 1,125 wiederöffnen, Toleranz Kleinmengen, Ausschuss 0,005 g. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
021edbfcc7
commit
b8180a06a1
25 changed files with 378 additions and 71 deletions
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, ',', '.'),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class ProductionIngredient extends Model
|
|||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'quantity_used' => 'decimal:2',
|
||||
'quantity_used' => 'decimal:3',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class StockDisposal extends Model
|
|||
{
|
||||
return [
|
||||
'disposed_at' => 'date',
|
||||
'quantity' => 'decimal:2',
|
||||
'quantity' => 'decimal:3',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
164
tests/Feature/QuantityPrecisionTest.php
Normal file
164
tests/Feature/QuantityPrecisionTest.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Models\Production;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\InventoryService;
|
||||
use App\Services\ProductionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Passport\Passport;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* AP-53 (Kundenwunsch 09.09.2026): Gramm-Mengen mit drei Nachkommastellen in
|
||||
* Produktion, Wareneingang und Ausschuss; einheitliche Rundung und Anzeige.
|
||||
*/
|
||||
function qpUser(): User
|
||||
{
|
||||
Country::query()->firstOrCreate(['code' => 'DE'], ['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]);
|
||||
$user = User::query()->create(['email' => uniqid('qp_', true).'@test.example', 'password' => bcrypt('password')]);
|
||||
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
function qpIngredient(): Ingredient
|
||||
{
|
||||
return Ingredient::query()->create(['name' => 'QP-Konservierer', 'trans_name' => '', 'inci' => 'QP', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0]);
|
||||
}
|
||||
|
||||
/** Rezeptur: 0,125 g je Stück (Soll bei 1 Stück = 0,125 g). */
|
||||
function qpProduct(Ingredient $ing, float $gram = 0.125): Product
|
||||
{
|
||||
$product = Product::query()->create(['name' => 'QP-Produkt', 'title' => 'QP-Produkt', 'active' => true]);
|
||||
ProductIngredient::query()->create(['product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => $gram, 'factor' => 1.0, 'recipe_type' => 'manufacturer']);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
function qpStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, float $qty = 10): StockEntry
|
||||
{
|
||||
return StockEntry::query()->create([
|
||||
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'packaging_item_id' => null,
|
||||
'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram',
|
||||
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => $qty,
|
||||
'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id,
|
||||
'received_at' => '2026-01-15', 'received_quantity' => $qty, 'batch_number' => 'QP-B',
|
||||
'best_before' => '2030-12-31', 'quality_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
test('AP-53: eingabe-parsing — komma mit drei stellen, „0.125" als dezimalzahl, „1.500" bleibt tausend', function () {
|
||||
expect(reFormatWawiNumber('0,125'))->toBe(0.125)
|
||||
->and(reFormatWawiNumber('1,005'))->toBe(1.005)
|
||||
->and(reFormatWawiNumber('0.125'))->toBe(0.125)
|
||||
->and(reFormatWawiNumber('5.12'))->toBe(5.12)
|
||||
->and(reFormatWawiNumber('1.500'))->toBe(1500.0)
|
||||
->and(reFormatWawiNumber('1.234,567'))->toBe(1234.567)
|
||||
->and(roundWawiQuantity(0.1234))->toBe(0.123)
|
||||
->and(roundWawiQuantity(0.1235))->toBe(0.124);
|
||||
});
|
||||
|
||||
test('AP-53: anzeige — bis zu drei nachkommastellen, mindestens zwei, optional ganzzahlig', function () {
|
||||
expect(formatWawiQuantity(0.125))->toBe('0,125')
|
||||
->and(formatWawiQuantity(12.5))->toBe('12,50')
|
||||
->and(formatWawiQuantity(1500))->toBe('1.500,00')
|
||||
->and(formatWawiQuantity(1.1204))->toBe('1,12')
|
||||
->and(formatWawiQuantity(1500, 0))->toBe('1.500')
|
||||
->and(formatWawiQuantity(0.125, 0))->toBe('0,125');
|
||||
});
|
||||
|
||||
test('AP-53: produktion bucht 0,125 g exakt und der chargen-restbestand rechnet auf drei stellen', function () {
|
||||
$user = qpUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = qpIngredient();
|
||||
$product = qpProduct($ing);
|
||||
$stock = qpStock($ing, $location, $supplier, $user, 10);
|
||||
|
||||
Passport::actingAs($user);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$this->post(route('admin.inventory.productions.store'), [
|
||||
'save_mode' => 'complete',
|
||||
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-09', 'quantity' => 1,
|
||||
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '0,125']],
|
||||
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'QP1']],
|
||||
])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
$production = Production::query()->where('product_id', $product->id)->sole();
|
||||
expect((float) $production->productionIngredients()->sole()->quantity_used)->toBe(0.125)
|
||||
->and(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(9.875)
|
||||
->and(app(ProductionService::class)->consumedByStockEntry([$stock->id])[$stock->id])->toBe(0.125);
|
||||
|
||||
// Detailansicht zeigt die dritte Stelle.
|
||||
$this->get(route('admin.inventory.productions.show', $production))->assertOk()->assertSee('0,125');
|
||||
});
|
||||
|
||||
test('AP-53: entwurf mit 1,125 g wird beim wiederöffnen als zahl übergeben (JS formatiert deutsch, nicht „1.125")', function () {
|
||||
$user = qpUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = qpIngredient();
|
||||
$product = qpProduct($ing, 1.125);
|
||||
$stock = qpStock($ing, $location, $supplier, $user, 10);
|
||||
|
||||
Passport::actingAs($user);
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$this->post(route('admin.inventory.productions.store'), [
|
||||
'save_mode' => 'draft',
|
||||
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-09', 'quantity' => 1,
|
||||
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $stock->batchGroupKey(), 'quantity_used' => '1,125']],
|
||||
])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
$draft = Production::query()->where('product_id', $product->id)->sole();
|
||||
$html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent();
|
||||
|
||||
// existingLines trägt den Wert als JSON-Zahl 1.125; fmtInput() macht daraus „1,125" im Feld.
|
||||
expect($html)->toContain('"quantity_used":1.125')
|
||||
->toContain('function fmtInput(');
|
||||
});
|
||||
|
||||
test('AP-53: toleranz bei kleinmengen — 0,131 g auf 0,125 g soll geht durch, 0,132 g nicht', function () {
|
||||
$user = qpUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = qpIngredient();
|
||||
$product = qpProduct($ing, 0.125);
|
||||
$stock = qpStock($ing, $location, $supplier, $user, 10);
|
||||
|
||||
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-09-09', 'quantity' => 1, 'notes' => null];
|
||||
$outputs = [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]];
|
||||
|
||||
// 5 % von 0,125 = 0,00625 > Sockel 0,005 → Toleranz 0,00625: 0,131 ist drin, 0,132 nicht.
|
||||
app(ProductionService::class)->store($head, [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '0,131']], $user->id, $outputs, false);
|
||||
|
||||
expect(fn () => app(ProductionService::class)->store($head, [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '0,132']], $user->id, $outputs, false))
|
||||
->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('AP-53: ausschuss akzeptiert milligramm und reduziert den restbestand auf drei stellen', function () {
|
||||
$user = qpUser();
|
||||
$location = Location::factory()->create();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$ing = qpIngredient();
|
||||
qpStock($ing, $location, $supplier, $user, 1);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$this->post(route('admin.inventory.stock-disposals.store'), [
|
||||
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
|
||||
'quantity' => '0,005', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-09-09',
|
||||
])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.995);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue