From 7164bd96d3693f1071727630941188fde9db6976 Mon Sep 17 00:00:00 2001
From: Yoga Pangestu
Date: Wed, 12 Aug 2026 20:16:57 +0700
Subject: [PATCH] feat: add validation for product prices in transaction
creation and editing
---
.../Master/Product/ProductVariantRequest.php | 2 +-
.../Admin/Manage/TransactionService.php | 36 ++++++++++++++-
.../Admin/Master/Product/ProductService.php | 44 +++++++++----------
.../Master/Product/ProductVariantService.php | 4 +-
.../pages/admin/manage/transaction/create.tsx | 25 ++++++++---
.../pages/admin/manage/transaction/edit.tsx | 25 ++++++++---
6 files changed, 97 insertions(+), 39 deletions(-)
diff --git a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php
index 6a068e4..30ffe60 100644
--- a/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php
+++ b/app/Http/Requests/Admin/Master/Product/ProductVariantRequest.php
@@ -31,7 +31,7 @@ public function rules(): array
'retail_stock' => ['required', 'integer', 'min:0'],
'photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'photo_keys.*' => ['required', 'string', 'max:500'],
- 'prices' => ['required', 'array', 'size:9'],
+ 'prices' => ['required', 'array'],
'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'],
];
diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php
index e518dec..afef58b 100644
--- a/app/Services/Admin/Manage/TransactionService.php
+++ b/app/Services/Admin/Manage/TransactionService.php
@@ -19,6 +19,7 @@
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
class TransactionService
{
@@ -165,6 +166,12 @@ public function store(array $data): Order
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
+ if ($totalAmount <= 0) {
+ throw ValidationException::withMessages([
+ 'discount' => 'Total harga tidak boleh nol atau kurang.',
+ ]);
+ }
+
$order = Order::create([
'created_by_id' => auth()->id(),
'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;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
+ if ($totalAmount <= 0) {
+ throw ValidationException::withMessages([
+ 'discount' => 'Total harga tidak boleh nol atau kurang.',
+ ]);
+ }
+
foreach ($itemRows as &$row) {
$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();
$variants = ProductVariant::query()
->whereKey($variantIds)
- ->with('productPrices:id,variant_id,type,price')
+ ->with(['productPrices:id,variant_id,type,price', 'product:id,name'])
->get();
+ $variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [
+ $v->id => $v->product->name.' - '.$v->name,
+ ]);
+
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices
->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 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'];
$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;
$subtotal += $itemSubtotal;
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
+
+ if ($capitalPrice <= 0) {
+ throw ValidationException::withMessages([
+ 'items' => 'Harga modal untuk "'.$label.'" belum diatur.',
+ ]);
+ }
+
$totalCost += $capitalPrice * $quantity;
return [
diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php
index a1693f1..c00ecdb 100644
--- a/app/Services/Admin/Master/Product/ProductService.php
+++ b/app/Services/Admin/Master/Product/ProductService.php
@@ -40,10 +40,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
- ->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
- ->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%"))
- ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
- ->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
+ ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
+ ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
+ ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
+ ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);
}))
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
@@ -58,7 +58,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('images');
- $variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
+ $variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
});
});
@@ -84,7 +84,7 @@ public function store(array $data): Product
// Bulk insert variants
$now = now();
- $variantRows = collect($data['variants'])->map(fn($v) => [
+ $variantRows = collect($data['variants'])->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@@ -98,7 +98,7 @@ public function store(array $data): Product
// Map variant name -> variant ID
$insertedVariants = ProductVariant::where('product_id', $product->id)->get();
- $variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]);
+ $variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]);
// Bulk insert prices
$priceRows = [];
@@ -144,7 +144,7 @@ public function store(array $data): Product
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru',
- body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
@@ -161,8 +161,8 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('images');
- $photoKeys = $media->map(fn($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
- $photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
+ $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
+ $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
return [
'id' => $variant->id,
@@ -172,7 +172,7 @@ public function getForEdit(Product $product): array
'retail_stock' => $variant->retail_stock,
'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls,
- 'prices' => $variant->productPrices->map(fn($p) => [
+ 'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value,
'price' => $p->price,
]),
@@ -230,7 +230,7 @@ public function update(Product $product, array $data): Product
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media')
->get()
- ->mapWithKeys(fn($v) => [$v->id => $v]);
+ ->mapWithKeys(fn ($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants
$oldStockDataMap = [];
@@ -256,7 +256,7 @@ public function update(Product $product, array $data): Product
// Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) {
$changedUpdates = collect($data['variants'])
- ->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds));
+ ->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds));
foreach ($changedUpdates as $variantData) {
$existingVariantsMap[$variantData['id']]->update([
@@ -269,11 +269,11 @@ public function update(Product $product, array $data): Product
}
// Bulk create new variants
- $newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id']));
+ $newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
$newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) {
- $newVariantRows = $newVariantsData->map(fn($v) => [
+ $newVariantRows = $newVariantsData->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@@ -290,7 +290,7 @@ public function update(Product $product, array $data): Product
->whereIn('name', $newVariantsData->pluck('name')->toArray())
->get();
- $newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray();
+ $newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray();
}
// Build variant_id lookup: existing by id, new by name
@@ -354,7 +354,7 @@ public function update(Product $product, array $data): Product
}
}
- $changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter();
+ $changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter();
$this->stockMutationService->recordBulkAdjustment(
$changedModels,
$oldStockDataMap,
@@ -388,7 +388,7 @@ public function update(Product $product, array $data): Product
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui',
- body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
@@ -414,7 +414,7 @@ public function destroy(Product $product): bool
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus',
- body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
@@ -439,7 +439,7 @@ public function approve(Product $product): void
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui',
- body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
@@ -455,7 +455,7 @@ public function reject(Product $product, string $reason = ''): void
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak',
- body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
@@ -471,7 +471,7 @@ public function resubmit(Product $product): void
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang',
- body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.',
+ body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
}
diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php
index 8751011..dafe278 100644
--- a/app/Services/Admin/Master/Product/ProductVariantService.php
+++ b/app/Services/Admin/Master/Product/ProductVariantService.php
@@ -50,7 +50,7 @@ public function getForRestock(): array
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
- });
+ })->toArray();
}
public function getForTransaction(): array
@@ -74,7 +74,7 @@ public function getForTransaction(): array
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
- });
+ })->toArray();
}
public function getForEdit(ProductVariant $variant): array
diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx
index beb4c1c..3785054 100644
--- a/resources/js/pages/admin/manage/transaction/create.tsx
+++ b/resources/js/pages/admin/manage/transaction/create.tsx
@@ -236,12 +236,16 @@ export default function TransactionCreate({
const incrementQuantity = useCallback(
(variantId: number, amount: number) => {
+ if (amount > 0 && getUnitPrice(variantId) <= 0) {
+ toast.error('Harga produk ini belum diatur.');
+ return;
+ }
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
}));
},
- [],
+ [getUnitPrice],
);
const cartItems: CartLine[] = (() => {
@@ -437,10 +441,14 @@ export default function TransactionCreate({
)}{' '}
pcs
·{' '}
- {formatCurrency(
- stockType === 'reject'
- ? (variant.prices?.reject ?? 0)
- : (variant.prices?.[priceType] ?? 0),
+ {getUnitPrice(variant.id) <= 0 ? (
+ Harga belum diatur
+ ) : (
+ formatCurrency(
+ stockType === 'reject'
+ ? (variant.prices?.reject ?? 0)
+ : (variant.prices?.[priceType] ?? 0),
+ )
)}
@@ -490,6 +498,7 @@ export default function TransactionCreate({
type="button"
variant="outline"
size="icon"
+ disabled={getUnitPrice(variant.id) <= 0}
onClick={() =>
incrementQuantity(
variant.id,
@@ -882,7 +891,11 @@ export default function TransactionCreate({
!selectedProductId ||
Object.values(quantities).every(
(q) => q <= 0,
- )
+ ) ||
+ Object.entries(quantities).some(
+ ([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
+ ) ||
+ total <= 0
}
>
{processing
diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx
index 74e7aeb..6f58eec 100644
--- a/resources/js/pages/admin/manage/transaction/edit.tsx
+++ b/resources/js/pages/admin/manage/transaction/edit.tsx
@@ -206,12 +206,16 @@ export default function TransactionEdit({
const incrementQuantity = useCallback(
(variantId: number, amount: number) => {
+ if (amount > 0 && getUnitPrice(variantId) <= 0) {
+ toast.error('Harga produk ini belum diatur.');
+ return;
+ }
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
}));
},
- [],
+ [getUnitPrice],
);
const cartItems: CartLine[] = (() => {
@@ -416,10 +420,14 @@ export default function TransactionEdit({
)}{' '}
pcs
·{' '}
- {formatCurrency(
- stockType === 'reject'
- ? (variant.prices?.reject ?? 0)
- : (variant.prices?.[priceType] ?? 0),
+ {getUnitPrice(variant.id) <= 0 ? (
+ Harga belum diatur
+ ) : (
+ formatCurrency(
+ stockType === 'reject'
+ ? (variant.prices?.reject ?? 0)
+ : (variant.prices?.[priceType] ?? 0),
+ )
)}
@@ -469,6 +477,7 @@ export default function TransactionEdit({
type="button"
variant="outline"
size="icon"
+ disabled={getUnitPrice(variant.id) <= 0}
onClick={() =>
incrementQuantity(
variant.id,
@@ -860,7 +869,11 @@ export default function TransactionEdit({
uploading ||
Object.values(quantities).every(
(q) => q <= 0,
- )
+ ) ||
+ Object.entries(quantities).some(
+ ([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
+ ) ||
+ total <= 0
}
>
{processing