feat: update material usage and stock fields to use decimal type for improved precision

This commit is contained in:
Yoga Pangestu 2026-08-15 20:03:00 +07:00
parent cff231097a
commit dd3a2aa1eb
15 changed files with 129 additions and 68 deletions

View File

@ -81,8 +81,8 @@ ### `raw_materials` → RawMaterial
- Relations: rawMaterialPrices(HasMany→RawMaterialPrice)
### `raw_material_prices` → RawMaterialPrice
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(int)
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(decimal(10,2),default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(decimal:2)
- GlobalScope: orderBy(variant)
- Relations: rawMaterial(BelongsTo→RawMaterial,withTrashed), cuttingMaterials(HasMany→CuttingMaterial), purchaseItems(HasMany→PurchaseItem)
- Accessor: photo_url → first media presigned S3 URL
@ -199,8 +199,8 @@ ### `cutting_material_combinations` → CuttingMaterialCombination
- Relations: cutting(BelongsTo→Cutting), cuttingMaterials(HasMany→CuttingMaterial,combination_id), user(BelongsTo→User)
### `cutting_materials` → CuttingMaterial
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(int) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(int), material_result(int)
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(decimal(10,2)) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(decimal:2), material_result(int)
- Accessor: formatted_material_result → 'X.XXX', formatted_material_usage → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), rawMaterialPrice(BelongsTo→RawMaterialPrice), combination(BelongsTo→CuttingMaterialCombination), user(BelongsTo→User)

View File

@ -23,7 +23,7 @@ public function rules(): array
'cutting_result' => ['required', 'integer', 'min:1'],
'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'materials.*.material_usage' => ['required', 'integer', 'min:1'],
'materials.*.material_usage' => ['required', 'numeric', 'min:0.01', 'decimal:0,2'],
'materials.*.material_result' => ['required', 'integer'],
'materials.*.combination_index' => ['nullable', 'integer'],
'combinations' => ['nullable', 'array'],

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class CuttingMaterial extends Model
protected function casts(): array
{
return [
'material_usage' => 'integer',
'material_usage' => 'decimal:2',
'material_result' => 'integer',
];
}
@ -34,7 +34,7 @@ protected function formattedMaterialResult(): Attribute
protected function formattedMaterialUsage(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_usage, 0, ',', '.'),
get: fn () => number_format((float) $this->material_usage, 2, ',', '.'),
);
}

View File

@ -29,7 +29,7 @@ protected function casts(): array
{
return [
'price' => 'integer',
'stock' => 'integer',
'stock' => 'decimal:2',
];
}
@ -43,7 +43,7 @@ protected function formattedPrice(): Attribute
protected function formattedStock(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock, 0, ',', '.'),
get: fn () => number_format((float) $this->stock, 2, ',', '.'),
);
}

View File

@ -259,7 +259,7 @@ public function store(array $data): Cutting
$pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id');
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
@ -320,7 +320,7 @@ public function store(array $data): Cutting
'updated_at' => $now,
]);
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($price && $usage > 0) {
$stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage;
}
@ -393,7 +393,7 @@ public function update(Cutting $cutting, array $data): Cutting
$pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id');
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
@ -452,7 +452,7 @@ public function update(Cutting $cutting, array $data): Cutting
'updated_at' => $now,
]);
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($price && $usage > 0) {
$stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage;
}

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()->numberBetween(0, 1000),
'stock' => round(fake()->randomFloat(2, 0, 1000), 2),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('raw_material_prices', function (Blueprint $table) {
$table->decimal('stock', 10, 2)->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('raw_material_prices', function (Blueprint $table) {
$table->unsignedInteger('stock')->default(0)->change();
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_usage', 10, 2)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->integer('material_usage')->change();
});
}
};

View File

@ -27,7 +27,7 @@ import type { CuttingCreateData } from './columns';
type MaterialState = {
raw_material_price_id: number;
material_usage: number;
material_usage: string;
material_result: number;
combination_id: number | null;
variant: string;
@ -54,7 +54,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
if (draft?.materials && draft.materials.length > 0) {
return draft.materials.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: String(m.material_usage),
material_result: m.material_result ?? 0,
combination_id: m.combination_index ?? null,
variant: m.variant,
@ -185,7 +185,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: null,
variant: price.variant,
@ -235,7 +235,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return {
raw_material_price_id: priceId,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
@ -284,7 +284,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
return sum + (price ? price.price * (parseFloat(String(m.material_usage)) || 0) : 0);
}, 0);
}, [materials, priceMap]);
@ -304,7 +304,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: parseFloat(String(m.material_usage)) || 0,
material_result: m.material_result,
combination_index: m.combination_id,
})),
@ -646,11 +646,13 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
<Input
type="text"
inputMode="decimal"
className="flex-1"
value={m.material_usage}
onChange={(e) => updateMaterial(index, 'material_usage', e.target.value)}
/>
</div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && (
@ -675,7 +677,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + (parseFloat(String(m.material_usage)) || 0), 0))}</span>
</div>
</SheetFooter>
</SheetContent>

