diff --git a/.DS_Store b/.DS_Store index c089f48..e09b6d6 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3200eff..41b32d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,16 +1,19 @@ -name: Deploy Portal API +name: Deploy Portal on: push: - branches: [ develop ] + branches: + - main workflow_dispatch: jobs: deploy: + name: Deploy to Production runs-on: ubuntu-latest + steps: - - name: Deploy to server - uses: appleboy/ssh-action@v1.0.3 + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} @@ -19,20 +22,18 @@ jobs: script_stop: true script: | set -e - - echo "📁 Go to project path" cd ${{ secrets.PROJECT_PATH }} - echo "🔄 Pulling latest code..." - git pull origin develop + echo "Pulling latest code..." + git pull origin main - echo "🐳 Rebuilding Docker container..." - docker compose up -d --build --remove-orphans portal + echo "Installing dependencies..." + composer install --no-dev --optimize-autoloader --no-interaction --no-progress - echo "⚡ Running Laravel optimizations..." - docker compose exec -T portal php artisan migrate --force - docker compose exec -T portal php artisan config:cache - docker compose exec -T portal php artisan route:cache - docker compose exec -T portal php artisan optimize + echo "Running migrations..." + php artisan migrate --force - echo "✅ Deployment completed!" \ No newline at end of file + echo "Optimizing..." + php artisan optimize + + echo "Deploy completed." \ No newline at end of file diff --git a/app/.DS_Store b/app/.DS_Store index b352716..d5c3a09 100644 Binary files a/app/.DS_Store and b/app/.DS_Store differ diff --git a/app/Enums/.DS_Store b/app/Enums/.DS_Store new file mode 100644 index 0000000..4b5683b Binary files /dev/null and b/app/Enums/.DS_Store differ diff --git a/app/Http/.DS_Store b/app/Http/.DS_Store index ade8750..e339e69 100644 Binary files a/app/Http/.DS_Store and b/app/Http/.DS_Store differ 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 0000000..6c6d725 Binary files /dev/null and b/app/Jobs/.DS_Store differ 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 0000000..b8fa2db Binary files /dev/null and b/app/Models/.DS_Store differ diff --git a/app/Models/Product.php b/app/Models/Product.php index a62223b..257d62d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -33,6 +33,7 @@ class Product extends Model 'is_active', 'sort_order', 'product_categories_id', + 'module_ip_address', 'published_at', ]; @@ -130,6 +131,11 @@ class Product extends Model * Relationship: Product has many addons (through product type) * Addons are linked via type in product_addon table */ + public function ipAddress() + { + return $this->belongsTo(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 0000000..32e84d3 Binary files /dev/null and b/app/Repositories/.DS_Store differ diff --git a/app/Services/.DS_Store b/app/Services/.DS_Store new file mode 100644 index 0000000..8940d66 Binary files /dev/null and b/app/Services/.DS_Store differ diff --git a/app/Services/InvoicePdfService.php b/app/Services/InvoicePdfService.php new file mode 100644 index 0000000..b80d90b --- /dev/null +++ b/app/Services/InvoicePdfService.php @@ -0,0 +1,39 @@ +load(['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 0000000..e698cb6 Binary files /dev/null and b/app/Support/.DS_Store differ diff --git a/app/View/.DS_Store b/app/View/.DS_Store new file mode 100644 index 0000000..4d3270d Binary files /dev/null and b/app/View/.DS_Store differ 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 5008ddf..66a4dbb 100644 Binary files a/database/.DS_Store and b/database/.DS_Store differ 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 0000000..6115e12 Binary files /dev/null and b/lang/.DS_Store differ diff --git a/public/.DS_Store b/public/.DS_Store index ea3ddae..1a9f62e 100644 Binary files a/public/.DS_Store and b/public/.DS_Store differ diff --git a/public/file/logo_primary.png b/public/file/logo_primary.png new file mode 100644 index 0000000..7e758e3 Binary files /dev/null and b/public/file/logo_primary.png differ diff --git a/public/fonts/.DS_Store b/public/fonts/.DS_Store new file mode 100644 index 0000000..0f65c47 Binary files /dev/null and b/public/fonts/.DS_Store differ diff --git a/public/js/.DS_Store b/public/js/.DS_Store new file mode 100644 index 0000000..72ec26c Binary files /dev/null and b/public/js/.DS_Store differ diff --git a/public/lib/.DS_Store b/public/lib/.DS_Store new file mode 100644 index 0000000..30ff466 Binary files /dev/null and b/public/lib/.DS_Store differ diff --git a/public/uploads/.DS_Store b/public/uploads/.DS_Store new file mode 100644 index 0000000..4d5dcdf Binary files /dev/null and b/public/uploads/.DS_Store differ diff --git a/public/vendor/.DS_Store b/public/vendor/.DS_Store new file mode 100644 index 0000000..48b3072 Binary files /dev/null and b/public/vendor/.DS_Store differ diff --git a/resources/.DS_Store b/resources/.DS_Store index 0a76604..a1e376c 100644 Binary files a/resources/.DS_Store and b/resources/.DS_Store differ diff --git a/resources/views/.DS_Store b/resources/views/.DS_Store index 5008ddf..53aaaae 100644 Binary files a/resources/views/.DS_Store and b/resources/views/.DS_Store differ 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 0000000..ada9623 Binary files /dev/null and b/storage/.DS_Store differ diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000..dde8d2d Binary files /dev/null and b/tests/.DS_Store differ