From 96778e7bed92fed45c8f65e86b00b4b4edbd71bc Mon Sep 17 00:00:00 2001 From: pbao9 Date: Mon, 11 May 2026 11:09:55 +0700 Subject: [PATCH] feat: implement VM snapshot management, enhance client resource loading, and refactor deployment workflow for production --- .DS_Store | Bin 10244 -> 10244 bytes .github/workflows/deploy.yml | 33 +- app/.DS_Store | Bin 10244 -> 10244 bytes app/Enums/.DS_Store | Bin 0 -> 6148 bytes app/Http/.DS_Store | Bin 6148 -> 8196 bytes .../Admin/Invoice/InvoiceTestController.php | 129 +++ .../Api/Client/ClientController.php | 10 +- .../Api/Client/Service/ServiceController.php | 59 ++ .../Api/Service/Concerns/ResolvesVm.php | 77 ++ .../Api/Service/OrderController.php | 51 +- .../Api/Service/SnapshotController.php | 197 ++++ .../Controllers/Api/Service/VmController.php | 64 ++ .../Api/Service/VmRebuildController.php | 219 +++++ app/Http/Helpers/function_helper.php | 17 +- .../Resources/Api/Client/ClientResource.php | 6 +- app/Http/Services/vCenter.php | 33 + app/Jobs/.DS_Store | Bin 0 -> 6148 bytes .../Mail/SendInformationVirtualMachine.php | 20 +- .../Provision/AssignIpToGuestJob.php | 34 +- .../RetryProvisioningOrchestrator.php | 359 +++++++ .../Provision/SendVmCredentialsEmailJob.php | 29 +- app/Mail/CreateVirtualMachine.php | 37 +- app/Models/.DS_Store | Bin 0 -> 6148 bytes app/Models/Product.php | 15 +- app/Providers/AppServiceProvider.php | 5 - app/Providers/RepositoryProvider.php | 268 ++---- app/Providers/RouteServiceProvider.php | 16 +- app/Repositories/.DS_Store | Bin 0 -> 12292 bytes app/Services/.DS_Store | Bin 0 -> 6148 bytes app/Services/InvoicePdfService.php | 39 + app/Support/.DS_Store | Bin 0 -> 6148 bytes app/View/.DS_Store | Bin 0 -> 6148 bytes composer.json | 1 + composer.lock | 524 ++++++++++- database/.DS_Store | Bin 6148 -> 6148 bytes ...dd_module_ip_address_to_products_table.php | 30 + lang/.DS_Store | Bin 0 -> 6148 bytes public/.DS_Store | Bin 8196 -> 8196 bytes public/file/logo_primary.png | Bin 0 -> 62744 bytes public/fonts/.DS_Store | Bin 0 -> 6148 bytes public/js/.DS_Store | Bin 0 -> 6148 bytes public/lib/.DS_Store | Bin 0 -> 10244 bytes public/uploads/.DS_Store | Bin 0 -> 6148 bytes public/vendor/.DS_Store | Bin 0 -> 10244 bytes resources/.DS_Store | Bin 6148 -> 6148 bytes resources/views/.DS_Store | Bin 6148 -> 6148 bytes resources/views/pdf/invoice.blade.php | 156 ++++ routes/api.php | 476 ++++++---- routes/client.php | 881 +++--------------- storage/.DS_Store | Bin 0 -> 6148 bytes tests/.DS_Store | Bin 0 -> 6148 bytes 51 files changed, 2536 insertions(+), 1249 deletions(-) create mode 100644 app/Enums/.DS_Store create mode 100644 app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php create mode 100644 app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php create mode 100644 app/Http/Controllers/Api/Service/SnapshotController.php create mode 100644 app/Http/Controllers/Api/Service/VmRebuildController.php create mode 100644 app/Jobs/.DS_Store create mode 100644 app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php create mode 100644 app/Models/.DS_Store create mode 100644 app/Repositories/.DS_Store create mode 100644 app/Services/.DS_Store create mode 100644 app/Services/InvoicePdfService.php create mode 100644 app/Support/.DS_Store create mode 100644 app/View/.DS_Store create mode 100644 database/migrations/2026_05_06_094235_add_module_ip_address_to_products_table.php create mode 100644 lang/.DS_Store create mode 100644 public/file/logo_primary.png create mode 100644 public/fonts/.DS_Store create mode 100644 public/js/.DS_Store create mode 100644 public/lib/.DS_Store create mode 100644 public/uploads/.DS_Store create mode 100644 public/vendor/.DS_Store create mode 100644 resources/views/pdf/invoice.blade.php create mode 100644 storage/.DS_Store create mode 100644 tests/.DS_Store diff --git a/.DS_Store b/.DS_Store index c089f4809616e15f3ff33c1045498f7b728a840b..e09b6d60c130c347653643703f1d5073b77798c7 100644 GIT binary patch literal 10244 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 G0Qdw3!p$oH diff --git a/app/Enums/.DS_Store b/app/Enums/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4b5683b08637ca4617acd9e379c053f72eb50f22 GIT binary patch literal 6148 zcmeHKu}T9$5S=yAgxI7JEEnwT@&Uq`oP{7LU~Fv8-%zlz5CjXq!Y{D0 z7yN^izL{Oj?OlSMNSJ}$w>LYp`|iQ*+!B##Pm>l=lZfhQjMWCZ1;+E-O19=boNQ!` zOS&4KwYvRuG%iJ}Llsa3{+j~)?QT$qhIBy#Dt*7ER-7NDaoiiGJ^1U5+0pIR!`ps2 zCiyjHe4alimz_09N4^!&qt; z(6R#H+=%g@CyDWIF!9Dw-+X|xpN5zi)R|`HO?Q58_V<3X%<`rHK*}9^5?~Yn z46Mu+hOtQ~Y@F3t>62G(p(W@K-~$C0KMm~Zq7~2zXa%$aS^=%VWmEv)*{m2T?|m_< zTdjar;J;LW?GGkaW=qocgymZYHarDDo5#z%U?1}U?qf<@lC~!-cf~nH_CTa7ktqfd z?l^8TINFl5Jz<4A5aA9)-kHb@h48zhpUL4sO2X<^E1(sKE5LI1VVK9f-Gg>0e-G;2 zpjCHi9T9KqE!PR!#o{N@m&;%2AHXs^ux@SBuGOx!J3+bTKVsijD0S;+{aSt1X>_ZF zk$LL1YmV3Oxr1Xj5VH2b@od^H(~f5c+}9EVgIKDC(Y?Lt+1umh%*>tradU5Ws)+sB zsr~&y+`gqb|-k0Vqr5PjVkl*2S8}=2E$3;X=G} z8+c0?d|sQ7X5Y)5*iaE55b~^Qr8zks*HYk?}LYLQXzDd5&Or zId;$K;&1MrvA9C2=T6L|*j3H9=+)h?XMax-ja0?6a3Q_(M|jJ)-jV&0VRXq>ASZ@L zS^mF}e*T|5i5}GoXa)Xi1xUVPRmvE`*}u`zXjraoV%@;X3d8k;r_jt;J zi}G^v^U^`uCI^VV*?dLJm~mpmjm7L79D>Y1Q6LcD1`@6yLpBzEXP(S2;|Vg7feB(f M$VP_E@jP>w0h25k`~Uy| diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php new file mode 100644 index 0000000..beda088 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php @@ -0,0 +1,129 @@ +where('invoice_number', $invoiceNumber) + ->first(); + + if (!$invoice) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy hóa đơn.', + ], 404); + } + + $customer = $invoice->customer; + if (!$customer?->email) { + return response()->json([ + 'success' => false, + 'message' => 'Khách hàng không có email.', + ], 400); + } + + try { + $pdfService = app(InvoicePdfService::class); + $invoicePdf = $pdfService->generate($invoice); + $invoicePdfFileName = $pdfService->generateFileName($invoice); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'message' => 'Lỗi tạo PDF: ' . $e->getMessage(), + ], 500); + } + + $vm = ModuleVmware::with(['customer', 'os.osGroup', 'order.orderItems']) + ->where('order_id', $invoice->order_id) + ->first(); + + if ($vm) { + SendVmCredentialsEmailJob::dispatch($vm->id); + + return response()->json([ + 'success' => true, + 'message' => 'Đã dispatch SendVmCredentialsEmailJob (có PDF hóa đơn đính kèm).', + 'data' => [ + 'invoice_id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'customer_email' => $customer->email, + 'vm_id' => $vm->id, + 'pdf_file_name' => $invoicePdfFileName, + ], + ]); + } + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + $emailData = [ + 'company_name' => $settingValue->company ?? 'Thế Giới Số', + 'company_logo' => $settingValue->logo ?? '', + 'client_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'product_name' => $invoice->invoiceItems->first()?->product?->name ?? 'Dịch vụ', + 'product_billing' => 'N/A', + 'product_price' => number_format((float) $invoice->total, 0, ',', '.'), + 'service_host' => 'N/A', + 'service_username' => 'N/A', + 'service_port' => 'N/A', + 'service_password' => 'N/A', + 'os_name' => 'N/A', + 'service_due_exp' => $invoice->due_date ? $invoice->due_date->format('d/m/Y') : 'N/A', + ]; + + SendInformationVirtualMachine::dispatch($emailData, $customer, $invoicePdf, $invoicePdfFileName); + + return response()->json([ + 'success' => true, + 'message' => 'Đã gửi email hóa đơn (không có VM liên kết, sử dụng thông tin hóa đơn).', + 'data' => [ + 'invoice_id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'customer_email' => $customer->email, + 'pdf_file_name' => $invoicePdfFileName, + ], + ]); + } + + public function previewInvoicePdf(Request $request, string $invoiceNumber) + { + $invoice = Invoice::with(['invoiceItems.product', 'customer', 'order.orderItems']) + ->where('invoice_number', $invoiceNumber) + ->first(); + + if (!$invoice) { + return response()->json([ + 'success' => false, + 'message' => 'Không tìm thấy hóa đơn.', + ], 404); + } + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + return Pdf::loadView('pdf.invoice', [ + 'invoice' => $invoice, + 'invoiceItems' => $invoice->invoiceItems, + 'customer' => $invoice->customer, + 'order' => $invoice->order, + 'companyName' => $settingValue->company ?? 'Thế Giới Số', + 'companyAddress' => $settingValue->address ?? '', + 'companyPhone' => $settingValue->phone ?? '', + 'companyEmail' => $settingValue->email ?? '', + ])->stream($invoice->invoice_number . '.pdf'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index bda0cdb..a70cf9f 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -87,7 +87,7 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource(auth('client')->user()), + 'client' => new ClientResource(auth('client')->user()->loadCount(['vps', 'hosting', 'domain'])), ], ]); } @@ -133,7 +133,7 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($result['client']), + 'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])), 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, // seconds @@ -185,7 +185,7 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($result['client']), + 'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])), 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, @@ -282,7 +282,7 @@ class ClientController extends Controller 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, - 'client' => new ClientResource($result['client']), + 'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])), ], ]); } catch (\Illuminate\Validation\ValidationException $e) { @@ -304,7 +304,7 @@ class ClientController extends Controller 'error' => false, 'message' => 'Cập nhật thông tin thành công', 'data' => [ - 'client' => new ClientResource($user), + 'client' => new ClientResource($user->loadCount(['vps', 'hosting', 'domain'])), ], ]); } diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php index 9012ae7..249c215 100644 --- a/app/Http/Controllers/Api/Client/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php @@ -6,9 +6,11 @@ use App\Http\Controllers\Controller; use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource; use \App\Http\Resources\Api\Client\Service\ServiceClientResource; use App\Http\Services\vCenter; +use App\Models\Order; use App\Models\Service; use App\Repositories\Api\ServiceClient\ServiceClientRepository; use App\Services\Api\ServiceClient\ServiceClientService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ServiceController extends Controller @@ -103,4 +105,61 @@ class ServiceController extends Controller ]); } + public function getTicketByOrderNum(Request $request, vCenter $serviceVCenter): JsonResponse + { + $orderNum = (string) $request->query('order_num', ''); + if ($orderNum === '') { + return response()->json(['error' => true, 'message' => 'Thiếu order_num'], 422); + } + + $customer = $request->user(); + $order = Order::with(['vmWare.vCenter']) + ->where('customer_id', $customer->id) + ->where('order_num', $orderNum) + ->first(); + + if (!$order) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy đơn hàng'], 404); + } + + $vm = $order->vmWare->first(); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Đơn hàng chưa có VM'], 404); + } + + $vcenter = $vm->vCenter; + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404); + } + + if (!$serviceVCenter->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']); + } + + $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); + + if ($consoleTicket === 'Login_Again') { + if ($serviceVCenter->loginServer($vcenter)) { + $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); + } else { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']); + } + } + + if (is_string($consoleTicket)) { + return response()->json(['error' => true, 'message' => "Failed to get console ticket: $consoleTicket"]); + } + + if ($consoleTicket['error']) { + return response()->json(['error' => true, 'message' => $consoleTicket['data']]); + } + + return response()->json([ + 'error' => false, + 'data' => $consoleTicket['data'], + 'order_num' => $order->order_num, + 'vm_id' => $vm->id, + ]); + } + } \ No newline at end of file diff --git a/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php new file mode 100644 index 0000000..da398a9 --- /dev/null +++ b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php @@ -0,0 +1,77 @@ +user(); + $vm = null; + + if (is_string($productId) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId)) { + $service = Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') + ->where('uuid', $productId) + ->where('customer_id', $customer->id) + ->first(); + if ($service && $service->vmware) { + $vm = $service->vmware; + } + } elseif (is_numeric($productId)) { + $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) + ->find((int) $productId); + } else { + $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) + ->where('vmware_id', $productId) + ->where('customer_id', $customer->id) + ->first(); + } + + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + + if ((int) $vm->customer_id !== (int) $customer->id) { + return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); + } + + return $vm; + } + + protected function getConsoleTicket(\App\Http\Services\vCenter $serviceVCenter, ModuleVmware $vm): array|JsonResponse + { + $vcenter = $vm->vCenter; + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404); + } + + if (!$serviceVCenter->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']); + } + + $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); + + if ($consoleTicket === 'Login_Again') { + if ($serviceVCenter->loginServer($vcenter)) { + $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); + } else { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']); + } + } + + if (is_string($consoleTicket)) { + return response()->json(['error' => true, 'message' => "Failed to get console ticket: $consoleTicket"]); + } + + if ($consoleTicket['error']) { + return response()->json(['error' => true, 'message' => $consoleTicket['data']]); + } + + return $consoleTicket; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php index fe7378a..ae4ee0a 100644 --- a/app/Http/Controllers/Api/Service/OrderController.php +++ b/app/Http/Controllers/Api/Service/OrderController.php @@ -7,12 +7,14 @@ use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Http\Controllers\Controller; use App\Jobs\VirtualMachine\Action\VMJobs; +use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; +use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator; use App\Models\Log as CustomerActivityLog; use App\Http\Requests\Api\Order\PayOrderRequest; use App\Http\Requests\Api\Order\StoreOrderRequest; use App\Models\Invoice; use App\Models\InvoiceItem; -use App\Models\ModuleVmware; + use App\Models\Order; use App\Models\Product; use App\Models\Service; @@ -194,6 +196,41 @@ class OrderController extends Controller } } + /** + * 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) + ->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.'); + } + + RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id); + + 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 // ========================================================================= @@ -305,15 +342,13 @@ class OrderController extends Controller { try { $services = Service::query() - ->with(['product', 'addons', 'invoice', 'orderItem']) + ->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->vm_id - ? ModuleVmware::with(['ipAddress', 'os'])->find((int) $service->vm_id) - : null; + $vmware = $service->vmware; return [ 'id' => $service->id, @@ -357,7 +392,7 @@ class OrderController extends Controller { try { $service = Service::query() - ->with(['product', 'addons', 'invoice', 'orderItem']) + ->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter']) ->where('uuid', $uuid) ->where('customer_id', $request->user()->id) ->first(); @@ -366,9 +401,7 @@ class OrderController extends Controller return $this->notFound('Không tìm thấy dịch vụ.'); } - $vmware = $service->vm_id - ? ModuleVmware::with(['ipAddress', 'os', 'vCenter'])->find((int) $service->vm_id) - : null; + $vmware = $service->vmware; $data = [ 'id' => $service->id, diff --git a/app/Http/Controllers/Api/Service/SnapshotController.php b/app/Http/Controllers/Api/Service/SnapshotController.php new file mode 100644 index 0000000..584907f --- /dev/null +++ b/app/Http/Controllers/Api/Service/SnapshotController.php @@ -0,0 +1,197 @@ +user(); + $payload = $request->validate([ + 'product' => ['required'], + 'description' => ['nullable', 'string', 'max:1000'], + ]); + + $vm = $this->resolveVmById($payload['product'], $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $today = now()->format('d-m-Y'); + $existingCount = ModuleSnapshot::where('module_vmware_id', $vm->id) + ->whereDate('created_at', today()) + ->count(); + $snapshotName = sprintf('%s-%s-%d', $vm->name, $today, $existingCount + 1); + + $snapshot = ModuleSnapshot::create([ + 'name' => $snapshotName, + 'description' => $payload['description'] ?? null, + 'customer_id' => $customer->id, + 'module_vmware_id' => $vm->id, + 'status' => 'pending', + 'snapshot_ref' => null, + ]); + + CreateSnapshot::dispatch($snapshot->id); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi yêu cầu tạo snapshot vào hàng đợi', + 'data' => [ + 'id' => $snapshot->id, + 'name' => $snapshot->name, + 'description' => $snapshot->description, + 'status' => 'pending', + ], + ]); + } + + public function index(Request $request, vCenter $serviceVCenter): JsonResponse + { + $productParam = $request->query('product'); + if (!$productParam) { + return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); + } + + $vm = $this->resolveVmById($productParam, $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $remote = $serviceVCenter->getSnapShot($vm); + if (is_string($remote) || ($remote['error'] ?? true)) { + return response()->json([ + 'error' => true, + 'message' => is_string($remote) ? $remote : ($remote['message'] ?? 'Không thể lấy danh sách snapshot'), + ], 400); + } + + $remoteItems = $remote['data'] ?? []; + foreach ($remoteItems as $item) { + $ref = (string) ($item['id'] ?? $item['name'] ?? ''); + $name = (string) ($item['name'] ?? $ref); + if ($name === '') { + continue; + } + + $snapshot = ModuleSnapshot::query() + ->where('module_vmware_id', $vm->id) + ->where(function ($q) use ($ref, $name) { + $q->where('snapshot_ref', $ref)->orWhere('name', $name); + }) + ->first(); + + $payload = [ + 'name' => $name, + 'description' => $item['description'] ?? null, + 'customer_id' => $request->user()->id, + 'module_vmware_id' => $vm->id, + 'status' => 'success', + 'snapshot_ref' => $ref ?: $name, + 'size_gb' => isset($item['size_gb']) ? (float) $item['size_gb'] : null, + 'size_last_synced_at' => now(), + ]; + + if ($snapshot) { + $snapshot->update($payload); + } else { + ModuleSnapshot::create($payload); + } + } + + $dbSnapshots = ModuleSnapshot::query() + ->where('module_vmware_id', $vm->id) + ->where('customer_id', $request->user()->id) + ->orderByDesc('created_at') + ->get() + ->map(function ($snap) { + return [ + 'id' => $snap->id, + 'name' => $snap->name, + 'description' => $snap->description, + 'snapshot_ref' => $snap->snapshot_ref, + 'status' => $snap->status, + 'size_gb' => $snap->size_gb, + 'created_at' => $snap->created_at?->toISOString(), + ]; + }); + + return response()->json([ + 'error' => false, + 'data' => $dbSnapshots, + ]); + } + + public function revert(Request $request): JsonResponse + { + $customer = $request->user(); + $payload = $request->validate([ + 'product' => ['required'], + 'snapshot_id' => ['required', 'numeric'], + ]); + + $vm = $this->resolveVmById($payload['product'], $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $snapshot = ModuleSnapshot::where('id', $payload['snapshot_id']) + ->where('module_vmware_id', $vm->id) + ->where('customer_id', $customer->id) + ->first(); + + if (!$snapshot) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404); + } + + RevertSnapshot::dispatch($vm->id, $snapshot->id); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi yêu cầu revert snapshot vào hàng đợi', + ]); + } + + public function destroy(Request $request): JsonResponse + { + $customer = $request->user(); + $payload = $request->validate([ + 'product' => ['required'], + 'snapshot_id' => ['required', 'numeric'], + ]); + + $vm = $this->resolveVmById($payload['product'], $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $snapshot = ModuleSnapshot::where('id', $payload['snapshot_id']) + ->where('module_vmware_id', $vm->id) + ->where('customer_id', $customer->id) + ->first(); + + if (!$snapshot) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404); + } + + RemoveSnapshot::dispatch($vm->id, $snapshot->id); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi yêu cầu xóa snapshot vào hàng đợi', + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index a5a9362..b7f7b21 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -6,8 +6,10 @@ use App\Actions\VM\ChangeIpVMAction; use App\Actions\VM\ChangePortVMAction; use App\Actions\VM\PowerOffVMAction; use App\Actions\VM\PowerOnVMAction; +use App\Http\Controllers\Api\Service\Concerns\ResolvesVm; use App\Http\Controllers\Controller; use App\Http\Services\Telegram; +use App\Http\Services\vCenter; use App\Models\ModuleVmware; use App\Models\Service; use Illuminate\Http\JsonResponse; @@ -16,6 +18,9 @@ use Illuminate\Support\Facades\Log; class VmController extends Controller { + use ResolvesVm { + ResolvesVm::resolveVmById as resolveVmForClient; + } // ------------------------------------------------------------------------- // Power ON // ------------------------------------------------------------------------- @@ -240,6 +245,65 @@ class VmController extends Controller ], 400); } + // ------------------------------------------------------------------------- + // Console Ticket + // ------------------------------------------------------------------------- + + public function consoleTicket(Request $request, vCenter $serviceVCenter): JsonResponse + { + $productId = $request->get('product'); + if (!$productId) { + return response()->json(['error' => true, 'message' => 'Please choose product']); + } + + $vm = ModuleVmware::with('vCenter')->find((int) $productId); + if (!$vm) { + return response()->json(['error' => true, 'message' => "Can't find product"]); + } + + $result = $this->getConsoleTicket($serviceVCenter, $vm); + if ($result instanceof JsonResponse) { + return $result; + } + + return response()->json([ + 'error' => false, + 'data' => $result['data'], + ]); + } + + // ------------------------------------------------------------------------- + // Restart VM + // ------------------------------------------------------------------------- + + public function restart(Request $request, vCenter $serviceVCenter): JsonResponse + { + $productId = $request->input('product'); + if (!$productId) { + return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); + } + + $vm = $this->resolveVmForClient($productId, $request); + if ($vm instanceof JsonResponse) { + return $vm; + } + + $result = $serviceVCenter->restartVM($vm, $vm->vmware_id); + + if (is_string($result) || ($result['error'] ?? true)) { + return response()->json([ + 'error' => true, + 'message' => is_string($result) ? $result : ($result['data'] ?? 'Không thể restart VM'), + ], 400); + } + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi lệnh restart VM', + 'data' => $result['data'] ?? 'success', + ]); + } + // ------------------------------------------------------------------------- // Shared helpers // ------------------------------------------------------------------------- diff --git a/app/Http/Controllers/Api/Service/VmRebuildController.php b/app/Http/Controllers/Api/Service/VmRebuildController.php new file mode 100644 index 0000000..d1920b7 --- /dev/null +++ b/app/Http/Controllers/Api/Service/VmRebuildController.php @@ -0,0 +1,219 @@ +query('product'); + if (!$productId) { + return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); + } + + $vm = ModuleVmware::with(['order.orderItems.product.osVersions.osGroup']) + ->find($productId); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + if ((int) $vm->customer_id !== (int) $request->user()->id) { + return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); + } + + $product = optional($vm->order?->orderItems?->first())->product; + if (!$product) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); + } + + $osOptions = $product->osVersions + ->where('status', 1) + ->map(fn($os) => [ + 'id' => $os->id, + 'name' => $os->name, + 'version' => $os->version, + 'group' => $os->osGroup?->name, + ]) + ->values(); + + return response()->json([ + 'error' => false, + 'data' => [ + 'product_id' => $product->id, + 'product_uuid' => $product->uuid, + 'product_name' => $product->name, + 'os_options' => $osOptions, + ], + ]); + } + + public function rebuild(Request $request, vCenter $serviceVCenter): JsonResponse + { + $customer = $request->user(); + $payload = $request->validate([ + 'product' => ['required', 'integer'], + 'os_id' => ['required', 'integer', 'exists:os,id'], + ]); + + $vm = ModuleVmware::with(['order.orderItems.product', 'vCenter', 'ipAddress.host.datacenter.vCenter']) + ->find($payload['product']); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + if ((int) $vm->customer_id !== (int) $customer->id) { + return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); + } + + $product = optional($vm->order?->orderItems?->first())->product; + if (!$product) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); + } + + $allowedOs = $product->osVersions()->where('status', 1)->pluck('id')->toArray(); + if (!in_array((int) $payload['os_id'], array_map('intval', $allowedOs), true)) { + return response()->json(['error' => true, 'message' => 'OS không hợp lệ cho product hiện tại'], 422); + } + + DB::table('reinstall_logs')->insert([ + 'product_id' => $vm->id, + 'product_type' => 4, + 'custome_id' => $customer->id, + 'os_id' => $payload['os_id'], + 'status' => 'pending', + 'initiator' => 'client', + 'note' => 'Client request rebuild from portal-client', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = $serviceVCenter->reBuildVPS((string) $vm->name); + if (is_string($result) || ($result['error'] ?? false)) { + return response()->json([ + 'error' => true, + 'message' => is_string($result) ? $result : ($result['message'] ?? 'Không thể rebuild VM'), + ], 400); + } + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi yêu cầu rebuild VM', + 'data' => $result, + ]); + } + + public function addonOptions(Request $request): JsonResponse + { + $productId = (int) $request->query('product'); + if (!$productId) { + return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); + } + + $vm = ModuleVmware::with(['order.orderItems.product.addons']) + ->find($productId); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + if ((int) $vm->customer_id !== (int) $request->user()->id) { + return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); + } + + $product = optional($vm->order?->orderItems?->first())->product; + if (!$product) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); + } + + $addons = $product->addons()->where('status', true)->get()->map(function ($addon) { + return [ + 'id' => $addon->id, + 'code' => strtolower((string) $addon->code), + 'name' => $addon->name, + 'price' => (float) $addon->price, + 'min' => (int) ($addon->min ?? 0), + 'max' => (int) ($addon->max ?? 0), + 'step' => (int) ($addon->step ?? 1), + ]; + }); + + return response()->json([ + 'error' => false, + 'data' => [ + 'product_id' => $product->id, + 'product_name' => $product->name, + 'base_specs' => [ + 'cpu' => (int) ($product->cpu ?? 0), + 'ram' => (int) ($product->ram ?? 0), + 'disk' => (int) ($product->disk ?? 0), + 'bandwidth' => (int) ($product->bandwidth ?? 0), + 'gpu' => $product->gpu, + ], + 'addons' => $addons->values(), + ], + ]); + } + + public function upgradeAddons(Request $request, vCenter $serviceVCenter): JsonResponse + { + $customer = $request->user(); + $payload = $request->validate([ + 'product' => ['required', 'integer'], + 'cpu' => ['nullable', 'integer', 'min:0'], + 'ram' => ['nullable', 'integer', 'min:0'], + 'disk' => ['nullable', 'integer', 'min:0'], + 'bandwidth' => ['nullable', 'integer', 'min:0'], + 'gpu' => ['nullable', 'string', 'max:255'], + ]); + + $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) + ->find($payload['product']); + if (!$vm) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); + } + if ((int) $vm->customer_id !== (int) $customer->id) { + return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); + } + + $vcenter = $vm->vCenter ?? $vm->ipAddress?->host?->datacenter?->vCenter; + if (!$vcenter || !$serviceVCenter->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vCenter'], 400); + } + + $applied = []; + + if (isset($payload['cpu']) && $payload['cpu'] > 0) { + $res = $serviceVCenter->UpgradeCPUVirtualMachine($vm->vmware_id, (int) $payload['cpu']); + if (is_array($res) && !($res['error'] ?? true)) { + $applied[] = 'cpu'; + } + } + if (isset($payload['ram']) && $payload['ram'] > 0) { + $res = $serviceVCenter->UpgradeMemoryVirtualMachine($vm->vmware_id, (int) $payload['ram']); + if (is_array($res) && !($res['error'] ?? true)) { + $applied[] = 'ram'; + } + } + if (isset($payload['disk']) && $payload['disk'] > 0) { + $res = $serviceVCenter->UpgradeDiskVirtualMachine($vm, (int) $payload['disk']); + if (is_array($res) && !($res['error'] ?? true)) { + $applied[] = 'disk'; + } + } + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi yêu cầu nâng cấp addons', + 'data' => [ + 'applied' => $applied, + 'note' => 'bandwidth/gpu hiện được ghi nhận yêu cầu, chưa có luồng tự động áp vào VM', + ], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Helpers/function_helper.php b/app/Http/Helpers/function_helper.php index 504815c..d2061c4 100644 --- a/app/Http/Helpers/function_helper.php +++ b/app/Http/Helpers/function_helper.php @@ -1,16 +1,13 @@ make(\App\Repositories\Setting\SettingCachedRepository::class); - // Nếu có dấu chấm => lấy theo group if (Str::contains($key, '.')) { [$group, $subKey] = explode('.', $key, 2); $groupData = $repo->getByKey($group); @@ -30,26 +27,28 @@ if (!function_exists('setting')) { if (!function_exists('format_price')) { function format_price($price, $suffix = '₫') { - if ($price === null) + if ($price === null) { return ''; + } return number_format($price, 0, ',', '.') . ' ' . $suffix; } } if (!function_exists('format_point')) { function format_point($point, $suffix = '𝑝') { - if ($point === null) + if ($point === null) { return ''; + } return number_format($point, 0, ',', '.') . ' ' . $suffix; } } - if (!function_exists('format_day')) { function format_day($date, $format = 'd/m/Y') { - if (empty($date)) + if (empty($date)) { return ''; + } try { return \Carbon\Carbon::parse($date)->format($format); } catch (\Exception $e) { @@ -83,7 +82,7 @@ if (!function_exists('generate_order_num')) { function generate_order_num(string $prefix = 'TGS', int $length = 6): string { do { - $randomNumber = random_int(10 ** ($length - 1), (10 ** $length) - 1); + $randomNumber = random_int(10 ** ($length - 1), 10 ** $length - 1); $orderNum = $prefix . $randomNumber; } while (\App\Models\Order::where('order_num', $orderNum)->exists()); @@ -104,4 +103,4 @@ function is_active_route($path) function show_class($path) { return request()->is(...(array) $path) ? 'show' : ''; -} \ No newline at end of file +} diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php index 46d620e..29b0ab3 100644 --- a/app/Http/Resources/Api/Client/ClientResource.php +++ b/app/Http/Resources/Api/Client/ClientResource.php @@ -48,11 +48,11 @@ class ClientResource extends JsonResource 'point_total' => $this->point, ], 'service' => [ - 'vps' => $this->vps()->count(), + 'vps' => $this->vps_count ?? $this->vps()->count(), 'proxy' => 0, 'server' => 0, - 'hosting' => $this->hosting()->count(), - 'domain' => $this->domain()->count(), + 'hosting' => $this->hosting_count ?? $this->hosting()->count(), + 'domain' => $this->domain_count ?? $this->domain()->count(), ] ]; } diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index 929d84b..ff039cf 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -103,6 +103,39 @@ class vCenter return "Login_Again"; } + /** + * Search VM by exact name in vCenter. + * Returns the first matching VM array or null if not found. + */ + public function findVirtualMachineByName(string $vmName): ?array + { + $check = $this->checkApiExits(); + if (!$check) { + Log::warning('findVirtualMachineByName: API check failed'); + return null; + } + + $response = Http::withoutVerifying() + ->withHeaders([ + 'vmware-api-session-id' => $this->token + ]) + ->get("https://$this->apiUri/rest/vcenter/vm", [ + 'filter.names.1' => $vmName, + ]); + + if (!$response->successful()) { + Log::warning('findVirtualMachineByName: HTTP failed', ['status' => $response->status()]); + return null; + } + + $json = $response->json(); + if (isset($json['type']) || empty($json['value'])) { + return null; + } + + return $json['value'][0] ?? null; + } + public function checkApiExits(): bool { diff --git a/app/Jobs/.DS_Store b/app/Jobs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6c6d725c5fec1dfe65f899037ec9c624f6b10f5b GIT binary patch literal 6148 zcmeHKy-EW?5S}%M2?{|lh2`NBm^U~>1RFt#D0Z435;5_DKZV_4XDujb<4f3B*~NG8 zA+)vgo88U1n@cp6h|Iw5cQ-q;^WB%b-7FEQ%6YR)v_M1!G{#^K-5BF>ZW)`go)S>$ zIS%QV+TC`!)`&CF;t&Nyfxo5zf4g~Fr*i?_ zbuXw#Ev#c_e)ZxJlZ zGMN3p-)%;It}XRx25H&l-nEp6z}p}+`4+ddC~9Aw>4be)8=^3 z5hS1`HlhIbY_`I@LFq*SQ9u+(72x$DL}QFBCIkHnBm(a9fEaVGd UWHB)a4@~|DXc?pv1%6e5PY2Vu00000 literal 0 HcmV?d00001 diff --git a/app/Jobs/Mail/SendInformationVirtualMachine.php b/app/Jobs/Mail/SendInformationVirtualMachine.php index 0570b01..99e6900 100644 --- a/app/Jobs/Mail/SendInformationVirtualMachine.php +++ b/app/Jobs/Mail/SendInformationVirtualMachine.php @@ -19,27 +19,23 @@ class SendInformationVirtualMachine implements ShouldQueue public $data = []; public $customer; - /** - * Create a new job instance. - * - * @return void - */ - public function __construct($data = [],$customer = []) + + protected ?string $invoicePdf; + protected ?string $invoicePdfFileName; + + public function __construct($data = [], $customer = [], ?string $invoicePdf = null, ?string $invoicePdfFileName = null) { $this->data = $data; $this->customer = $customer; + $this->invoicePdf = $invoicePdf; + $this->invoicePdfFileName = $invoicePdfFileName; } - /** - * Execute the job. - * - * @return void - */ public function handle() { try { - $email = new CreateVirtualMachine(2,$this->data); + $email = new CreateVirtualMachine(2, $this->data, $this->invoicePdf, $this->invoicePdfFileName); Mail::to($this->customer->email)->send($email); diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index c6016b8..68f91c0 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -58,7 +58,7 @@ class AssignIpToGuestJob implements ShouldQueue // ───────────────────────────────────────────────────────────────────────── - public function handle(VsphereHostSshService $ssh): void + public function handle(VsphereHostSshService $ssh, vCenter $vCenterService): void { Log::info('AssignIpToGuestJob: start', [ 'module_vmware_id' => $this->moduleVmwareId, @@ -198,6 +198,38 @@ class AssignIpToGuestJob implements ShouldQueue ]); ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); + + // Still send credential email — try to get IP from vSphere guest identity + // so the customer at least gets server info even if IP assignment failed + try { + $moduleVmware = ModuleVmware::find($this->moduleVmwareId); + if ($moduleVmware && !$moduleVmware->ip) { + $vcenter = $moduleVmware->vCenter ?? vCenter::first(); + if ($vcenter && $vCenterService->loginServer($vcenter)) { + $guestInfo = $vCenterService->GetIPAddressVirtualMachine($this->vmId); + if (isset($guestInfo['error']) && $guestInfo['error'] === false) { + $guestIp = $guestInfo['data']['ip_address'] ?? null; + if ($guestIp) { + $moduleVmware->update(['ip' => $guestIp, 'status' => 1]); + Log::info('AssignIpToGuestJob: recovered IP from guest identity', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'ip' => $guestIp, + ]); + } + } + } + } + + SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); + Log::info('AssignIpToGuestJob: dispatched SendVmCredentialsEmailJob after failed attempts', [ + 'module_vmware_id' => $this->moduleVmwareId, + ]); + } catch (\Throwable $e) { + Log::error('AssignIpToGuestJob: failed to send email after exhausted attempts', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'error' => $e->getMessage(), + ]); + } } public function failed(\Throwable $e): void diff --git a/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php new file mode 100644 index 0000000..35aca11 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php @@ -0,0 +1,359 @@ + $this->orderId, + 'customer_id' => $this->customerId, + ]); + + $orderItems = OrderItem::where('order_id', $this->orderId)->get(); + + if ($orderItems->isEmpty()) { + $this->failStep('order_items', 'Không tìm thấy order_items cho order #' . $this->orderId, $telegram); + return; + } + + foreach ($orderItems as $item) { + $this->processItem($item, $vCenterService, $telegram); + } + } + + private function processItem(OrderItem $item, vCenter $vCenterService, Telegram $telegram): void + { + $serverName = $item->server_name; + + if (empty($serverName)) { + $this->failStep('order_item', "OrderItem #{$item->id} không có server_name", $telegram); + return; + } + + // ── Already linked and has IP? Skip ───────────────────────────── + $existingService = Service::where('order_item_id', $item->id)->whereNull('deleted_at')->first(); + if ($existingService && $existingService->vm_id) { + $vmware = ModuleVmware::find($existingService->vm_id); + if ($vmware && $vmware->ip) { + Log::info('RetryProvisioningOrchestrator: already linked with IP, skipping', [ + 'order_item_id' => $item->id, + 'service_id' => $existingService->id, + 'vm_id' => $existingService->vm_id, + 'ip' => $vmware->ip, + ]); + return; + } + } + + // ── Step 1: Resolve vcenter from DB chain ──────────────────────── + $vcenterModel = $this->resolveVcenter($item, $telegram); + + if (!$vcenterModel) { + $vcenterModel = VcenterModel::first(); + if (!$vcenterModel) { + $this->failStep('vcenter', 'Không tìm thấy vCenter nào trong hệ thống cho order_item #' . $item->id, $telegram); + return; + } + Log::warning('RetryProvisioningOrchestrator: fallback to first vcenter', ['vcenter_id' => $vcenterModel->id]); + } + + // ── Step 2: Login vCenter and search VM ───────────────────────── + $loginResult = $vCenterService->loginServer($vcenterModel); + if (!$loginResult) { + $this->failStep('vcenter_login', 'Không thể đăng nhập vào vCenter ' . $vcenterModel->host . ' cho VM ' . $serverName, $telegram); + return; + } + + // Search by REST API first + $vm = $vCenterService->findVirtualMachineByName($serverName); + + // If REST search fails, try listing all VMs and filter manually + if (!$vm) { + Log::info('RetryProvisioningOrchestrator: REST search returned null, trying list-all fallback', [ + 'server_name' => $serverName, + ]); + $allVms = $vCenterService->getAllVirtualMachine(); + if (is_array($allVms)) { + foreach ($allVms as $candidate) { + if (isset($candidate['name']) && $candidate['name'] === $serverName) { + $vm = $candidate; + Log::info('RetryProvisioningOrchestrator: found VM via list-all fallback', [ + 'server_name' => $serverName, + 'vm_id' => $candidate['vm'] ?? null, + ]); + break; + } + } + } + } + + if ($vm) { + // ── VM EXISTS → link to DB + assign IP + send email ──────────── + $vmId = $vm['vm'] ?? null; + if (!$vmId) { + $this->failStep('vm_search', 'Tìm thấy VM nhưng không lấy được vm_id cho ' . $serverName, $telegram); + return; + } + + Log::info('RetryProvisioningOrchestrator: VM found in vSphere', [ + 'server_name' => $serverName, + 'vm_id' => $vmId, + 'vcenter' => $vcenterModel->host, + ]); + + try { + $moduleVmware = $this->linkVmToDb($item, $vmId, $vcenterModel, $serverName); + + // Determine OS family for AssignIpToGuestJob + $osModel = $item->os_id ? Os::find($item->os_id) : null; + $osFamily = $this->resolveOsFamily($osModel); + + // If VM has no IP yet → dispatch AssignIpToGuestJob + if (!$moduleVmware->ip) { + AssignIpToGuestJob::dispatch( + moduleVmwareId: $moduleVmware->id, + vmId: $vmId, + osFamily: $osFamily, + guestPassword: $moduleVmware->plain_password ?? \App\Services\VmProvisionService::TEMPLATE_PASSWORD, + ); + Log::info('RetryProvisioningOrchestrator: dispatched AssignIpToGuestJob', [ + 'module_vmware_id' => $moduleVmware->id, + 'vm_id' => $vmId, + ]); + } else { + // Already has IP → just send credential email + SendVmCredentialsEmailJob::dispatch($moduleVmware->id); + Log::info('RetryProvisioningOrchestrator: dispatched SendVmCredentialsEmailJob', [ + 'module_vmware_id' => $moduleVmware->id, + ]); + } + + $telegram->SendMessage( + "✅ Phục hồi VM thành công\n" + . "━━━━━━━━━━━━━━━━━━━━\n" + . "🖥️ Tên VM: {$serverName}\n" + . "🔑 VM ID: {$vmId}\n" + . "📦 Đơn hàng: #{$this->orderId}\n" + . "🌐 Có IP: " . ($moduleVmware->ip ? 'Có (' . $moduleVmware->ip . ')' : 'Chưa có — đang gán IP') . "\n" + . "⏱️ Thời gian: " . now()->format('d/m/Y H:i:s') + ); + } catch (\Throwable $e) { + $this->failStep('link_vm', 'Lỗi liên kết VM ' . $serverName . ': ' . $e->getMessage(), $telegram); + } + } else { + // ── VM NOT in vSphere → dispatch CreateVirtualMachineJob ────── + Log::info('RetryProvisioningOrchestrator: VM not found in vSphere, dispatching CreateVirtualMachineJob', [ + 'server_name' => $serverName, + 'order_item_id' => $item->id, + ]); + + CreateVirtualMachineJob::dispatch($item, true); + + $telegram->SendMessage( + "🆕 Tạo VM mới\n" + . "━━━━━━━━━━━━━━━━━━━━\n" + . "🖥️ Tên VM: {$serverName}\n" + . "📦 Đơn hàng: #{$this->orderId}\n" + . "📝 VM không có trong vSphere, đã gửi job tạo mới\n" + . "⏱️ Thời gian: " . now()->format('d/m/Y H:i:s') + ); + } + } + + /** + * Resolve vcenter from DB chain: + * Path 1: order_items.os_id → os → osGroup → host → datacenter → vcenter + * Path 2: invoices → invoice_items → product_id → os_product → os → osGroup → host → datacenter → vcenter + * Path 3: module_vmware with same order_id (partial provisioning) + */ + private function resolveVcenter(OrderItem $item, Telegram $telegram): ?VcenterModel + { + if ($item->os_id) { + $os = Os::with('osGroup.host.datacenter.vCenter')->find($item->os_id); + $vcenter = $os?->osGroup?->host?->datacenter?->vCenter; + if ($vcenter) { + Log::info('resolveVcenter: found via os → osGroup chain', ['vcenter_id' => $vcenter->id]); + return $vcenter; + } + } + + $invoice = Invoice::where('order_id', $item->order_id)->first(); + if ($invoice) { + $invoiceItem = $invoice->invoiceItems()->whereNotNull('product_id')->first(); + if ($invoiceItem) { + $osRecord = DB::table('os_product') + ->where('product_id', $invoiceItem->product_id) + ->first(); + + if ($osRecord) { + $os = Os::with('osGroup.host.datacenter.vCenter')->find($osRecord->os_id); + $vcenter = $os?->osGroup?->host?->datacenter?->vCenter; + if ($vcenter) { + Log::info('resolveVcenter: found via invoice_items → os_product chain', ['vcenter_id' => $vcenter->id]); + return $vcenter; + } + } + } + } + + $existingVmware = ModuleVmware::where('order_id', $item->order_id)->first(); + if ($existingVmware?->vcenter_id) { + $vcenter = VcenterModel::find($existingVmware->vcenter_id); + if ($vcenter) { + Log::info('resolveVcenter: found via existing module_vmware', ['vcenter_id' => $vcenter->id]); + return $vcenter; + } + } + + $this->failStep('resolve_vcenter', 'Không thể tìm vcenter cho order_item #' . $item->id, $telegram); + return null; + } + + /** + * Link an existing VM from vSphere to module_vmware + services tables. + * Returns the ModuleVmware record. + */ + private function linkVmToDb(OrderItem $item, string $vmId, VcenterModel $vcenterModel, string $serverName): ModuleVmware + { + $order = $item->order; + $customer = $order->customer; + + $months = (int) ($item->months ?? 0); + $firstPayment = (int) $item->price * ($months ?: 1); + $nextDueDate = $months > 0 ? now()->addMonths($months)->timestamp : null; + + $moduleVmware = ModuleVmware::where('vmware_id', $vmId) + ->where('vcenter_id', $vcenterModel->id) + ->first(); + + if (!$moduleVmware) { + $moduleVmware = ModuleVmware::create([ + 'name' => $serverName, + 'module_ip_address' => null, + 'ip' => null, + 'port' => 3389, + 'order_id' => $order->id, + 'first_payment' => $firstPayment, + 'next_payment' => $firstPayment, + 'next_due_date' => $nextDueDate, + 'next_invoice' => $nextDueDate, + 'user' => 'Administrator', + 'password' => \App\Services\VmProvisionService::TEMPLATE_PASSWORD, + 'plain_password' => \App\Services\VmProvisionService::TEMPLATE_PASSWORD, + 'hostname' => $serverName, + 'vmware_id' => $vmId, + 'vcenter_id' => $vcenterModel->id, + 'os_id' => $item->os_id ?? 1, + 'customer_id' => $customer->id, + 'status' => 1, + ]); + } + + $service = Service::withTrashed()->firstOrNew([ + 'order_item_id' => $item->id, + ]); + + if ($service->trashed()) { + $service->restore(); + } + + $service->fill([ + 'customer_id' => $customer->id, + 'product_id' => $item->product_id, + 'invoice_id' => $order->invoice?->id, + 'vm_id' => $moduleVmware->id, + 'server_name' => $serverName, + 'status' => 'active', + 'started_at' => now(), + 'expires_at' => $months > 0 ? now()->addMonths($months) : null, + ]); + + if (!$service->exists && !$service->uuid) { + $service->uuid = (string) \Illuminate\Support\Str::uuid(); + } + + $service->save(); + + if (!$moduleVmware->service_id) { + $moduleVmware->update(['service_id' => $service->id]); + } + + Log::info('RetryProvisioningOrchestrator: VM linked to DB', [ + 'module_vmware_id' => $moduleVmware->id, + 'service_id' => $service->id, + 'vmware_id' => $vmId, + ]); + + return $moduleVmware; + } + + private function resolveOsFamily(?Os $os): string + { + if (!$os) { + return 'LINUX'; + } + + $groupName = strtolower($os->osGroup?->name ?? ''); + if (str_contains($groupName, 'windows')) { + return 'WINDOWS'; + } + + return 'LINUX'; + } + + private function failStep(string $step, string $message, Telegram $telegram): void + { + Log::error("RetryProvisioningOrchestrator [{$step}]: {$message}", [ + 'order_id' => $this->orderId, + ]); + + $telegram->SendErrorMessage( + "Bước: {$step}\n" + . "Đơn hàng: #{$this->orderId}\n" + . "Lỗi: {$message}" + ); + } +} diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index 61c7542..a878835 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Provision; use App\Jobs\Mail\SendInformationVirtualMachine; +use App\Models\Invoice; use App\Models\ModuleVmware; use App\Models\Setting; +use App\Services\InvoicePdfService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -17,7 +19,7 @@ use Illuminate\Support\Facades\Log; /** * SendVmCredentialsEmailJob * - * Sends the final VM credential email to the customer. + * Sends the final VM credential email to the customer with an attached PDF invoice. * Always loads fresh data from DB so it gets the real IP and * the final password saved by ChangePasswordJob. * @@ -80,12 +82,35 @@ class SendVmCredentialsEmailJob implements ShouldQueue : 'N/A', ]; - SendInformationVirtualMachine::dispatch($emailData, $customer); + // Generate invoice PDF attachment (synchronous, no queue) + $invoicePdf = null; + $invoicePdfFileName = null; + + $invoice = Invoice::where('order_id', $vm->order_id) + ->with(['invoiceItems.product', 'customer', 'order.orderItems']) + ->first(); + + if ($invoice) { + try { + $pdfService = app(InvoicePdfService::class); + $invoicePdf = $pdfService->generate($invoice); + $invoicePdfFileName = $pdfService->generateFileName($invoice); + } catch (\Throwable $e) { + Log::error('SendVmCredentialsEmailJob: failed to generate invoice PDF', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'invoice_id' => $invoice->id, + 'error' => $e->getMessage(), + ]); + } + } + + SendInformationVirtualMachine::dispatch($emailData, $customer, $invoicePdf, $invoicePdfFileName); Log::info('SendVmCredentialsEmailJob: email dispatched', [ 'module_vmware_id' => $this->moduleVmwareId, 'customer_email' => $customer->email, 'ip' => $vm->ip, + 'invoice_attached' => $invoicePdf !== null, ]); } diff --git a/app/Mail/CreateVirtualMachine.php b/app/Mail/CreateVirtualMachine.php index 8a98ff9..b1f9030 100644 --- a/app/Mail/CreateVirtualMachine.php +++ b/app/Mail/CreateVirtualMachine.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Models\Template; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; @@ -16,22 +17,17 @@ class CreateVirtualMachine extends Mailable protected $template = null; protected $data; - /** - * Create a new message instance. - * - * @return void - */ - public function __construct($template = 0, $data = []) + protected ?string $invoicePdf = null; + protected ?string $invoicePdfFileName = null; + + public function __construct($template = 0, $data = [], ?string $invoicePdf = null, ?string $invoicePdfFileName = null) { $this->data = $data; $this->template = Template::find($template); + $this->invoicePdf = $invoicePdf; + $this->invoicePdfFileName = $invoicePdfFileName; } - /** - * Get the message envelope. - * - * @return Envelope - */ public function envelope() { return new Envelope( @@ -39,11 +35,6 @@ class CreateVirtualMachine extends Mailable ); } - /** - * Get the message content definition. - * - * @return Content - */ public function content() { return new Content( @@ -83,13 +74,15 @@ class CreateVirtualMachine extends Mailable ); } - /** - * Get the attachments for the message. - * - * @return array - */ - public function attachments() + public function attachments(): array { + if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { + return [ + Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName) + ->mime('application/pdf'), + ]; + } + return []; } } diff --git a/app/Models/.DS_Store b/app/Models/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b8fa2db1378cbce4d0156609533051313d55bda9 GIT binary patch literal 6148 zcmeHKJ5Iwu5PcgWVMLRHBIVuyrJ#2r=LER`O5JOJEmyPg0e00~uaaKd4a$hv4x2GJsmF6?oGdu)p9$zoOY zyv^_%8IWgpj45uhLXOt+E9+LepFGMc%a%p8q(}08u^s2nuhafMWwri>SDnk-(m5@` z91plci5dkqxYIZ4uunUux3lw?$ET1}Z?AS%hy3>B)gr{E#(*(k4E$*ZShGcvQ$-t% z0b{@z*fAjA2Tv7DBQ}cR)4?IM0K_59K{%IQLSlTxG-9L36^hePqK3NIVmJ+_-ABJP zVxy?xaIyJt@njb}6cbelongsTo(ModuleIpAddress::class, 'module_ip_address'); + } + public function addons() { return $this->hasMany(Addon::class, 'type', 'product_type') @@ -141,11 +147,10 @@ class Product extends Model */ public function getMonthlyPricingAttribute() { - return $this->pricings() - ->where('is_active', 1) - ->where('billing_cycle', 'monthly') - ->first() - ?? $this->pricings()->where('is_active', 1)->orderBy('price')->first(); + $pricings = $this->pricings; + + return $pricings->firstWhere('billing_cycle', 'monthly') + ?? $pricings->where('is_active', 1)->sortBy('price')->first(); } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3577a19..1f5b1cd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,7 +11,6 @@ use App\Observers\ModuleIpAddressObserver; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -57,10 +56,6 @@ class AppServiceProvider extends ServiceProvider \App\Repositories\Employee\EmployeeRepositoryInterface::class, \App\Repositories\Employee\EmployeeRepository::class ); - - if (File::exists(base_path('app/Http/Helpers/function_helper.php'))) { - File::requireOnce(base_path('app/Http/Helpers/function_helper.php')); - } } /** diff --git a/app/Providers/RepositoryProvider.php b/app/Providers/RepositoryProvider.php index 2f37131..59ad53a 100644 --- a/app/Providers/RepositoryProvider.php +++ b/app/Providers/RepositoryProvider.php @@ -119,282 +119,132 @@ class RepositoryProvider extends ServiceProvider public function register() { //Addon - $this->app->bind( - AddonRepositoryInterface::class, - AddonCachedRepository::class - ); + $this->app->singleton(AddonRepositoryInterface::class, AddonCachedRepository::class); //Banned - $this->app->bind( - BannedRepositoryInterface::class, - BannedCachedRepository::class - ); + $this->app->singleton(BannedRepositoryInterface::class, BannedCachedRepository::class); //Customer - $this->app->bind( - CustomerRepositoryInterface::class, - CustomerCachedRepository::class - ); + $this->app->singleton(CustomerRepositoryInterface::class, CustomerCachedRepository::class); //Customer - $this->app->bind( - CustomerRepositoryInterface::class, - CustomerCachedRepository::class - ); + $this->app->singleton(CustomerCachedRepository::class); //Customer Log - $this->app->bind( - CustomerLogRepositoryInterface::class, - CustomerLogCachedRepository::class - ); + $this->app->singleton(CustomerLogRepositoryInterface::class, CustomerLogCachedRepository::class); //Discount - $this->app->bind( - DiscountRepositoryInterface::class, - DiscountCachedRepository::class - ); + $this->app->singleton(DiscountRepositoryInterface::class, DiscountCachedRepository::class); //Domain - $this->app->bind( - DomainRepositoryInterface::class, - DomainCachedRepository::class - ); + $this->app->singleton(DomainRepositoryInterface::class, DomainCachedRepository::class); //Domain Type - $this->app->bind( - DomainTypeRepositoryInterface::class, - DomainTypeCachedRepository::class - ); + $this->app->singleton(DomainTypeRepositoryInterface::class, DomainTypeCachedRepository::class); //File - $this->app->bind( - FileRepositoryInterface::class, - FileCachedRepository::class - ); + $this->app->singleton(FileRepositoryInterface::class, FileCachedRepository::class); //Group - $this->app->bind( - GroupRepositoryInterface::class, - GroupCachedRepository::class - ); + $this->app->singleton(GroupRepositoryInterface::class, GroupCachedRepository::class); //Hosting - $this->app->bind( - HostingRepositoryInterface::class, - HostingCachedRepository::class - ); + $this->app->singleton(HostingRepositoryInterface::class, HostingCachedRepository::class); //Invoice - $this->app->bind( - InvoiceRepositoryInterface::class, - InvoiceCachedRepository::class - ); + $this->app->singleton(InvoiceRepositoryInterface::class, InvoiceCachedRepository::class); //Invoice Attachment - $this->app->bind( - InvoiceAttachmentRepositoryInterface::class, - InvoiceAttachmentRepository::class - ); + $this->app->singleton(InvoiceAttachmentRepositoryInterface::class, InvoiceAttachmentRepository::class); //Invoice Item - $this->app->bind( - InvoiceItemRepositoryInterface::class, - InvoiceItemCachedRepository::class - ); + $this->app->singleton(InvoiceItemRepositoryInterface::class, InvoiceItemCachedRepository::class); //Log - $this->app->bind( - LogRepositoryInterface::class, - LogCachedRepository::class - ); + $this->app->singleton(LogRepositoryInterface::class, LogCachedRepository::class); //Mail - $this->app->bind( - MailRepositoryInterface::class, - MailCachedRepository::class - ); + $this->app->singleton(MailRepositoryInterface::class, MailCachedRepository::class); //Note - $this->app->bind( - NoteRepositoryInterface::class, - NoteCachedRepository::class - ); + $this->app->singleton(NoteRepositoryInterface::class, NoteCachedRepository::class); //Order - $this->app->bind( - OrderRepositoryInterface::class, - OrderCachedRepository::class - ); + $this->app->singleton(OrderRepositoryInterface::class, OrderCachedRepository::class); //Os - $this->app->bind( - OsRepositoryInterface::class, - OsCachedRepository::class - ); + $this->app->singleton(OsRepositoryInterface::class, OsCachedRepository::class); // Os Group - $this->app->bind( - OsGroupRepositoryInterface::class, - OsGroupCachedRepository::class - ); + $this->app->singleton(OsGroupRepositoryInterface::class, OsGroupCachedRepository::class); // Payment - $this->app->bind( - PaymentReppositoryInterface::class, - PaymentCachedReppository::class - ); + $this->app->singleton(PaymentReppositoryInterface::class, PaymentCachedReppository::class); // Menu - $this->app->bind( - MenuRepositoryInterface::class, - MenuCachedRepository::class - ); + $this->app->singleton(MenuRepositoryInterface::class, MenuCachedRepository::class); //Post - $this->app->bind( - PostRepositoryInterface::class, - PostCachedRepository::class - ); + $this->app->singleton(PostRepositoryInterface::class, PostCachedRepository::class); //Setting - $this->app->bind( - SettingRepositoryInterface::class, - SettingCachedRepository::class - ); + $this->app->singleton(SettingRepositoryInterface::class, SettingCachedRepository::class); //Staff - $this->app->bind( - StaffRepositoryInterface::class, - StaffRepository::class - ); + $this->app->singleton(StaffRepositoryInterface::class, StaffRepository::class); //Template - $this->app->bind( - TemplateRepositoryInterface::class, - TemplateCachedRepository::class - ); + $this->app->singleton(TemplateRepositoryInterface::class, TemplateCachedRepository::class); //Ticket - $this->app->bind( - TicketRepositoryInterface::class, - TicketCachedRepository::class - ); + $this->app->singleton(TicketRepositoryInterface::class, TicketCachedRepository::class); //Ticket Reply - $this->app->bind( - TicketReplyRepositoryInterface::class, - TicketReplyCachedRepository::class - ); + $this->app->singleton(TicketReplyRepositoryInterface::class, TicketReplyCachedRepository::class); //Transaction - $this->app->bind( - TransactionRepositoryInterface::class, - TransactionCachedRepository::class - ); + $this->app->singleton(TransactionRepositoryInterface::class, TransactionCachedRepository::class); //Service - $this->app->bind( - ServiceRepositoryInterface::class, - ServiceRepository::class - ); + $this->app->singleton(ServiceRepositoryInterface::class, ServiceRepository::class); //Module Datacenter - $this->app->bind( - DatacenterRepositoryInterface::class, - DatacenterCachedRepository::class - ); + $this->app->singleton(DatacenterRepositoryInterface::class, DatacenterCachedRepository::class); //Module Datastore - $this->app->bind( - DatastoreRepositoryInterface::class, - DatastoreCachedRepository::class - ); + $this->app->singleton(DatastoreRepositoryInterface::class, DatastoreCachedRepository::class); // Module DnsServer - $this->app->bind( - DnsServerRepositoryInterface::class, - DnsServerCachedRepository::class - ); + $this->app->singleton(DnsServerRepositoryInterface::class, DnsServerCachedRepository::class); //Module Domain - $this->app->bind( - ModuleDomainRepositoryInterface::class, - ModuleDomainCachedRepository::class - ); + $this->app->singleton(ModuleDomainRepositoryInterface::class, ModuleDomainCachedRepository::class); //Module Folder - $this->app->bind( - FolderRepositoryInterface::class, - FolderCachedRepository::class - ); + $this->app->singleton(FolderRepositoryInterface::class, FolderCachedRepository::class); //Host - $this->app->bind( - HostRepositoryInterface::class, - HostCachedRepository::class - ); + $this->app->singleton(HostRepositoryInterface::class, HostCachedRepository::class); //Hosting - $this->app->bind( - ModuleHostingRepositoryInterface::class, - ModuleHostingCachedRepository::class - ); + $this->app->singleton(ModuleHostingRepositoryInterface::class, ModuleHostingCachedRepository::class); //Hosting Server - $this->app->bind( - HostingServerRepositoryInterface::class, - HostingServerCachedRepository::class - ); + $this->app->singleton(HostingServerRepositoryInterface::class, HostingServerCachedRepository::class); //IpAddress - $this->app->bind( - IpAddressRepositoryInterface::class, - IpAddressCachedRepository::class - ); + $this->app->singleton(IpAddressRepositoryInterface::class, IpAddressCachedRepository::class); //Network - $this->app->bind( - NetworkRepositoryInterface::class, - NetworkCachedRepository::class - ); + $this->app->singleton(NetworkRepositoryInterface::class, NetworkCachedRepository::class); //Snapshot - $this->app->bind( - SnapshotRepositoryInterface::class, - SnapshotCachedRepository::class - ); + $this->app->singleton(SnapshotRepositoryInterface::class, SnapshotCachedRepository::class); //Snapshot Limit - $this->app->bind( - SnapshotLimitRepositoryInterface::class, - SnapshotLimitCachedRepository::class - ); + $this->app->singleton(SnapshotLimitRepositoryInterface::class, SnapshotLimitCachedRepository::class); //vmware - $this->app->bind( - VmwareRepositoryInterface::class, - VmwareCachedRepository::class - ); + $this->app->singleton(VmwareRepositoryInterface::class, VmwareCachedRepository::class); //User - $this->app->bind( - UserRepositoryInterface::class, - UserCachedRepository::class - ); + $this->app->singleton(UserRepositoryInterface::class, UserCachedRepository::class); //vCenter - $this->app->bind( - vCenterRepositoryInterface::class, - vCenterCachedRepository::class - ); + $this->app->singleton(vCenterRepositoryInterface::class, vCenterCachedRepository::class); //vps - $this->app->bind( - VPSRepositoryInterface::class, - VPSCachedRepository::class - ); + $this->app->singleton(VPSRepositoryInterface::class, VPSCachedRepository::class); //Module IP Pool - $this->app->bind( - IPPoolRepositoryInterface::class, - IPPoolCachedRepository::class - ); + $this->app->singleton(IPPoolRepositoryInterface::class, IPPoolCachedRepository::class); //Module VirtualMachine - $this->app->bind( - VirtualMachineRepositoryInterface::class, - VirtualMachineRepository::class - ); + $this->app->singleton(VirtualMachineRepositoryInterface::class, VirtualMachineRepository::class); // Product - $this->app->bind( - ProductRepositoryInterface::class, - ProductRepository::class - ); + $this->app->singleton(ProductRepositoryInterface::class, ProductRepository::class); // Product Category - $this->app->bind( - ProductCategoryRepositoryInterface::class, - ProductCategoryRepository::class - ); + $this->app->singleton(ProductCategoryRepositoryInterface::class, ProductCategoryRepository::class); // Client API - $this->app->bind( + $this->app->singleton( \App\Repositories\Api\Client\ClientRepositoryInterface::class, - \App\Repositories\Api\Client\ClientRepository::class + \App\Repositories\Api\Client\ClientRepository::class, ); // Service API - $this->app->bind( + $this->app->singleton( \App\Repositories\Api\Service\ServiceRepositoryInterface::class, - \App\Repositories\Api\Service\ServiceRepository::class + \App\Repositories\Api\Service\ServiceRepository::class, ); // Service Client API - $this->app->bind( + $this->app->singleton( \App\Repositories\Api\ServiceClient\ServiceClientRepositoryInterface::class, - \App\Repositories\Api\ServiceClient\ServiceClientRepository::class + \App\Repositories\Api\ServiceClient\ServiceClientRepository::class, ); // DNS Manager - $this->app->bind(DnsManagerServerRepositoryInterface::class, DnsManagerServerRepository::class); - $this->app->bind(DnsDomainRepositoryInterface::class, DnsDomainRepository::class); - $this->app->bind(DnsRecordRepositoryInterface::class, DnsRecordRepository::class); + $this->app->singleton(DnsManagerServerRepositoryInterface::class, DnsManagerServerRepository::class); + $this->app->singleton(DnsDomainRepositoryInterface::class, DnsDomainRepository::class); + $this->app->singleton(DnsRecordRepositoryInterface::class, DnsRecordRepository::class); } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index a0cfe49..6d96fec 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -38,28 +38,20 @@ class RouteServiceProvider extends ServiceProvider $this->configureRateLimiting(); $this->routes(function () { - // 1. Agency API — domain riêng → chỉ client.php - Route::domain(config('app.api_agency_url')) - ->middleware(['api', 'json.response']) + Route::middleware(['api', 'json.response']) ->namespace($this->namespace) ->group(base_path('routes/client.php')); - // 2. System API — bind về APP_URL - Route::domain(config('app.url')) - ->prefix('api') + Route::prefix('api') ->middleware(['api', 'json.response']) ->namespace($this->namespace) ->group(base_path('routes/api.php')); - // 3. Admin Backend routes - Route::domain(config('app.url')) - ->middleware('web') + Route::middleware('web') ->namespace($this->namespace) ->group(base_path('routes/admin.php')); - // 4. Web routes - Route::domain(config('app.url')) - ->middleware('web') + Route::middleware('web') ->namespace($this->namespace) ->group(base_path('routes/web.php')); }); diff --git a/app/Repositories/.DS_Store b/app/Repositories/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..32e84d3673c1c44811f33dff7e3ab8e2a4a34c98 GIT binary patch literal 12292 zcmeHNTWDNW6y3KolZ;8k))#0&2Wx#&u_*ZXkV%{xV z4xPWT2DdoBjI$dVEtNlyPif9hZ5|qJH)qr2IyD-*XWF~*zo4KL6|d2O=sY^|XxF=sl0tTRR6SBM#tQ$JRj97_?uZlneL|%>=Vpr z#_F>&COMbhahxu<+4iiVcmeM?b{Q5lZ_h5vYhsf3sHzUEf<}vl`WFEQ~b#3EW&uYetd4S3wB=@Nc9Cr@o>s*Wq>KVj) zwwIcw<<4t8)t`+Xg^+sIUn2BmP+m_lDxjxKgK%aKWt8;nfD#vz8lJq|X%KPG9*PHe zJ86j99(EVa-lX>7{QbrUg^(IveEIzchSPSK`5f6gicwlm4PMg;h)^dS6b z(k%MN5pZSur=3ud!$>VBu6J4jIIn0WTtHU`ba9J$l%JM%=CV7XNyVj}7jANTG8nI= z2ouoJEp^a#s9~q}YDJ|3P*i}F+8(>bX)8o~m8+u*^v>Z)w(zcxz&~#1ZE9(~=S^9? z0i@Ks<94Mti1vC{M+fwl=^-f1jZUSFQ0QV((-nPAlZbm2p?H9|6Ft)1;B9f6&yI~f zAAvoCkQ(~8It@9LS5k}$=&8`dG=(QMogFQ!KR}C!N*%uqI2{V%HG~nGzXpPMLaAE8P&ArzV#6wk-R59*uM0Ax*o83k~1IGd`FqD zgZj+usD)8~OtY4FUq!_w_wuOoJcIG0Z4pN6FuqqY`&i)h7@j<@5i_gv4O^fkhmm?V zEjT>^oY%AxPV1Uwf2!2rIS6KsypG1~NPD!7S8O?q)OC2D(3SZt`LtHTXy3R>NwY!=Oj|X_zOQa8ARb1-& z<)HH|gYkNbFaaG^>bI-9Hg+M#v+KI@H_(#9NIeG*ANuKJ>QeycHLZlxx@w@rocH9NrQfP+ zzDDgc2&rT0$ZNmaz0e%WKZ#8)=t zm80feJF84zmw%S2>sRCY75t`6`+fu0sQ@YK@z*P^9TcLyc84}Q1@d#mioU`k@!LOc zef`D1|1+R?i4H^u&P@lBa$~5`hq~GC)^oMw@7ilPhjB7y87Ck3+oa%PzK%y}zK%y} nzK%y}20n|e+j2225ykNz{1Cv6;kqY|<9{6g<-F?L9{>LaOXv%( literal 0 HcmV?d00001 diff --git a/app/Services/.DS_Store b/app/Services/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8940d663e26d162634b2f2e1a7af90991c1cee93 GIT binary patch literal 6148 zcmeHKJ5Iwu5S<~3utbxR25GO58#u;rfm{HRAcB-&Hxy9qX%JVSqobfi9EF}YI|K0w zixNdJBkjK3`PxtNu7`-Yx>-($#za(L2(m1CMAW^eJ#!W#OODBG(QMkD6uYaEXCKg% zTDn?acAvj`*;+PDUC-Ce9Gc?(_4NAo?Rna@E`PALt8UhaFdES@ozsF=bdlHGALZ6= z-J{2c_ous5w-di{BH(uXNwdEiat97&VV!U#ekd-0Yk7f zjEd>#z?515;1KF0SW7Pi>_2jM%7-twF r;umEB!8-d1z(&rI!(>!@kTv{D!>A}(M4!Te{v(hG@yQwZ1qMCload(['invoiceItems.product', 'customer', 'order.orderItems']); + + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + $customer = $invoice->customer; + $order = $invoice->order; + + $data = [ + 'invoice' => $invoice, + 'invoiceItems' => $invoice->invoiceItems, + 'customer' => $customer, + 'order' => $order, + 'companyName' => $settingValue->company ?? 'Thế Giới Số', + 'companyAddress' => $settingValue->address ?? '', + 'companyPhone' => $settingValue->phone ?? '', + 'companyEmail' => $settingValue->email ?? '', + ]; + + return Pdf::loadView('pdf.invoice', $data)->output(); + } + + public function generateFileName(Invoice $invoice): string + { + return 'Hoa-don-' . $invoice->invoice_number . '.pdf'; + } +} \ No newline at end of file diff --git a/app/Support/.DS_Store b/app/Support/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e698cb6b58ce8e97b92b7133a786a8af1831cf44 GIT binary patch literal 6148 zcmeHKK~BUl478UX3S9QcF<;mpgsS_3y&(>;+tqSt>4MmM4_tTy|3KmeJc7?Kj-%45 zE#ia#*^)D`*L-=0uc21!u=-MnuL%2QrHm8RRfWK^wZK168yhc$?uj zG9Y_*LK${#OV`xdzjC)&ZR%>TN%PZ`UsdP%x~yft)7OjL`?G&LpY|=j?YqC}t}o{k zhj2+H)pUmwwxwHo=;xR`j`?)+d%0}$8_ZSJpSgYIH;f3~-XK z@(iyOYwO_Uq}Dp<6;wq08pSpQoluJ5E2a1h8U%JH9iWfcD8d4pJ%rBh82b80TiRl(51jnIcxRUA$16vk;10;Tig}>kj_yWXp z9FZ`- ztK&B^z|U@*4#-hKC2alt)~o6K!WE?yef~5QSGYW1tla|h>BIf&$>#BSe>ise?b!0F zvA=oaGRBQME4*hLs@hd1d;L+(8c*MkUyklVjXmoX?z6(LBU|;6<@!{V0cAiL_$v&c zXS1X`f@+lkWk4CI8Q|}Og)(Lly;eDw-kG8=jOQATId;+h2y+noq|DV#farrybBEizvTfii|7U6f#{Dwph1l? I@S_ZT0EqQj3;+NC literal 0 HcmV?d00001 diff --git a/composer.json b/composer.json index 357315d..841e1a1 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "php": "^8.1 || ^8.2", "ext-openssl": "*", "ext-ssh2": "*", + "barryvdh/laravel-dompdf": "^3.1", "darkaonline/l5-swagger": "^8.6", "developermithu/tallcraftui": "^2.0", "guzzlehttp/guzzle": "^7.2", diff --git a/composer.lock b/composer.lock index a1f4de9..d647be2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ecbffc37a569b4d4ec0bca6e269a7adf", + "content-hash": "7fcda92447f50f1d050355e46c988906", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.6.0", @@ -912,6 +989,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -3173,6 +3405,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -5290,6 +5589,86 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, { "name": "spatie/backtrace", "version": "1.8.1", @@ -8210,6 +8589,149 @@ ], "time": "2026-02-09T09:33:46+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/database/.DS_Store b/database/.DS_Store index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..66a4dbb08c77d1717c266bcd4c6c447ac3ba0355 100644 GIT binary patch literal 6148 zcmeHKOHRWu5FMAIwtPe-HY`|J+@LoIRX9P{Nc2ak)R5BBeU|K5BE(@>lnb!p7My@L z9=A=JM6f~#%}Dl3oHzE%ixbC0WZH{Ci>OINbri-*164urbuPK6_?82paec%zqcII> zJ{jfM+VB@0;AhvMYwA*;?kV;BTAh9zO+|9;w3rpeVc46*JzT9{E=M0v!ScL}Irt%S zdXpa6qC4872(xO0xz&ZnF;w323`_CMA}-^lHJ&|x9^Sn>jfH(i@kZr$+>Fj$r?W5w^P-fc-kB*^mi;>p$`tUM-d~@1ztc|L2Rz}b2&BTxz>VP_+4iq?mHJhWpFQ~OT zpbn@5YX|uIU{M$YkBOlBbf9uS8o(-=UErAa5}ac_1|AbZTtOH~1xl*%TMQ%VXt$mh zcuWK(os9p*F@9v@cPPeR delta 69 zcmZoMXfc=|#>AjHu~2NHo+1YW5HK<@2yFhyEXJ~lWft>hb`E|Hpgd6EJM(0I5k*d* SG(!SN-DDFU<;^i7E0_Tq_YVO8 diff --git a/database/migrations/2026_05_06_094235_add_module_ip_address_to_products_table.php b/database/migrations/2026_05_06_094235_add_module_ip_address_to_products_table.php new file mode 100644 index 0000000..7f2b6fa --- /dev/null +++ b/database/migrations/2026_05_06_094235_add_module_ip_address_to_products_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('module_ip_address')->nullable()->after('product_categories_id'); + $table->foreign('module_ip_address') + ->references('id') + ->on('module_ip_address') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['module_ip_address']); + $table->dropColumn('module_ip_address'); + }); + } +}; diff --git a/lang/.DS_Store b/lang/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6115e122e25c33ef9a707e7884ae287a427ae8a3 GIT binary patch literal 6148 zcmeHKJ5Iwu5S$mD2UvosDRO}*1QDb-PD5&+BOHiB&~gGQO5WQUBRe=P zLTEee4I_cZ3yZbGWf;xM7>espYXzkrPhr3Q4 zV?;#|>ZXTTY72EG~K`4BOP$*@t(M+c_d0szM_ zXMxUMf;rJJ88(XWK!T zK%ap#T~2ua-{O-QZ1VdlzH$bffq%w87?<;MhL^It^~dY+u8kNA3<~2KDiG+sp8za) gj$CA?`XhD3CBsIMyU?D~f&L?q3GvAp_yq>O03yslQvd(} literal 0 HcmV?d00001 diff --git a/public/.DS_Store b/public/.DS_Store index ea3ddae7d6253ee705ee9a8aacf359d01091b866..1a9f62e9b06fdf0282d4cddf2a90cdf1365b6468 100644 GIT binary patch delta 420 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 literal 0 HcmV?d00001 diff --git a/public/js/.DS_Store b/public/js/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..72ec26c16f222fbeafac70ae2bd7a83d0adcbcab GIT binary patch literal 6148 zcmeHKy-ve05I#4B2C;O6&9BfmFjd%*`U3P%1*uV@RxmJGyaO8pD-#ntV&Pp7D?8u$ ztWq~rYzS3%()n}FclPB=l*0eEHL|r1vp^U8q3_l33vo@p^Exc&L9?SWxHyBrC z-SAbz|73ul-5%96q!~>qeSZ6|$LVTW6~$;?jbP2+hflZ9cW)<~9+O`@>Wj5Er1OAq z>d+ErFX);sX-w1P9m>uYO^@|F?mr$sUv5Gk8~c>icP`t#l6*4H`KTxZ%78Mk>kOb~ zv*d?@Iw}LofHJURfWHqG%IG3`!SLz85Ly6W8|EOGb1%U$HlmB@1+fA#5(<=16I%=; z;jr82=OTJR2`3Xy#y;_66FU?WPKVtFcQQ^;M`b`6NEyiFvdi`VbbJ4w2I-YDpbYF3 z1EyUJiau^B*4D<&ajmt`b0`b@dBIf(CZQC=mrL;xGzjdLCqNg`3&H}?AAvxF4$8ov GGVlck4N=+v literal 0 HcmV?d00001 diff --git a/public/lib/.DS_Store b/public/lib/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..30ff466d5eface4297a1661c180ba0409aeb3cf0 GIT binary patch literal 10244 zcmeI1ONd-W6o$|3=@~oDgam@%@*)TlP%jqL{!1e@!%3ZGA>T%J9U+!Cw0UC`H6bep%Jw^8_TOHwSV9p@D6wf zyaV0=@4&6#0KT*Nsw~>*KfMFq0q=lwfaix0H^*ueCpMa?10QA!fLO+3bK!UF15BPU ziq$AiY&2O>rrkXRQPv>ZVgxoj(nFRbR--tv(PpQ>W~U&kIf!;BfvFw&LL;Z3YNP-3 z4tNLB4%G3eLJewBmp0}8z1i;$jQq~_pIMr2zgAo8^w&7;pTk?fyi@(*M7FNAC*9!O z@@ib?1+ID@tx+F8BihD21m%EF7M!a5^TlkHWc%`{W6LP7+mGprm5Ohk@!(q%OhXWk zLD;5_q6-FZXK5tc_GkmY<@pta--2dF4$1h_73bT`Tyab9J(qf)AZqt@ zw0RG2)Sw-_%QpV%CR|;q^zj~x=(QP2$-nWHGESBQ|XfFuG5Ywp@|*K z(utI zw5tK0MKad|&%IWpm3*C5$Cq2SbZ{?v#U0d{`!?C)w4xjgo;jUXNX9c~9b=*#NtUn5 z;JF<yt2rmA9=?st8fF)LubCLZ51iuD zw1h&k9k{+b?RzOzOqH)vJP$?l39s_Z_d#M!MW2MAS%|+cXee2J`^&K;G%-n@Msd(J zRD*V5j&9LR^82TGI>~kQhU3aCQ}(b~TE%G(4=QvXZgtH3Yt}QW=hKr7Fs3w;@B6X?=er4^Ft4|*}~SE?N8R=!H}9Aus{Mq?K%_HE_Q z+;^8`nq>I(4#(i~%G$HiRlRf9xL$wV90%rXH6hPfr}F>*e{*1bE#3j|!2jYvP_C`k zPNM4QY&`{0!jlkp9XDUBo7iZw;DaUn+#JW_4?d2+Cwg{-BVS0bMsZ@J$&TkHJEF;s qXixM1vwsHo{r?I0@Av=iv|_reuer18;$6L5)f6k$`sNne|NjddHzRKV literal 0 HcmV?d00001 diff --git a/public/uploads/.DS_Store b/public/uploads/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4d5dcdf9ac5c7ec8d40fa324f1a2befab0e383e4 GIT binary patch literal 6148 zcmeHKK~BR!4D^lLT(7(#y?}7q z!|63$P}$0W`giN=t2sP8eHgFqpO+HB;u&~qpY`vt*e2qv)z{99Q;n?DzOS=e2#)dqYItV!ysX6cCY*mE-9d zY=ni+^O{?ZL`SY7ddMf*qDM4OtJI~XoLUd41L}Y}pbn@5>cIct0KT(%@lV>QTXjGk zPzOd1aQ_fsgBU}J^=h*LOh7VKjjU~+;eR-!nx(WK&yxzI4(tDv-`U8qpUKqS zr8ed+wx~zjsCmci*M*u(<+=4POudQgEt#9tI%%#@=S;579`0s`xG>;yO<}VNmz=#A&?c?Z2AySoj{u*oSdftxR@^6=lM5(73o~QpFc)?;x*mA z>~Go>r@zab$MNJfaL=2VXXU+3>ZcrJji_=NFD`wE@3n9}mvFo?#TU1{392p#S82^W zlUGE(oXX{U^2*^8l*wHluN3%Z(f9Ftau?egDCfai!)N}+Yk?Tw0RnG{ESGlc&5R_Z@O_^a5i<`Wtn~cHD<|Z z5|Sc~$kD?+=3ggz;vJsG@zHsgW97oPgd?R%IEpj^hkZ+Qs}86G>VP_+4jhgHM{TnK z&;Q>j{{8=O;IvhBKpi*`2fV53a&-|+MRV)9MtQD1zG$N#z6;!>md>S&I7vQfP2_hgx= Q=h(bWlo_`RBW@`E0IikOk^lez literal 0 HcmV?d00001 diff --git a/resources/.DS_Store b/resources/.DS_Store index 0a76604366bf91f2e00c1a17a314fcc176cfa122..a1e376c799c879cc7a22799c5cab4c6889fdcfee 100644 GIT binary patch literal 6148 zcmeHKy-ve05I&b8idZTpx_M^n8-yy1OnCuli&~_liCQRP^P4duRwSN*ot1^}>;i}(OZ}fcI{XHZ-F(T~THKvLa0P_^|1b<#9Ys^C?;ecNe3x(c{BOo7emYUd576OF3GE zZfKXbn9C*ImS?GKjbH1n)p@+UJ>93zejb(hEdQKCyGMO|_htNTvRCJzu~2NHo}w@#0|NsP3otMgmjxH)<>cq3Pb}1AWZW#n{F-HR0P_T< l&Fmcf96$}59htu~Pv#fVB`mF;Q%yo}wrR0|Nsi1A_nqLk>f6PP$=ma(-^X#Kh(GAPF{xREAuJ zM21XcNuW3o2rL5(43H!0a|?i)fxrMn zFf$Y|q%fqx+y@e3V2~~a5>Odr%YB(I@6Wjkwj3^tuohDmVj;{v1(4AU3>(w`u}(G+ kVcE>h!OZ~-u8oP`nJ4p$D6%pEB^4$ciU@D^5ZS{F090a6e*gdg delta 70 zcmZoMXfc=|#>AjHu~2NHo+1YW5HK<@2y7N)`o_9hfoT`xW_AvK4xj>{$am(+{342+ UKzW7)kiy9(Jj$D6L{=~Z01<``T>t<8 diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php new file mode 100644 index 0000000..2268ed4 --- /dev/null +++ b/resources/views/pdf/invoice.blade.php @@ -0,0 +1,156 @@ + + + + + + Hóa đơn #{{ $invoice->invoice_number }} + + + +
+
+
+

