refactor: update stock and quantity fields to use integer type across models, requests, factories, and migrations; introduce NumberInput component for consistent numeric input handling

This commit is contained in:
Yoga Pangestu 2026-08-02 14:59:13 +07:00
parent 9637671713
commit 38b4a31c6b
28 changed files with 217 additions and 188 deletions

View File

@ -42,11 +42,11 @@ public function rules(): array
'variants' => ['required_unless:mode,existing', 'array', 'min:1'],
'variants.*.variant' => ['required_unless:mode,existing', 'string', 'max:200'],
'variants.*.price' => ['required_unless:mode,existing', 'integer', 'min:0'],
'variants.*.stock' => ['required_unless:mode,existing', 'numeric', 'min:0'],
'variants.*.stock' => ['required_unless:mode,existing', 'integer', 'min:0'],
'variants.*.photo_key' => ['required_unless:mode,existing', 'string', 'max:500'],
'existing_items' => ['required_if:mode,existing', 'array', 'min:1'],
'existing_items.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
'existing_items.*.quantity' => ['required', 'numeric', 'min:0.0001'],
'existing_items.*.quantity' => ['required', 'integer', 'min:1'],
'existing_items.*.unit_price' => ['required', 'integer', 'min:0'],
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
'discount' => ['nullable', 'integer', 'min:0'],

View File

@ -35,7 +35,7 @@ public function rules(): array
'variants.*.id' => ['nullable', 'integer'],
'variants.*.variant' => ['required', 'string', 'max:200'],
'variants.*.price' => ['required', 'integer', 'min:0'],
'variants.*.stock' => ['required', 'numeric', 'min:0'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_key' => ['required', 'string', 'max:500'],
];
}

View File

@ -24,7 +24,7 @@ public function rules(): array
return [
'variant' => ['required', 'string', 'max:200'],
'price' => ['required', 'integer', 'min:0'],
'stock' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
'photo_key' => ['required', 'string', 'max:500'],
];
}

View File

@ -16,7 +16,7 @@ class CuttingMaterial extends Model
protected function casts(): array
{
return [
'material_usage' => 'decimal:2',
'material_usage' => 'integer',
'material_result' => 'integer',
];
}

View File

