From 3243473843dbb99ac1403bb6c507591458a2586d Mon Sep 17 00:00:00 2001 From: baodev0 Date: Mon, 15 Jun 2026 09:11:57 +0700 Subject: [PATCH] update logic --- .DS_Store | Bin 10244 -> 10244 bytes .claude/settings.local.json | 8 +- app/Console/Commands/SyncLegacyPortal.php | 56 ++ app/DTOs/Order/ClientOrderFilters.php | 30 + .../Api/Admin/Customer/CustomerController.php | 113 +++ .../Api/Admin/VCenter/VCenterController.php | 2 + .../Api/Service/OrderControllerRefactor.php | 236 +++++ .../Api/Order/CheckServerNameRequest.php | 31 + .../Api/Order/RenewServiceRequest.php | 32 + .../Service/Order/InvoiceDetailResource.php | 56 ++ .../Service/Order/InvoiceSummaryResource.php | 25 + .../Api/Service/Order/OrderDetailResource.php | 40 + .../Api/Service/Order/OrderItemResource.php | 40 + .../Service/Order/OrderSummaryResource.php | 30 + .../Service/Order/ServiceDetailResource.php | 55 ++ .../Service/Order/ServiceSummaryResource.php | 33 + app/Jobs/VirtualMachine/Sync/SyncNetwork.php | 9 +- app/Models/ModuleVmware.php | 1 + .../Api/Order/OrderRepository.php | 21 +- app/Services/Api/Order/OrderQueryService.php | 260 +++++ app/Services/Imports/LegacyPortalImporter.php | 285 ++++++ config/database.php | 16 + ...15_000001_add_legacy_id_to_sync_tables.php | 32 + ..._000002_add_legacy_id_to_module_vmware.php | 23 + docs/admin-folder-usage.md | 121 +++ docs/refactor-priority.md | 44 + public/css/app.css | 2 +- routes/api.php | 2 + routes/client.php | 6 +- storage/backups/OrderController.original.php | 917 ++++++++++++++++++ 30 files changed, 2502 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/SyncLegacyPortal.php create mode 100644 app/DTOs/Order/ClientOrderFilters.php create mode 100644 app/Http/Controllers/Api/Service/OrderControllerRefactor.php create mode 100644 app/Http/Requests/Api/Order/CheckServerNameRequest.php create mode 100644 app/Http/Requests/Api/Order/RenewServiceRequest.php create mode 100644 app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php create mode 100644 app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php create mode 100644 app/Http/Resources/Api/Service/Order/OrderDetailResource.php create mode 100644 app/Http/Resources/Api/Service/Order/OrderItemResource.php create mode 100644 app/Http/Resources/Api/Service/Order/OrderSummaryResource.php create mode 100644 app/Http/Resources/Api/Service/Order/ServiceDetailResource.php create mode 100644 app/Http/Resources/Api/Service/Order/ServiceSummaryResource.php create mode 100644 app/Services/Api/Order/OrderQueryService.php create mode 100644 app/Services/Imports/LegacyPortalImporter.php create mode 100644 database/migrations/2026_06_15_000001_add_legacy_id_to_sync_tables.php create mode 100644 database/migrations/2026_06_15_000002_add_legacy_id_to_module_vmware.php create mode 100644 docs/admin-folder-usage.md create mode 100644 docs/refactor-priority.md create mode 100644 storage/backups/OrderController.original.php diff --git a/.DS_Store b/.DS_Store index af256511ea937cb48d1ea4debce7c5b51f4ad76b..47fb12f186c27fac6821df179b5a46cee1b08c29 100644 GIT binary patch delta 14 VcmZn(XbD&#$+Wv{Gm|6}KL97~1atrZ delta 14 VcmZn(XbD&#$+W{}Gm|6}KL93t1UCQx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9e2b433..fe39b1a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,13 @@ "Bash(redis-cli --no-auth-warning lrange \"the_gioi_so_horizon:queue:default\" 0 4)", "Bash(redis-cli *)", "Bash(echo \"exit=$?\")", - "Read(//Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal-client/app/\\(main\\)/dich-vu/**)" + "Read(//Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal-client/app/\\(main\\)/dich-vu/**)", + "Read(//Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/**)", + "Bash(grep -n -A 50 \"CREATE TABLE \\\\`users\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", + "Bash(grep -n -A 40 \"CREATE TABLE \\\\`services\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", + "Bash(grep -n -A 30 \"CREATE TABLE \\\\`products\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", + "Bash(grep -n -A 30 \"CREATE TABLE \\\\`vmware\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", + "Bash(pnpm tsc *)" ] } } diff --git a/app/Console/Commands/SyncLegacyPortal.php b/app/Console/Commands/SyncLegacyPortal.php new file mode 100644 index 0000000..db14d24 --- /dev/null +++ b/app/Console/Commands/SyncLegacyPortal.php @@ -0,0 +1,56 @@ +option('preview'); + $importer->setDefaultVpsProductId((int) $this->option('default-product')); + + $map = []; + foreach ((array) $this->option('map') as $pair) { + if (! str_contains($pair, ':')) continue; + [$legacy, $new] = explode(':', $pair, 2); + $map[(int) $legacy] = (int) $new; + } + if ($map) { + $importer->setProductMap($map); + $this->info('Product map: ' . json_encode($map)); + } + + if (! $preview && ! $this->confirm('This will write to the current DB. Continue?', true)) { + $this->warn('Aborted.'); + return self::SUCCESS; + } + + try { + $summary = $importer->import( + preview: $preview, + log: fn ($msg) => $this->line($msg), + ); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + $this->newLine(); + $this->info('=== Summary ==='); + foreach ($summary as $section => $stats) { + $this->line(str_pad(ucfirst($section), 12) . ': ' . json_encode($stats)); + } + + return self::SUCCESS; + } +} diff --git a/app/DTOs/Order/ClientOrderFilters.php b/app/DTOs/Order/ClientOrderFilters.php new file mode 100644 index 0000000..2d70d79 --- /dev/null +++ b/app/DTOs/Order/ClientOrderFilters.php @@ -0,0 +1,30 @@ + $this->totalPrice, + 'pending' => $this->pending, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 40352e5..f764d4e 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -575,6 +575,119 @@ class CustomerController extends Controller ]); } + /** + * Create a service for a customer. For VPS products, accepts a + * `virtual_machine_id` from the vCenter inventory and auto-creates the + * matching module_vmware row so `services.vm_id` can be linked. + */ + public function createService(string $uuid, Request $request): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + if (!$customer) { + return response()->json(['error' => true, 'message' => "Can't find customer"], 404); + } + + $data = $request->validate([ + 'product_id' => 'required|integer|exists:products,id', + 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', + 'server_name' => 'nullable|string|max:255', + 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', + 'auto_renew' => 'nullable|boolean', + 'started_at' => 'nullable|date', + 'expires_at' => 'nullable|date', + 'note' => 'nullable|string', + ]); + + $product = \App\Models\Product::find($data['product_id']); + if ($product->product_type === 'vps' && empty($data['virtual_machine_id'])) { + return response()->json([ + 'error' => true, + 'message' => 'VPS service requires a virtual_machine_id', + ], 422); + } + + try { + DB::beginTransaction(); + + $vmId = null; + if (!empty($data['virtual_machine_id'])) { + $vm = \App\Models\VirtualMachines::find($data['virtual_machine_id']); + $moduleVm = \App\Models\ModuleVmware::create([ + 'customer_id' => $customer->id, + 'name' => $vm->name, + 'vmware_id' => $vm->vm_id, + 'virtual_machine_id' => $vm->id, + 'hostname' => $data['server_name'] ?? $vm->name, + 'status' => 1, + ]); + $vmId = $moduleVm->id; + } + + $service = \App\Models\Service::create([ + 'customer_id' => $customer->id, + 'product_id' => $data['product_id'], + 'vm_id' => $vmId, + 'server_name' => $data['server_name'] ?? null, + 'status' => $data['status'] ?? 'active', + 'auto_renew' => (bool) ($data['auto_renew'] ?? false), + 'started_at' => $data['started_at'] ?? now(), + 'expires_at' => $data['expires_at'] ?? null, + 'note' => $data['note'] ?? null, + ]); + + DB::commit(); + return response()->json([ + 'error' => false, + 'message' => 'Service created', + 'data' => $service->load(['product']), + ]); + } catch (\Throwable $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + /** + * List virtual machines from vCenter inventory for the VPS picker. + * Annotates each VM with `assigned_service_id` if already linked to a service. + */ + public function listAvailableVms(Request $request): JsonResponse + { + $search = trim((string) $request->get('q', '')); + + $assignments = DB::table('module_vmware as mv') + ->join('services as s', 's.vm_id', '=', 'mv.id') + ->whereNotNull('mv.virtual_machine_id') + ->select( + 'mv.virtual_machine_id', + 's.id as service_id', + 's.customer_id as service_customer_id', + ) + ->get() + ->keyBy('virtual_machine_id'); + + $vms = DB::table('virtual_machines') + ->select('id', 'vm_id', 'name', 'power_state', 'cpu', 'ram_mb', 'ip') + ->where('is_template', 0) + ->where('is_system', 0) + ->when($search, fn ($q) => $q->where(function ($w) use ($search) { + $w->where('name', 'like', "%{$search}%") + ->orWhere('vm_id', 'like', "%{$search}%") + ->orWhere('ip', 'like', "%{$search}%"); + })) + ->orderBy('name') + ->limit(200) + ->get() + ->map(function ($vm) use ($assignments) { + $a = $assignments->get($vm->id); + $vm->assigned_service_id = $a->service_id ?? null; + $vm->assigned_customer_id = $a->service_customer_id ?? null; + return $vm; + }); + + return response()->json(['error' => false, 'data' => $vms]); + } + public function getOrders(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); diff --git a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php index 0c325b5..43095e7d 100644 --- a/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php +++ b/app/Http/Controllers/Api/Admin/VCenter/VCenterController.php @@ -189,6 +189,7 @@ class VCenterController extends Controller 'db_id' => $vm->id, 'uuid' => $vm->uuid, 'vm_id' => $vm->vm_id, + 'ip' => $vm->ip, 'power_state' => $vm->power_state, 'folder' => $vm->folder, 'cpu' => $vm->cpu, @@ -263,6 +264,7 @@ class VCenterController extends Controller 'db_id' => $vm->id, 'uuid' => $vm->uuid, 'vm_id' => $vm->vm_id, + 'ip' => $vm->ip, 'power_state' => $vm->power_state, 'folder' => $vm->folder, 'cpu' => $vm->cpu, diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php new file mode 100644 index 0000000..85a4c3e --- /dev/null +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -0,0 +1,236 @@ +orderQueryService->getOrdersForCustomer( + (int) $request->user()->id, + ClientOrderFilters::fromArray($request->only(['total_price', 'pending'])) + ); + + return response()->json(['success' => true, 'data' => $orders]); + } + + public function checkServerName(CheckServerNameRequest $request): JsonResponse + { + $result = $this->orderQueryService->checkServerName($request->string('name')->toString()); + + return response()->json($result['available'] ? ['available' => true] : $result); + } + + public function getClientOrders(Request $request): JsonResponse + { + $orders = $this->orderRepository->getOrders((int) $request->user()->id, []); + + return response()->json([ + 'success' => true, + 'data' => OrderSummaryResource::collection($orders->getCollection()->load('invoice')), + ]); + } + + public function getClientOrderDetail(Request $request, int $id): JsonResponse + { + $order = $this->orderQueryService->findCustomerOrder((int) $request->user()->id, $id); + + if (! $order) { + return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404); + } + + return response()->json(['success' => true, 'data' => new OrderDetailResource($order)]); + } + + public function storeOrder(StoreOrderRequest $request): JsonResponse + { + try { + $customer = $request->user(); + $data = $request->validated(); + + $product = \App\Models\Product::where('uuid', $data['product_id'])->first(); + if (! $product) { + return response()->json(['success' => false, 'message' => 'Sản phẩm không tìm thấy hoặc đã bị xóa.'], 404); + } + + $data['product_id'] = $product->id; + $result = $this->orderService->create($data, $customer); + + $order = $result['order']->load(['orderItems.product', 'invoice', 'customer']); + + return response()->json([ + 'success' => true, + 'message' => 'Tạo đơn hàng thành công.', + 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.', + 'data' => [ + 'order' => new OrderDetailResource($order), + 'invoice' => new InvoiceDetailResource($result['invoice']->load(['customer', 'order'])), + 'items' => OrderItemResource::collection($order->orderItems), + ], + ], 201); + } catch (\Throwable $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } + } + + public function payOrder(int $id, PayOrderRequest $request): JsonResponse + { + $order = $this->orderRepository->find($id); + + if (! $order) { + return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404); + } + + if ($order->customer_id !== $request->user()->id) { + return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403); + } + + $this->orderService->pay($order, $request->payment_method); + + $this->activityLogger->log( + event: 'payment', + logName: 'Order', + description: "Customer {$request->user()->email} thanh toán đơn hàng #{$order->order_num} qua {$request->payment_method}", + subject: $order, + newValues: ['payment_method' => $request->payment_method, 'total' => $order->total], + ); + + return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']); + } + + public function retryOrderProvisioning(Request $request, int $id): JsonResponse + { + $order = $this->orderRepository->find($id); + + if (! $order) { + return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404); + } + + $result = $this->orderQueryService->retryProvisioning($order, (int) $request->user()->id); + + if (! $result['ok']) { + return response()->json(['success' => false, 'message' => $result['message']], $result['code']); + } + + $this->activityLogger->log( + event: 'retry_provisioning', + logName: 'Order', + description: "Customer {$request->user()->email} yêu cầu khởi tạo lại dịch vụ cho đơn hàng #{$order->order_num}", + subject: $order, + ); + + return response()->json(['success' => true, 'message' => $result['message']]); + } + + public function getClientInvoices(Request $request): JsonResponse + { + $invoices = $this->orderQueryService->getCustomerInvoices((int) $request->user()->id); + + return response()->json(['success' => true, 'data' => InvoiceSummaryResource::collection($invoices)]); + } + + public function getClientInvoiceDetail(Request $request, int $id): JsonResponse + { + $invoice = $this->orderQueryService->findCustomerInvoice((int) $request->user()->id, $id); + + if (! $invoice) { + return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404); + } + + return response()->json(['success' => true, 'data' => new InvoiceDetailResource($invoice)]); + } + + public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse + { + $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNumber); + + if (! $order?->invoice) { + return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404); + } + + return response()->json(['success' => true, 'data' => new InvoiceDetailResource($order->invoice->setRelation('order', $order))]); + } + + public function getClientServices(Request $request): JsonResponse + { + $services = $this->orderQueryService->getCustomerServices((int) $request->user()->id); + + return response()->json(['success' => true, 'data' => ServiceSummaryResource::collection($services)]); + } + + public function getClientServiceDetail(Request $request, string $uuid): JsonResponse + { + $service = $this->orderQueryService->findCustomerService((int) $request->user()->id, $uuid); + + if (! $service) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404); + } + + return response()->json(['success' => true, 'data' => new ServiceDetailResource($service)]); + } + + public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse + { + $validated = $request->validate(['auto_renew' => ['required', 'boolean']]); + + $service = \App\Models\Service::query() + ->where('id', $id) + ->where('customer_id', $request->user()->id) + ->first(); + + if (! $service) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404); + } + + $service->update(['auto_renew' => (bool) $validated['auto_renew']]); + + $this->activityLogger->log( + event: 'updated', + logName: 'Service', + description: "Customer {$request->user()->email} " . ($validated['auto_renew'] ? 'bật' : 'tắt') . " tự động gia hạn dịch vụ #{$service->id}", + subject: $service, + newValues: ['auto_renew' => (bool) $validated['auto_renew']], + ); + + return response()->json(['success' => true, 'message' => 'Cập nhật tự động gia hạn thành công.', 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew]]); + } + + public function renewService(RenewServiceRequest $request, int $id): JsonResponse + { + $result = $this->orderQueryService->renewService($id, (int) $request->user()->id, (int) $request->validated()['months']); + + if (! $result['ok']) { + return response()->json(['success' => false, 'message' => $result['message']], $result['code']); + } + + return response()->json(['success' => true, 'message' => $result['message'], 'data' => $result['data']]); + } +} diff --git a/app/Http/Requests/Api/Order/CheckServerNameRequest.php b/app/Http/Requests/Api/Order/CheckServerNameRequest.php new file mode 100644 index 0000000..659a1d0 --- /dev/null +++ b/app/Http/Requests/Api/Order/CheckServerNameRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'Tên server không được để trống.', + 'name.string' => 'Tên server không hợp lệ.', + 'name.max' => 'Tên server tối đa 255 ký tự.', + ]; + } +} diff --git a/app/Http/Requests/Api/Order/RenewServiceRequest.php b/app/Http/Requests/Api/Order/RenewServiceRequest.php new file mode 100644 index 0000000..bcb5546 --- /dev/null +++ b/app/Http/Requests/Api/Order/RenewServiceRequest.php @@ -0,0 +1,32 @@ + ['required', 'integer', 'min:1', 'max:24'], + ]; + } + + public function messages(): array + { + return [ + 'months.required' => 'Vui lòng chọn số tháng gia hạn.', + 'months.integer' => 'Số tháng gia hạn không hợp lệ.', + 'months.min' => 'Số tháng gia hạn tối thiểu là 1.', + 'months.max' => 'Số tháng gia hạn tối đa là 24.', + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php new file mode 100644 index 0000000..6265a62 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php @@ -0,0 +1,56 @@ + $this->id, + 'invoice_number' => $this->invoice_number, + 'invoice_type' => $this->invoice_type, + 'status' => $this->status, + 'status_text' => InvoiceStatus::tryFrom((int) $this->status)?->label() ?? '-', + 'sub_total' => (float) $this->sub_total, + 'discount_amount' => (float) ($this->discount_amount ?? 0), + 'tax' => (float) $this->tax, + 'total' => (float) $this->total, + 'payment_method' => $this->payment_method, + 'auto_renew' => (bool) $this->auto_renew, + 'created_at' => $this->created_at?->toISOString(), + 'due_date' => $this->due_date?->toISOString(), + 'paid_at' => $this->paid_at?->toISOString(), + 'customer' => $this->whenLoaded('customer', fn () => [ + 'id' => $this->customer->id, + 'name' => $this->customer->fullname, + 'email' => $this->customer->email, + 'address' => $this->customer->address, + ]), + 'order' => $this->whenLoaded('order', fn () => [ + 'id' => $this->order->id, + 'order_num' => $this->order->order_num, + 'total' => (float) $this->order->total, + ]), + 'items' => $this->whenLoaded('order', fn () => $this->order->orderItems->map(fn ($item) => [ + 'id' => $item->id, + 'product_name' => $item->product?->name, + 'server_name' => $item->server_name, + 'billing_cycle' => $item->billing_cycle, + 'price' => (float) $item->price, + 'months' => $item->months, + 'os_id' => $item->os_id, + ])->values()->all()), + 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($a) => [ + 'id' => $a->id, + 'filename' => $a->filename, + 'url' => $a->url, + 'mime_type' => $a->mime_type, + ])->all()), + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php b/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php new file mode 100644 index 0000000..7c3d226 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php @@ -0,0 +1,25 @@ + $this->id, + 'invoice_number' => $this->invoice_number, + 'service_name' => $this->order?->orderItems->first()?->product?->name ?? ('Đơn hàng ' . $this->order?->order_num), + 'total' => (float) $this->total, + 'status' => $this->status, + 'status_text' => InvoiceStatus::tryFrom((int) $this->status)?->label() ?? '-', + 'created_at' => $this->created_at?->toISOString(), + 'order_id' => $this->order?->id, + 'order_num' => $this->order?->order_num, + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/OrderDetailResource.php b/app/Http/Resources/Api/Service/Order/OrderDetailResource.php new file mode 100644 index 0000000..b53a152 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/OrderDetailResource.php @@ -0,0 +1,40 @@ + $this->id, + 'order_num' => $this->order_num, + 'status' => $this->status, + 'status_text' => OrderStatus::from((int) $this->status)->label(), + 'total' => (float) $this->total, + 'created_at' => $this->created_at?->toISOString(), + 'customer' => $this->whenLoaded('customer', fn () => [ + 'id' => $this->customer->id, + 'name' => $this->customer->fullname, + 'email' => $this->customer->email, + ]), + 'invoice' => $this->whenLoaded('invoice', fn () => [ + 'id' => $this->invoice->id, + 'invoice_number' => $this->invoice->invoice_number, + 'status' => $this->invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $this->invoice->status)?->label() ?? '-', + 'sub_total' => (float) $this->invoice->sub_total, + 'tax' => (float) $this->invoice->tax, + 'total' => (float) $this->invoice->total, + 'due_date' => $this->invoice->due_date?->toISOString(), + 'paid_at' => $this->invoice->paid_at?->toISOString(), + ]), + 'items' => $this->whenLoaded('orderItems') ? OrderItemResource::collection($this->orderItems) : [], + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/OrderItemResource.php b/app/Http/Resources/Api/Service/Order/OrderItemResource.php new file mode 100644 index 0000000..a821db9 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/OrderItemResource.php @@ -0,0 +1,40 @@ + $this->id, + 'billing_cycle' => $this->billing_cycle, + 'months' => $this->months, + 'server_name' => $this->server_name, + 'os_id' => $this->os_id, + 'price' => (float) $this->price, + 'service' => $this->whenLoaded('service', fn () => [ + 'id' => $this->service->id, + 'status' => $this->service->status, + 'status_text' => $this->service->status_label, + 'retry_requested_at' => $this->service->retry_requested_at?->toISOString(), + 'can_retry_provisioning' => $this->service->status === 'pending' && ! $this->service->retry_requested_at, + ]), + 'product' => $this->whenLoaded('product', fn () => [ + 'id' => $this->product->id, + 'uuid' => $this->product->uuid, + 'name' => $this->product->name, + 'product_type' => $this->product->product_type, + 'specs' => [ + 'cpu' => $this->product->cpu, + 'ram' => $this->product->ram, + 'disk' => $this->product->disk, + 'bandwidth' => $this->product->bandwidth, + ], + ]), + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/OrderSummaryResource.php b/app/Http/Resources/Api/Service/Order/OrderSummaryResource.php new file mode 100644 index 0000000..67c2488 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/OrderSummaryResource.php @@ -0,0 +1,30 @@ + $this->id, + 'order_num' => $this->order_num, + 'status' => $this->status, + 'status_text' => OrderStatus::from((int) $this->status)->label(), + 'total' => (float) $this->total, + 'created_at' => $this->created_at?->toISOString(), + 'invoice' => $this->whenLoaded('invoice', fn () => [ + 'id' => $this->invoice->id, + 'invoice_number' => $this->invoice->invoice_number, + 'status' => $this->invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $this->invoice->status)?->label() ?? '-', + 'total' => (float) $this->invoice->total, + ]), + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/ServiceDetailResource.php b/app/Http/Resources/Api/Service/Order/ServiceDetailResource.php new file mode 100644 index 0000000..831d45c --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/ServiceDetailResource.php @@ -0,0 +1,55 @@ + $this->id, + 'uuid' => $this->uuid, + 'service_name' => $this->product?->name ?? $this->server_name, + 'server_name' => $this->server_name, + 'product_type' => $this->product?->product_type, + 'billing_cycle' => $this->orderItem?->billing_cycle, + 'status' => $this->status, + 'status_text' => $this->status_label, + 'auto_renew' => (bool) $this->auto_renew, + 'started_at' => $this->started_at?->toISOString(), + 'expires_at' => $this->expires_at?->toISOString(), + 'note' => $this->note, + 'invoice_id' => $this->invoice_id, + 'invoice_number' => $this->invoice?->invoice_number, + 'product' => $this->whenLoaded('product', fn () => [ + 'id' => $this->product->id, + 'uuid' => $this->product->uuid, + 'name' => $this->product->name, + 'product_type' => $this->product->product_type, + 'specs' => [ + 'cpu' => $this->product->cpu, + 'ram' => $this->product->ram, + 'disk' => $this->product->disk, + 'bandwidth' => $this->product->bandwidth, + ], + ]), + 'addons' => $this->whenLoaded('addons', fn () => $this->addons->map(fn ($a) => [ + 'type' => $a->addon_type, + 'value' => $a->addon_value, + 'price' => $a->price, + ])->all()), + 'vmware' => $this->whenLoaded('vmware', fn () => $this->vmware ? [ + 'id' => $this->vmware->id, + 'name' => $this->vmware->name, + 'hostname' => $this->vmware->hostname, + 'ip_address' => $this->vmware->ipAddress?->ip, + 'os_name' => $this->vmware->os?->name, + 'status' => $this->vmware->status, + 'next_due_date' => $this->vmware->next_due_date?->toISOString(), + ] : null), + ]; + } +} diff --git a/app/Http/Resources/Api/Service/Order/ServiceSummaryResource.php b/app/Http/Resources/Api/Service/Order/ServiceSummaryResource.php new file mode 100644 index 0000000..7804bf9 --- /dev/null +++ b/app/Http/Resources/Api/Service/Order/ServiceSummaryResource.php @@ -0,0 +1,33 @@ + $this->id, + 'uuid' => $this->uuid, + 'service_name' => $this->product?->name ?? $this->server_name, + 'server_name' => $this->server_name, + 'product_type' => $this->product?->product_type, + 'billing_cycle' => $this->orderItem?->billing_cycle, + 'status' => $this->status, + 'status_text' => $this->status_label, + 'auto_renew' => (bool) $this->auto_renew, + 'started_at' => $this->started_at?->toISOString(), + 'expires_at' => $this->expires_at?->toISOString(), + 'invoice_id' => $this->invoice_id, + 'invoice_number' => $this->invoice?->invoice_number, + 'addons' => $this->whenLoaded('addons', fn () => $this->addons->map(fn ($a) => [ + 'type' => $a->addon_type, + 'value' => $a->addon_value, + 'price' => $a->price, + ])->all()), + ]; + } +} diff --git a/app/Jobs/VirtualMachine/Sync/SyncNetwork.php b/app/Jobs/VirtualMachine/Sync/SyncNetwork.php index fe39c06..c8406ae 100644 --- a/app/Jobs/VirtualMachine/Sync/SyncNetwork.php +++ b/app/Jobs/VirtualMachine/Sync/SyncNetwork.php @@ -40,21 +40,20 @@ class SyncNetwork implements ShouldQueue { foreach ($this->data as $item) { - $this->vCenter->apiUri = $item->host; - if (!$item->token) { $token = $this->vCenter->loginServer($item); if ($token) { vCenter::where('id', $item->id)->update(['token' => $token]); - $this->vCenter->token = $token; + $item->token = $token; } } - $this->vCenter->token = $item->token; - if (! $this->vCenter->token) { + if (! $item->token) { continue; } + $this->vCenter->setConnection($item->host, $item->token); + $dataCenter = ModuleDatacenter::where('vcenter_id', $item->id)->select('id', 'datacenter_id')->get(); foreach ($dataCenter as $_item) { $allHost = ModuleHost::where('module_datacenter_id', $_item->id) diff --git a/app/Models/ModuleVmware.php b/app/Models/ModuleVmware.php index 01bf20c..d2d57e7 100644 --- a/app/Models/ModuleVmware.php +++ b/app/Models/ModuleVmware.php @@ -33,6 +33,7 @@ class ModuleVmware extends Model 'ip', 'dns', 'plain_password', + 'virtual_machine_id', ]; // NOTE: password must NOT use 'hashed' cast — it is a VM guest credential diff --git a/app/Repositories/Api/Order/OrderRepository.php b/app/Repositories/Api/Order/OrderRepository.php index 39b59ff..f071646 100644 --- a/app/Repositories/Api/Order/OrderRepository.php +++ b/app/Repositories/Api/Order/OrderRepository.php @@ -25,13 +25,9 @@ class OrderRepository extends BaseApiRepository implements OrderRepositoryInterf $addons = $data['addons'] ?? []; unset($data['addons']); - return OrderItem::create(array_merge( - ['order_id' => $orderId], - $data, - // Preserve addon selections as JSON; read again at provisioning time - // to build service_addons records. - ['addons_meta' => !empty($addons) ? $addons : null], - )); + return OrderItem::create( + array_merge(['order_id' => $orderId], $data, ['addons_meta' => !empty($addons) ? $addons : null]), + ); } // ------------------------------------------------------------------------- @@ -41,15 +37,15 @@ class OrderRepository extends BaseApiRepository implements OrderRepositoryInterf public function processPayment(Order $order, string $method): Order { $order->update([ - 'status' => OrderStatus::ACTIVE->value, + 'status' => OrderStatus::ACTIVE->value, 'payment_method' => $method, ]); if ($order->invoice) { $order->invoice->update([ - 'status' => InvoiceStatus::PAID->value, + 'status' => InvoiceStatus::PAID->value, 'payment_method' => $method, - 'paid_at' => now(), + 'paid_at' => now(), ]); } @@ -62,10 +58,7 @@ class OrderRepository extends BaseApiRepository implements OrderRepositoryInterf public function hasPendingOrder(int $customerId): bool { - return $this->model - ->where('customer_id', $customerId) - ->where('status', OrderStatus::PENDING->value) - ->exists(); + return $this->model->where('customer_id', $customerId)->where('status', OrderStatus::PENDING->value)->exists(); } // ------------------------------------------------------------------------- diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php new file mode 100644 index 0000000..1681abe --- /dev/null +++ b/app/Services/Api/Order/OrderQueryService.php @@ -0,0 +1,260 @@ +orderRepository->getOrders($customerId, $filters->toArray()); + } + + public function checkServerName(string $name): array + { + $exists = OrderItem::where('server_name', $name)->exists() + || Service::where('server_name', $name)->exists() + || ModuleVmware::where('name', $name)->exists() + || ModuleVmware::where('hostname', $name)->exists() + || VirtualMachines::where('name', $name)->exists(); + + return [ + 'available' => ! $exists, + 'message' => $exists + ? 'Tên server này đã được sử dụng, vui lòng chọn tên khác.' + : null, + ]; + } + + public function findCustomerOrder(int $customerId, int $id): ?Order + { + return Order::query() + ->with(['invoice', 'orderItems.product', 'orderItems.service', 'customer']) + ->where('customer_id', $customerId) + ->where('id', $id) + ->first(); + } + + public function getCustomerInvoices(int $customerId) + { + return Invoice::query() + ->with(['order.orderItems.product']) + ->where('customer_id', $customerId) + ->orderByDesc('created_at') + ->get(); + } + + public function findCustomerInvoice(int $customerId, int $id): ?Invoice + { + return Invoice::query() + ->with([ + 'order.orderItems.product', + 'order.vmWare.ipAddress', + 'order.vmWare.os', + 'customer', + 'attachments', + ]) + ->where('customer_id', $customerId) + ->where('id', $id) + ->first(); + } + + public function findCustomerInvoiceByOrderNum(int $customerId, string $orderNumber): ?Order + { + return Order::query() + ->with([ + 'invoice.invoiceItems', + 'invoice.attachments', + 'invoice.customer', + 'orderItems.product', + 'customer', + 'vmWare.ipAddress', + 'vmWare.os', + ]) + ->where('customer_id', $customerId) + ->where('order_num', $orderNumber) + ->first(); + } + + public function getCustomerServices(int $customerId) + { + return Service::query() + ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os']) + ->where('customer_id', $customerId) + ->orderByDesc('created_at') + ->get(); + } + + public function findCustomerService(int $customerId, string $uuid): ?Service + { + return Service::query() + ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter']) + ->where('uuid', $uuid) + ->where('customer_id', $customerId) + ->first(); + } + + public function canRetryProvisioning(Service $service): bool + { + if ($service->status !== 'pending' || $service->retry_requested_at) { + return false; + } + + $paidAt = $service->invoice?->paid_at; + + return $paidAt ? Carbon::parse($paidAt)->diffInMinutes(now()) >= 180 : false; + } + + public function retryProvisioning(Order $order, int $customerId): array + { + $order = Order::where('id', $order->id) + ->with(['invoice', 'orderItems.service']) + ->where('customer_id', $customerId) + ->first(); + + if (! $order) { + return ['ok' => false, 'message' => 'Đơn hàng không tồn tại.', 'code' => 404]; + } + + if (! $order->invoice || (int) $order->invoice->status !== InvoiceStatus::PAID->value) { + return ['ok' => false, 'message' => 'Đơn hàng chưa được thanh toán.', 'code' => 400]; + } + + $paidAt = $order->invoice->paid_at ? Carbon::parse($order->invoice->paid_at) : null; + if (! $paidAt || $paidAt->diffInMinutes(now()) < 180) { + return ['ok' => false, 'message' => 'Vui lòng chờ khoảng 3 tiếng sau khi thanh toán mới có thể khởi tạo lại dịch vụ.', 'code' => 400]; + } + + $service = $order->orderItems->first()?->service; + if (! $service || (int) $service->customer_id !== $customerId) { + return ['ok' => false, 'message' => 'Không tìm thấy dịch vụ hợp lệ để khởi tạo lại.', 'code' => 400]; + } + + if ($service->status !== 'pending') { + return ['ok' => false, 'message' => 'Dịch vụ đã được khởi tạo hoặc không ở trạng thái chờ.', 'code' => 400]; + } + + if ($service->retry_requested_at) { + return ['ok' => false, 'message' => 'Dịch vụ đã được yêu cầu khởi tạo lại một lần. Vui lòng liên hệ kỹ thuật qua mailto:kythuat@tgs.com.vn', 'code' => 400]; + } + + $service->update(['retry_requested_at' => now()]); + RetryProvisioningOrchestrator::dispatch($order->id, $customerId); + + return ['ok' => true, 'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.']; + } + + public function renewService(int $serviceId, int $customerId, int $months): array + { + $customer = \App\Models\Customer::find($customerId); + + $service = Service::query() + ->with(['orderItem.product']) + ->where('id', $serviceId) + ->where('customer_id', $customerId) + ->whereNotIn('status', ['terminated', 'cancelled']) + ->first(); + + if (! $service) { + return ['ok' => false, 'message' => 'Không tìm thấy dịch vụ hoặc dịch vụ đã bị hủy.', 'code' => 404]; + } + + $orderItem = $service->orderItem; + if (! $orderItem || (int) $orderItem->price <= 0) { + return ['ok' => false, 'message' => 'Không thể xác định giá gia hạn dịch vụ.', 'code' => 400]; + } + + $price = (int) $orderItem->price * $months; + + if ($customer->total < $price) { + return ['ok' => false, 'message' => sprintf('Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($price)), 'code' => 400]; + } + + $needsVmStart = $service->vm_id !== null; + + $invoice = DB::transaction(function () use ($customer, $service, $orderItem, $price, $months) { + $customer->total -= $price; + $customer->save(); + + \App\Models\Log::create([ + 'agent' => 'api', + 'client_ip' => request()?->ip(), + 'customer_id' => $customer->id, + 'description' => sprintf('Gia hạn dịch vụ #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.', $service->server_name ?? $service->id, number_format($price), number_format($customer->total)), + ]); + + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $customer->id, + 'order_id' => null, + 'invoice_type' => 2, + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'status' => InvoiceStatus::PAID->value, + 'paid_at' => now(), + 'payment_method' => 'credit', + ]); + + \App\Models\InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $service->product_id, + 'type' => 'renewal', + 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $price, + 'total_price' => $price, + ]); + + $baseDate = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at : now(); + $expiresAt = Carbon::parse($baseDate)->addMonths($months); + + $service->update([ + 'status' => 'active', + 'expires_at' => $expiresAt, + 'suspended_at' => null, + 'invoice_id' => $invoice->id, + ]); + + return $invoice; + }); + + if ($needsVmStart) { + dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5)); + } + + return [ + 'ok' => true, + 'message' => 'Gia hạn thành công. Dịch vụ đã được kích hoạt lại.', + 'data' => [ + 'invoice_number' => $invoice->invoice_number, + 'total' => (float) $invoice->total, + 'expires_at' => $service->fresh()?->expires_at?->toISOString(), + ], + ]; + } +} diff --git a/app/Services/Imports/LegacyPortalImporter.php b/app/Services/Imports/LegacyPortalImporter.php new file mode 100644 index 0000000..931d81d --- /dev/null +++ b/app/Services/Imports/LegacyPortalImporter.php @@ -0,0 +1,285 @@ + new product_id. Built from CLI option / config. */ + protected array $productMap = []; + + public function setProductMap(array $map): self + { + $this->productMap = $map; + return $this; + } + + public function setDefaultVpsProductId(int $id): self + { + $this->defaultVpsProductId = $id; + return $this; + } + + /** + * @return array{customers: array, services: array, products: array} + */ + public function import(bool $preview = false, ?callable $log = null): array + { + $log ??= fn ($_msg) => null; + + $summary = [ + 'customers' => ['created' => 0, 'skipped_conflict' => 0, 'already_synced' => 0, 'errors' => 0], + 'vmware' => ['created' => 0, 'updated' => 0, 'vcenter_matched' => 0, 'vcenter_missing' => 0, 'errors' => 0], + 'services' => ['created' => 0, 'updated' => 0, 'skipped_no_customer' => 0, 'vm_linked' => 0, 'vm_unlinked' => 0, 'errors' => 0], + 'products' => ['mapped' => 0, 'fallback_default' => 0], + ]; + + $log("→ Connecting to legacy DB and counting rows..."); + $legacyUsers = DB::connection('legacy')->table('users')->count(); + $legacyServices = DB::connection('legacy')->table('services')->count(); + $legacyVmware = DB::connection('legacy')->table('vmware')->count(); + $log(" legacy.users = {$legacyUsers}, legacy.services = {$legacyServices}, legacy.vmware = {$legacyVmware}"); + + if ($preview) { + $log("PREVIEW MODE — no writes will be performed."); + } + + // ----- Customers ----- + $log("→ Syncing customers..."); + $customerIdMap = []; // legacy user_id => new customer_id + + DB::connection('legacy')->table('users')->orderBy('id')->chunk(200, function ($rows) use (&$summary, &$customerIdMap, $preview, $log) { + foreach ($rows as $row) { + try { + $existingByLegacy = Customer::where('legacy_id', $row->id)->first(); + if ($existingByLegacy) { + $customerIdMap[$row->id] = $existingByLegacy->id; + $summary['customers']['already_synced']++; + continue; + } + + $email = trim((string) $row->email) ?: null; + $phone = trim((string) ($row->sdt ?? '')) ?: null; + + $conflict = Customer::query() + ->when($email, fn ($q) => $q->where('email', $email)) + ->when($phone, fn ($q, $p) => $q->orWhere('phone', $p)) + ->first(); + + if ($conflict) { + $customerIdMap[$row->id] = $conflict->id; + $summary['customers']['skipped_conflict']++; + $log(" · skip user#{$row->id} ({$email}) — conflict with customer#{$conflict->id}"); + if (! $preview) { + // Backfill legacy_id on existing record so future runs are idempotent. + $conflict->forceFill(['legacy_id' => $row->id])->saveQuietly(); + } + continue; + } + + $payload = [ + 'legacy_id' => $row->id, + 'fullname' => $row->fullname, + 'avatar' => $row->thumb ?: null, + 'gender' => (int) ($row->gender ?? 0), + 'address' => $row->address, + 'company' => $row->company, + 'phone' => $phone, + 'email' => $email, + 'email_verify' => $row->email_verification, + 'password' => $row->password, + 'credit' => (int) ($row->price ?? 0), + 'total' => (int) ($row->total ?? 0), + 'identity_number' => $row->cccd, + 'front_identity_number' => $row->cccd_mattruoc, + 'back_identity_number' => $row->cccd_matsau, + 'status' => (int) ($row->is_active ?? 1), + 'created_at' => $row->create_at, + 'updated_at' => $row->update_at, + ]; + + if ($preview) { + // sentinel so services chunk can resolve in dry-run + $customerIdMap[$row->id] = -1; + $summary['customers']['created']++; + continue; + } + + $customer = new Customer(); + $customer->uuid = (string) Str::uuid(); + $customer->forceFill($payload)->saveQuietly(); + + $customerIdMap[$row->id] = $customer->id; + $summary['customers']['created']++; + } catch (\Throwable $e) { + $summary['customers']['errors']++; + Log::warning("LegacyPortalImporter customer#{$row->id} failed", ['error' => $e->getMessage()]); + $log(" ! user#{$row->id} ERROR: " . $e->getMessage()); + } + } + }); + + if (! $preview) { + Customer::whereNotNull('legacy_id')->pluck('id', 'legacy_id') + ->each(fn ($newId, $legacyId) => $customerIdMap[$legacyId] = $newId); + } + + // ----- VMware (module_vmware) ----- + $log("→ Syncing vmware → module_vmware..."); + $vmwareIdMap = []; // legacy_vmware.id => new module_vmware.id + + // Build vCenter inventory lookup: vmware_id string => virtual_machines.id + $vcenterLookup = DB::table('virtual_machines')->pluck('id', 'vm_id'); + + DB::connection('legacy')->table('vmware')->orderBy('id')->chunk(200, function ($rows) use (&$summary, &$vmwareIdMap, $customerIdMap, $vcenterLookup, $preview, $log) { + foreach ($rows as $row) { + try { + $newCustomerId = $customerIdMap[$row->user_id] ?? null; + $vcVmId = $row->vmware_id; // e.g. "vm-4327" + $vmInventoryId = $vcVmId ? ($vcenterLookup[$vcVmId] ?? null) : null; + + if ($vmInventoryId) $summary['vmware']['vcenter_matched']++; + else $summary['vmware']['vcenter_missing']++; + + $payload = [ + 'legacy_id' => $row->id, + 'customer_id' => $newCustomerId, + 'name' => $row->vmware_name, + 'ip' => $row->vmware_ip, + 'port' => is_numeric($row->vmware_port) ? (int) $row->vmware_port : null, + 'user' => $row->vmware_user, + 'password' => $row->vmware_password, + 'hostname' => $row->vmware_hostname, + 'vmware_id' => $vcVmId, + 'virtual_machine_id' => $vmInventoryId, + 'status' => (int) ($row->vmware_status ?? 1), + 'created_at' => $row->create_at, + 'updated_at' => $row->update_at, + ]; + + if ($preview) { + $vmwareIdMap[$row->id] = -1; + $summary['vmware']['created']++; + continue; + } + + $existing = DB::table('module_vmware')->where('legacy_id', $row->id)->first(); + if ($existing) { + DB::table('module_vmware')->where('id', $existing->id)->update($payload); + $vmwareIdMap[$row->id] = $existing->id; + $summary['vmware']['updated']++; + } else { + $newId = DB::table('module_vmware')->insertGetId($payload); + $vmwareIdMap[$row->id] = $newId; + $summary['vmware']['created']++; + } + } catch (\Throwable $e) { + $summary['vmware']['errors']++; + Log::warning("LegacyPortalImporter vmware#{$row->id} failed", ['error' => $e->getMessage()]); + $log(" ! vmware#{$row->id} ERROR: " . $e->getMessage()); + } + } + }); + + if (! $preview) { + DB::table('module_vmware')->whereNotNull('legacy_id')->select('id', 'legacy_id')->get() + ->each(fn ($r) => $vmwareIdMap[$r->legacy_id] = $r->id); + } + + // ----- Services ----- + $log("→ Syncing services..."); + + DB::connection('legacy')->table('services')->orderBy('id')->chunk(200, function ($rows) use (&$summary, $customerIdMap, $vmwareIdMap, $preview, $log) { + foreach ($rows as $row) { + try { + $newCustomerId = $customerIdMap[$row->user_id] ?? null; + if (! $newCustomerId) { + $summary['services']['skipped_no_customer']++; + continue; + } + + $productId = $this->resolveProductId((int) $row->product_id, $summary); + $newVmId = $row->vmware_id !== null ? ($vmwareIdMap[$row->vmware_id] ?? null) : null; + if ($newVmId) $summary['services']['vm_linked']++; + else $summary['services']['vm_unlinked']++; + + $payload = [ + 'legacy_id' => $row->id, + 'customer_id' => $newCustomerId === -1 ? null : $newCustomerId, + 'product_id' => $productId, + 'legacy_product_id' => (int) $row->product_id, + 'vm_id' => $newVmId === -1 ? null : $newVmId, + 'status' => $this->mapStatus((int) $row->is_active), + 'auto_renew' => (bool) $row->auto_pay, + 'started_at' => $row->create_at, + 'expires_at' => $row->date_exp, + 'note' => 'product_info(legacy): ' . $row->product_info, + 'created_at' => $row->create_at, + 'updated_at' => $row->create_at, + ]; + + if ($preview) { + $summary['services']['created']++; + continue; + } + + $service = Service::where('legacy_id', $row->id)->first(); + + if ($service) { + $service->forceFill($payload)->saveQuietly(); + $summary['services']['updated']++; + } else { + $service = new Service(); + $service->uuid = (string) Str::uuid(); + $service->forceFill($payload)->saveQuietly(); + $summary['services']['created']++; + } + } catch (\Throwable $e) { + $summary['services']['errors']++; + Log::warning("LegacyPortalImporter service#{$row->id} failed", ['error' => $e->getMessage()]); + $log(" ! service#{$row->id} ERROR: " . $e->getMessage()); + } + } + }); + + return $summary; + } + + protected function resolveProductId(int $legacyProductId, array &$summary): int + { + if (isset($this->productMap[$legacyProductId])) { + $summary['products']['mapped']++; + return $this->productMap[$legacyProductId]; + } + + $summary['products']['fallback_default']++; + return $this->defaultVpsProductId; + } + + /** Convert legacy `is_active` int → new lifecycle string. */ + protected function mapStatus(int $legacy): string + { + return match ($legacy) { + 0 => 'expired', + 1 => 'active', + 2 => 'pending', + 3 => 'suspended', + default => 'pending', + }; + } +} diff --git a/config/database.php b/config/database.php index 003f745..3f88b2c 100644 --- a/config/database.php +++ b/config/database.php @@ -63,6 +63,22 @@ return [ ]) : [], ], + 'legacy' => [ + 'driver' => 'mysql', + 'host' => env('LEGACY_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('LEGACY_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('LEGACY_DB_DATABASE', 'portal_legacy'), + 'username' => env('LEGACY_DB_USERNAME', env('DB_USERNAME', 'forge')), + 'password' => env('LEGACY_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => false, + 'engine' => null, + ], + 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), diff --git a/database/migrations/2026_06_15_000001_add_legacy_id_to_sync_tables.php b/database/migrations/2026_06_15_000001_add_legacy_id_to_sync_tables.php new file mode 100644 index 0000000..82ed497 --- /dev/null +++ b/database/migrations/2026_06_15_000001_add_legacy_id_to_sync_tables.php @@ -0,0 +1,32 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + + Schema::table('services', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + $table->unsignedBigInteger('legacy_product_id')->nullable()->index()->after('product_id'); + }); + + Schema::table('products', function (Blueprint $table) { + $table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + + public function down(): void + { + Schema::table('customers', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + Schema::table('services', function (Blueprint $t) { + $t->dropColumn(['legacy_id', 'legacy_product_id']); + }); + Schema::table('products', fn (Blueprint $t) => $t->dropColumn('legacy_id')); + } +}; diff --git a/database/migrations/2026_06_15_000002_add_legacy_id_to_module_vmware.php b/database/migrations/2026_06_15_000002_add_legacy_id_to_module_vmware.php new file mode 100644 index 0000000..97eeec3 --- /dev/null +++ b/database/migrations/2026_06_15_000002_add_legacy_id_to_module_vmware.php @@ -0,0 +1,23 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + $table->unsignedBigInteger('virtual_machine_id')->nullable()->index()->after('vmware_id') + ->comment('FK → virtual_machines.id when the vCenter VM still exists'); + }); + } + + public function down(): void + { + Schema::table('module_vmware', function (Blueprint $table) { + $table->dropColumn(['legacy_id', 'virtual_machine_id']); + }); + } +}; diff --git a/docs/admin-folder-usage.md b/docs/admin-folder-usage.md new file mode 100644 index 0000000..84ad321 --- /dev/null +++ b/docs/admin-folder-usage.md @@ -0,0 +1,121 @@ +# Báo cáo sử dụng folder `app/Http/Controllers/admin` + +Ngày kiểm tra: 2026-06-01 +Phạm vi quét: toàn bộ `portal/` (routes, controllers, services, repositories, jobs, configs, seeders, …). + +## Kết luận nhanh + +> Folder `app/Http/Controllers/admin` **gần như là legacy/không còn được dùng**. +> Toàn bộ chức năng admin đã được migrate sang REST API namespace mới `App\Http\Controllers\Api\Admin\*` (xem comment trong `routes/web.php` dòng 12, 18). +> +> **Chỉ duy nhất 1 file** trong folder `admin/` còn được tham chiếu bởi code ngoài folder: +> +> - `app/Http/Controllers/admin/VCenterManagerController.php` +> — được import & dùng tại `routes/api.php:3` cho nhóm route `vcenter-manager/vm/*` (powerOn, powerOff, restart, snapshot, suspend, getPoweredOffVms). + +Các file còn lại trong `admin/` **không** được import bởi bất kỳ file PHP nào ngoài chính folder `admin/` (không bởi `routes/api.php`, không bởi `routes/client.php`, không bởi service/repository/job/config/seeder/web.php). + +> Lưu ý phương pháp: kiểm tra bằng cách quét toàn bộ chuỗi FQN `App\Http\Controllers\admin\…` trên toàn dự án. Một số controller có tên trùng (ví dụ `CustomerController`, `OrderController`, `PostController`, `SettingController`, `AddonController`, `InvoiceController`, `PaymentController`, `DomainController`, `ProductController`, `ProductCategoryController`) **trùng tên** với các controller mới trong `App\Http\Controllers\Api\Admin\*` hoặc `App\Http\Controllers\Api\Client\*` — các route file chỉ import bản mới, KHÔNG import bản trong `admin/`. + +--- + +## 1) File ĐANG được dùng (1 file) + +| File | Nơi dùng | Mục đích | +|---|---|---| +| `admin/VCenterManagerController.php` | `routes/api.php` (line 3 import, group `vcenter-manager` trong `Route::prefix('administrator')`) | Power operations cho VM (power-on/off, restart, snapshot, suspend, lấy danh sách VM tắt nguồn) | + +Ghi chú nội bộ: `VCenterManagerController` này cũng được tham chiếu bởi một số file khác trong cùng `admin/` (ví dụ `package/Datacenter.php`, `package/Host.php`, …) — đây là tham chiếu nội bộ trong folder legacy. + +--- + +## 2) File KHÔNG được dùng (toàn bộ phần còn lại) + +Không file nào dưới đây được import/sử dụng bởi code ngoài folder `admin/`: + +### 2.1. Top-level `admin/` (24 file) + +- `AddonController.php` +- `CouponController.php` +- `CustomerController.php` +- `DomainController.php` +- `GroupController.php` +- `InvoiceController.php` +- `LoginController.php` +- `MailController.php` +- `MainController.php` +- `MediaController.php` +- `MenuController.php` +- `ModuleDNSController.php` +- `ModuleHostingController.php` +- `ModuleIPPoolController.php` +- `ModulevCenterController.php` +- `OrderController.php` +- `PaymentController.php` +- `PostController.php` +- `RolesController.php` +- `SettingController.php` +- `SupportController.php` +- `TaskController.php` +- `TemplateController.php` +- `UserController.php` + +### 2.2. `admin/package/` (9 file) + +- `Datacenter.php` +- `Datastore.php` +- `Folder.php` +- `Group.php` +- `Host.php` +- `IPMapping.php` +- `Network.php` +- `OsVersion.php` +- `SyncController.php` + +> Cảnh báo cùng tên: các tên `Datacenter`, `Datastore`, `Folder`, `Host`, `Network`, `Group`, `OsVersion` xuất hiện rất nhiều trong dự án (Models, Repositories, Jobs, Views, configs như `admin-sidebar.php`). **Tuy nhiên** các tham chiếu đó trỏ tới `App\Models\*` / `App\Repositories\*` / `App\View\Components\Module\*`, KHÔNG phải các controller trong `admin/package/`. Đã xác nhận bằng cách grep FQN `App\Http\Controllers\admin\package\…` — không có file nào ngoài folder này import chúng. + +### 2.3. `admin/product/` (2 file) + +- `ProductCategoryController.php` (đã thay thế bởi `Api/Admin/Product/ProductCategoryController.php` và `Api/Service/ProductCategoryController.php`) +- `ProductController.php` (đã thay thế bởi `Api/Admin/Product/ProductController.php`) + +### 2.4. `admin/vm/` (1 file) + +- `VirtualMachineController.php` + +--- + +## 3) Đối chiếu nhanh với 2 file route chính + +### `routes/client.php` +Không import bất kỳ class nào từ namespace `App\Http\Controllers\admin\*`. Toàn bộ controller đến từ `App\Http\Controllers\Api\Client\*`, `App\Http\Controllers\Api\Service\*`, `App\Http\Controllers\Api\Setting\*`, `App\Http\Controllers\Api\Transaction\*`, `App\Http\Controllers\Api\NotificationController`. + +### `routes/api.php` +Chỉ có **1 dòng** import từ `admin/`: +```php +use App\Http\Controllers\admin\VCenterManagerController; // dòng 3 +``` +Dùng trong group: +```php +Route::prefix('vcenter-manager') + ->controller(VCenterManagerController::class) + ->group(function () { + Route::post('vm/power-on', 'powerOn'); + Route::post('vm/power-off', 'powerOff'); + Route::post('vm/restart', 'restart'); + Route::post('vm/snapshot', 'snapshot'); + Route::post('vm/suspend', 'suspend'); + Route::get ('vms/powered-off', 'getPoweredOffVms'); + }); +``` + +--- + +## 4) Đề xuất + +1. **Có thể xoá an toàn** toàn bộ folder `app/Http/Controllers/admin/` **trừ** `VCenterManagerController.php`. +2. Hoặc tốt hơn: di chuyển `VCenterManagerController` sang namespace mới `App\Http\Controllers\Api\Admin\VCenter\` cho đồng nhất, rồi xoá toàn bộ folder `admin/`. +3. Trước khi xoá nên: + - Chạy `composer dump-autoload` và test toàn bộ test suite. + - Kiểm tra thêm cache, queue config, scheduled commands, custom artisan command có hardcode tên class không (đã grep — không thấy). + - Kiểm tra Blade views (folder `admin/` views nếu còn) có gọi route name cũ không. diff --git a/docs/refactor-priority.md b/docs/refactor-priority.md new file mode 100644 index 0000000..6181504 --- /dev/null +++ b/docs/refactor-priority.md @@ -0,0 +1,44 @@ +# Refactor Priority + +Danh sách file nên chỉnh sửa, chia theo mức độ ưu tiên dựa trên độ lớn controller, số nhánh nghiệp vụ, và mức độ trộn giữa validation, query, mapping response. + +## Cao + +- `app/Http/Controllers/Api/Service/OrderController.php` - controller lớn nhất, ôm nhiều luồng order, invoice, payment, VM. +- `app/Http/Controllers/admin/CustomerController.php` - nhiều action, nhiều query, nhiều workflow admin/customer. +- `app/Http/Controllers/admin/VCenterManagerController.php` - nhiều helper nội bộ, tree/detail API, query và render view lẫn nhau. +- `app/Http/Controllers/Api/Admin/Customer/CustomerController.php` - CRUD + thống kê + response mapping. +- `app/Http/Controllers/Api/Service/VmController.php` - nhiều flow VM, log, Telegram, xử lý lỗi lặp. +- `app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php` - CRUD + datatable + business rules. +- `app/Http/Controllers/Api/Client/Service/ServiceController.php` - nhiều endpoint, lookup VM/vCenter lặp. +- `app/Http/Controllers/Api/Admin/Product/ProductController.php` - validate lớn, create/update, sync pricing/OS. +- `app/Http/Controllers/Api/Transaction/TransactionController.php` - callback VNPay, update balance, log, job dispatch. +- `app/Http/Controllers/admin/OrderController.php` - nhiều khả năng đang gom nhiều workflow. + +## Trung bình + +- `app/Http/Controllers/Api/Admin/VCenter/VCenterController.php` - controller có vẻ vừa query vừa mapping response. +- `app/Http/Controllers/Api/Admin/IpPool/IpPoolController.php` - nhiều validation và action CRUD. +- `app/Http/Controllers/Api/Admin/DnsManager/DomainController.php` - CRUD + logic domain management. +- `app/Http/Controllers/Api/Admin/DnsManager/RecordController.php` - tương tự, cần tách request/DTO nếu logic lớn. +- `app/Http/Controllers/Api/Client/Service/DomainController.php` - validate và xử lý domain cho client. +- `app/Http/Controllers/Api/Transaction/ManualDepositController.php` - nhiều nhánh xử lý nạp/hủy/kiểm tra pending. +- `app/Http/Controllers/Api/Admin/Payment/PaymentController.php` - đang dùng `request->all()` và repository filter. +- `app/Http/Controllers/Api/Admin/Order/OrderController.php` - validate + create + upload + trạng thái. +- `app/Http/Controllers/Api/Admin/Addon/AddonController.php` - CRUD + bulk actions. +- `app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php` - nhiều action và bulk IDs. + +## Thấp + +- `app/Http/Controllers/Api/Admin/Setting/SettingController.php` - ít action hơn, nhưng vẫn nên chuẩn hoá validation. +- `app/Http/Controllers/Api/Admin/Bank/BankController.php` - nhỏ hơn, chủ yếu CRUD. +- `app/Http/Controllers/Api/Admin/Auth/AuthController.php` - nhỏ, ít thay đổi. +- `app/Http/Controllers/Api/Client/ClientController.php` - đã có service/request, chỉ cần dọn dẹp dần. +- `app/Http/Controllers/Api/Service/SnapshotController.php` - nhiều validate nhưng scope hẹp hơn. +- `app/Http/Controllers/Api/Service/PaymentController.php` - kiểm tra nếu bắt đầu phình ra. + +## Ghi chú + +- Ưu tiên tách DTO trước cho các file ở mục **Cao**, vì chúng có payload rõ và đang trộn nhiều trách nhiệm. +- Các file ở mục **Trung bình** nên xử lý sau khi đã ổn định nhóm **Cao**. +- Không tính `vendor/`, `.env`, hoặc file hệ thống Git. diff --git a/public/css/app.css b/public/css/app.css index bf9d9f3..c7ef3c9 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -16730,7 +16730,7 @@ ui-toast { font-display: swap; } :root { - --color-primary: #f66d2f; + --color-primary: #f16723; --color-primary-light: #ff914d; --color-primary-dark: #9a4b1f; --color-primary-100: #f9b8a0; diff --git a/routes/api.php b/routes/api.php index d7ea3d7..d4706a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -120,6 +120,7 @@ Route::prefix('v1') ->controller(CustomerController::class) ->group(function () { Route::get('trash', 'trash')->middleware('perm:customer.view'); + Route::get('vps/available', 'listAvailableVms')->middleware('perm:customer.view'); Route::get('/', 'index')->middleware('perm:customer.view'); Route::post('/', 'store')->middleware('perm:customer.create'); Route::get('{uuid}', 'show')->middleware('perm:customer.view'); @@ -130,6 +131,7 @@ Route::prefix('v1') Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit'); Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); + Route::post('{uuid}/services', 'createService')->middleware('perm:customer.edit'); Route::get('{uuid}/orders', 'getOrders')->middleware('perm:customer.view'); Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view'); Route::get('{uuid}/transactions', 'getTransactions')->middleware('perm:customer.view'); diff --git a/routes/client.php b/routes/client.php index 43a0d3f..c1af0e3 100644 --- a/routes/client.php +++ b/routes/client.php @@ -4,7 +4,7 @@ use App\Http\Controllers\Api\Setting\SettingController; use App\Http\Controllers\Api\Client\ClientController; use App\Http\Controllers\Api\Client\Service\DomainController as ClientDomainController; use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController; -use App\Http\Controllers\Api\Service\OrderController; +use App\Http\Controllers\Api\Service\OrderControllerRefactor; use App\Http\Controllers\Api\Service\ProductCategoryController; use App\Http\Controllers\Api\Service\ServiceController; use App\Http\Controllers\Api\Service\SnapshotController; @@ -39,7 +39,7 @@ Route::prefix('v1') }); Route::middleware(['api.auth']) - ->controller(OrderController::class) + ->controller(OrderControllerRefactor::class) ->group(function () { Route::get('orders', 'getClientOrders')->name('orders.list'); Route::get('orders/{id}', 'getClientOrderDetail')->name('orders.detail'); @@ -138,7 +138,7 @@ Route::prefix('v1') ->prefix('orders') ->as('orders.') ->group(function () { - Route::controller(OrderController::class)->group(function () { + Route::controller(OrderControllerRefactor::class)->group(function () { Route::get('/', 'getList')->name('list'); Route::get('check-server-name', 'checkServerName')->name('check-server-name'); Route::post('/', 'storeOrder')->name('store'); diff --git a/storage/backups/OrderController.original.php b/storage/backups/OrderController.original.php new file mode 100644 index 0000000..c3fad65 --- /dev/null +++ b/storage/backups/OrderController.original.php @@ -0,0 +1,917 @@ +orderRepo->getOrders( + $request->user()->id, + $request->only(['total_price', 'pending']) + ); + return response()->json(['success' => true, 'data' => $orders]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * GET /v1/orders/check-server-name?name=xxx + * Kiểm tra tên server đã tồn tại chưa (order_items, services, module_vmware, virtual_machines) + */ + public function checkServerName(Request $request): JsonResponse + { + $name = trim($request->query('name', '')); + + if ($name === '') { + return response()->json(['available' => false, 'message' => 'Tên server không được để trống.']); + } + + $exists = + OrderItem::where('server_name', $name)->exists() || + Service::where('server_name', $name)->exists() || + ModuleVmware::where('name', $name)->exists() || + ModuleVmware::where('hostname', $name)->exists() || + VirtualMachines::where('name', $name)->exists(); + + if ($exists) { + return response()->json([ + 'available' => false, + 'message' => 'Tên server này đã được sử dụng, vui lòng chọn tên khác.', + ]); + } + + return response()->json(['available' => true]); + } + + /** + * GET /v1/client/orders — customer's order list + */ + public function getClientOrders(Request $request): JsonResponse + { + try { + $orders = Order::query() + ->with(['invoice']) + ->where('customer_id', $request->user()->id) + ->orderByDesc('created_at') + ->get(); + + $data = $orders->map(fn(Order $order) => [ + 'id' => $order->id, + 'order_num' => $order->order_num, + 'status' => $order->status, + 'status_text' => OrderStatus::from((int) $order->status)->label(), + 'total' => (float) $order->total, + 'created_at' => $this->isoDate($order->created_at), + 'invoice' => $order->invoice ? [ + 'id' => $order->invoice->id, + 'invoice_number' => $order->invoice->invoice_number, + 'status' => $order->invoice->status, + 'total' => $order->invoice->total, + ] : null, + ]); + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * GET /v1/client/orders/{id} — order detail + */ + public function getClientOrderDetail(Request $request, int $id): JsonResponse + { + try { + $order = Order::query() + ->with(['invoice', 'orderItems.product', 'orderItems.service', 'customer']) + ->where('customer_id', $request->user()->id) + ->where('id', $id) + ->first(); + + if (!$order) { + return $this->notFound('Đơn hàng không tồn tại.'); + } + + return response()->json(['success' => true, 'data' => $this->formatOrderDetail($order)]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * POST /v1/orders — create a new order + * + * @OA\Post(path="/orders", operationId="storeOrder", tags={"Orders"}, + * security={{"bearerAuth":{}}}, + * @OA\RequestBody(required=true, @OA\JsonContent( + * required={"product_id","billing_cycle"}, + * @OA\Property(property="product_id", type="string", example="uuid-here"), + * @OA\Property(property="billing_cycle", type="string", example="annually", + * enum={"monthly","quarterly","semi_annually","annually","biennially"}), + * @OA\Property(property="server_name", type="string", example="my-vps-01"), + * @OA\Property(property="os_id", type="integer", example=1), + * @OA\Property(property="addons", type="array", @OA\Items( + * @OA\Property(property="addon_id", type="integer", example=1), + * @OA\Property(property="qty", type="integer", example=2) + * )) + * )), + * @OA\Response(response=201, description="Order created successfully") + * ) + */ + public function storeOrder(StoreOrderRequest $request): JsonResponse + { + try { + $customer = $request->user(); + $data = $request->validated(); + + $product = Product::where('uuid', $data['product_id'])->first(); + if (!$product) { + throw new \Exception('Sản phẩm không tìm thấy hoặc đã bị xóa.'); + } + $data['product_id'] = $product->id; + + $result = $this->orderService->create($data, $customer); + + $order = $result['order']->load(['orderItems.product', 'invoice', 'customer']); + $invoice = $result['invoice']; + + $this->activityLogger->log( + event: 'created', + logName: 'Order', + description: "Customer {$customer->email} tạo đơn hàng #{$order->order_num} - Tổng: " . number_format($order->total) . ' VNĐ', + subject: $order, + newValues: ['order_num' => $order->order_num, 'total' => $order->total, 'product_id' => $data['product_id']], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Tạo đơn hàng thành công.', + 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.', + 'data' => [ + 'order' => $this->formatOrderSummary($order), + 'invoice' => $this->formatInvoiceSummary($invoice), + 'items' => $this->formatOrderItems($order->orderItems), + ], + ], 201); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * POST /v1/orders/{id}/pay — pay for a pending order + * + * @OA\Post(path="/orders/{id}/pay", operationId="payOrder", tags={"Orders"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody(required=true, @OA\JsonContent( + * required={"payment_method"}, + * @OA\Property(property="payment_method", type="string", + * enum={"credit","bank_transfer","vnpay","momo"}) + * )), + * @OA\Response(response=200, description="Payment successful") + * ) + */ + public function payOrder(int $id, PayOrderRequest $request): JsonResponse + { + try { + $order = $this->orderRepo->find($id); + + if (!$order) { + return $this->notFound('Đơn hàng không tồn tại.'); + } + if ($order->customer_id !== $request->user()->id) { + return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403); + } + + $this->orderService->pay($order, $request->payment_method); + + $this->activityLogger->log( + event: 'payment', + logName: 'Order', + description: "Customer {$request->user()->email} thanh toán đơn hàng #{$order->order_num} qua {$request->payment_method}", + subject: $order, + newValues: ['payment_method' => $request->payment_method, 'total' => $order->total], + ); + + return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * POST /v1/client/orders/{id}/retry — retry VM provisioning for a paid order + * + * Dispatches RetryProvisioningOrchestrator which: + * 1. Searches vSphere for existing VM by server_name + * 2. If found → links VM to module_vmware + services + * 3. If not found → dispatches CreateVirtualMachineJob + * 4. Each failure step notifies Telegram + */ + public function retryOrderProvisioning(Request $request, int $id): JsonResponse + { + try { + $order = Order::where('id', $id) + ->with(['invoice', 'orderItems.service']) + ->where('customer_id', $request->user()->id) + ->first(); + + if (!$order) { + return $this->notFound('Đơn hàng không tồn tại.'); + } + + if (!$order->invoice || (int) $order->invoice->status !== InvoiceStatus::PAID->value) { + return $this->error('Đơn hàng chưa được thanh toán.'); + } + + $paidAt = $order->invoice->paid_at ? Carbon::parse($order->invoice->paid_at) : null; + if (!$paidAt || $paidAt->diffInMinutes(now()) < 180) { + return $this->error('Vui lòng chờ khoảng 3 tiếng sau khi thanh toán mới có thể khởi tạo lại dịch vụ.'); + } + + $service = $order->orderItems->first()?->service; + if (!$service || (int) $service->customer_id !== (int) $request->user()->id) { + return $this->error('Không tìm thấy dịch vụ hợp lệ để khởi tạo lại.'); + } + + if ($service->status !== 'pending') { + return $this->error('Dịch vụ đã được khởi tạo hoặc không ở trạng thái chờ.'); + } + + if ($service->retry_requested_at) { + return $this->error('Dịch vụ đã được yêu cầu khởi tạo lại một lần. Vui lòng liên hệ kỹ thuật qua mailto:kythuat@tgs.com.vn'); + } + + $service->update(['retry_requested_at' => now()]); + + RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id); + + $this->activityLogger->log( + event: 'retry_provisioning', + logName: 'Order', + description: "Customer {$request->user()->email} yêu cầu khởi tạo lại dịch vụ cho đơn hàng #{$order->order_num}", + subject: $order, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.', + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + // ========================================================================= + // Invoices + // ========================================================================= + + /** + * GET /v1/client/invoices — customer's invoice list + */ + public function getClientInvoices(Request $request): JsonResponse + { + try { + $invoices = Invoice::query() + ->with(['order.orderItems.product']) + ->where('customer_id', $request->user()->id) + ->orderByDesc('created_at') + ->get(); + + $data = $invoices->map(fn(Invoice $invoice) => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'service_name' => $invoice->order?->orderItems->first()?->product?->name + ?? ('Đơn hàng ' . $invoice->order?->order_num), + 'total' => (float) $invoice->total, + 'status' => $invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', + 'created_at' => $this->isoDate($invoice->created_at), + 'order_id' => $invoice->order?->id, + 'order_num' => $invoice->order?->order_num, + ])->values(); + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * GET /v1/client/invoices/{id} — invoice detail + */ + public function getClientInvoiceDetail(Request $request, int $id): JsonResponse + { + try { + $invoice = Invoice::query() + ->with([ + 'order.orderItems.product', + 'order.vmWare.ipAddress', + 'order.vmWare.os', + 'customer', + 'attachments', + ]) + ->where('customer_id', $request->user()->id) + ->where('id', $id) + ->first(); + + if (!$invoice) { + return $this->notFound('Hóa đơn không tồn tại.'); + } + + return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * GET /v1/client/invoices/order/{orderNumber} — invoice detail by order number + */ + public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse + { + try { + $order = Order::query() + ->with([ + 'invoice.invoiceItems', + 'invoice.attachments', + 'invoice.customer', + 'orderItems.product', + 'customer', + 'vmWare.ipAddress', + 'vmWare.os', + ]) + ->where('customer_id', $request->user()->id) + ->where('order_num', $orderNumber) + ->first(); + + if (!$order?->invoice) { + return $this->notFound('Hóa đơn không tồn tại.'); + } + + // Reuse formatInvoiceDetail with already-loaded relations + $invoice = $order->invoice; + // Attach order + vmWare so formatter can use them + $invoice->setRelation('order', $order); + $invoice->setRelation('customer', $order->customer); + + return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + // ========================================================================= + // Services (dich-vu) + // ========================================================================= + + /** + * GET /v1/client/dich-vu — list of customer's services + */ + public function getClientServices(Request $request): JsonResponse + { + try { + $services = Service::query() + ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os']) + ->where('customer_id', $request->user()->id) + ->orderByDesc('created_at') + ->get(); + + $data = $services->map(function (Service $service) { + $vmware = $service->vmware; + + return [ + 'id' => $service->id, + 'service_name' => $service->product?->name ?? $service->server_name, + 'server_name' => $service->server_name, + 'product_type' => $service->product?->product_type, + 'billing_cycle' => $service->orderItem?->billing_cycle, + 'status' => $service->status, + 'status_text' => $service->status_label, + 'auto_renew' => (bool) $service->auto_renew, + 'started_at' => $this->isoDate($service->started_at), + 'expires_at' => $this->isoDate($service->expires_at), + 'invoice_id' => $service->invoice_id, + 'invoice_number' => $service->invoice?->invoice_number, + 'addons' => $service->addons->map(fn($a) => [ + 'type' => $a->addon_type, + 'value' => $a->addon_value, + 'price' => $a->price, + ])->all(), + 'vmware' => $vmware ? [ + 'id' => $vmware->id, + 'name' => $vmware->name, + 'hostname' => $vmware->hostname, + 'ip_address' => $vmware->ipAddress?->ip, + 'os_name' => $vmware->os?->name, + 'status' => $vmware->status, + ] : null, + ]; + })->values(); + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * GET /v1/client/dich-vu/{uuid} — service detail by UUID + */ + public function getClientServiceDetail(Request $request, string $uuid): JsonResponse + { + try { + $service = Service::query() + ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter']) + ->where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->first(); + + if (!$service) { + return $this->notFound('Không tìm thấy dịch vụ.'); + } + + $vmware = $service->vmware; + + $data = [ + 'id' => $service->id, + 'uuid' => $service->uuid, + 'service_name' => $service->product?->name ?? $service->server_name, + 'server_name' => $service->server_name, + 'product_type' => $service->product?->product_type, + 'billing_cycle' => $service->orderItem?->billing_cycle, + 'status' => $service->status, + 'status_text' => $service->status_label, + 'auto_renew' => (bool) $service->auto_renew, + 'started_at' => $this->isoDate($service->started_at), + 'expires_at' => $this->isoDate($service->expires_at), + 'note' => $service->note, + 'invoice_id' => $service->invoice_id, + 'invoice_number' => $service->invoice?->invoice_number, + 'product' => $service->product ? [ + 'id' => $service->product->id, + 'uuid' => $service->product->uuid, + 'name' => $service->product->name, + 'product_type' => $service->product->product_type, + 'specs' => [ + 'cpu' => $service->product->cpu, + 'ram' => $service->product->ram, + 'disk' => $service->product->disk, + 'bandwidth' => $service->product->bandwidth, + ], + ] : null, + 'addons' => $service->addons->map(fn($a) => [ + 'type' => $a->addon_type, + 'value' => $a->addon_value, + 'price' => $a->price, + ])->all(), + 'vmware' => $vmware ? [ + 'id' => $vmware->id, + 'name' => $vmware->name, + 'hostname' => $vmware->hostname, + 'ip_address' => $vmware->ipAddress?->ip, + 'os_name' => $vmware->os?->name, + 'status' => $vmware->status, + ] : null, + ]; + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service + */ + public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse + { + try { + $validated = $request->validate(['auto_renew' => ['required', 'boolean']]); + + $service = Service::query() + ->where('id', $id) + ->where('customer_id', $request->user()->id) + ->first(); + + if (!$service) { + return $this->notFound('Không tìm thấy dịch vụ.'); + } + + $service->update(['auto_renew' => (bool) $validated['auto_renew']]); + + $this->activityLogger->log( + event: 'updated', + logName: 'Service', + description: "Customer {$request->user()->email} " . ($validated['auto_renew'] ? 'bật' : 'tắt') . " tự động gia hạn dịch vụ #{$service->id}", + subject: $service, + newValues: ['auto_renew' => (bool) $validated['auto_renew']], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Cập nhật tự động gia hạn thành công.', + 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew], + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + // ========================================================================= + // Services — renewal + // ========================================================================= + + /** + * POST /v1/client/dich-vu/{id}/renew + * + * Pay-and-activate renewal in one step: + * 1. Validate balance + * 2. Debit customer balance (inside transaction) + * 3. Create PAID renewal invoice (inside transaction) + * 4. Extend service expires_at + set status = active (inside transaction) + * 5. Dispatch VMJobs 'Start' OUTSIDE transaction to power on suspended VM + */ + public function renewService(Request $request, int $id): JsonResponse + { + try { + $customer = $request->user(); + + $service = Service::query() + ->with(['orderItem.product']) + ->where('id', $id) + ->where('customer_id', $customer->id) + ->whereNotIn('status', ['terminated', 'cancelled']) + ->first(); + + if (!$service) { + return $this->notFound('Không tìm thấy dịch vụ hoặc dịch vụ đã bị hủy.'); + } + + $orderItem = $service->orderItem; + if (!$orderItem || (int) $orderItem->price <= 0) { + return $this->error('Không thể xác định giá gia hạn dịch vụ.'); + } + + $validated = $request->validate(['months' => ['required', 'integer', 'min:1', 'max:24']]); + + $price = (int) $orderItem->price * (int) $validated['months']; + $months = (int) $validated['months']; + + // Validate balance before opening a transaction + if ($customer->total < $price) { + return $this->error(sprintf( + 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format($customer->total), + number_format($price) + )); + } + + $needsVmStart = $service->vm_id !== null; + + $invoice = DB::transaction(function () use ($customer, $service, $orderItem, $price, $months) { + // 1. Debit balance + $customer->total -= $price; + $customer->save(); + + // 2. Activity log + CustomerActivityLog::create([ + 'agent' => 'api', + 'client_ip' => request()?->ip(), + 'customer_id' => $customer->id, + 'description' => sprintf( + 'Gia hạn dịch vụ #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.', + $service->server_name ?? $service->id, + number_format($price), + number_format($customer->total) + ), + ]); + + // 3. Create PAID renewal invoice + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $customer->id, + 'order_id' => null, + 'invoice_type' => 2, // renewal + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'status' => InvoiceStatus::PAID->value, + 'paid_at' => now(), + 'payment_method' => 'credit', + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $service->product_id, + 'type' => 'renewal', + 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $price, + 'total_price' => $price, + ]); + + // 4. Extend expires_at from today (or current expires_at if still in future) + $baseDate = $service->expires_at && $service->expires_at->isFuture() + ? $service->expires_at + : now(); + $expiresAt = Carbon::parse($baseDate)->addMonths($months); + + $service->update([ + 'status' => 'active', + 'expires_at' => $expiresAt, + 'suspended_at' => null, + 'invoice_id' => $invoice->id, + ]); + + return $invoice; + }); + + // 5. Start VM outside transaction (DB already committed) + if ($needsVmStart) { + dispatch(new VMJobs((int) $service->vm_id, 'Start', false)) + ->delay(now()->addSeconds(5)); + } + + $this->activityLogger->log( + event: 'renew', + logName: 'Service', + description: "Customer {$customer->email} gia hạn dịch vụ #{$service->id} ({$service->server_name}) - {$months} tháng - " . number_format($price) . ' VNĐ', + subject: $service, + newValues: ['months' => $months, 'price' => $price, 'invoice_number' => $invoice->invoice_number], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Gia hạn thành công. Dịch vụ đã được kích hoạt lại.', + 'data' => [ + 'invoice_number' => $invoice->invoice_number, + 'total' => (float) $invoice->total, + 'expires_at' => $service->fresh()?->expires_at?->toISOString(), + ], + ]); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + // ========================================================================= + // Private — formatters + // ========================================================================= + + private function formatOrderSummary(Order $order): array + { + return [ + 'id' => $order->id, + 'order_num' => $order->order_num, + 'status' => $order->status, + 'status_text' => OrderStatus::from((int) $order->status)->label(), + 'sub_total' => $order->sub_total, + 'total' => $order->total, + 'created_at' => $this->isoDate($order->created_at), + ]; + } + + private function formatInvoiceSummary(Invoice $invoice): array + { + return [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'sub_total' => $invoice->sub_total, + 'tax' => $invoice->tax, + 'total' => $invoice->total, + 'status' => $invoice->status, + 'due_date' => $this->isoDate($invoice->due_date), + ]; + } + + private function formatOrderItems($items): array + { + return $items->map(fn($item) => [ + 'id' => $item->id, + 'billing_cycle' => $item->billing_cycle, + 'months' => $item->months, + 'server_name' => $item->server_name, + 'os_id' => $item->os_id, + 'price' => (float) $item->price, + 'service' => $item->service ? [ + 'id' => $item->service->id, + 'status' => $item->service->status, + 'status_text' => $item->service->status_label, + 'retry_requested_at' => $this->isoDate($item->service->retry_requested_at), + 'can_retry_provisioning' => $this->canRetryProvisioning($item->service), + ] : null, + 'product' => $item->product ? [ + 'id' => $item->product->id, + 'uuid' => $item->product->uuid, + 'name' => $item->product->name, + 'product_type' => $item->product->product_type, + 'specs' => [ + 'cpu' => $item->product->cpu, + 'ram' => $item->product->ram, + 'disk' => $item->product->disk, + 'bandwidth' => $item->product->bandwidth, + ], + ] : null, + ])->all(); + } + + private function formatOrderDetail(Order $order): array + { + $invoice = $order->invoice; + + return [ + 'id' => $order->id, + 'order_num' => $order->order_num, + 'status' => $order->status, + 'status_text' => OrderStatus::from((int) $order->status)->label(), + 'total' => (float) $order->total, + 'created_at' => $this->isoDate($order->created_at), + 'customer' => [ + 'id' => $order->customer?->id, + 'name' => $order->customer?->fullname, + 'email' => $order->customer?->email, + ], + 'invoice' => $invoice ? [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'status' => $invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', + 'sub_total' => $invoice->sub_total, + 'tax' => $invoice->tax, + 'total' => $invoice->total, + 'due_date' => $this->isoDate($invoice->due_date), + 'paid_at' => $this->isoDate($invoice->paid_at), + ] : null, + 'items' => $this->formatOrderItems($order->orderItems), + ]; + } + + private function canRetryProvisioning(Service $service): bool + { + if ($service->status !== 'pending') { + return false; + } + + if ($service->retry_requested_at) { + return false; + } + + $paidAt = $service->invoice?->paid_at; + if (!$paidAt) { + return false; + } + + return Carbon::parse($paidAt)->diffInMinutes(now()) >= 180; + } + + private function formatInvoiceDetail(Invoice $invoice): array + { + $order = $invoice->order; + $vmware = $order?->vmWare->first(); + + return [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'invoice_type' => $invoice->invoice_type, + 'status' => $invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', + 'sub_total' => (float) $invoice->sub_total, + 'discount_amount' => (float) ($invoice->discount_amount ?? 0), + 'tax' => (float) $invoice->tax, + 'total' => (float) $invoice->total, + 'payment_method' => $invoice->payment_method, + 'auto_renew' => (bool) $invoice->auto_renew, + 'created_at' => $this->isoDate($invoice->created_at), + 'due_date' => $this->isoDate($invoice->due_date), + 'paid_at' => $this->isoDate($invoice->paid_at), + 'customer' => $invoice->customer ? [ + 'id' => $invoice->customer->id, + 'name' => $invoice->customer->fullname, + 'email' => $invoice->customer->email, + 'address' => $invoice->customer->address, + ] : null, + 'order' => $order ? [ + 'id' => $order->id, + 'order_num' => $order->order_num, + 'total' => (float) $order->total, + ] : null, + 'service' => ($firstItem = $order?->orderItems->first()) ? [ + 'product_id' => $firstItem->product?->id, + 'product_uuid' => $firstItem->product?->uuid, + 'name' => $firstItem->product?->name, + 'billing_cycle' => $firstItem->billing_cycle, + 'server_name' => $firstItem->server_name, + 'specs' => [ + 'cpu' => $firstItem->product?->cpu, + 'ram' => $firstItem->product?->ram, + 'disk' => $firstItem->product?->disk, + ], + ] : null, + 'items' => $order ? $order->orderItems->map(fn($item) => [ + 'id' => $item->id, + 'product_name' => $item->product?->name, + 'server_name' => $item->server_name, + 'billing_cycle' => $item->billing_cycle, + 'price' => (float) $item->price, + 'months' => $item->months, + 'os_id' => $item->os_id, + ])->values()->all() : [], + 'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [ + 'id' => $a->id, + 'filename' => $a->filename, + 'url' => $a->url, + 'mime_type' => $a->mime_type, + ])->all(), + 'vmware' => $vmware ? [ + 'id' => $vmware->id, + 'name' => $vmware->name, + 'hostname' => $vmware->hostname, + 'ip_address' => $vmware->ipAddress?->ip, + 'os_name' => $vmware->os?->name, + 'status' => $vmware->status, + 'next_due_date' => $this->isoDate($vmware->next_due_date), + ] : null, + ]; + } + + // ========================================================================= + // Micro-helpers + // ========================================================================= + + private function isoDate($value): ?string + { + if (empty($value)) { + return null; + } + try { + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value)->toISOString(); + } + if (is_numeric($value)) { + return Carbon::createFromTimestamp((int) $value)->toISOString(); + } + return Carbon::parse($value)->toISOString(); + } catch (\Throwable) { + return null; + } + } + + private function error(string $message, int $code = 400): JsonResponse + { + return response()->json(['success' => false, 'message' => $message], $code); + } + + private function notFound(string $message): JsonResponse + { + return response()->json(['success' => false, 'message' => $message], 404); + } +}