feat: add validation for product prices in transaction creation and editing

This commit is contained in:
Yoga Pangestu 2026-08-12 20:16:57 +07:00
parent 806c42dfd2
commit 7164bd96d3
6 changed files with 97 additions and 39 deletions

View File

@ -31,7 +31,7 @@ public function rules(): array
'retail_stock' => ['required', 'integer', 'min:0'], 'retail_stock' => ['required', 'integer', 'min:0'],
'photo_keys' => ['required', 'array', 'min:1', 'max:5'], 'photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'photo_keys.*' => ['required', 'string', 'max:500'], 'photo_keys.*' => ['required', 'string', 'max:500'],
'prices' => ['required', 'array', 'size:9'], 'prices' => ['required', 'array'],
'prices.*.type' => ['required', Rule::in(PriceType::values())], 'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'], 'prices.*.price' => ['required', 'integer', 'min:0'],
]; ];

View File

@ -19,6 +19,7 @@
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransactionService class TransactionService
{ {
@ -165,6 +166,12 @@ public function store(array $data): Order
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
if ($totalAmount <= 0) {
throw ValidationException::withMessages([
'discount' => 'Total harga tidak boleh nol atau kurang.',
]);
}
$order = Order::create([ $order = Order::create([
'created_by_id' => auth()->id(), 'created_by_id' => auth()->id(),
'customer_id' => $data['customer_id'] ?? null, 'customer_id' => $data['customer_id'] ?? null,
@ -232,6 +239,12 @@ public function update(Order $order, array $data): Order
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
if ($totalAmount <= 0) {
throw ValidationException::withMessages([
'discount' => 'Total harga tidak boleh nol atau kurang.',
]);
}
foreach ($itemRows as &$row) { foreach ($itemRows as &$row) {
$row['order_id'] = $order->id; $row['order_id'] = $order->id;
} }
@ -303,9 +316,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
$variants = ProductVariant::query() $variants = ProductVariant::query()
->whereKey($variantIds) ->whereKey($variantIds)
->with('productPrices:id,variant_id,type,price') ->with(['productPrices:id,variant_id,type,price', 'product:id,name'])
->get(); ->get();
$variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [
$v->id => $v->product->name.' - '.$v->name,
]);
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType); ->first(fn ($p) => $p->type === $resolvedPriceType);
@ -320,13 +337,28 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) { return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $stockType, &$subtotal, &$totalCost) {
$quantity = (int) $item['quantity']; $quantity = (int) $item['quantity'];
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); $unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0);
$label = $variantLabels[$item['product_variant_id']] ?? ' Produk';
if ($unitPrice <= 0) {
throw ValidationException::withMessages([
'items' => 'Harga untuk "'.$label.'" belum diatur.',
]);
}
$itemSubtotal = $unitPrice * $quantity; $itemSubtotal = $unitPrice * $quantity;
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
if ($capitalPrice <= 0) {
throw ValidationException::withMessages([
'items' => 'Harga modal untuk "'.$label.'" belum diatur.',
]);
}
$totalCost += $capitalPrice * $quantity; $totalCost += $capitalPrice * $quantity;
return [ return [

View File

@ -50,7 +50,7 @@ public function getForRestock(): array
->first(fn ($price) => $price->type === PriceType::REJECT); ->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0; $variant->reject_price = $rejectPrice?->price ?? 0;
}); });
}); })->toArray();
} }
public function getForTransaction(): array public function getForTransaction(): array
@ -74,7 +74,7 @@ public function getForTransaction(): array
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices; $variant->prices = $prices;
}); });
}); })->toArray();
} }
public function getForEdit(ProductVariant $variant): array public function getForEdit(ProductVariant $variant): array

View File

@ -236,12 +236,16 @@ export default function TransactionCreate({
const incrementQuantity = useCallback( const incrementQuantity = useCallback(
(variantId: number, amount: number) => { (variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
})); }));
}, },
[], [getUnitPrice],
); );
const cartItems: CartLine[] = (() => { const cartItems: CartLine[] = (() => {
@ -437,10 +441,14 @@ export default function TransactionCreate({
)}{' '} )}{' '}
pcs pcs
·{' '} ·{' '}
{formatCurrency( {getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject ?? 0) ? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0), : (variant.prices?.[priceType] ?? 0),
)
)} )}
</p> </p>
</div> </div>
@ -490,6 +498,7 @@ export default function TransactionCreate({
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() => onClick={() =>
incrementQuantity( incrementQuantity(
variant.id, variant.id,
@ -882,7 +891,11 @@ export default function TransactionCreate({
!selectedProductId || !selectedProductId ||
Object.values(quantities).every( Object.values(quantities).every(
(q) => q <= 0, (q) => q <= 0,
) ) ||
Object.entries(quantities).some(
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
) ||
total <= 0
} }
> >
{processing {processing

View File

@ -206,12 +206,16 @@ export default function TransactionEdit({
const incrementQuantity = useCallback( const incrementQuantity = useCallback(
(variantId: number, amount: number) => { (variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
})); }));
}, },
[], [getUnitPrice],
); );
const cartItems: CartLine[] = (() => { const cartItems: CartLine[] = (() => {
@ -416,10 +420,14 @@ export default function TransactionEdit({
)}{' '} )}{' '}
pcs pcs
·{' '} ·{' '}
{formatCurrency( {getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject ?? 0) ? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0), : (variant.prices?.[priceType] ?? 0),
)
)} )}
</p> </p>
</div> </div>
@ -469,6 +477,7 @@ export default function TransactionEdit({
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() => onClick={() =>
incrementQuantity( incrementQuantity(
variant.id, variant.id,
@ -860,7 +869,11 @@ export default function TransactionEdit({
uploading || uploading ||
Object.values(quantities).every( Object.values(quantities).every(
(q) => q <= 0, (q) => q <= 0,
) ) ||
Object.entries(quantities).some(
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
) ||
total <= 0
} }
> >
{processing {processing