View File

@ -26,7 +26,7 @@ import type { CuttingCreateData, CuttingForEdit } from './columns';
type MaterialState = {
id?: number;
raw_material_price_id: number;
material_usage: number;
material_usage: string;
material_result: number;
combination_id: number | null;
variant: string;
@ -63,7 +63,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return {
id: m.id,
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: String(m.material_usage),
material_result: m.material_result ?? 0,
combination_id:
m.combination_id !== null
@ -168,7 +168,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: null,
variant: price.variant,
@ -218,7 +218,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return {
raw_material_price_id: priceId,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
@ -262,7 +262,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
return sum + (price ? price.price * (parseFloat(String(m.material_usage)) || 0) : 0);
}, 0);
}, [materials, priceMap]);
@ -282,7 +282,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: parseFloat(String(m.material_usage)) || 0,
material_result: m.material_result,
combination_index: m.combination_id,
})),
@ -624,11 +624,13 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
<Input
type="text"
inputMode="decimal"
className="flex-1"
value={m.material_usage}
onChange={(e) => updateMaterial(index, 'material_usage', e.target.value)}
/>
</div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && (
@ -653,7 +655,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + (parseFloat(String(m.material_usage)) || 0), 0))}</span>
</div>
</SheetFooter>
</SheetContent>

View File

@ -12,7 +12,6 @@ import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -31,7 +30,7 @@ import {
type VariantState = {
variant: string;
price: number;
stock: number;
stock: string;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
@ -50,7 +49,7 @@ export default function RawMaterialCreate() {
return draft.variants.map((v) => ({
variant: v.variant,
price: v.price,
stock: v.stock,
stock: String(v.stock),
photo: v.photo ?? null,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
uploading: false,
@ -61,7 +60,7 @@ export default function RawMaterialCreate() {
{
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -92,7 +91,7 @@ export default function RawMaterialCreate() {
{
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -178,7 +177,7 @@ export default function RawMaterialCreate() {
variants: variantsRef.current.map((v) => ({
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
stock: parseFloat(String(v.stock)) || 0,
photo_key: v.photo,
})),
};
@ -422,17 +421,19 @@ export default function RawMaterialCreate() {
*
</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={
variant.stock
}
onValueChange={(
val,
onChange={(
e,
) =>
updateVariant(
variantIndex,
'stock',
val,
e.target.value,
)
}
/>

View File

@ -12,7 +12,6 @@ import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -37,7 +36,7 @@ type VariantState = {
id: number | null;
variant: string;
price: number;
stock: number;
stock: string;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
@ -58,7 +57,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: v.id,
variant: v.variant,
price: v.price,
stock: v.stock,
stock: String(v.stock),
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
@ -73,7 +72,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: null,
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -106,7 +105,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: null,
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -193,7 +192,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: v.id,
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
stock: parseFloat(String(v.stock)) || 0,
photo_key: v.photo,
})),
};
@ -438,19 +437,19 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
*
</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={
Number(
variant.stock,
) || 0
variant.stock
}
onValueChange={(
val,
onChange={(
e,
) =>
updateVariant(
variantIndex,
'stock',
val,
e.target.value,
)
}
/>

View File

@ -4,7 +4,6 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -28,7 +27,7 @@ type Props = {
export default function RawMaterialVariantEdit({ variant }: Props) {
const [variantName, setVariantName] = useState(variant.variant);
const [price, setPrice] = useState(variant.price);
const [stock, setStock] = useState(Number(variant.stock) || 0);
const [stock, setStock] = useState(String(variant.stock) || '0');
const [photo, setPhoto] = useState<string | null>(variant.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(variant.photo_url);
const [uploading, setUploading] = useState(false);
@ -37,7 +36,7 @@ export default function RawMaterialVariantEdit({ variant }: Props) {
return {
variant: variantName,
price: Number(price),
stock: Number(stock),
stock: parseFloat(String(stock)) || 0,
photo_key: photo,
};
}
@ -101,9 +100,11 @@ export default function RawMaterialVariantEdit({ variant }: Props) {
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={stock}
onValueChange={setStock}
onChange={(e) => setStock(e.target.value)}
/>
<InputError message={errors.stock} />
</div>