feat: implement order cancellation logic to restore stock while preserving existing order items
This commit is contained in:
parent
30fcfb4f20
commit
482ce9f3b8
@ -36,7 +36,7 @@ public function create(): Response
|
|||||||
->whereNull('order_id')
|
->whereNull('order_id')
|
||||||
->latest()
|
->latest()
|
||||||
->get(),
|
->get(),
|
||||||
'orderStatus' => OrderStatus::options(),
|
'orderStatus' => collect(OrderStatus::options())->filter(fn ($opt) => $opt['value'] !== OrderStatus::CANCELLED->value)->values()->toArray(),
|
||||||
'orderChannels' => OrderChannel::options(),
|
'orderChannels' => OrderChannel::options(),
|
||||||
'paymentMethods' => PaymentMethod::options(),
|
'paymentMethods' => PaymentMethod::options(),
|
||||||
'priceTypes' => PriceType::options(),
|
'priceTypes' => PriceType::options(),
|
||||||
@ -136,21 +136,51 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($validated, $order) {
|
DB::transaction(function () use ($validated, $order) {
|
||||||
$totalItemsPrice = collect($validated['items'])->sum(fn ($item) => $item['qty'] * $item['price']);
|
$isCancelled = $validated['order_status'] === OrderStatus::CANCELLED->value;
|
||||||
$cogs = collect($validated['items'])->sum(function ($item) {
|
$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']);
|
$product = Product::find($item['product_id']);
|
||||||
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
|
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
|
||||||
|
|
||||||
return $purchasePrice * $item['qty'];
|
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([
|
$order->update([
|
||||||
'customer_name' => $validated['customer_name'],
|
'customer_name' => $validated['customer_name'],
|
||||||
'cogs' => $cogs,
|
'cogs' => $cogs,
|
||||||
@ -162,7 +192,7 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
|
|||||||
'order_channel' => $validated['order_channel'],
|
'order_channel' => $validated['order_channel'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($items as $item) {
|
||||||
$order->items()->create([
|
$order->items()->create([
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'product_id' => $item['product_id'],
|
'product_id' => $item['product_id'],
|
||||||
@ -172,6 +202,7 @@ public function update(OrderRequest $request, Order $order): RedirectResponse
|
|||||||
'price_type' => $item['price_type'],
|
'price_type' => $item['price_type'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Potong stok (karena status sekarang bukan gagal)
|
||||||
$product = Product::find($item['product_id']);
|
$product = Product::find($item['product_id']);
|
||||||
if ($product->stock < $item['qty']) {
|
if ($product->stock < $item['qty']) {
|
||||||
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
|
throw new \InvalidArgumentException("Stok produk {$product->name} tidak mencukupi. Sisa stok: {$product->stock}");
|
||||||
|
|||||||
@ -27,20 +27,22 @@ public function authorize(): bool
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$isCancelled = $this->input('order_status') === OrderStatus::CANCELLED->value;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'customer_name' => ['required', 'string', 'max:100'],
|
'customer_name' => ['required', 'string', 'max:100'],
|
||||||
'subtotal' => ['required', 'integer', 'min:0'],
|
'subtotal' => ['nullable', 'integer', 'min:0'],
|
||||||
'discount' => ['required', 'integer', 'min:0'],
|
'discount' => ['required', 'integer', 'min:0'],
|
||||||
'payment' => ['required', 'integer', 'min:0'],
|
'payment' => ['required', 'integer', 'min:0'],
|
||||||
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
|
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
|
||||||
'order_status' => ['required', Rule::enum(OrderStatus::class)],
|
'order_status' => ['required', Rule::enum(OrderStatus::class)],
|
||||||
'order_channel' => ['required', Rule::enum(OrderChannel::class)],
|
'order_channel' => ['required', Rule::enum(OrderChannel::class)],
|
||||||
'items' => ['required', 'array', 'min:1'],
|
'items' => [$isCancelled ? 'nullable' : 'required', 'array', $isCancelled ? 'min:0' : 'min:1'],
|
||||||
'items.*.product_id' => ['required', Rule::exists('products', 'id')->whereNull('deleted_at')],
|
'items.*.product_id' => ['required_unless:order_status,cancelled', Rule::exists('products', 'id')->whereNull('deleted_at')],
|
||||||
'items.*.qty' => ['required', 'integer', 'min:1'],
|
'items.*.qty' => ['required_unless:order_status,cancelled', 'integer', 'min:1'],
|
||||||
'items.*.price' => ['required', 'integer', 'min:0'],
|
'items.*.price' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
|
||||||
'items.*.total' => ['required', 'integer', 'min:0'],
|
'items.*.total' => ['required_unless:order_status,cancelled', 'integer', 'min:0'],
|
||||||
'items.*.price_type' => ['required', Rule::enum(PriceType::class)],
|
'items.*.price_type' => ['required_unless:order_status,cancelled', Rule::enum(PriceType::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Enums\OrderStatus;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
|
||||||
class OrderObserver
|
class OrderObserver
|
||||||
@ -40,8 +41,14 @@ public function updating(Order $order): void
|
|||||||
*/
|
*/
|
||||||
public function deleting(Order $order): void
|
public function deleting(Order $order): void
|
||||||
{
|
{
|
||||||
foreach ($order->items as $item) {
|
$isCancelled = ($order->order_status instanceof OrderStatus ? $order->order_status->value : $order->order_status) === OrderStatus::CANCELLED->value;
|
||||||
$item->product->increment('stock', $item->qty);
|
|
||||||
|
if (! $isCancelled) {
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$item->product->increment('stock', $item->qty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$order->items()->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,9 @@
|
|||||||
->component('admin/manage/order/create')
|
->component('admin/manage/order/create')
|
||||||
->has('products')
|
->has('products')
|
||||||
->has('cartItems')
|
->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);
|
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 () {
|
it('can delete an order and restore stock', function () {
|
||||||
$product = Product::factory()->create(['stock' => 5]);
|
$product = Product::factory()->create(['stock' => 5]);
|
||||||
$order = Order::factory()->create();
|
$order = Order::factory()->create();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user