From 5649674eb4e2b2e9abcabef21604e4feca7050e6 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Wed, 11 Mar 2026 10:14:32 +0700 Subject: [PATCH] feat: Implement payment method fields, refactor order creation logic to support detailed order items and VM provisioning, and add admin notifications. --- app/Events/OrderProvisioned.php | 44 +++++++ .../Api/Admin/Order/OrderController.php | 108 +++++++++++------- .../Api/Admin/Product/ProductController.php | 26 +++++ app/Http/Requests/OrderRequest.php | 8 +- .../Create/CreateVirtualMachineJob.php | 16 +++ app/Models/OrderItem.php | 13 +-- ...ent_method_to_invoice_and_order_tables.php | 31 +++++ routes/api.php | 18 +-- routes/channels.php | 5 + 9 files changed, 208 insertions(+), 61 deletions(-) create mode 100644 app/Events/OrderProvisioned.php create mode 100644 database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php diff --git a/app/Events/OrderProvisioned.php b/app/Events/OrderProvisioned.php new file mode 100644 index 0000000..2d13ec7 --- /dev/null +++ b/app/Events/OrderProvisioned.php @@ -0,0 +1,44 @@ + $this->orderId, + 'order_num' => $this->orderNum, + 'customer_name' => $this->customerName, + 'vm_id' => $this->vmId, + 'success' => $this->success, + 'message' => $this->message, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php index c517eb2..5d1e874 100644 --- a/app/Http/Controllers/Api/Admin/Order/OrderController.php +++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php @@ -100,27 +100,28 @@ class OrderController extends Controller $relid_data = []; foreach ($request->product as $key => $item) { - $product = null; - $relid_data[$key]['type'] = $item['type']; - - $product = match ((int) $item['type']) { - 3 => $this->hostingRepository->find($item['product_id']), - 4 => $this->vPSRepository->find($item['product_id']), - default => null - }; + $product = \App\Models\Product::with('category')->find($item['product_id']); if (!$product) { return response()->json([ 'error' => true, - 'message' => "Invalid product." + 'message' => "Sản phẩm không tồn tại (ID: {$item['product_id']})." ], 422); } + // Derive legacy numeric type from product_type for module creation + $moduleType = match ($product->product_type) { + 'hosting' => 3, + 'vps' => 4, + default => 0, + }; + $relid_data[$key]['type'] = $moduleType; + $message = $product->name; $relid_data[$key]['data'] = [ 'customer_id' => $request->customer_id, 'status' => 0, - 'name' => $item['domain'] + 'name' => $item['domain'] ?? $product->name, ]; $billingDetails = $this->calculateBillingDetails($item['billing']); @@ -132,16 +133,25 @@ class OrderController extends Controller $generalCode = Helper::generateUniqueCode(); + $billingCycle = match ((int) $item['billing']) { + 2 => 'monthly', + 3 => 'quarterly', + 4 => 'semi_annually', + 5 => 'annually', + default => 'monthly', + }; + $product_item[$key] = [ - 'customer_id' => $request->customer_id, - 'type_id' => $item['type'], - 'product_id' => $item['product_id'], - 'description' => $message, - 'amount' => $price_select, - 'billing' => $item['billing'], - 'tax' => 1, - 'due_date' => Carbon::today()->hour(23)->minute(59)->second(59)->timestamp, - 'code' => $generalCode + 'product_id' => $product->id, + 'product_type' => $product->product_type, + 'months' => $billingDetails['billing'], + 'billing_cycle' => $billingCycle, + 'os_id' => $item['os_id'] ?? null, + 'quantity' => (int) ($item['quantity'] ?? 1), + 'server_name' => $item['domain'] ?? null, + 'price' => (int) $price_select, + 'invoice_type' => 1, + 'code' => $generalCode, ]; $relid_data[$key]['code'] = $generalCode; @@ -160,24 +170,23 @@ class OrderController extends Controller $order_item = [ 'order_num' => Helper::generateUniqueCode(), 'customer_id' => $request->customer_id, - 'date' => Carbon::today()->timestamp, 'invoice_id' => 0, - 'discount_id' => $request->discount_id ?? 0, + 'discount_id' => $request->discount_id ?: null, 'amount' => $total + ($total * (10 / 100)), - 'payment_id' => $request->payment_id, + 'payment_method' => $request->payment_method, 'user_id' => Auth::guard('admin')->id(), 'status' => $request->status, 'client_ip' => $request->getClientIp() ]; $invoice_data = [ - 'date' => Carbon::today()->timestamp, - 'due_date' => Carbon::today()->hour(23)->minute(59)->second(59)->timestamp, - 'date_paid' => (int)$request->status === 1 ? Carbon::today()->timestamp : null, + 'date' => Carbon::today()->toDateString(), + 'due_date' => Carbon::today()->toDateString(), + 'date_paid' => (int)$request->status === 1 ? Carbon::today()->toDateString() : null, 'sub_total' => $total, 'tax' => $total * (10 / 100), 'total' => $total + ($total * (10 / 100)), - 'payment_id' => $request->payment_id, + 'payment_method' => $request->payment_method, 'customer_id' => $request->create_invoice ? $request->customer_id : 0, 'status' => $request->status ]; @@ -221,20 +230,41 @@ class OrderController extends Controller } } - $product_item = array_filter(array_map(function ($array) use ($relid) { - if (isset($relid[$array['code']])) { - $array['relid'] = $relid[$array['code']]; - unset($array['code']); - return $array; + $product_item = array_map(function ($array) { + unset($array['code']); + return $array; + }, $product_item); + + $invoiceItemData = array_map(function ($item) { + return \Illuminate\Support\Arr::except($item, ['billing_cycle']); + }, $product_item); + + $this->invoiceItemRepository->insert($invoiceItemData); + + $now = now(); + \App\Models\OrderItem::insert(array_map(fn($item) => [ + 'order_id' => $order->id, + 'product_id' => $item['product_id'], + 'billing_cycle' => $item['billing_cycle'], + 'months' => $item['months'], + 'os_id' => $item['os_id'], + 'quantity' => $item['quantity'], + 'server_name' => $item['server_name'], + 'price' => $item['price'], + 'created_at' => $now, + 'updated_at' => $now, + ], $product_item)); + + // Dispatch VM creation job for VPS/cloud products + $vpsTypes = ['vps', 'cloud']; + $createdOrderItems = \App\Models\OrderItem::where('order_id', $order->id)->get(); + foreach ($createdOrderItems as $orderItem) { + $pt = $orderItem->product?->product_type ?? $orderItem->billing_cycle; + if (in_array($orderItem->product?->product_type, $vpsTypes)) { + \App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob::dispatch($orderItem) + ->delay(now()->addSeconds(10)); } - return null; - }, $product_item)); - - $this->invoiceItemRepository->insert($product_item); - - $last_id = $this->invoiceItemRepository->filter(['limit' => count($product_item)], [], [], ['id'])->pluck('id')->toArray(); - - $order->orderItem()->sync($last_id); + } DB::commit(); diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 584c17d..8d1cfd4 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -145,4 +145,30 @@ class ProductController extends Controller 'message' => 'Xoá sản phẩm thành công.', ]); } + + public function getOs(int $id): JsonResponse + { + $product = Product::find($id); + + if (!$product) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy sản phẩm.'], 404); + } + + $osList = $product->osVersions() + ->with('osGroup') + ->where('status', 1) + ->get() + ->map(fn($os) => [ + 'id' => $os->id, + 'name' => $os->name, + 'version' => $os->version, + 'os_group_name' => $os->osGroup?->name, + 'template_id' => $os->template_id, + ]); + + return response()->json([ + 'error' => false, + 'data' => $osList, + ]); + } } diff --git a/app/Http/Requests/OrderRequest.php b/app/Http/Requests/OrderRequest.php index 73218e6..95907b3 100644 --- a/app/Http/Requests/OrderRequest.php +++ b/app/Http/Requests/OrderRequest.php @@ -23,12 +23,12 @@ class OrderRequest extends FormRequest { return [ 'customer_id' => 'required', - 'payment_id' => 'required', + 'payment_method' => 'required', 'status' => 'required', - 'product.*.type' => 'required|numeric|not__in:0', 'product.*.product_id' => 'required|numeric|not__in:0', - 'product.*.billing' => 'required|not__in:0', - 'product.*.quantity' => 'required|min:1', + 'product.*.os_id' => 'nullable|integer', + 'product.*.billing' => 'required|not__in:0', + 'product.*.quantity' => 'required|min:1', ]; } } diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index fb7f270..d90829d 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -285,6 +285,22 @@ class CreateVirtualMachineJob implements ShouldQueue ] ); $this->writeCustomerSuccessLog(); + + try { + $order = $this->item->order; + $customer = $order->customer; + broadcast(new \App\Events\OrderProvisioned( + orderId: $order->id, + orderNum: $order->order_num, + customerName: trim($customer->firstname . ' ' . $customer->lastname), + vmId: (string) $vmId, + success: true, + message: 'Máy ảo đã được khởi tạo thành công cho khách hàng ' . trim($customer->firstname . ' ' . $customer->lastname) . ' (Đơn #' . $order->order_num . ')', + )); + } catch (\Throwable $e) { + Log::warning('Không thể broadcast OrderProvisioned: ' . $e->getMessage()); + } + return true; } diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index d6bdb16..4b88bbd 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -25,20 +25,11 @@ class OrderItem extends Model public function getProductAttribute() { - // New flow: Check if product exists in Product table first if ($this->product_id) { - $product = Product::find($this->product_id); - if ($product) { - return $product; - } + return Product::find($this->product_id); } - // Legacy flow: fallback to old system - return match ($this->product_type) { - 3 => Hosting::find($this->product_id), - 4 => Vps::find($this->product_id), - default => null, - }; + return null; } /** diff --git a/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php b/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php new file mode 100644 index 0000000..af05f41 --- /dev/null +++ b/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php @@ -0,0 +1,31 @@ +string('payment_method')->nullable()->after('status'); + }); + + Schema::table('order', function (Blueprint $table) { + if (!Schema::hasColumn('order', 'payment_method')) { + $table->string('payment_method')->nullable()->after('status'); + } + }); + } + + public function down(): void + { + Schema::table('invoice', function (Blueprint $table) { + $table->dropColumn('payment_method'); + }); + + Schema::table('order', function (Blueprint $table) { + $table->dropColumn('payment_method'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index df5a87d..13fb4a1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -91,19 +91,23 @@ Route::prefix('v1') Route::post('/', [ProductController::class, 'store'])->middleware('perm:product-service.create'); Route::post('import', [ProductController::class, 'import'])->middleware('perm:product-service.create'); Route::get('{id}', [ProductController::class, 'show'])->middleware('perm:product-service.view'); + Route::get('{id}/os', [ProductController::class, 'getOs'])->middleware('perm:product-service.view'); Route::put('{id}', [ProductController::class, 'update'])->middleware('perm:product-service.edit'); Route::delete('{id}', [ProductController::class, 'destroy'])->middleware('perm:product-service.destroy'); Route::post('{id}/duplicate', [ProductController::class, 'duplicate'])->middleware('perm:product-service.create'); }); // Product Categories / Groups (module: product-service) - Route::prefix('product-categories')->group(function () { - Route::get('/', [ProductCategoryController::class, 'index'])->middleware('perm:product-service.view'); - Route::post('/', [ProductCategoryController::class, 'store'])->middleware('perm:product-service.create'); - Route::get('{id}', [ProductCategoryController::class, 'show'])->middleware('perm:product-service.view'); - Route::put('{id}', [ProductCategoryController::class, 'update'])->middleware('perm:product-service.edit'); - Route::delete('{id}', [ProductCategoryController::class, 'destroy'])->middleware('perm:product-service.destroy'); - }); + Route::prefix('product-categories') + ->controller(ProductCategoryController::class) + ->as('product-categories.') + ->group(function () { + Route::get('/', 'index')->middleware('perm:product-service.view')->name('index'); + Route::post('/', 'store')->middleware('perm:product-service.create')->name('store'); + Route::get('{id}', 'show')->middleware('perm:product-service.view')->name('show'); + Route::put('{id}', 'update')->middleware('perm:product-service.edit')->name('update'); + Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy')->name('destroy'); + }); // Orders (module: order) Route::prefix('orders')->group(function () { diff --git a/routes/channels.php b/routes/channels.php index 60e88b4..e8565b7 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -35,3 +35,8 @@ Broadcast::channel('Panel.Status.{id}', function ($user, $id) { Broadcast::channel('user.{id}', function ($user, $id) { return (int) $user->id === (int) $id; }, ['guards' => ['client']]); + +// Admin notifications channel (public — all authenticated admins) +Broadcast::channel('admin-notifications', function () { + return true; +}, ['guards' => ['admin', 'admins-api']]);