{{ $companyName }}

+ @if(isset($companyAddress)) +

{{ $companyAddress }}

+ @endif + @if(isset($companyPhone)) +

Tel: {{ $companyPhone }}

+ @endif + @if(isset($companyEmail)) +

Email: {{ $companyEmail }}

+ @endif +
+
+

HÓA ĐƠN

+

Mã hóa đơn: #{{ $invoice->invoice_number }}

+

Ngày tạo: {{ $invoice->created_at ? $invoice->created_at->format('d/m/Y') : '—' }}

+

Hạn thanh toán: {{ $invoice->due_date ? \Carbon\Carbon::parse($invoice->due_date)->format('d/m/Y') : '—' }}

+

+ Trạng thái: + @if($invoice->status == 2) + Đã thanh toán + @elseif($invoice->status == 1) + Chưa thanh toán + @else + Đã hủy + @endif +

+
+
+ +
+
+

Thông tin khách hàng

+

{{ $customer->fullname }}

+ @if($customer->email) +

Email: {{ $customer->email }}

+ @endif + @if($customer->phone) +

Điện thoại: {{ $customer->phone }}

+ @endif + @if($customer->address) +

Địa chỉ: {{ $customer->address }}

+ @endif +
+
+

Thông tin đơn hàng

+

Mã đơn hàng: #{{ $order->order_num ?? '—' }}

