feat: implement order cancellation logic to restore stock while preserving existing order items
Some checks failed
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-05-02 19:29:11 +07:00
parent 30fcfb4f20
commit 482ce9f3b8
4 changed files with 111 additions and 20 deletions

View File

@ -36,7 +36,7 @@ public function create(): Response
->whereNull('order_id')
->latest()
->get(),
'orderStatus' => OrderStatus::options(),
'orderStatus' => collect(OrderStatus::options())->filter(fn ($opt) => $opt['value'] !== OrderStatus::CANCELLED->value)->values()->toArray(),
'orderChannels' => OrderChannel::options(),
'paymentMethods' => PaymentMethod::options(),
'priceTypes' => PriceType::options(),
@ -136,21 +136,51 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
try {
DB::transaction(function () use ($validated, $order) {
$totalItemsPrice = collect($validated['items'])->sum(fn ($item) => $item['qty'] * $item['price']);
$cogs = collect($validated['items'])->sum(function ($item) {
$isCancelled = $validated['order_status'] === OrderStatus::CANCELLED->value;
$wasCancelled = ($order->order_status instanceof OrderStatus ? $order->order_status->value : $order->order_status) === OrderStatus::CANCELLED->value;
// 1. Jika status SEKARANG adalah GAGAL
if ($isCancelled) {
// Kembalikan stok jika sebelumnya TIDAK gagal
if (! $wasCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
// Update metadata saja, jangan hapus item (sesuai request)
$order->update([
'customer_name' => $validated['customer_name'],
'discount' => $validated['discount'],
'payment' => $validated['payment'],
'payment_method' => $validated['payment_method'],
'order_status' => $validated['order_status'],
'order_channel' => $validated['order_channel'],
]);
return;
}
// 2. Jika status SEKARANG TIDAK GAGAL
// Kembalikan stok lama jika sebelumnya TIDAK gagal (karena kita akan mengganti item)
if (! $wasCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
// Proses update item (hapus lama, buat baru)
$order->items()->delete();
$items = $validated['items'] ?? [];
$totalItemsPrice = collect($items)->sum(fn ($item) => $item['qty'] * $item['price']);
$cogs = collect($items)->sum(function ($item) {
$product = Product::find($item['product_id']);
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
return $purchasePrice * $item['qty'];
});
// Restore stock for old items
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
$order->items()->delete();
$order->update([
'customer_name' => $validated['customer_name'],
'cogs' => $cogs,
@ -162,7 +192,7 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
'order_channel' => $validated['order_channel'],
]);
foreach ($validated['items'] as $item) {
foreach ($items as $item) {
$order->items()->create([
'user_id' => auth()->id(),
'product_id' => $item['product_id'],
@ -172,6 +202,7 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
'price_type' => $item['price_type'],
]);
// Potong stok (karena status sekarang bukan gagal)
$product = Product::find($item['product_id']);
if ($product->stock < $item['qty']) {
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");

View File

@ -27,20 +27,22 @@ public function authorize(): bool
*/
public function rules(): array
{
$isCancelled = $this->input('order_status') === OrderStatus::CANCELLED->value;
return [
'customer_name' => ['required', 'string', 'max:100'],
'subtotal' => ['required', 'integer', 'min:0'],
'subtotal' => ['nullable', 'integer', 'min:0'],
'discount' => ['required', 'integer', 'min:0'],
'payment' => ['required', 'integer', 'min:0'],
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
'order_status' => ['required', Rule::enum(OrderStatus::class)],
'order_channel' => ['required', Rule::enum(OrderChannel::class)],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', Rule::exists('products', 'id')->whereNull('deleted_at')],
'items.*.qty' => ['required', 'integer', 'min:1'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.total' => ['required', 'integer', 'min:0'],
'items.*.price_type' => ['required', Rule::enum(PriceType::class)],
'items' => [$isCancelled ? 'nullable' : 'required', 'array', $isCancelled ? 'min:0' : 'min:1'],
'items.*.product_id' => ['required_unless:order_status,cancelled', Rule::exists('products', 'id')->whereNull('deleted_at')],
'items.*.qty' => ['required_unless:order_status,cancelled', 'integer', 'min:1'],
'items.*.price' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
'items.*.total' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
'items.*.price_type' => ['required_unless:order_status,cancelled', Rule::enum(PriceType::class)],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Observers;
use App\Enums\OrderStatus;
use App\Models\Order;
class OrderObserver
@ -40,8 +41,14 @@ public function updating(Order $order): void
*/
public function deleting(Order $order): void
{
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
$isCancelled = ($order->order_status instanceof OrderStatus ? $order->order_status->value : $order->order_status) === OrderStatus::CANCELLED->value;
if (! $isCancelled) {
foreach ($order->items as $item) {
$item->product->increment('stock', $item->qty);
}
}
$order->items()->delete();
}
}

View File

@ -79,6 +79,9 @@
->component('admin/manage/order/create')
->has('products')
->has('cartItems')
->where('orderStatus', function ($status) {
return collect($status)->every(fn ($item) => $item['value'] !== OrderStatus::CANCELLED->value);
})
);
});
@ -337,6 +340,54 @@
expect($product2->fresh()->stock)->toBe(7);
});
it('restores stock and preserves items when order status is changed to gagal', function () {
$product = Product::factory()->create(['stock' => 10]);
$order = Order::factory()->create(['order_status' => OrderStatus::PENDING->value]);
$item = $order->items()->create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'qty' => 3,
'price' => 1000,
'total' => 3000,
'price_type' => PriceType::RETAIL->value,
]);
$product->decrement('stock', 3);
expect($product->fresh()->stock)->toBe(7);
$newData = [
'customer_name' => $order->customer_name,
'subtotal' => 3000,
'discount' => 0,
'payment' => 3000,
'payment_method' => $order->payment_method,
'order_status' => OrderStatus::CANCELLED->value, // Change to Gagal
'order_channel' => $order->order_channel,
'items' => [
// Even if we send items, they shouldn't be deleted/updated based on my early return
[
'product_id' => $product->id,
'qty' => 5, // different qty
'price' => 1000,
'total' => 5000,
'price_type' => PriceType::RETAIL->value,
],
],
];
patchJson(route('order.update', $order), $newData)
->assertRedirect();
// Stock should be restored (7 + 3 = 10)
expect($product->fresh()->stock)->toBe(10);
// Items should be preserved (original item with qty 3, NOT deleted/recreated with qty 5)
expect($order->items()->count())->toBe(1);
expect($order->items()->first()->qty)->toBe(3);
expect($order->items()->first()->id)->toBe($item->id); // Same ID means not deleted/recreated
});
it('can delete an order and restore stock', function () {
$product = Product::factory()->create(['stock' => 5]);
$order = Order::factory()->create();