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 @@ + + +
+ + +{{ $companyAddress }}
+ @endif + @if(isset($companyPhone)) +Tel: {{ $companyPhone }}
+ @endif + @if(isset($companyEmail)) +Email: {{ $companyEmail }}
+ @endif +{{ $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 +Mã đơn hàng: #{{ $order->order_num ?? '—' }}
+ @if(isset($order) && $order->created_at) +Ngày đặt: {{ $order->created_at->format('d/m/Y') }}
+ @endif +| # | +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, ',', '.') }} đ | +
| 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, ',', '.') }} đ | +