+ @if(isset($order) && $order->created_at) +

Ngày đặt: {{ $order->created_at->format('d/m/Y') }}

+ @endif +
+
+ + + + + + + + + + + + + + @foreach($invoiceItems as $i => $item) + + + + + + + + + @endforeach + +
#Dịch vụChu kỳSố lượngĐơn giáThành tiền
{{ $i + 1 }}{{ $item->description ?? ($item->product?->name ?? 'Dịch vụ') }}{{ $item->billing_label }}{{ $item->quantity ?? 1 }}{{ number_format($item->unit_price, 0, ',', '.') }} đ{{ number_format($item->total_price, 0, ',', '.') }} đ
+ +
+ + + + + + @if($invoice->discount_amount > 0) + + + + + @endif + @if($invoice->tax > 0) + + + + + @endif + + + + +
Tạm tính{{ number_format($invoice->sub_total, 0, ',', '.') }} đ
Giảm giá-{{ number_format($invoice->discount_amount, 0, ',', '.') }} đ
Thuế{{ number_format($invoice->tax, 0, ',', '.') }} đ
Tổng cộng{{ number_format($invoice->total, 0, ',', '.') }} đ
+
+ + @if($invoice->note) +
+ Ghi chú: {{ $invoice->note }} +
+ @endif + + +
+ + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 8fefa02..1b82179 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Admin\Customer\CustomerController; use App\Http\Controllers\Api\Admin\Employee\EmployeeController; use App\Http\Controllers\Api\Admin\Role\RolePermissionController; use App\Http\Controllers\Api\Admin\Invoice\InvoiceController; +use App\Http\Controllers\Api\Admin\Invoice\InvoiceTestController; use App\Http\Controllers\Api\Admin\Order\OrderController; use App\Http\Controllers\Api\Admin\Payment\PaymentController; use App\Http\Controllers\Api\Admin\Product\ProductCategoryController; @@ -38,6 +39,15 @@ use Illuminate\Support\Facades\Route; */ Route::prefix('v1') + ->group(function () { + // Invoice Test — Send invoice email with PDF attachment + Route::prefix('invoice-test') + ->controller(InvoiceTestController::class) + ->group(function () { + Route::post('{invoiceNumber}/send-email', 'sendInvoiceEmail'); + Route::get('{invoiceNumber}/preview-pdf', 'previewInvoicePdf'); + }); + }) /* Api for Admin */ ->group(function () { Route::prefix('administrator')->group(function () { @@ -56,7 +66,10 @@ Route::prefix('v1') Route::get('/', [RolePermissionController::class, 'index']); Route::get('all-permissions', [RolePermissionController::class, 'allPermissions']); Route::post('permissions', [RolePermissionController::class, 'createPermissionGroup']); - Route::delete('permissions/group/{module}', [RolePermissionController::class, 'deletePermissionGroup']); + Route::delete('permissions/group/{module}', [ + RolePermissionController::class, + 'deletePermissionGroup', + ]); Route::patch('permissions/{id}', [RolePermissionController::class, 'renamePermission']); Route::put('{roleId}', [RolePermissionController::class, 'update']); }); @@ -68,120 +81,147 @@ Route::prefix('v1') Route::post('/', [EmployeeController::class, 'store'])->middleware('perm:administrator.create'); Route::get('{id}', [EmployeeController::class, 'show'])->middleware('perm:administrator.view'); Route::put('{id}', [EmployeeController::class, 'update'])->middleware('perm:administrator.edit'); - Route::delete('{id}', [EmployeeController::class, 'destroy'])->middleware('perm:administrator.destroy'); + Route::delete('{id}', [EmployeeController::class, 'destroy'])->middleware( + 'perm:administrator.destroy', + ); }); // Customers (module: customer) - Route::prefix('customers')->controller(CustomerController::class)->group(function () { - Route::get('trash', 'trash')->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'); - Route::put('{uuid}', 'update')->middleware('perm:customer.edit'); - Route::delete('{uuid}', 'destroy')->middleware('perm:customer.destroy'); - Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy'); - Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit'); - Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); - Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); - 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'); - Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view'); - Route::post('{uuid}/restore', 'restore')->middleware('perm:customer.edit'); - Route::delete('{uuid}/force', 'forceDelete')->middleware('perm:customer.destroy'); - }); + Route::prefix('customers') + ->controller(CustomerController::class) + ->group(function () { + Route::get('trash', 'trash')->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'); + Route::put('{uuid}', 'update')->middleware('perm:customer.edit'); + Route::delete('{uuid}', 'destroy')->middleware('perm:customer.destroy'); + Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy'); + Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit'); + Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); + Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); + 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'); + Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view'); + Route::post('{uuid}/restore', 'restore')->middleware('perm:customer.edit'); + Route::delete('{uuid}/force', 'forceDelete')->middleware('perm:customer.destroy'); + }); // Products (module: product-service) - Route::prefix('products')->controller(ProductController::class)->group(function () { - Route::get('export', 'export')->middleware('perm:product-service.manager'); - Route::get('/', 'index')->middleware('perm:product-service.view'); - Route::post('/', 'store')->middleware('perm:product-service.create'); - Route::post('import', 'import')->middleware('perm:product-service.create'); - Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit'); - Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy'); + Route::prefix('products') + ->controller(ProductController::class) + ->group(function () { + Route::get('export', 'export')->middleware('perm:product-service.manager'); + Route::get('/', 'index')->middleware('perm:product-service.view'); + Route::post('/', 'store')->middleware('perm:product-service.create'); + Route::post('import', 'import')->middleware('perm:product-service.create'); + Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit'); + Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy'); - Route::controller(ProductController::class)->group(function () { - Route::get('os', 'getOsList')->middleware('perm:product-service.view'); + Route::controller(ProductController::class)->group(function () { + Route::get('os', 'getOsList')->middleware('perm:product-service.view'); - Route::get('{id}', 'show')->middleware('perm:product-service.view'); - Route::get('{id}/os', 'getOs')->middleware('perm:product-service.view'); - Route::get('{id}/operating-systems', 'getOperatingSystems')->middleware('perm:product-service.view'); - Route::put('{id}', 'update')->middleware('perm:product-service.edit'); - Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy'); - Route::post('{id}/duplicate', 'duplicate')->middleware('perm:product-service.create'); + Route::get('{id}', 'show')->middleware('perm:product-service.view'); + Route::get('{id}/os', 'getOs')->middleware('perm:product-service.view'); + Route::get('{id}/operating-systems', 'getOperatingSystems')->middleware( + 'perm:product-service.view', + ); + Route::put('{id}', 'update')->middleware('perm:product-service.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy'); + Route::post('{id}/duplicate', 'duplicate')->middleware('perm:product-service.create'); + }); }); - }); - 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('trashed', 'trashed')->middleware('perm:product-service.view')->name('trashed'); - Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit')->name('bulk-update-status'); - Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy')->name('bulk-destroy'); - Route::post('bulk-restore', 'bulkRestore')->middleware('perm:product-service.edit')->name('bulk-restore'); - Route::post('bulk-force-delete', 'bulkForceDelete')->middleware('perm:product-service.destroy')->name('bulk-force-delete'); - 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'); - Route::post('{id}/restore', 'restore')->middleware('perm:product-service.edit')->name('restore'); - Route::delete('{id}/force', 'forceDelete')->middleware('perm:product-service.destroy')->name('force-delete'); - }); + Route::get('/', 'index')->middleware('perm:product-service.view')->name('index'); + Route::post('/', 'store')->middleware('perm:product-service.create')->name('store'); + Route::get('trashed', 'trashed')->middleware('perm:product-service.view')->name('trashed'); + Route::post('bulk-update-status', 'bulkUpdateStatus') + ->middleware('perm:product-service.edit') + ->name('bulk-update-status'); + Route::post('bulk-destroy', 'bulkDestroy') + ->middleware('perm:product-service.destroy') + ->name('bulk-destroy'); + Route::post('bulk-restore', 'bulkRestore') + ->middleware('perm:product-service.edit') + ->name('bulk-restore'); + Route::post('bulk-force-delete', 'bulkForceDelete') + ->middleware('perm:product-service.destroy') + ->name('bulk-force-delete'); + 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'); + Route::post('{id}/restore', 'restore') + ->middleware('perm:product-service.edit') + ->name('restore'); + Route::delete('{id}/force', 'forceDelete') + ->middleware('perm:product-service.destroy') + ->name('force-delete'); + }); // Orders (module: order) - Route::prefix('orders')->controller(OrderController::class)->group(function () { - Route::get('/', 'index')->middleware('perm:order.view'); - Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create'); - Route::post('/', 'store')->middleware('perm:order.create'); - Route::get('{id}', 'show')->middleware('perm:order.view'); - Route::put('{id}', 'update')->middleware('perm:order.edit'); - Route::delete('{id}', 'destroy')->middleware('perm:order.destroy'); - Route::post('{id}/accept', 'accept')->middleware('perm:order.edit'); - Route::post('{id}/cancel', 'cancel')->middleware('perm:order.edit'); - }); + Route::prefix('orders') + ->controller(OrderController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:order.view'); + Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create'); + Route::post('/', 'store')->middleware('perm:order.create'); + Route::get('{id}', 'show')->middleware('perm:order.view'); + Route::put('{id}', 'update')->middleware('perm:order.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:order.destroy'); + Route::post('{id}/accept', 'accept')->middleware('perm:order.edit'); + Route::post('{id}/cancel', 'cancel')->middleware('perm:order.edit'); + }); // Invoices (module: invoice) - Route::prefix('invoices')->controller(InvoiceController::class)->group(function () { - Route::get('/', 'index')->middleware('perm:invoice.view'); - Route::get('/datatable', 'datatable')->middleware('perm:invoice.view'); - Route::post('/', 'store')->middleware('perm:invoice.create'); - Route::get('{id}', 'show')->middleware('perm:invoice.view'); - Route::put('{id}', 'update')->middleware('perm:invoice.edit'); - Route::delete('{id}', 'destroy')->middleware('perm:invoice.destroy'); - Route::post('{id}/paid', 'markAsPaid')->middleware('perm:invoice.edit'); - Route::post('{id}/cancel', 'cancel')->middleware('perm:invoice.edit'); - // Attachments - Route::get('{id}/attachments', 'listAttachments')->middleware('perm:invoice.view'); - Route::post('{id}/attachments', 'uploadAttachment')->middleware('perm:invoice.edit'); - Route::delete('{id}/attachments/{attachmentId}', 'deleteAttachment')->middleware('perm:invoice.edit'); - // Send mail notification - Route::post('{id}/send-mail', 'sendMail')->middleware('perm:invoice.edit'); - }); + Route::prefix('invoices') + ->controller(InvoiceController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:invoice.view'); + Route::get('/datatable', 'datatable')->middleware('perm:invoice.view'); + Route::post('/', 'store')->middleware('perm:invoice.create'); + Route::get('{id}', 'show')->middleware('perm:invoice.view'); + Route::put('{id}', 'update')->middleware('perm:invoice.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:invoice.destroy'); + Route::post('{id}/paid', 'markAsPaid')->middleware('perm:invoice.edit'); + Route::post('{id}/cancel', 'cancel')->middleware('perm:invoice.edit'); + // Attachments + Route::get('{id}/attachments', 'listAttachments')->middleware('perm:invoice.view'); + Route::post('{id}/attachments', 'uploadAttachment')->middleware('perm:invoice.edit'); + Route::delete('{id}/attachments/{attachmentId}', 'deleteAttachment')->middleware( + 'perm:invoice.edit', + ); + // Send mail notification + Route::post('{id}/send-mail', 'sendMail')->middleware('perm:invoice.edit'); + }); // Payments (module: payment) - Route::prefix('payments')->controller(PaymentController::class)->group(function () { - Route::get('for-select', 'forSelect'); - Route::get('/', 'index')->middleware('perm:payment.view'); - Route::post('/', 'store')->middleware('perm:payment.create'); - Route::get('{id}', 'show')->middleware('perm:payment.view'); - Route::put('{id}', 'update')->middleware('perm:payment.edit'); - Route::delete('{id}', 'destroy')->middleware('perm:payment.destroy'); - }); + Route::prefix('payments') + ->controller(PaymentController::class) + ->group(function () { + Route::get('for-select', 'forSelect'); + Route::get('/', 'index')->middleware('perm:payment.view'); + Route::post('/', 'store')->middleware('perm:payment.create'); + Route::get('{id}', 'show')->middleware('perm:payment.view'); + Route::put('{id}', 'update')->middleware('perm:payment.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:payment.destroy'); + }); // OS Groups & OS Entries (module: module-vcenter) Route::prefix('os-groups') ->controller(OsGroupController::class) ->as('os-groups.') ->group(function () { - Route::get('/', 'index')->middleware('perm:module-vcenter.manager')->name('index'); - Route::post('/', 'store')->middleware('perm:module-vcenter.manager')->name('store'); - Route::get('{id}', 'show')->middleware('perm:module-vcenter.manager')->name('show'); - Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager')->name('update'); - Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager')->name('destroy'); - }); + Route::get('/', 'index')->middleware('perm:module-vcenter.manager')->name('index'); + Route::post('/', 'store')->middleware('perm:module-vcenter.manager')->name('store'); + Route::get('{id}', 'show')->middleware('perm:module-vcenter.manager')->name('show'); + Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager')->name('update'); + Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager')->name('destroy'); + }); Route::prefix('os') ->controller(OsController::class) @@ -193,144 +233,165 @@ Route::prefix('v1') }); // VPS (module: module-vcenter) - Route::prefix('vps')->controller(VPSController::class)->group(function () { - Route::get('/', 'index')->middleware('perm:module-vcenter.view'); - Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); - Route::put('{id}', 'update')->middleware('perm:module-vcenter.edit'); - Route::post('{id}/power-on', 'powerOn')->middleware('perm:module-vcenter.edit'); - Route::post('{id}/power-off', 'powerOff')->middleware('perm:module-vcenter.edit'); - Route::post('{id}/restart', 'restart')->middleware('perm:module-vcenter.edit'); - Route::get('{id}/console-ticket', 'getConsoleTicket')->middleware('perm:module-vcenter.view'); - }); + Route::prefix('vps') + ->controller(VPSController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:module-vcenter.view'); + Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); + Route::put('{id}', 'update')->middleware('perm:module-vcenter.edit'); + Route::post('{id}/power-on', 'powerOn')->middleware('perm:module-vcenter.edit'); + Route::post('{id}/power-off', 'powerOff')->middleware('perm:module-vcenter.edit'); + Route::post('{id}/restart', 'restart')->middleware('perm:module-vcenter.edit'); + Route::get('{id}/console-ticket', 'getConsoleTicket')->middleware('perm:module-vcenter.view'); + }); // vCenter (module: module-vcenter) - Route::prefix('vcenter')->controller(VCenterController::class)->group(function () { - Route::get('tree', 'tree')->middleware('perm:module-vcenter.view'); - Route::get('console-ticket', 'consoleTicket')->middleware('perm:module-vcenter.view'); - Route::post('/', 'store')->middleware('perm:module-vcenter.create'); - Route::post('sync', 'sync')->middleware('perm:module-vcenter.edit'); - Route::put('node/{type}/{id}', 'updateNode')->middleware('perm:module-vcenter.edit'); - Route::delete('node/{type}/{id}', 'destroyNode')->middleware('perm:module-vcenter.destroy'); - }); + Route::prefix('vcenter') + ->controller(VCenterController::class) + ->group(function () { + Route::get('tree', 'tree')->middleware('perm:module-vcenter.view'); + Route::get('console-ticket', 'consoleTicket')->middleware('perm:module-vcenter.view'); + Route::post('/', 'store')->middleware('perm:module-vcenter.create'); + Route::post('sync', 'sync')->middleware('perm:module-vcenter.edit'); + Route::put('node/{type}/{id}', 'updateNode')->middleware('perm:module-vcenter.edit'); + Route::delete('node/{type}/{id}', 'destroyNode')->middleware('perm:module-vcenter.destroy'); + }); // DNS Manager — Domains (module: domain.manager) - Route::prefix('domains')->controller(DnsDomainController::class)->group(function () { - Route::get('/', 'index')->middleware('perm:domain.manager'); - Route::post('/', 'store')->middleware('perm:domain.manager'); - Route::get('{id}', 'show')->middleware('perm:domain.manager'); - Route::put('{id}', 'update')->middleware('perm:domain.manager'); - Route::delete('{id}', 'destroy')->middleware('perm:domain.manager'); - // DNS Records - Route::get('{id}/dns', 'dnsIndex')->middleware('perm:domain.manager'); - Route::post('{id}/dns', 'dnsStore')->middleware('perm:domain.manager'); - Route::put('{id}/dns/{recordId}', 'dnsUpdate')->middleware('perm:domain.manager'); - Route::delete('{id}/dns/{recordId}', 'dnsDestroy')->middleware('perm:domain.manager'); - }); + Route::prefix('domains') + ->controller(DnsDomainController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:domain.manager'); + Route::post('/', 'store')->middleware('perm:domain.manager'); + Route::get('{id}', 'show')->middleware('perm:domain.manager'); + Route::put('{id}', 'update')->middleware('perm:domain.manager'); + Route::delete('{id}', 'destroy')->middleware('perm:domain.manager'); + // DNS Records + Route::get('{id}/dns', 'dnsIndex')->middleware('perm:domain.manager'); + Route::post('{id}/dns', 'dnsStore')->middleware('perm:domain.manager'); + Route::put('{id}/dns/{recordId}', 'dnsUpdate')->middleware('perm:domain.manager'); + Route::delete('{id}/dns/{recordId}', 'dnsDestroy')->middleware('perm:domain.manager'); + }); // DNS Manager — Servers (module: domain.manager) - Route::prefix('dns-servers')->controller(DnsServerController::class)->group(function () { - Route::get('/', 'index')->middleware('perm:domain.manager'); - Route::post('/', 'store')->middleware('perm:domain.manager'); - Route::get('{id}', 'show')->middleware('perm:domain.manager'); - Route::put('{id}', 'update')->middleware('perm:domain.manager'); - Route::delete('{id}', 'destroy')->middleware('perm:domain.manager'); - }); + Route::prefix('dns-servers') + ->controller(DnsServerController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:domain.manager'); + Route::post('/', 'store')->middleware('perm:domain.manager'); + Route::get('{id}', 'show')->middleware('perm:domain.manager'); + Route::put('{id}', 'update')->middleware('perm:domain.manager'); + Route::delete('{id}', 'destroy')->middleware('perm:domain.manager'); + }); // Staff Online - Route::prefix('staff-online')->controller(StaffOnlineController::class)->group(function () { - Route::get('/', 'index'); - Route::post('ping', 'ping'); - }); + Route::prefix('staff-online') + ->controller(StaffOnlineController::class) + ->group(function () { + Route::get('/', 'index'); + Route::post('ping', 'ping'); + }); // Queue Monitor (Horizon) - Route::prefix('queue')->controller(QueueMonitorController::class)->group(function () { - Route::get('jobs/recent', 'recent'); - Route::get('jobs/pending', 'pending'); - Route::get('jobs/failed', 'failed'); - Route::get('jobs/metrics', 'metrics'); - }); + Route::prefix('queue') + ->controller(QueueMonitorController::class) + ->group(function () { + Route::get('jobs/recent', 'recent'); + Route::get('jobs/pending', 'pending'); + Route::get('jobs/failed', 'failed'); + Route::get('jobs/metrics', 'metrics'); + }); // vCenter Manager Power Operations (module: module-vcenter) - Route::prefix('vcenter-manager')->controller(VCenterManagerController::class)->group(function () { - Route::post('vm/power-on', 'powerOn')->middleware('perm:module-vcenter.edit'); - Route::post('vm/power-off', 'powerOff')->middleware('perm:module-vcenter.edit'); - Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); - }); + Route::prefix('vcenter-manager') + ->controller(VCenterManagerController::class) + ->group(function () { + Route::post('vm/power-on', 'powerOn')->middleware('perm:module-vcenter.edit'); + Route::post('vm/power-off', 'powerOff')->middleware('perm:module-vcenter.edit'); + Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); + }); }); }); - Route::prefix('system')->middleware(['api.auth'])->group(function () { + Route::prefix('system') + ->middleware(['api.auth']) + ->group(function () { + Route::post('get-all-image', function (Media $media) { + $files = $media->getAllMedia(); + return response()->json(['data' => $files]); + })->name('api.GetAllImage'); - Route::post('get-all-image', function (Media $media) { - $files = $media->getAllMedia(); - return response()->json(['data' => $files]); - })->name('api.GetAllImage'); + Route::get('get-ticket-console', function (Request $request, ServiceVCenter $serviceVCenter) { + $product_id = $request->get('product'); + if (!$product_id) { + return response()->json(['error' => true, 'message' => 'Please choose product']); + } + $product = ModuleVmware::with('ipAddress.host.datacenter.vCenter')->find($product_id); + if (!$product) { + return response()->json(['error' => true, 'message' => "Can't find product"]); + } + $vcenter = null; + if ( + $product->ipAddress && + $product->ipAddress->host && + $product->ipAddress->host->datacenter && + $product->ipAddress->host->datacenter->vCenter + ) { + $vcenter = $product->ipAddress->host->datacenter->vCenter; + } - Route::get('get-ticket-console', function (Request $request, ServiceVCenter $serviceVCenter) { - $product_id = $request->get('product'); - if (!$product_id) { - return response()->json(['error' => true, 'message' => "Please choose product"]); - } - $product = ModuleVmware::with('ipAddress.host.datacenter.vCenter')->find($product_id); - if (!$product) { - return response()->json(['error' => true, 'message' => "Can't find product"]); - } + if (!$vcenter) { + return response()->json(['error' => true, 'message' => "Can't find vCenter information"]); + } - $vcenter = null; - if ($product->ipAddress && $product->ipAddress->host && $product->ipAddress->host->datacenter && $product->ipAddress->host->datacenter->vCenter) { - $vcenter = $product->ipAddress->host->datacenter->vCenter; - } - - if (!$vcenter) { - return response()->json(['error' => true, 'message' => "Can't find vCenter information"]); - } - - // Đăng nhập vCenter trước khi lấy ticket - if (!$serviceVCenter->loginServer($vcenter)) { - return response()->json([ - 'error' => true, - 'message' => 'Không thể đăng nhập vào vCenter' - ]); - } - - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - - // Nếu login hết hạn, thử login lại 1 lần nữa - if ($ConsoleTicket === 'Login_Again') { - if ($serviceVCenter->loginServer($vcenter)) { - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - } else { + // Đăng nhập vCenter trước khi lấy ticket + if (!$serviceVCenter->loginServer($vcenter)) { return response()->json([ 'error' => true, - 'message' => 'Không thể đăng nhập lại vào vCenter' + 'message' => 'Không thể đăng nhập vào vCenter', ]); } - } - // Nếu vẫn lỗi, trả về lỗi rõ ràng - if (is_string($ConsoleTicket)) { - return response()->json([ - 'error' => true, - 'message' => "Failed to get console ticket: $ConsoleTicket" - ]); - } + $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - if ($ConsoleTicket['error']) { - return response()->json([ - 'error' => true, - 'message' => $ConsoleTicket['data'] - ]); - } + // Nếu login hết hạn, thử login lại 1 lần nữa + if ($ConsoleTicket === 'Login_Again') { + if ($serviceVCenter->loginServer($vcenter)) { + $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); + } else { + return response()->json([ + 'error' => true, + 'message' => 'Không thể đăng nhập lại vào vCenter', + ]); + } + } - return response()->json([ - 'error' => false, - 'data' => $ConsoleTicket['data'], - 'host' => $vcenter->host, - 'vmware-api-session-id' => $serviceVCenter->token - ])->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false); - })->name('api.GetConsoleTicket'); - }); + // Nếu vẫn lỗi, trả về lỗi rõ ràng + if (is_string($ConsoleTicket)) { + return response()->json([ + 'error' => true, + 'message' => "Failed to get console ticket: $ConsoleTicket", + ]); + } + + if ($ConsoleTicket['error']) { + return response()->json([ + 'error' => true, + 'message' => $ConsoleTicket['data'], + ]); + } + + return response() + ->json([ + 'error' => false, + 'data' => $ConsoleTicket['data'], + 'host' => $vcenter->host, + 'vmware-api-session-id' => $serviceVCenter->token, + ]) + ->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false); + })->name('api.GetConsoleTicket'); + }); Route::prefix('hello')->group(function () { return 'heloo world'; @@ -338,8 +399,11 @@ Route::prefix('v1') }); Route::any('{any}', function () { - return response()->json([ - 'error' => true, - 'message' => 'API Route not found' - ], 404); + return response()->json( + [ + 'error' => true, + 'message' => 'API Route not found', + ], + 404, + ); })->where('any', '.*'); diff --git a/routes/client.php b/routes/client.php index 9d0ef41..1265ca2 100644 --- a/routes/client.php +++ b/routes/client.php @@ -2,764 +2,181 @@ use App\Http\Controllers\Api\Setting\SettingController; use App\Http\Controllers\Api\Client\ClientController; +use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController; use App\Http\Controllers\Api\Service\OrderController; use App\Http\Controllers\Api\Service\ProductCategoryController; use App\Http\Controllers\Api\Service\ServiceController; +use App\Http\Controllers\Api\Service\SnapshotController; use App\Http\Controllers\Api\Service\VmController; -use App\Http\Controllers\Api\Client\Service\ServiceController as ServiceClientController; +use App\Http\Controllers\Api\Service\VmRebuildController; use App\Http\Controllers\Api\Transaction\TransactionController; use Illuminate\Support\Facades\Route; -Route::prefix('v1')->as('api.')->group(function () { - // Auth - Route::prefix('client')->as('client.') - ->group(function () { - Route::controller(ClientController::class)->group(function () { - Route::post('login', 'login')->name('login'); - Route::post('register', 'register')->name('register'); - Route::post('refresh', 'refresh')->name('refresh'); +Route::prefix('v1') + ->as('api.') + ->group(function () { + // Auth + Route::prefix('client') + ->as('client.') + ->group(function () { + Route::controller(ClientController::class)->group(function () { + Route::post('login', 'login')->name('login'); + Route::post('register', 'register')->name('register'); + Route::post('refresh', 'refresh')->name('refresh'); - Route::group(['middleware' => ['api.auth']], function () { - Route::get('me', 'me')->name('me'); - Route::post('logout', 'logout')->name('logout'); - Route::put('update', 'update')->name('update'); + Route::group(['middleware' => ['api.auth']], function () { + Route::get('me', 'me')->name('me'); + Route::post('logout', 'logout')->name('logout'); + Route::put('update', 'update')->name('update'); + }); + }); + + Route::middleware(['api.auth']) + ->controller(OrderController::class) + ->group(function () { + Route::get('orders', 'getClientOrders')->name('orders.list'); + Route::get('orders/{id}', 'getClientOrderDetail')->name('orders.detail'); + Route::post('orders/{id}/retry', 'retryOrderProvisioning')->name('orders.retry'); + Route::get('invoices', 'getClientInvoices')->name('invoices.list'); + Route::get('invoices/{id}', 'getClientInvoiceDetail')->name('invoices.detail'); + Route::get('invoices/order/{orderNum}', 'getClientInvoiceDetailByOrderNum')->name( + 'invoices.detailByOrderNum', + ); + Route::get('dich-vu', 'getClientServices')->name('services.list'); + Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name( + 'services.autoRenew.update', + ); + Route::post('dich-vu/{id}/renew', 'renewService')->name('services.renew'); + }); + + Route::middleware(['api.auth']) + ->prefix('client-service') + ->controller(ClientServiceController::class) + ->as('client-service.') + ->group(function () { + Route::get('/', 'getService')->name('list'); + Route::get('{uuid}', 'getDetail')->name('detail'); + Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket'); + }); + + Route::middleware(['api.auth']) + ->get('get-ticket-console', [ClientServiceController::class, 'getTicketByOrderNum']) + ->name('console-ticket'); + + Route::middleware(['api.auth']) + ->prefix('service') + ->controller(ServiceController::class) + ->as('service.') + ->group(function () { + Route::get('/', 'getService')->name('list'); + Route::get('{uuid}', 'getDetailServiceCustomer')->name('detail'); + }); + + Route::middleware(['api.auth']) + ->controller(TransactionController::class) + ->group(function () { + Route::get('payment-history', 'getClientPaymentHistory')->name('payment-history.list'); + }); + }); + + // Services (Products) - Public & Protected Routes + Route::prefix('services') + ->as('services.') + ->group(function () { + Route::controller(ServiceController::class)->group(function () { + Route::get('get-list', 'getService')->name('getList'); + Route::get('{product:slug}', 'getDetailService')->name('detail'); }); }); - Route::middleware(['api.auth'])->controller(OrderController::class)->group(function () { - Route::get('orders', 'getClientOrders')->name('orders.list'); - Route::get('orders/{id}', 'getClientOrderDetail')->name('orders.detail'); - Route::get('invoices', 'getClientInvoices')->name('invoices.list'); - Route::get('invoices/{id}', 'getClientInvoiceDetail')->name('invoices.detail'); - Route::get('invoices/order/{orderNum}', 'getClientInvoiceDetailByOrderNum')->name('invoices.detailByOrderNum'); - Route::get('dich-vu', 'getClientServices')->name('services.list'); - Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name('services.autoRenew.update'); - Route::post('dich-vu/{id}/renew', 'renewService')->name('services.renew'); + Route::middleware(['api.auth']) + ->prefix('orders') + ->as('orders.') + ->group(function () { + Route::controller(OrderController::class)->group(function () { + Route::get('/', 'getList')->name('list'); + Route::post('/', 'storeOrder')->name('store'); + Route::post('{id}/pay', 'payOrder')->name('pay'); + }); }); - - Route::middleware(['api.auth'])->prefix('client-service')->controller(ServiceClientController::class)->as('client-service.')->group(function () { - Route::get('/', 'getService')->name('list'); - Route::get('{uuid}', 'getDetail')->name('detail'); - Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket'); + Route::prefix('setting') + ->as('setting.') + ->group(function () { + Route::controller(SettingController::class)->group(function () { + Route::get('/', 'getSetting')->name('get'); + }); }); - - Route::middleware(['api.auth'])->prefix('service')->controller(ServiceController::class)->as('service.')->group(function () { - Route::get('/', 'getService')->name('list'); - Route::get('{uuid}', 'getDetailServiceCustomer')->name('detail'); + // Product Categories - Public Routes (no auth required) + Route::prefix('group') + ->as('product-categories.') + ->group(function () { + Route::controller(ProductCategoryController::class)->group(function () { + Route::get('get-list', 'getCategories')->name('getList'); + Route::get('{product_categories:slug}', 'getDetailCategory')->name('detail'); + Route::get('{parentSlug}/{childSlug}', 'getChildCategoryWithParent')->name('childWithParent'); + }); }); - Route::middleware(['api.auth'])->controller(TransactionController::class)->group(function () { - Route::get('payment-history', 'getClientPaymentHistory')->name('payment-history.list'); + // Addons - Get available addons for a product + Route::prefix('products') + ->as('products.') + ->group(function () { + Route::get('{productId}/addons', [ServiceController::class, 'getProductAddons'])->name('addons'); }); - Route::middleware(['api.auth'])->get('get-ticket-console', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $orderNum = (string)$request->query('order_num', ''); - if ($orderNum === '') { - return response()->json(['error' => true, 'message' => 'Thiếu order_num'], 422); - } - - $customer = $request->user(); - $order = \App\Models\Order::with(['vmWare.vCenter']) - ->where('customer_id', $customer->id) - ->where('order_num', $orderNum) - ->first(); - - if (!$order) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy đơn hàng'], 404); - } - - $vm = $order->vmWare->first(); - if (!$vm) { - return response()->json(['error' => true, 'message' => 'Đơn hàng chưa có VM'], 404); - } - - $vcenter = $vm->vCenter; - if (!$vcenter) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404); - } - - if (!$serviceVCenter->loginServer($vcenter)) { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']); - } - - $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); - - if ($consoleTicket === 'Login_Again') { - if ($serviceVCenter->loginServer($vcenter)) { - $consoleTicket = $serviceVCenter->GetConsoleTickets($vm->vmware_id); - } else { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']); - } - } - - if (is_string($consoleTicket)) { - return response()->json(['error' => true, 'message' => "Failed to get console ticket: $consoleTicket"]); - } - - if ($consoleTicket['error']) { - return response()->json(['error' => true, 'message' => $consoleTicket['data']]); - } - - return response()->json([ - 'error' => false, - 'data' => $consoleTicket['data'], - 'order_num' => $order->order_num, - 'vm_id' => $vm->id, - ]); - })->name('client.console-ticket'); - }); - - // Services (Products) - Public & Protected Routes - Route::prefix('services')->as('services.')->group(function () { - Route::controller(ServiceController::class)->group(function () { - Route::get('get-list', 'getService')->name('getList'); - Route::get('{product:slug}', 'getDetailService')->name('detail'); - }); + Route::middleware(['api.auth']) + ->prefix('transaction') + ->as('transaction.') + ->group(function () { + Route::controller(TransactionController::class)->group(function () { + Route::get('get-list', 'getTransactions')->name('getList'); + Route::post('pay', 'handle')->name('pay'); + Route::get('verify', 'verifyPayment')->name('verify'); + Route::get('detail-by-code', 'getByPublicCode')->name('detailByCode'); + }); + }); }); - - - Route::middleware(['api.auth'])->prefix('orders')->as('orders.')->group(function () { - Route::controller(OrderController::class)->group(function () { - Route::get('/', 'getList')->name('list'); - Route::post('/', 'storeOrder')->name('store'); - Route::post('{id}/pay', 'payOrder')->name('pay'); - }); - }); - - Route::prefix('setting')->as('setting.')->group(function () { - Route::controller(SettingController::class)->group(function () { - Route::get('/', 'getSetting')->name('get'); - }); - }); - - // Product Categories - Public Routes (no auth required) - Route::prefix('group')->as('product-categories.')->group(function () { - Route::controller(ProductCategoryController::class)->group(function () { - Route::get('get-list', 'getCategories')->name('getList'); - Route::get('{product_categories:slug}', 'getDetailCategory')->name('detail'); - Route::get('{parentSlug}/{childSlug}', 'getChildCategoryWithParent')->name('childWithParent'); - }); - }); - - // Addons - Get available addons for a product - Route::prefix('products')->as('products.')->group(function () { - Route::get('{productId}/addons', [ServiceController::class, 'getProductAddons'])->name('addons'); - }); - - - Route::middleware(['api.auth'])->prefix('transaction')->as('transaction.')->group(function () { - Route::controller(TransactionController::class)->group(function () { - Route::get('get-list', 'getTransactions')->name('getList'); - Route::post('pay', 'handle')->name('pay'); - Route::get('verify', 'verifyPayment')->name('verify'); - Route::get('detail-by-code', 'getByPublicCode')->name('detailByCode'); - }); - }); - -}); - Route::get('v1/payment/vnpay/return', [TransactionController::class, 'returnCallback']); -Route::prefix('v1/system')->middleware(['api.auth'])->group(function () { - // VM power & network operations - Route::controller(VmController::class)->group(function () { - Route::post('vm-power-on', 'powerOn') ->name('vm.power-on'); - Route::post('vm-power-off', 'powerOff') ->name('vm.power-off'); - Route::post('vm-change-ip', 'changeIp') ->name('vm.change-ip'); - Route::post('vm-change-port','changePort') ->name('vm.change-port'); - }); - - Route::get('get-ticket-console', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $product_id = $request->get('product'); - if (!$product_id) { - return response()->json(['error' => true, 'message' => "Please choose product"]); - } - $product = \App\Models\ModuleVmware::with('vCenter')->find($product_id); - if (!$product) { - return response()->json(['error' => true, 'message' => "Can't find product"]); - } - - $vcenter = $product->vCenter; - - if (!$vcenter) { - return response()->json(['error' => true, 'message' => "Can't find vCenter information"]); - } - - if (!$serviceVCenter->loginServer($vcenter)) { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter']); - } - - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - - if ($ConsoleTicket === 'Login_Again') { - if ($serviceVCenter->loginServer($vcenter)) { - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - } else { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter']); - } - } - - if (is_string($ConsoleTicket)) { - return response()->json(['error' => true, 'message' => "Failed to get console ticket: $ConsoleTicket"]); - } - - if ($ConsoleTicket['error']) { - return response()->json(['error' => true, 'message' => $ConsoleTicket['data']]); - } - - return response()->json([ - 'error' => false, - 'data' => $ConsoleTicket['data'], // wss://10.10.10.120:443/ticket/... - ]); - }); - - Route::post('vm-restart', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $productId = $request->input('product'); - $customer = $request->user(); - - if (!$productId) { - return response()->json([ - 'error' => true, - 'message' => 'Thiếu product id', - ], 422); - } - - // Kiểm tra nếu là UUID (Service UUID) - $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId); - - $product = null; - if ($isUuid) { - // Tìm qua Service UUID - $service = \App\Models\Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') - ->where('uuid', $productId) - ->where('customer_id', $customer->id) - ->first(); - if ($service && $service->vmware) { - $product = $service->vmware; - } - } elseif (is_numeric($productId)) { - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter')->find((int)$productId); - } else { - // Tìm theo vmware_id (ví dụ: vm-3238) - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter') - ->where('vmware_id', $productId) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$product) { - return response()->json([ - 'error' => true, - 'message' => 'Không tìm thấy VM', - ], 404); - } - - if ((int)$product->customer_id !== (int)$customer->id) { - return response()->json([ - 'error' => true, - 'message' => 'Bạn không có quyền thao tác VM này', - ], 403); - } - - $result = $serviceVCenter->restartVM($product, $product->vmware_id); - - if (is_string($result) || ($result['error'] ?? true)) { - return response()->json([ - 'error' => true, - 'message' => is_string($result) ? $result : ($result['data'] ?? 'Không thể restart VM'), - ], 400); - } - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi lệnh restart VM', - 'data' => $result['data'] ?? 'success', - ]); - }); - - Route::post('vm-create-snapshot', function (\Illuminate\Http\Request $request) { - $customer = $request->user(); - $payload = $request->validate([ - 'product' => ['required'], - 'description' => ['nullable', 'string', 'max:1000'], - ]); - - // Kiểm tra nếu là UUID (Service UUID) - $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $payload['product']); - - if ($isUuid) { - // Tìm qua Service UUID - $service = \App\Models\Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') - ->where('uuid', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - - if (!$service || !$service->vmware) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - $product = $service->vmware; - } elseif (is_numeric($payload['product'])) { - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter')->find((int)$payload['product']); - } else { - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter') - ->where('vmware_id', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$product->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - // Auto-generate snapshot name: {vm_name}-{date}-{count} - $today = now()->format('d-m-Y'); - $existingCount = \App\Models\ModuleSnapshot::where('module_vmware_id', $product->id) - ->whereDate('created_at', today()) - ->count(); - $snapshotName = sprintf('%s-%s-%d', $product->name, $today, $existingCount + 1); - - // Create snapshot record with pending status - $snapshot = \App\Models\ModuleSnapshot::create([ - 'name' => $snapshotName, - 'description' => $payload['description'] ?? null, - 'customer_id' => $customer->id, - 'module_vmware_id' => $product->id, - 'status' => 'pending', - 'snapshot_ref' => null, - ]); - - // Dispatch job to create snapshot - \App\Jobs\VirtualMachine\Snapshot\CreateSnapshot::dispatch($snapshot->id); - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi yêu cầu tạo snapshot vào hàng đợi', - 'data' => [ - 'id' => $snapshot->id, - 'name' => $snapshot->name, - 'description' => $snapshot->description, - 'status' => 'pending', - ], - ]); - }); - - Route::get('vm-snapshots', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $customer = $request->user(); - $productParam = $request->query('product'); - if (!$productParam) { - return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); - } - - // Kiểm tra nếu là UUID (Service UUID) - $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productParam); - - if ($isUuid) { - // Tìm qua Service UUID - $service = \App\Models\Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') - ->where('uuid', $productParam) - ->where('customer_id', $customer->id) - ->first(); - - if (!$service || !$service->vmware) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - $product = $service->vmware; - } elseif (is_numeric($productParam)) { - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter')->find((int)$productParam); - } else { - $product = \App\Models\ModuleVmware::with('ipAddress.host.datacenter.vCenter', 'vCenter') - ->where('vmware_id', $productParam) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$product->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - $remote = $serviceVCenter->getSnapShot($product); - if (is_string($remote) || ($remote['error'] ?? true)) { - return response()->json([ - 'error' => true, - 'message' => is_string($remote) ? $remote : ($remote['message'] ?? 'Không thể lấy danh sách snapshot'), - ], 400); - } - - $remoteItems = $remote['data'] ?? []; - foreach ($remoteItems as $item) { - $ref = (string)($item['id'] ?? $item['name'] ?? ''); - $name = (string)($item['name'] ?? $ref); - if ($name === '') { - continue; - } - - $snapshot = \App\Models\ModuleSnapshot::query() - ->where('module_vmware_id', $product->id) - ->where(function ($q) use ($ref, $name) { - $q->where('snapshot_ref', $ref)->orWhere('name', $name); - }) - ->first(); - - $payload = [ - 'name' => $name, - 'description' => $item['description'] ?? null, - 'customer_id' => $customer->id, - 'module_vmware_id' => $product->id, - 'status' => 'success', - 'snapshot_ref' => $ref ?: $name, - 'size_gb' => isset($item['size_gb']) ? (float)$item['size_gb'] : null, - 'size_last_synced_at' => now(), - ]; - - if ($snapshot) { - $snapshot->update($payload); - } else { - \App\Models\ModuleSnapshot::create($payload); - } - } - - $dbSnapshots = \App\Models\ModuleSnapshot::query() - ->where('module_vmware_id', $product->id) - ->where('customer_id', $customer->id) - ->orderByDesc('created_at') - ->get() - ->map(function ($snap) { - return [ - 'id' => $snap->id, - 'name' => $snap->name, - 'description' => $snap->description, - 'snapshot_ref' => $snap->snapshot_ref, - 'status' => $snap->status, - 'size_gb' => $snap->size_gb, - 'created_at' => $snap->created_at?->toISOString(), - ]; - }); - - return response()->json([ - 'error' => false, - 'data' => $dbSnapshots, - ]); - }); - - Route::post('vm-revert-snapshot', function (\Illuminate\Http\Request $request) { - $customer = $request->user(); - $payload = $request->validate([ - 'product' => ['required'], - 'snapshot_id' => ['required', 'numeric'], - ]); - - // Kiểm tra nếu là UUID (Service UUID) - $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $payload['product']); - - if ($isUuid) { - // Tìm qua Service UUID - $service = \App\Models\Service::with('vmware') - ->where('uuid', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - - if (!$service || !$service->vmware) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - $product = $service->vmware; - } elseif (is_numeric($payload['product'])) { - $product = \App\Models\ModuleVmware::find((int)$payload['product']); - } else { - $product = \App\Models\ModuleVmware::where('vmware_id', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$product->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - // Find snapshot - $snapshot = \App\Models\ModuleSnapshot::where('id', $payload['snapshot_id']) - ->where('module_vmware_id', $product->id) - ->where('customer_id', $customer->id) - ->first(); - - if (!$snapshot) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404); - } - - // Dispatch job - \App\Jobs\VirtualMachine\Snapshot\RevertSnapshot::dispatch($product->id, $snapshot->id); - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi yêu cầu revert snapshot vào hàng đợi', - ]); - }); - - Route::post('vm-delete-snapshot', function (\Illuminate\Http\Request $request) { - $customer = $request->user(); - $payload = $request->validate([ - 'product' => ['required'], - 'snapshot_id' => ['required', 'numeric'], - ]); - - // Kiểm tra nếu là UUID (Service UUID) - $isUuid = preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $payload['product']); - - if ($isUuid) { - // Tìm qua Service UUID - $service = \App\Models\Service::with('vmware') - ->where('uuid', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - - if (!$service || !$service->vmware) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - $product = $service->vmware; - } elseif (is_numeric($payload['product'])) { - $product = \App\Models\ModuleVmware::find((int)$payload['product']); - } else { - $product = \App\Models\ModuleVmware::where('vmware_id', $payload['product']) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$product->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - // Find snapshot - $snapshot = \App\Models\ModuleSnapshot::where('id', $payload['snapshot_id']) - ->where('module_vmware_id', $product->id) - ->where('customer_id', $customer->id) - ->first(); - - if (!$snapshot) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy snapshot'], 404); - } - - // Dispatch job - \App\Jobs\VirtualMachine\Snapshot\RemoveSnapshot::dispatch($product->id, $snapshot->id); - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi yêu cầu xóa snapshot vào hàng đợi', - ]); - }); - - Route::get('vm-rebuild-options', function (\Illuminate\Http\Request $request) { - $customer = $request->user(); - $productId = (int)$request->query('product'); - if (!$productId) { - return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); - } - - $vm = \App\Models\ModuleVmware::with(['order.orderItems.product.osVersions.osGroup']) - ->find($productId); - if (!$vm) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$vm->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - $product = optional($vm->order?->orderItems?->first())->product; - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); - } - - $osOptions = $product->osVersions - ->where('status', 1) - ->map(fn($os) => [ - 'id' => $os->id, - 'name' => $os->name, - 'version' => $os->version, - 'group' => $os->osGroup?->name, - ]) - ->values(); - - return response()->json([ - 'error' => false, - 'data' => [ - 'product_id' => $product->id, - 'product_uuid' => $product->uuid, - 'product_name' => $product->name, - 'os_options' => $osOptions, - ], - ]); - }); - - Route::post('vm-rebuild', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $customer = $request->user(); - $payload = $request->validate([ - 'product' => ['required', 'integer'], - 'os_id' => ['required', 'integer', 'exists:os,id'], - ]); - - $vm = \App\Models\ModuleVmware::with(['order.orderItems.product', 'vCenter', 'ipAddress.host.datacenter.vCenter']) - ->find($payload['product']); - if (!$vm) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$vm->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - $product = optional($vm->order?->orderItems?->first())->product; - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); - } - - $allowedOs = $product->osVersions()->where('status', 1)->pluck('id')->toArray(); - if (!in_array((int)$payload['os_id'], array_map('intval', $allowedOs), true)) { - return response()->json(['error' => true, 'message' => 'OS không hợp lệ cho product hiện tại'], 422); - } - - \Illuminate\Support\Facades\DB::table('reinstall_logs')->insert([ - 'product_id' => $vm->id, - 'product_type' => 4, - 'custome_id' => $customer->id, - 'os_id' => $payload['os_id'], - 'status' => 'pending', - 'initiator' => 'client', - 'note' => 'Client request rebuild from portal-client', - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $result = $serviceVCenter->reBuildVPS((string)$vm->name); - if (is_string($result) || ($result['error'] ?? false)) { - return response()->json([ - 'error' => true, - 'message' => is_string($result) ? $result : ($result['message'] ?? 'Không thể rebuild VM'), - ], 400); - } - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi yêu cầu rebuild VM', - 'data' => $result, - ]); - }); - - Route::get('vm-addon-options', function (\Illuminate\Http\Request $request) { - $customer = $request->user(); - $productId = (int)$request->query('product'); - if (!$productId) { - return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); - } - - $vm = \App\Models\ModuleVmware::with(['order.orderItems.product.addons']) - ->find($productId); - if (!$vm) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$vm->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - $product = optional($vm->order?->orderItems?->first())->product; - if (!$product) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); - } - - $addons = $product->addons()->where('status', true)->get()->map(function ($addon) { - return [ - 'id' => $addon->id, - 'code' => strtolower((string)$addon->code), - 'name' => $addon->name, - 'price' => (float)$addon->price, - 'min' => (int)($addon->min ?? 0), - 'max' => (int)($addon->max ?? 0), - 'step' => (int)($addon->step ?? 1), - ]; +Route::prefix('v1/system') + ->middleware(['api.auth']) + ->group(function () { + // VM power & network operations + Route::controller(VmController::class)->group(function () { + Route::post('vm-power-on', 'powerOn')->name('vm.power-on'); + Route::post('vm-power-off', 'powerOff')->name('vm.power-off'); + Route::post('vm-change-ip', 'changeIp')->name('vm.change-ip'); + Route::post('vm-change-port', 'changePort')->name('vm.change-port'); + Route::get('get-ticket-console', 'consoleTicket')->name('vm.console-ticket'); + Route::post('vm-restart', 'restart')->name('vm.restart'); }); - return response()->json([ - 'error' => false, - 'data' => [ - 'product_id' => $product->id, - 'product_name' => $product->name, - 'base_specs' => [ - 'cpu' => (int)($product->cpu ?? 0), - 'ram' => (int)($product->ram ?? 0), - 'disk' => (int)($product->disk ?? 0), - 'bandwidth' => (int)($product->bandwidth ?? 0), - 'gpu' => $product->gpu, - ], - 'addons' => $addons->values(), - ], - ]); + // VM Snapshot operations + Route::controller(SnapshotController::class)->group(function () { + Route::get('vm-snapshots', 'index')->name('vm.snapshots'); + Route::post('vm-create-snapshot', 'store')->name('vm.create-snapshot'); + Route::post('vm-revert-snapshot', 'revert')->name('vm.revert-snapshot'); + Route::post('vm-delete-snapshot', 'destroy')->name('vm.delete-snapshot'); + }); + + // VM Rebuild & Addon operations + Route::controller(VmRebuildController::class)->group(function () { + Route::get('vm-rebuild-options', 'options')->name('vm.rebuild-options'); + Route::post('vm-rebuild', 'rebuild')->name('vm.rebuild'); + Route::get('vm-addon-options', 'addonOptions')->name('vm.addon-options'); + Route::post('vm-upgrade-addons', 'upgradeAddons')->name('vm.upgrade-addons'); + }); }); - Route::post('vm-upgrade-addons', function (\Illuminate\Http\Request $request, \App\Http\Services\vCenter $serviceVCenter) { - $customer = $request->user(); - $payload = $request->validate([ - 'product' => ['required', 'integer'], - 'cpu' => ['nullable', 'integer', 'min:0'], - 'ram' => ['nullable', 'integer', 'min:0'], - 'disk' => ['nullable', 'integer', 'min:0'], - 'bandwidth' => ['nullable', 'integer', 'min:0'], - 'gpu' => ['nullable', 'string', 'max:255'], - ]); - - $vm = \App\Models\ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) - ->find($payload['product']); - if (!$vm) { - return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404); - } - if ((int)$vm->customer_id !== (int)$customer->id) { - return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); - } - - $vcenter = $vm->vCenter ?? $vm->ipAddress?->host?->datacenter?->vCenter; - if (!$vcenter || !$serviceVCenter->loginServer($vcenter)) { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vCenter'], 400); - } - - $applied = []; - - if (isset($payload['cpu']) && $payload['cpu'] > 0) { - $res = $serviceVCenter->UpgradeCPUVirtualMachine($vm->vmware_id, (int)$payload['cpu']); - if (is_array($res) && !($res['error'] ?? true)) $applied[] = 'cpu'; - } - if (isset($payload['ram']) && $payload['ram'] > 0) { - $res = $serviceVCenter->UpgradeMemoryVirtualMachine($vm->vmware_id, (int)$payload['ram']); - if (is_array($res) && !($res['error'] ?? true)) $applied[] = 'ram'; - } - if (isset($payload['disk']) && $payload['disk'] > 0) { - $res = $serviceVCenter->UpgradeDiskVirtualMachine($vm, (int)$payload['disk']); - if (is_array($res) && !($res['error'] ?? true)) $applied[] = 'disk'; - } - - return response()->json([ - 'error' => false, - 'message' => 'Đã gửi yêu cầu nâng cấp addons', - 'data' => [ - 'applied' => $applied, - 'note' => 'bandwidth/gpu hiện được ghi nhận yêu cầu, chưa có luồng tự động áp vào VM', - ], - ]); - }); -}); - - Route::any('{any}', function () { - return response()->json([ - 'error' => true, - 'message' => 'API Route not found' - ], 404); + return response()->json( + [ + 'error' => true, + 'message' => 'API Route not found', + ], + 404, + ); })->where('any', '.*'); diff --git a/storage/.DS_Store b/storage/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ada96232d24bcb882f3b52d583c36faff2c10e79 GIT binary patch literal 6148 zcmeHKJx?1!5S_JMn51!mBFg&%6mCOvSVxu8=|};-8!vPO7Cfryytf#>8FrQr=A_na{soO*1}+7vt^U z2cd9q^ZLoOb1|Ficle3>)p7rCsY65R!mraH*LlA>F01i5e7R`;czxn@xU-heW>#H} z*Aef_G)M1f06u-N+rkbzpbt4_^k=8M%I8*D+B>^C6>cT#`Rry_=J-`^m|u;0VAZwz z{^Xasx~}>;pUZLlW;OjOTqbjU24(qN-@op!$=%f+ssPSxw)%66>Qw<%KoyuM!23gh z!5BKsEXvjaC!YYo8p2|@mNN?&Z~z!O%q$`TGj=M_PBrcm!`SJF2d*x3m|3*b$w+6c z<2oDng<_;T;(?Tt2`#Eu1yq5&0{ngkYw-EM^6&3|DN?5jr~*$@0aJ_GQ49Cx&epv^TtYNTmU1m|Hz`5gCF8C;3zz{<&;0s{rFtdmVOkV^<26d{yqbhI<346DN literal 0 HcmV?d00001 diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..dde8d2de630b0bb9dfcfbe13398934587043549a GIT binary patch literal 6148 zcmeHKJ5Iwu5SL%CN-Pq9RXhBBfLA>kw0I6_H5#omH~191p0!<(Irv1KDs zAp|qh>|4*rvwmKAyhOy)?Q%jiCZZfFI6A;EAmSJ8$tYRG$?+ardZbl(JDFYAy=XK1 zMh4{F?bA7}=!!PfdVkegm&;H6h^j7%MOiQ4$&YTH-(UUP=}^@28&T^!*)6+jvJWYv zOM0Mt+UQQ&`A#m{zV;ek%4Q8sZdHF)AM$tW&ZVx2F<=ZB1OJ5q^lXv*P|-$Xz!)$F z8V2P1;Glv&VNi^p4h-=K0L);Hf_eOh13EDPeZrs!3xqWlsHvQe7_8~AyAWJ-r-D|3CZs{NGKoD`UVI_)`pUR?Lbi9x3(K w-or_+b