@ -16,7 +16,7 @@ class PurchaseItem extends Model
protected function casts(): array
{
return [
'quantity' => 'decimal:4',
'quantity' => 'integer',
'unit_price' => 'integer',
'subtotal' => 'integer',
];

View File

@ -21,7 +21,7 @@ protected function casts(): array
{
return [
'price' => 'integer',
'stock' => 'decimal:4',
'stock' => 'integer',
];
}

View File

@ -16,9 +16,9 @@ class StockMutation extends Model
protected function casts(): array
{
return [
'quantity' => 'decimal:4',
'stock_before' => 'decimal:4',
'stock_after' => 'decimal:4',
'quantity' => 'integer',
'stock_before' => 'integer',
'stock_after' => 'integer',
];
}

View File

@ -143,7 +143,7 @@ public function getForEdit(Purchase $purchase): array
'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
'existing_quantities' => $singleMaterial
? $items->mapWithKeys(fn (PurchaseItem $item) => [
(int) $item->raw_material_price_id => (float) $item->quantity,
(int) $item->raw_material_price_id => (int) $item->quantity,
])->all()
: [],
];
@ -201,7 +201,7 @@ private function createFromExisting(array $data): Purchase
foreach ($data['existing_items'] as $item) {
RawMaterialPrice::whereKey($item['raw_material_price_id'])
->increment('stock', (float) $item['quantity']);
->increment('stock', (int) $item['quantity']);
}
if (! empty($data['photo_key'])) {
@ -325,7 +325,7 @@ public function update(Purchase $purchase, array $data): Purchase
// get adjusted by the difference instead of being reset.
$oldItems->each(function (PurchaseItem $item) {
if ($item->rawMaterialPrice) {
$item->rawMaterialPrice->decrement('stock', (float) $item->quantity);
$item->rawMaterialPrice->decrement('stock', (int) $item->quantity);
}
});
@ -366,7 +366,7 @@ public function update(Purchase $purchase, array $data): Purchase
foreach ($data['existing_items'] as $item) {
RawMaterialPrice::whereKey($item['raw_material_price_id'])
->increment('stock', (float) $item['quantity']);
->increment('stock', (int) $item['quantity']);
}
} else {
// 2a. Always reuse the purchase's existing material in place;
@ -402,7 +402,7 @@ public function update(Purchase $purchase, array $data): Purchase
}
if ($price) {
$price->increment('stock', (float) $v['stock']);
$price->increment('stock', (int) $v['stock']);
$price->update(['price' => $v['price']]);
} else {
$price = $rawMaterial->rawMaterialPrices()->create([
@ -493,7 +493,7 @@ public function delete(Purchase $purchase): bool
// Remove the stock the purchase added, keep the variants.
$purchase->purchaseItems->each(function (PurchaseItem $item) {
if ($item->rawMaterialPrice) {
$item->rawMaterialPrice->decrement('stock', (float) $item->quantity);
$item->rawMaterialPrice->decrement('stock', (int) $item->quantity);
}
});

View File

@ -15,7 +15,7 @@ public function definition(): array
'user_id' => User::factory(),
'cutting_id' => Cutting::factory(),
'raw_material_price_id' => RawMaterialPrice::factory(),
'material_usage' => fake()->randomFloat(2, 0.1, 100),
'material_usage' => fake()->numberBetween(1, 100),
'material_result' => fake()->numberBetween(0, 100),
];
}

View File

@ -11,7 +11,7 @@ class PurchaseItemFactory extends Factory
{
public function definition(): array
{
$quantity = fake()->randomFloat(4, 0.5, 100);
$quantity = fake()->numberBetween(1, 100);
$unitPrice = fake()->numberBetween(1000, 100000);
return [

View File

@ -13,7 +13,7 @@ public function definition(): array
'raw_material_id' => RawMaterial::factory(),
'variant' => fake()->words(2, true),
'price' => fake()->numberBetween(1000, 500000),
'stock' => fake()->randomFloat(4, 0, 1000),
'stock' => fake()->numberBetween(0, 1000),
];
}
}

View File

@ -9,8 +9,8 @@ class StockMutationFactory extends Factory
{
public function definition(): array
{
$stockBefore = fake()->randomFloat(4, 0, 1000);
$quantity = fake()->randomFloat(4, -100, 100);
$stockBefore = fake()->numberBetween(0, 1000);
$quantity = fake()->numberBetween(-100, 100);
return [
'stockable_type' => fake()->word(),

View File

@ -15,7 +15,7 @@ public function up(): void
$table->string('variant', 200);
$table->unsignedInteger('price');
$table->decimal('stock', 18, 4)->default(0);
$table->unsignedInteger('stock')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

View File

@ -15,7 +15,7 @@ public function up(): void
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('raw_material_price_id')->constrained()->cascadeOnDelete();
$table->decimal('quantity', 18, 4);
$table->unsignedInteger('quantity');
$table->unsignedBigInteger('unit_price');
$table->unsignedBigInteger('subtotal');

View File

@ -16,7 +16,7 @@ public function up(): void
$table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete();
$table->foreignId('combination_id')->nullable()->constrained('cutting_material_combinations')->cascadeOnDelete();
$table->decimal('material_usage', 18, 2);
$table->integer('material_usage');
$table->integer('material_result')->nullable();
$table->timestamp('created_at')->useCurrent();

View File

@ -16,9 +16,9 @@ public function up(): void
$table->morphs('stockable');
$table->string('type');
$table->nullableMorphs('source');
$table->decimal('quantity', 18, 4);
$table->decimal('stock_before', 18, 4);
$table->decimal('stock_after', 18, 4);
$table->integer('quantity');
$table->integer('stock_before');
$table->integer('stock_after');
$table->string('stock_quality')->nullable();
$table->string('description')->nullable();

View File

@ -0,0 +1,103 @@
import { useCallback, useRef, useState } from 'react';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@/components/ui/input-group';
type NumberInputProps = {
id?: string;
name?: string;
defaultValue?: number;
value?: number;
onValueChange?: (value: number) => void;
placeholder?: string;
disabled?: boolean;
min?: number;
max?: number;
suffix?: string;
className?: string;
};
function formatDisplay(value: number): string {
return value.toLocaleString('id-ID');
}
function parseText(value: string): number {
const cleaned = value.replace(/[^0-9]/g, '');
return cleaned === '' ? 0 : parseInt(cleaned, 10);
}
export function NumberInput({
id,
name,
defaultValue = 0,
value,
onValueChange,
placeholder = '0',
disabled = false,
min,
max,
suffix,
className,
}: NumberInputProps) {
const isControlled = value !== undefined;
const [displayValue, setDisplayValue] = useState(
formatDisplay(isControlled ? value : defaultValue),
);
const lastValidRef = useRef(isControlled ? value : defaultValue);
const [prevValue, setPrevValue] = useState(value);
if (isControlled && value !== prevValue) {
setPrevValue(value);
setDisplayValue(formatDisplay(value));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const raw = parseText(e.target.value);
let clamped = raw;
if (min !== undefined && raw < min) {
clamped = min;
}
if (max !== undefined && raw > max) {
clamped = max;
}
lastValidRef.current = clamped;
setDisplayValue(formatDisplay(clamped));
onValueChange?.(clamped);
},
[min, max, onValueChange],
);
const handleBlur = useCallback(() => {
setDisplayValue(formatDisplay(lastValidRef.current));
}, []);
return (
<InputGroup className={className}>
<InputGroupInput
id={id}
name={name}
type="text"
inputMode="numeric"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
/>
{suffix && (
<InputGroupAddon align="inline-end">
<InputGroupText>{suffix}</InputGroupText>
</InputGroupAddon>
)}
</InputGroup>
);
}

View File

@ -16,6 +16,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -694,24 +695,18 @@ export default function PurchaseCreate({ data }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
value={
variant.stock
}
onChange={(
e,
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -936,12 +931,8 @@ export default function PurchaseCreate({ data }: Props) {
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
min={
0
}
step="0.0001"
<NumberInput
min={0}
className="w-24 text-center"
value={
quantities[
@ -950,16 +941,12 @@ export default function PurchaseCreate({ data }: Props) {
] ??
0
}
onChange={(
e,
onValueChange={(
val,
) =>
updateQuantity(
price.id,
Number(
e
.target
.value,
),
val,
)
}
/>
@ -1250,19 +1237,11 @@ export default function PurchaseCreate({ data }: Props) {
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
className="w-20 text-center"
value={item.quantity}
onChange={(e) =>
item.onSet(
Number(
e.target.value,
),
)
}
onValueChange={item.onSet}
/>
<Button
type="button"

View File

@ -16,6 +16,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -592,24 +593,18 @@ export default function PurchaseEdit({ purchase, data }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
value={
variant.stock
}
onChange={(
e,
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -834,12 +829,8 @@ export default function PurchaseEdit({ purchase, data }: Props) {
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
min={
0
}
step="0.0001"
<NumberInput
min={0}
className="w-24 text-center"
value={
quantities[
@ -848,16 +839,12 @@ export default function PurchaseEdit({ purchase, data }: Props) {
] ??
0
}
onChange={(
e,
onValueChange={(
val,
) =>
updateQuantity(
price.id,
Number(
e
.target
.value,
),
val,
)
}
/>
@ -1148,19 +1135,11 @@ export default function PurchaseEdit({ purchase, data }: Props) {
>
<Minus className="h-4 w-4" />
</Button>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
className="w-20 text-center"
value={item.quantity}
onChange={(e) =>
item.onSet(
Number(
e.target.value,
),
)
}
onValueChange={item.onSet}
/>
<Button
type="button"

View File

@ -11,6 +11,7 @@ import { useCallback, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUploadMultiple } from '@/components/file-upload-multiple';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -656,21 +657,18 @@ export default function ProductCreate({ categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -689,21 +687,18 @@ export default function ProductCreate({ categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -722,21 +717,18 @@ export default function ProductCreate({ categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
val,
)
}
/>

View File

@ -11,6 +11,7 @@ import { useCallback, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUploadMultiple } from '@/components/file-upload-multiple';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -706,21 +707,18 @@ export default function ProductEdit({ product, categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -739,21 +737,18 @@ export default function ProductEdit({ product, categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
val,
)
}
/>
@ -772,21 +767,18 @@ export default function ProductEdit({ product, categories }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
onValueChange={(
val,
) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
val,
)
}
/>

View File

@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { FileUploadMultiple } from '@/components/file-upload-multiple';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -129,15 +130,10 @@ export default function ProductVariantEdit({ variant }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={stock}
onChange={(e) =>
setStock(
Number(e.target.value),
)
}
onValueChange={setStock}
/>
<InputError
message={errors.stock}
@ -150,15 +146,10 @@ export default function ProductVariantEdit({ variant }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={rejectStock}
onChange={(e) =>
setRejectStock(
Number(e.target.value),
)
}
onValueChange={setRejectStock}
/>
<InputError
message={errors.reject_stock}
@ -171,15 +162,10 @@ export default function ProductVariantEdit({ variant }: Props) {
*
</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
value={retailStock}
onChange={(e) =>
setRetailStock(
Number(e.target.value),
)
}
onValueChange={setRetailStock}
/>
<InputError
message={errors.retail_stock}

View File

@ -1,5 +1,6 @@
import { Form } from '@inertiajs/react';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -8,7 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { transferStock } from '@/routes/admin/master/products/variants';
@ -77,11 +77,9 @@ export function TransferStockDialog({
*
</span>
</Label>
<Input
<NumberInput
id="quantity"
name="quantity"
type="number"
min={1}
max={variant.stock}
placeholder="Masukkan jumlah transfer"
/>

View File

@ -1,6 +1,7 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -340,16 +341,14 @@ export default function RawMaterialCreate() {
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
value={variant.stock}
onChange={(e) =>
onValueChange={(val) =>
updateVariant(
variantIndex,
'stock',
Number(e.target.value),
val,
)
}
/>

View File

@ -1,6 +1,7 @@
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -356,16 +357,17 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
value={Number(variant.stock) || 0}
onChange={(e) =>
value={
Number(variant.stock) ||
0
}
onValueChange={(val) =>
updateVariant(
variantIndex,
'stock',
Number(e.target.value),
val,
)
}
/>

View File

@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -96,12 +97,10 @@ export default function RawMaterialVariantEdit({ variant }: Props) {
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<Input
type="number"
<NumberInput
min={0}
step="0.0001"
value={stock}
onChange={(e) => setStock(Number(e.target.value))}
onValueChange={setStock}
/>
<InputError message={errors.stock} />
</div>

View File

@ -208,7 +208,7 @@ function makeValidPurchasePayload(array $overrides = []): array
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('purchases.data.0.purchase_items.0.unit_price', 50000)
->where('purchases.data.0.purchase_items.0.quantity', '10.0000')
->where('purchases.data.0.purchase_items.0.quantity', 10)
->where('purchases.data.0.purchase_items.0.subtotal', 500000)
->where('purchases.data.0.purchase_items.0.raw_material_price.variant', 'Merah')
->where('purchases.data.0.purchase_items.0.raw_material_price.raw_material.name', 'Bahan Baku Test')
@ -375,7 +375,7 @@ function makeValidPurchasePayload(array $overrides = []): array
expect($item->purchase_id)->toBe($purchase->id);
expect($item->raw_material_price_id)->not->toBeNull();
expect($item->quantity)->toBe('10.0000');
expect($item->quantity)->toBe(10);
expect($item->unit_price)->toBe(50000);
expect($item->subtotal)->toBe(500000);
});
@ -397,7 +397,7 @@ function makeValidPurchasePayload(array $overrides = []): array
expect($price)->not->toBeNull();
expect($price->variant)->toBe('Merah');
expect($price->price)->toBe(50000);
expect($price->stock)->toBe('10.0000');
expect($price->stock)->toBe(10);
});
test('purchase calculates multiple variant subtotals correctly', function () {

View File

@ -271,18 +271,18 @@ function makeValidRawMaterialPayload(array $overrides = []): array
$response->assertSessionHasNoErrors();
});
test('raw material can be created with decimal stock', function () {
test('raw material can be created with integer stock', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([
'variants' => [['variant' => 'Desimal', 'price' => 50000, 'stock' => 1.5, 'photo_key' => 'raw-material-variant/decimal.jpg']],
'variants' => [['variant' => 'Bilangan Bulat', 'price' => 50000, 'stock' => 2, 'photo_key' => 'raw-material-variant/integer.jpg']],
]));
$response->assertSessionHasNoErrors();
$price = RawMaterialPrice::where('variant', 'Desimal')->first();
expect((float) $price->stock)->toBe(1.5);
$price = RawMaterialPrice::where('variant', 'Bilangan Bulat')->first();
expect($price->stock)->toBe(2);
});
test('raw material can be created as inactive', function () {