serviceRepository->filter( ['customer_uuid' => $uuid], [ 'product', 'product.pricings' => fn ($query) => $query->where('is_active', 1)->select('id', 'product_id', 'billing_cycle'), 'orderItem', 'invoice', 'vmware.os.osGroup', ...Service::HOSTING_ACCOUNT_RELATIONS, ], ); ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware')); \App\Support\HostingCredentials::attach($services); return $services; } public function attachService(string $uuid, int $serviceId): Service { return $this->assignment->attach(Service::findOrFail($serviceId), $this->resolveCustomer($uuid)); } public function bulkDeleteServices(string $uuid, array $ids): int { $customer = $this->resolveCustomer($uuid); return DB::transaction(function () use ($customer, $ids) { $services = Service::where('customer_id', $customer->id)->whereIn('id', $ids)->get(); foreach ($services as $service) { $this->assignment->detach($service); } return $services->count(); }); } /** * Cập nhật thông tin dịch vụ của khách hàng (server_name/hostname, status, ngày,...). * Đồng bộ hostname sang module_vmware nếu service đã gắn VM. */ public function updateService(string $uuid, int $serviceId, array $data): Service { $customer = $this->resolveCustomer($uuid); $service = Service::where('customer_id', $customer->id)->where('id', $serviceId)->first(); abort_if(!$service, 404, 'Không tìm thấy dịch vụ'); return DB::transaction(function () use ($customer, $service, $data) { $vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port']; $service->fill(collect($data)->except(['price', 'billing_cycle', 'product_id', ...$vmFields])->all()); $service->save(); if (!empty($data['product_id']) && (int) $data['product_id'] !== $service->product_id) { $this->replaceProduct($service, Product::findOrFail($data['product_id'])); } if (!empty($data['virtual_machine_id'])) { $this->assignVirtualMachine($service, $customer, (int) $data['virtual_machine_id']); } $price = isset($data['price']) ? (int) $data['price'] : null; if (!$service->order_item_id && ($price !== null || !empty($data['billing_cycle']))) { $this->orderRecorder->ensureForService($service, $price, $data['billing_cycle'] ?? null); $service->refresh(); } if (!empty($data['billing_cycle']) && $service->orderItem) { $service->orderItem->update([ 'billing_cycle' => $data['billing_cycle'], 'months' => OrderHelper::cycleToMonths($data['billing_cycle']), ]); } if ($price !== null && $service->orderItem) { $service->orderItem->update(['price' => $price]); $this->orderRecorder->syncUnpaidInvoice($service->refresh(), $price); } // Đồng bộ sang module_vmware nếu có gán VM if ($service->vm_id) { $vmData = array_filter([ 'hostname' => $data['server_name'] ?? null, 'os_id' => $data['os_id'] ?? null, 'cpu' => $data['vm_cpu'] ?? null, 'ram_mb' => $data['vm_ram_mb'] ?? null, 'disk_gb' => $data['vm_disk_gb'] ?? null, 'ip' => $data['vm_ip'] ?? null, 'dns' => $data['vm_dns'] ?? null, 'user' => $data['vm_user'] ?? null, 'password' => $data['vm_password'] ?? null, 'plain_password' => $data['vm_password'] ?? null, 'port' => $data['vm_port'] ?? null, ], fn($v) => $v !== null); if ($vmData) { ModuleVmware::findOrFail($service->vm_id)->update($vmData); } } $service->load(['product', 'orderItem', 'invoice', 'vmware.os.osGroup']); ModuleVmware::resolveVirtualMachineIds([$service->vmware]); return $service; }); } private function replaceProduct(Service $service, Product $product): void { $service->update(['product_id' => $product->id, 'custom_product_id' => null, 'type' => $product->product_type]); $service->orderItem?->update(['product_id' => $product->id]); } private function assignVirtualMachine(Service $service, Customer $customer, int $virtualMachineId): void { $vm = VirtualMachines::with('datacenter')->findOrFail($virtualMachineId); $current = $service->vmware; if ($current && ($current->virtual_machine_id === $vm->id || $current->vmware_id === $vm->vm_id)) { return; } $takenBy = DB::table('module_vmware as mv') ->join('services as s', 's.vm_id', '=', 'mv.id') ->where(fn($q) => $q->where('mv.virtual_machine_id', $vm->id)->orWhere('mv.vmware_id', $vm->vm_id)) ->where('s.id', '!=', $service->id) ->value('s.id'); abort_if($takenBy, 422, "VM {$vm->name} đang gán cho dịch vụ #{$takenBy}."); if ($current) { $current->update([ 'virtual_machine_id' => $vm->id, 'vmware_id' => $vm->vm_id, 'name' => $vm->name, 'vcenter_id' => $vm->datacenter->vcenter_id ?? null, 'cpu' => $vm->cpu, 'ram_mb' => $vm->ram_mb, 'ip' => $vm->ip, ]); return; } $price = (int) ($service->orderItem->price ?? 0); $vmId = $this->createModuleVmwareIfNeeded( ['virtual_machine_id' => $vm->id, 'server_name' => $service->server_name], $customer, $service->orderItem?->order, $price, $service->expires_at ?? now(), ); ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); $service->update(['vm_id' => $vmId]); } /** * Create a service for a customer. For VPS products, accepts a * `virtual_machine_id` from the vCenter inventory and auto-creates the * matching module_vmware row so `services.vm_id` can be linked. */ public function createService(string $uuid, array $data, string $clientIp): Service { $customer = $this->resolveCustomer($uuid); $custom = $data['custom'] ?? null; $product = $custom ? null : Product::find($data['product_id']); abort_if( $product?->product_type === 'vps' && empty($data['virtual_machine_id']), 422, 'VPS service requires a virtual_machine_id', ); $billingCycle = $data['billing_cycle'] ?? 'monthly'; $months = OrderHelper::cycleToMonths($billingCycle); abort_if($custom && !in_array($months, CustomProduct::CYCLE_MONTHS, true), 422, 'Chu kỳ không hợp lệ cho sản phẩm custom.'); $listPrice = $custom ? (int) round((float) $custom['unit_price']) * $months : $this->orderRecorder->listPrice($product, $billingCycle, $data['server_name'] ?? null); abort_if( !isset($data['price']) && $listPrice === null && $product?->product_type === 'domain', 422, 'Tên miền không có giá trong bảng giá tên miền, vui lòng nhập giá.', ); $basePrice = (int) round((float) ($data['price'] ?? $listPrice)); $paymentStatus = $data['payment_status'] ?? 'paid'; $paidAmount = match ($paymentStatus) { 'unpaid' => 0, 'partial' => (int) round((float) $data['amount_paid']), default => $basePrice, }; abort_if( $paymentStatus === 'partial' && $paidAmount >= $basePrice, 422, 'Số tiền trả trước phải nhỏ hơn giá dịch vụ. Khách trả đủ thì chọn "Đã thanh toán".', ); $paymentMethod = $paymentStatus === 'unpaid' ? null : $data['payment_method']; abort_if( $paymentMethod === 'balance' && (int) $customer->total < $paidAmount, 422, sprintf( 'Số dư khách không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format((int) $customer->total), number_format($paidAmount), ), ); return DB::transaction(function () use ($customer, $product, $custom, $data, $billingCycle, $months, $basePrice, $paidAmount, $paymentStatus, $paymentMethod, $clientIp) { $customProduct = $custom ? CustomProduct::create($custom) : null; ['order' => $order, 'orderItem' => $orderItem, 'invoice' => $invoice] = $this->orderRecorder->create( $customer->id, $product, $billingCycle, $basePrice, $data['server_name'] ?? null, paid: $paymentStatus === 'paid', paymentMethod: $paymentMethod, clientIp: $clientIp, amountPaid: $paidAmount, label: $customProduct?->name, ); // Ngày bắt đầu/hết hạn dùng chung cho cả module_vmware (next_due_date/next_invoice) và services. $startedAt = $data['started_at'] ?? now(); $expiresAt = $data['expires_at'] ?? Carbon::parse($startedAt)->addMonths($months); // module_vmware.order_id phải trỏ về order vừa tạo — client console-ticket/rebuild // resolve VM qua service->orderItem->order->vmWare, không qua services.vm_id. $vmId = $this->createModuleVmwareIfNeeded($data, $customer, $order, $basePrice, $expiresAt); if ($paymentMethod === 'balance') { $customer->total = (int) $customer->total - $paidAmount; $customer->save(); } if ($paidAmount > 0) { $proofPaths = $data['_proof_paths'] ?? []; $paymentNote = $paymentMethod === 'transfer' ? 'Admin tạo dịch vụ - thanh toán chuyển khoản' : 'Admin tạo dịch vụ - trừ số dư khách hàng'; Transaction::create([ 'uuid' => (string) Str::uuid(), 'public_code' => Transaction::newPublicCode('SVC'), 'customer_id' => $customer->id, 'user_id' => Auth::id(), 'trans' => $order->order_num, 'amount' => $paidAmount, 'type' => $paymentMethod === 'transfer' ? 'transfer' : 'balance', 'status' => 1, 'note' => $paymentStatus === 'partial' ? sprintf('%s (trả trước %s/%s VNĐ)', $paymentNote, number_format($paidAmount), number_format($basePrice)) : $paymentNote, 'proof_image' => !empty($proofPaths) ? json_encode($proofPaths) : null, ]); } // 5) Service — updateOrCreate để tránh trùng khi cùng VM được gán lại $serviceLookup = $vmId ? ['customer_id' => $customer->id, 'vm_id' => $vmId] : [ 'customer_id' => $customer->id, 'product_id' => $product?->id, 'custom_product_id' => $customProduct?->id, 'vm_id' => null, 'server_name' => $data['server_name'] ?? null, ]; $replacedOrderItemId = Service::where($serviceLookup)->value('order_item_id'); $service = Service::updateOrCreate($serviceLookup, [ 'customer_id' => $customer->id, 'product_id' => $product?->id, 'custom_product_id' => $customProduct?->id, 'type' => $customProduct?->category, 'vm_id' => $vmId, 'order_item_id' => $orderItem->id, 'invoice_id' => $invoice->id, 'server_name' => $data['server_name'] ?? null, 'status' => $data['status'] ?? 'active', 'auto_renew' => (bool) ($data['auto_renew'] ?? false), 'started_at' => $startedAt, 'expires_at' => $expiresAt, 'note' => $data['note'] ?? null, ]); $this->orderRecorder->discardOrder($replacedOrderItemId); // module_vmware.service_id chỉ biết được sau khi Service đã tồn tại. if ($vmId) { ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); } return $service->load(['product', 'customProduct', 'vmware.os.osGroup']); }); } private function createModuleVmwareIfNeeded(array $data, Customer $customer, ?Order $order, int $basePrice, string|Carbon $expiresAt): ?int { if (empty($data['virtual_machine_id'])) { return null; } $vm = VirtualMachines::with('datacenter')->find($data['virtual_machine_id']); $vcenterId = $vm->datacenter->vcenter_id ?? null; $moduleVm = ModuleVmware::updateOrCreate( ['virtual_machine_id' => $vm->id], [ 'customer_id' => $customer->id, 'order_id' => $order?->id, 'name' => $vm->name, 'vmware_id' => $vm->vm_id, 'vcenter_id' => $vcenterId, 'hostname' => $data['server_name'] ?? $vm->name, 'cpu' => $data['vm_cpu'] ?? $vm->cpu, 'ram_mb' => $data['vm_ram_mb'] ?? $vm->ram_mb, 'disk_gb' => $data['vm_disk_gb'] ?? null, 'os_id' => $data['os_id'] ?? null, 'ip' => $data['vm_ip'] ?? $vm->ip, 'dns' => $data['vm_dns'] ?? null, 'user' => $data['vm_user'] ?? null, 'password' => $data['vm_password'] ?? null, 'plain_password' => $data['vm_password'] ?? null, 'port' => $data['vm_port'] ?? null, 'status' => 1, 'first_payment' => $basePrice, 'next_payment' => $basePrice, 'next_due_date' => Carbon::parse($expiresAt)->timestamp, 'next_invoice' => Carbon::parse($expiresAt)->timestamp, ], ); return $moduleVm->id; } /** * List virtual machines from vCenter inventory for the VPS picker. * Annotates each VM with `assigned_service_id` if already linked to a service. */ public function listAvailableVms(string $search, int $page = 1, int $perPage = 200): array { $assignments = DB::table('module_vmware as mv') ->join('services as s', 's.vm_id', '=', 'mv.id') ->whereNotNull('mv.virtual_machine_id') ->select('mv.virtual_machine_id', 's.id as service_id', 's.customer_id as service_customer_id') ->get() ->keyBy('virtual_machine_id'); $query = DB::table('virtual_machines as vm') ->leftJoin('module_datacenter as dc', 'dc.id', '=', 'vm.datacenter_id') ->leftJoin('module_host as h', 'h.id', '=', 'vm.host_id') ->leftJoin('module_vcenter as vc', 'vc.id', '=', 'dc.vcenter_id') ->where('vm.is_template', 0) ->where('vm.is_system', 0) ->when($search, fn($q) => $q->where(function ($w) use ($search) { $w->where('vm.name', 'like', "%{$search}%") ->orWhere('vm.vm_id', 'like', "%{$search}%") ->orWhere('vm.ip', 'like', "%{$search}%"); })); $total = (clone $query)->count(); $data = $query ->select( 'vm.id', 'vm.vm_id', 'vm.name', 'vm.power_state', 'vm.cpu', 'vm.ram_mb', 'vm.ip', 'vc.host as vcenter_name', 'dc.name as datacenter_name', 'h.name as host_name', ) ->orderBy('vm.name') ->forPage($page, $perPage) ->get() ->map(function ($vm) use ($assignments) { $a = $assignments->get($vm->id); $vm->assigned_service_id = $a->service_id ?? null; $vm->assigned_customer_id = $a->service_customer_id ?? null; return $vm; }); return ['data' => $data, 'total' => $total]; } }