get('per_page', 15), 500); $query = $this->customerRepository->filter( $request->all(), [], ['hosting', 'vps', 'domain'], ['*'], true ); $paginator = $query->paginate($perPage); return response()->json([ 'error' => false, 'data' => [ 'items' => CustomerResource::collection($paginator), 'total' => $paginator->total(), 'per_page' => $paginator->perPage(), 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), ], ]); } public function store(CreateCustomerRequest $request): JsonResponse { $existingCustomer = $this->customerRepository->findBy('email', $request->get('email')); if ($existingCustomer) { return response()->json([ 'error' => true, 'message' => 'Email already exists on the system!' ], 422); } try { DB::beginTransaction(); $data = $request->validated(); $data['password'] = Hash::make($request->password); $data['email_marketing'] = $request->email_marketing === "on" ? 1 : 0; $customer = $this->customerRepository->create($data); if ($request->exists('note')) { Customer::query() ->whereKey($customer->id) ->update(['note' => $request->input('note')]); $customer = Customer::query()->find($customer->id); } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer created successfully', 'data' => new CustomerResource($customer) ], 201); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function show(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $customer = $this->customerRepository->findWithRelationships( $customer->id, ['mail', 'file'], [ 'invoice' => [ 'invoices_status_count_1' => ['key' => 'status', 'condition' => '=', 'value' => 1], 'invoices_status_count_0' => ['key' => 'status', 'condition' => '=', 'value' => 0] ], 'hosting' => 'hosting_count', 'vps' => 'vps_count', 'domain' => 'domain_count', 'ticket' => 'ticket_count' ], [ 'invoice' => [ 'invoices_status_1' => ['key' => 'status', 'condition' => '=', 'value' => 1, 'sum' => 'total'], 'invoices_status_0' => ['key' => 'status', 'condition' => '=', 'value' => 0, 'sum' => 'total'] ] ] ); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $customer->refresh(); return response()->json([ 'error' => false, 'data' => new CustomerResource($customer) ]); } public function update(string $uuid, CreateCustomerRequest $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } try { DB::beginTransaction(); $data = $request->validated(); unset($data['password']); if (!empty($request->password)) { $data['password'] = Hash::make($request->password); } $this->customerRepository->update($customer->id, $data); if ($request->exists('note')) { Customer::query() ->whereKey($customer->id) ->update(['note' => $request->input('note')]); } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer updated successfully', 'data' => new CustomerResource(Customer::query()->find($customer->id)) ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function destroy(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } try { DB::beginTransaction(); Invoice::where('customer_id', $customer->id)->delete(); Order::where('customer_id', $customer->id)->delete(); Transaction::where('customer_id', $customer->id)->delete(); $customer->delete(); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer deleted successfully' ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function trash(Request $request): JsonResponse { $perPage = min((int) $request->get('per_page', 15), 500); $query = Customer::onlyTrashed() ->with(['hosting', 'vps', 'domain']); if ($filter = $request->get('filter')) { $query->where(function ($q) use ($filter) { $filter = trim($filter); $q->where('fullname', 'LIKE', "%{$filter}%") ->orWhere('email', 'LIKE', "%{$filter}%") ->orWhere('phone', 'LIKE', "%{$filter}%"); }); } $paginator = $query->paginate($perPage); return response()->json([ 'error' => false, 'data' => [ 'items' => CustomerResource::collection($paginator), 'total' => $paginator->total(), 'per_page' => $paginator->perPage(), 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), ], ]); } public function restore(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { $customer = $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); } if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } try { DB::beginTransaction(); $customer->restore(); Invoice::withTrashed()->where('customer_id', $customer->id)->restore(); Order::withTrashed()->where('customer_id', $customer->id)->restore(); Transaction::withTrashed()->where('customer_id', $customer->id)->restore(); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer restored successfully' ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function forceDelete(string $uuid): JsonResponse { $customer = $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } try { DB::beginTransaction(); Invoice::withTrashed()->where('customer_id', $customer->id)->forceDelete(); Order::withTrashed()->where('customer_id', $customer->id)->forceDelete(); Transaction::withTrashed()->where('customer_id', $customer->id)->forceDelete(); $customer->forceDelete(); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer permanently deleted' ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } /** * Tạo mã impersonate một lần (one-time code) để admin đăng nhập hộ khách hàng * vào giao diện portal-client. Trả về `code` (hiệu lực 120s) — portal-client * sẽ đổi code lấy token qua endpoint public `v1/client/impersonate/exchange`. */ public function loginAs(Request $request, string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } if ((int) $customer->status !== 1) { return response()->json([ 'error' => true, 'message' => 'Khách hàng không hoạt động, không thể đăng nhập hộ.', ], 422); } try { $token = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer); $refreshToken = app(\App\Services\Api\Client\ClientAuthService::class) ->createRefreshToken($customer->id); $clientResource = (new \App\Http\Resources\Api\Client\ClientResource( $customer->loadCount(['vps', 'hosting', 'domain']) ))->resolve($request); $code = Str::random(48); \Illuminate\Support\Facades\Cache::put("impersonate:{$code}", [ 'token' => $token, 'refresh_token' => $refreshToken, 'client' => $clientResource, ], 120); $admin = Auth::guard('admin')->user(); Log::create([ 'agent' => 'admin', 'client_ip' => $request->ip(), 'description' => sprintf( '%s đăng nhập hộ khách hàng %s', $admin->username ?? $admin->email ?? 'Admin', $customer->email, ), 'customer_id' => $customer->id, ]); return response()->json([ 'error' => false, 'data' => ['code' => $code], ]); } catch (\Throwable $e) { return response()->json([ 'error' => true, 'message' => $e->getMessage(), ], 500); } } public function resetPassword(Request $request, string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $password = Str::random(10); try { DB::beginTransaction(); $this->customerRepository->update($customer->id, ['password' => Hash::make($password)]); DB::commit(); Mail::to($customer->email)->send(new \App\Mail\Customer\PasswordResetByAdmin([ 'fullname' => $customer->fullname, 'email' => $customer->email, 'password' => $password, ])); return response()->json([ 'error' => false, 'message' => 'Password reset successfully, new password has been sent to customer email', ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function sendPasswordEmail(Request $request, string $uuid): JsonResponse { $request->validate([ 'password' => 'required|string|min:8|max:64', ]); $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } $password = (string) $request->input('password'); try { DB::beginTransaction(); $this->customerRepository->update($customer->id, [ 'password' => Hash::make($password), ]); $passwordMail = new \App\Mail\Customer\PasswordResetByAdmin([ 'fullname' => $customer->fullname, 'email' => $customer->email, 'password' => $password, ]); \Illuminate\Support\Facades\Mail::to($customer->email)->send($passwordMail); \App\Models\Mail::create([ 'subject' => $passwordMail->envelope()->subject, 'to' => $customer->email, 'customer_id' => $customer->id, 'message' => $passwordMail->render(), ]); Log::create([ 'agent' => 'admin', 'client_ip' => $request->ip(), 'description' => sprintf( 'Admin gửi lại mật khẩu mới cho customer %s', $customer->email, ), 'customer_id' => $customer->id, ]); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Đã gửi mật khẩu mới cho khách hàng.', ]); } catch (\Throwable $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage(), ], 500); } } public function sendEmail(Request $request, string $uuid): JsonResponse { $data = $request->validate([ 'template_id' => 'nullable|integer|exists:template,id', 'subject' => 'required_without:template_id|nullable|string|max:255', 'message' => 'required_without:template_id|nullable|string', 'to' => 'nullable|string|max:1000', 'cc' => 'nullable|string|max:1000', 'bcc' => 'nullable|string|max:1000', ]); $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } $template = null; $subject = trim((string) ($data['subject'] ?? '')); $message = trim((string) ($data['message'] ?? '')); if (!empty($data['template_id'])) { $template = Template::query() ->whereKey($data['template_id']) ->where('status', 1) ->first(); if (!$template) { return response()->json([ 'error' => true, 'message' => 'Template không tồn tại hoặc đang bị tắt.', ], 422); } $subject = trim((string) $template->subject); $message = trim((string) $template->message); } if ($subject === '' || $message === '') { return response()->json([ 'error' => true, 'message' => 'Subject và content không được để trống.', ], 422); } $to = $this->parseEmailRecipients($data['to'] ?? null); if ($to === []) { $to = [$customer->email]; } $cc = $this->parseEmailRecipients($data['cc'] ?? null); $bcc = $this->parseEmailRecipients($data['bcc'] ?? null); if ($to === []) { return response()->json([ 'error' => true, 'message' => 'Không có địa chỉ email nhận hợp lệ.', ], 422); } try { $mailer = Mail::to($to); if ($cc !== []) { $mailer->cc($cc); } if ($bcc !== []) { $mailer->bcc($bcc); } $mailer->send(new ManualCustomerEmail($subject, $message)); \App\Models\Mail::create([ 'template_id' => $template->id ?? 0, 'subject' => $subject, 'to' => implode(', ', $to), 'cc' => implode(', ', $cc), 'customer_id' => $customer->id, 'message' => $message, ]); $admin = Auth::guard('admin')->user(); Log::create([ 'agent' => 'admin', 'client_ip' => $request->ip(), 'description' => sprintf( '%s gửi email cho customer %s%s', $admin->username ?? $admin->email ?? 'Admin', $customer->email, $template ? sprintf(' bằng template #%s', $template->id) : '', ), 'customer_id' => $customer->id, ]); return response()->json([ 'error' => false, 'message' => 'Đã gửi email cho khách hàng.', ]); } catch (\Throwable $e) { return response()->json([ 'error' => true, 'message' => $e->getMessage(), ], 500); } } public function addCredit(Request $request, string $uuid): JsonResponse { $request->validate([ 'amount' => 'required|numeric|min:1', 'action' => 'required|in:+,-' ]); $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $admin = Auth::guard('admin')->user(); try { DB::beginTransaction(); $amount = (int) $request->amount; $isAdd = $request->action === "+"; if ($isAdd) { $this->customerRepository->update($customer->id, [ 'credit' => (int) $customer->credit + $amount, 'total' => (int) $customer->total + $amount, ]); } else { $this->customerRepository->update($customer->id, [ 'credit' => (int) $customer->credit - $amount, 'total' => (int) $customer->total - $amount, ]); } Transaction::create([ 'uuid' => (string) Str::uuid(), 'trans' => 'ADMIN' . now()->format('YmdHis'), 'amount' => $amount, 'customer_id' => $customer->id, 'user_id' => $admin?->id, 'status' => TransactionStatus::COMPLETED->value, 'type' => $isAdd ? 'admin_add_credit' : 'admin_deduct_credit', 'note' => sprintf( '%s %s tiền trực tiếp cho khách hàng: %s VNĐ', $admin->username ?? $admin->email ?? 'Admin', $isAdd ? 'cộng' : 'trừ', number_format($amount) ), ]); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Credit updated successfully' ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } private function parseEmailRecipients(?string $value): array { if (!$value) { return []; } return collect(preg_split('/[,;]+/', $value) ?: []) ->map(fn($email) => trim((string) $email)) ->filter(fn($email) => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) ->unique() ->values() ->all(); } public function addTotal(Request $request, string $uuid): JsonResponse { $request->validate([ 'amount' => 'required|numeric|min:1', 'note' => 'required|string|max:1000', ]); $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } $admin = Auth::guard('admin')->user(); if (!$admin) { return response()->json([ 'error' => true, 'message' => 'Unauthorized', ], 401); } try { DB::beginTransaction(); $amount = (int) $request->amount; $transaction = Transaction::create([ 'uuid' => (string) Str::uuid(), 'trans' => 'ADMIN' . now()->format('YmdHis'), 'amount' => $amount, 'customer_id' => $customer->id, 'user_id' => $admin->id, 'status' => 1, 'type' => 'admin_add_total', 'note' => $request->string('note')->trim()->toString(), ]); $customer->update([ 'total' => (int) $customer->total + $amount, ]); Log::create([ 'agent' => 'admin', 'client_ip' => $request->ip(), 'description' => sprintf( '%s cộng tiền trực tiếp cho customer %s: %s VNĐ - %s', $admin->username ?? $admin->email, $customer->email, number_format($amount), $transaction->note, ), 'customer_id' => $customer->id, ]); DB::commit(); return response()->json([ 'error' => false, 'message' => 'Total updated successfully', 'data' => [ 'transaction' => [ 'uuid' => $transaction->uuid, 'amount' => $transaction->amount, 'note' => $transaction->note, 'admin' => $admin->username ?? $admin->email, ], ], ]); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage(), ], 500); } } public function getProducts(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $hostingServices = $this->moduleHostingRepository->filter(['customer_id' => $customer->id], ['server']); $vpsServices = $this->vmwareRepository->filter( ['customer_id' => $customer->id], ['ipaddress.host.datacenter.vCenter', 'os.osGroup'] ); $allService = collect(); foreach ($hostingServices as $hosting) { $allService->push([ 'product_id' => $hosting->id, 'product_type' => 3, 'name' => $hosting->name, 'description' => $hosting->name . ' (Hosting)' ]); } foreach ($vpsServices as $vps) { $allService->push([ 'product_id' => $vps->id, 'product_type' => 4, 'name' => $vps->name, 'description' => $vps->name . ' (VPS)' ]); } return response()->json([ 'error' => false, 'data' => $allService->values() ]); } public function getInvoices(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $invoices = $this->invoiceRepository->filter( ['customer_id' => $customer->id], ['payment'] ); // Frontend đọc ngày thanh toán qua `date_paid` — alias từ cột paid_at. $invoices->each(fn($invoice) => $invoice->date_paid = $invoice->paid_at); return response()->json([ 'error' => false, 'data' => $invoices ]); } /** * Sửa hóa đơn của khách hàng, kể cả số tiền (sub_total/tax/total). Không * tự đồng bộ lại invoice_items hay customer.total. */ public function updateInvoice(string $uuid, int $invoiceId, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $invoice = Invoice::where('customer_id', $customer->id) ->where('id', $invoiceId) ->first(); if (!$invoice) { return response()->json(['error' => true, 'message' => 'Không tìm thấy hóa đơn'], 404); } $data = $request->validate([ 'invoice_number' => 'nullable|string|max:50', 'status' => 'nullable|integer|in:1,2,3,4,5', 'payment_method' => 'nullable|in:transfer,balance', 'date' => 'nullable|date', 'due_date' => 'nullable|date', 'date_paid' => 'nullable|date', 'note' => 'nullable|string', 'sub_total' => 'nullable|integer|min:0', 'tax' => 'nullable|integer|min:0', 'total' => 'nullable|integer|min:0', ]); // `date_paid` là tên field phía frontend — cột thật trong DB là paid_at. if (array_key_exists('date_paid', $data)) { $data['paid_at'] = $data['date_paid']; unset($data['date_paid']); } // Đi qua repository (không gọi $invoice->update() trực tiếp) để cache // của invoiceRepository->filter() (dùng ở getInvoices) được xoá theo, // nếu không danh sách hóa đơn sẽ hiển thị dữ liệu cũ cho tới khi cache hết hạn. $this->invoiceRepository->update($invoiceId, $data); $invoice = $invoice->refresh(); $invoice->date_paid = $invoice->paid_at; return response()->json([ 'error' => false, 'message' => 'Đã cập nhật hóa đơn', 'data' => $invoice->load('payment'), ]); } public function getTransactions(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $transactions = $this->transactionRepository->filter( ['customer_id' => $customer->id], ['user'] ); return response()->json([ 'error' => false, 'data' => $transactions ]); } /** * Sửa thông tin giao dịch, kể cả amount (để sửa số tiền nhập/hiển thị sai). * Không tự động cộng/trừ lại customer.total khi sửa amount — chiều +/- của * amount phụ thuộc vào type (balance trừ tiền, transfer/admin_add_total * cộng tiền,...) và không có field nào lưu lại chiều đó, nên tự suy luận * dễ làm sai số dư hơn là sửa đúng. Nếu cần đồng bộ lại số dư, dùng * "Cộng tiền" ở tab Tổng quan sau khi sửa xong. */ public function updateTransaction(string $uuid, int $transactionId, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $transaction = Transaction::where('customer_id', $customer->id) ->where('id', $transactionId) ->first(); if (!$transaction) { return response()->json(['error' => true, 'message' => 'Không tìm thấy giao dịch'], 404); } $data = $request->validate([ 'trans' => 'nullable|string|max:255', 'note' => 'nullable|string', 'type' => 'nullable|string|max:50', 'amount' => 'nullable|integer', ]); // Đi qua repository để cache của transactionRepository->filter() // (dùng ở getTransactions) được xoá theo — xem ghi chú ở updateInvoice. $this->transactionRepository->update($transactionId, $data); return response()->json([ 'error' => false, 'message' => 'Đã cập nhật giao dịch', 'data' => $transaction->refresh()->load('user'), ]); } public function getDomains(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $domains = $this->moduleDomainRepository->filter( ['customer_id' => $customer->id], ['customer'] ); return response()->json([ 'error' => false, 'data' => $domains ]); } public function getDnsDomains(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } $domains = \App\Models\DnsDomain::query() ->with('server:id,name,ip_address') ->withCount(['ns', 'record']) ->whereHas('customer', fn ($q) => $q->where('customers.id', $customer->id)) ->orderByDesc('id') ->get() ->map(function ($d) { return [ 'id' => $d->id, 'domain_name' => $d->domain_name, 'is_created' => (int) $d->is_created, 'plesk_id' => $d->plesk_id, 'ns_count' => (int) ($d->ns_count ?? 0), 'record_count' => (int) ($d->record_count ?? 0), 'server' => $d->server ? [ 'id' => $d->server->id, 'name' => $d->server->name, 'ip_address' => $d->server->ip_address, ] : null, 'created_at' => $d->created_at, 'updated_at' => $d->updated_at, ]; }); return response()->json([ 'error' => false, 'data' => $domains, ]); } public function getDnsDomainRecords(string $uuid, int $domainId): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid, ], 404); } $owns = \Illuminate\Support\Facades\DB::table('dns_domain_user') ->where('domain_id', $domainId) ->where('user_id', $customer->id) ->exists(); if (!$owns) { return response()->json([ 'error' => true, 'message' => 'Domain không thuộc về khách hàng này', ], 403); } $records = \App\Models\DnsRecord::query() ->where('dns_manager_domain_id', $domainId) ->orderBy('type') ->orderBy('name') ->get(); return response()->json([ 'error' => false, 'data' => $records, ]); } public function getServices(string $uuid, Request $request): JsonResponse { $services = $this->serviceRepository->filter( ['customer_uuid' => $uuid], ['product', 'orderItem', 'invoice', 'vmware.os.osGroup'] ); return response()->json([ 'error' => false, 'data' => $services ]); } /** * Xoá nhiều dịch vụ của khách hàng. Chỉ xoá bản ghi trong bảng `services`, * không xoá VM (module_vmware / virtual_machines) liên kết. */ public function bulkDeleteServices(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $data = $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'integer', ]); $deleted = \App\Models\Service::where('customer_id', $customer->id) ->whereIn('id', $data['ids']) ->delete(); return response()->json([ 'error' => false, 'message' => 'Đã xoá ' . $deleted . ' dịch vụ', 'data' => ['deleted' => $deleted], ]); } /** * 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, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $service = \App\Models\Service::where('customer_id', $customer->id) ->where('id', $serviceId) ->first(); if (!$service) { return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404); } $data = $request->validate([ 'server_name' => 'nullable|string|max:255', 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', 'auto_renew' => 'nullable|boolean', 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', 'price' => 'nullable|numeric|min:0', // module_vmware fields 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', 'vm_ram_mb' => 'nullable|integer|min:1', 'vm_disk_gb' => 'nullable|integer|min:1', 'vm_ip' => 'nullable|string|max:45', 'vm_dns' => 'nullable|string|max:100', 'vm_user' => 'nullable|string|max:255', 'vm_password' => 'nullable|string|max:255', 'vm_port' => 'nullable|integer|min:1|max:65535', ]); try { DB::beginTransaction(); $service->fill(collect($data)->except([ 'price', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port', ])->all()); $service->save(); if (array_key_exists('price', $data) && $data['price'] !== null && $service->orderItem) { $service->orderItem->update(['price' => $data['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) { \App\Models\ModuleVmware::where('id', $service->vm_id)->update($vmData); } } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Cập nhật dịch vụ thành công', 'data' => $service->load(['product', 'orderItem', 'invoice', 'vmware.os.osGroup']), ]); } catch (\Throwable $e) { DB::rollBack(); return response()->json(['error' => true, 'message' => $e->getMessage()], 500); } } /** * Create a service for a customer. For VPS products, accepts a * `virtual_machine_id` from the vCenter inventory and auto-creates the * matching module_vmware row so `services.vm_id` can be linked. */ public function createService(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $data = $request->validate([ 'product_id' => 'required|integer|exists:products,id', 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'server_name' => 'nullable|string|max:255', 'status' => 'nullable|in:pending,active,suspended,expired,cancelled,terminated', 'auto_renew' => 'nullable|boolean', 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', // New: billing & payment 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', 'payment_method' => 'required|in:transfer,balance', 'amount_paid' => 'nullable|numeric|min:0', 'proof_images' => 'nullable|array', 'proof_images.*' => 'file|image|max:5120', // module_vmware fields — admin can override the vCenter snapshot / fill in what's not tracked there (disk, OS, credentials, port) 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', 'vm_ram_mb' => 'nullable|integer|min:1', 'vm_disk_gb' => 'nullable|integer|min:1', 'vm_ip' => 'nullable|string|max:45', 'vm_dns' => 'nullable|string|max:100', 'vm_user' => 'nullable|string|max:255', 'vm_password' => 'nullable|string|max:255', 'vm_port' => 'nullable|integer|min:1|max:65535', ]); $product = \App\Models\Product::with('pricings')->find($data['product_id']); if ($product->product_type === 'vps' && empty($data['virtual_machine_id'])) { return response()->json([ 'error' => true, 'message' => 'VPS service requires a virtual_machine_id', ], 422); } $billingCycle = $data['billing_cycle'] ?? 'monthly'; $months = $this->cycleToMonths($billingCycle); $pricing = $product->pricings ->where('billing_cycle', $billingCycle) ->first(); // Số tiền đã thanh toán: admin nhập tay để khớp với thực tế (có thể lệch giá niêm yết do khuyến mãi/thoả thuận) $basePrice = isset($data['amount_paid']) ? (int) $data['amount_paid'] : (int) ($pricing->price ?? 0); $paymentMethod = $data['payment_method']; // Đối với balance: kiểm tra số dư trước khi vào transaction if ($paymentMethod === 'balance' && (int) $customer->total < $basePrice) { return response()->json([ 'error' => true, 'message' => sprintf( 'Số dư khách không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format((int) $customer->total), number_format($basePrice) ), ], 422); } // Upload proof images (chuyển khoản) $proofPaths = []; if ($paymentMethod === 'transfer' && $request->hasFile('proof_images')) { foreach ($request->file('proof_images') as $file) { $proofPaths[] = $file->store('payment-proofs', 'public'); } } try { DB::beginTransaction(); // 1) Order + OrderItem (mô phỏng flow customer đặt hàng) $order = Order::create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $customer->id, 'date' => now()->toDateString(), 'sub_total' => $basePrice, 'discount_amount' => 0, 'total' => $basePrice, 'status' => OrderStatus::ACTIVE->value, 'payment_method' => $paymentMethod, 'client_ip' => $request->ip(), ]); $orderItem = \App\Models\OrderItem::create([ 'order_id' => $order->id, 'product_id' => $product->id, 'billing_cycle' => $billingCycle, 'months' => $months, 'server_name' => $data['server_name'] ?? null, 'quantity' => 1, 'price' => $basePrice, ]); // 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\Carbon::parse($startedAt)->addMonths($months); // 2) ModuleVmware: snapshot từ virtual_machines. `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 = null; if (!empty($data['virtual_machine_id'])) { $vm = \App\Models\VirtualMachines::with('datacenter') ->find($data['virtual_machine_id']); $vcenterId = $vm->datacenter->vcenter_id ?? null; $moduleVm = \App\Models\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\Carbon::parse($expiresAt)->timestamp, 'next_invoice' => \Carbon\Carbon::parse($expiresAt)->timestamp, ] ); $vmId = $moduleVm->id; } // 3) Invoice — đánh dấu PAID luôn vì admin đứng ra tạo & xác nhận thanh toán $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $customer->id, 'order_id' => $order->id, 'invoice_type' => 1, 'date' => now()->toDateString(), 'sub_total' => $basePrice, 'discount_amount' => 0, 'tax' => 0, 'total' => $basePrice, 'status' => InvoiceStatus::PAID->value, 'payment_method' => $paymentMethod, 'paid_at' => now(), 'due_date' => now()->addMonths($months)->toDateString(), ]); \App\Models\InvoiceItem::create([ 'invoice_id' => $invoice->id, 'product_id' => $product->id, 'type' => 'service', 'description' => $product->name ?? 'Dịch vụ', 'months' => $months, 'quantity' => 1, 'unit_price' => $basePrice, 'total_price' => $basePrice, ]); // 4) Payment processing if ($paymentMethod === 'balance') { $customer->total = (int) $customer->total - $basePrice; $customer->save(); } Transaction::create([ 'uuid' => (string) Str::uuid(), 'customer_id' => $customer->id, 'user_id' => Auth::id(), 'trans' => $order->order_num, 'amount' => $basePrice, 'type' => $paymentMethod === 'transfer' ? 'transfer' : 'balance', 'status' => 1, // paid 'note' => $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', '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, 'vm_id' => null, 'server_name' => $data['server_name'] ?? null, ]; $service = \App\Models\Service::updateOrCreate( $serviceLookup, [ 'customer_id' => $customer->id, 'product_id' => $product->id, '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, ] ); // module_vmware.service_id chỉ biết được sau khi Service đã tồn tại. if ($vmId) { \App\Models\ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Service created', 'data' => $service->load(['product', 'vmware.os.osGroup']), ]); } catch (\Throwable $e) { DB::rollBack(); return response()->json(['error' => true, 'message' => $e->getMessage()], 500); } } /** * List virtual machines from vCenter inventory for the VPS picker. * Annotates each VM with `assigned_service_id` if already linked to a service. */ public function listAvailableVms(Request $request): JsonResponse { $search = trim((string) $request->get('q', '')); $assignments = DB::table('module_vmware as mv') ->join('services as s', 's.vm_id', '=', 'mv.id') ->whereNotNull('mv.virtual_machine_id') ->select( 'mv.virtual_machine_id', 's.id as service_id', 's.customer_id as service_customer_id', ) ->get() ->keyBy('virtual_machine_id'); $vms = DB::table('virtual_machines 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') ->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', ) ->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}%"); })) ->orderBy('vm.name') ->limit(200) ->get() ->map(function ($vm) use ($assignments) { $a = $assignments->get($vm->id); $vm->assigned_service_id = $a->service_id ?? null; $vm->assigned_customer_id = $a->service_customer_id ?? null; return $vm; }); return response()->json(['error' => false, 'data' => $vms]); } public function getOrders(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json([ 'error' => true, 'message' => "Can't find customer by UUID #" . $uuid ], 404); } $orders = $this->orderRepository->filter( ['client' => $customer->id], ['customer', 'payment', 'invoice'] ); return response()->json([ 'error' => false, 'data' => $orders ]); } /** * Sửa đơn hàng của khách hàng, kể cả số tiền (sub_total/discount_amount/ * total). Không tự đồng bộ lại invoice/invoice_item hay customer.total — * nếu số tiền hóa đơn liên kết cũng cần sửa, sửa riêng ở updateInvoice. */ public function updateOrder(string $uuid, int $orderId, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $order = Order::where('customer_id', $customer->id) ->where('id', $orderId) ->first(); if (!$order) { return response()->json(['error' => true, 'message' => 'Không tìm thấy đơn hàng'], 404); } $data = $request->validate([ 'order_num' => 'nullable|string|max:255', 'status' => 'nullable|integer|in:0,1,2,3', 'payment_method' => 'nullable|in:transfer,balance', 'date' => 'nullable|date', 'sub_total' => 'nullable|integer|min:0', 'discount_amount' => 'nullable|integer|min:0', 'total' => 'nullable|integer|min:0', ]); // Đi qua repository để cache của orderRepository->filter() // (dùng ở getOrders) được xoá theo — xem ghi chú ở updateInvoice. $this->orderRepository->update($orderId, $data); return response()->json([ 'error' => false, 'message' => 'Đã cập nhật đơn hàng', 'data' => $order->refresh()->load(['payment', 'invoice']), ]); } public function updateStatus(string $uuid, Request $request): JsonResponse { $request->validate(['status' => ['required', 'integer', 'in:0,1,2,3']]); $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer by UUID #" . $uuid], 404); } $customer->status = $request->input('status'); $customer->save(); return response()->json(['error' => false, 'message' => 'Cập nhật trạng thái thành công']); } public function bulkDelete(Request $request): JsonResponse { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); $uuids = $request->input('uuids'); $customers = Customer::whereIn('uuid', $uuids)->get(); try { DB::beginTransaction(); foreach ($customers as $c) { Invoice::where('customer_id', $c->id)->delete(); Order::where('customer_id', $c->id)->delete(); Transaction::where('customer_id', $c->id)->delete(); $c->delete(); } DB::commit(); return response()->json(['error' => false, 'message' => "Đã xoá {$customers->count()} khách hàng"]); } catch (\Exception $e) { DB::rollBack(); return response()->json(['error' => true, 'message' => $e->getMessage()], 500); } } public function bulkRestore(Request $request): JsonResponse { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); $customers = Customer::onlyTrashed()->whereIn('uuid', $request->input('uuids'))->get(); try { DB::beginTransaction(); foreach ($customers as $c) { $c->restore(); Invoice::withTrashed()->where('customer_id', $c->id)->restore(); Order::withTrashed()->where('customer_id', $c->id)->restore(); Transaction::withTrashed()->where('customer_id', $c->id)->restore(); } DB::commit(); return response()->json(['error' => false, 'message' => "Đã khôi phục {$customers->count()} khách hàng"]); } catch (\Exception $e) { DB::rollBack(); return response()->json(['error' => true, 'message' => $e->getMessage()], 500); } } public function bulkForceDelete(Request $request): JsonResponse { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); $customers = Customer::onlyTrashed()->whereIn('uuid', $request->input('uuids'))->get(); try { DB::beginTransaction(); foreach ($customers as $c) { Invoice::withTrashed()->where('customer_id', $c->id)->forceDelete(); Order::withTrashed()->where('customer_id', $c->id)->forceDelete(); Transaction::withTrashed()->where('customer_id', $c->id)->forceDelete(); $c->forceDelete(); } DB::commit(); return response()->json(['error' => false, 'message' => "Đã xoá vĩnh viễn {$customers->count()} khách hàng"]); } catch (\Exception $e) { DB::rollBack(); return response()->json(['error' => true, 'message' => $e->getMessage()], 500); } } public function bulkStatus(Request $request): JsonResponse { $request->validate([ 'uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string'], 'status' => ['required', 'integer', 'in:0,1,2,3'], ]); $count = Customer::whereIn('uuid', $request->input('uuids')) ->update(['status' => $request->input('status')]); return response()->json(['error' => false, 'message' => "Đã cập nhật trạng thái {$count} khách hàng"]); } /** * Ghi chú của khách hàng (không gắn với dịch vụ cụ thể). */ public function getCustomerNotes(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $notes = Note::with('user:id,username') ->where('customer_id', $customer->id) ->whereNull('service_id') ->latest() ->get(); return response()->json(['error' => false, 'data' => $notes]); } public function storeCustomerNote(string $uuid, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $data = $request->validate(['note' => 'required|string']); $note = Note::create([ 'note' => $data['note'], 'customer_id' => $customer->id, 'user_id' => Auth::id(), ]); return response()->json([ 'error' => false, 'message' => 'Đã thêm ghi chú', 'data' => $note->load('user:id,username'), ]); } public function destroyCustomerNote(string $uuid, int $noteId): JsonResponse { return $this->destroyNote($uuid, $noteId); } /** * Ghi chú gắn với 1 dịch vụ cụ thể của khách hàng. */ public function getServiceNotes(string $uuid, int $serviceId): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $notes = Note::with('user:id,username') ->where('customer_id', $customer->id) ->where('service_id', $serviceId) ->latest() ->get(); return response()->json(['error' => false, 'data' => $notes]); } public function storeServiceNote(string $uuid, int $serviceId, Request $request): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $data = $request->validate(['note' => 'required|string']); $note = Note::create([ 'note' => $data['note'], 'customer_id' => $customer->id, 'service_id' => $serviceId, 'user_id' => Auth::id(), ]); return response()->json([ 'error' => false, 'message' => 'Đã thêm ghi chú', 'data' => $note->load('user:id,username'), ]); } public function destroyServiceNote(string $uuid, int $serviceId, int $noteId): JsonResponse { return $this->destroyNote($uuid, $noteId); } private function destroyNote(string $uuid, int $noteId): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); if (!$customer) { return response()->json(['error' => true, 'message' => "Can't find customer"], 404); } $deleted = Note::where('id', $noteId)->where('customer_id', $customer->id)->delete(); if (!$deleted) { return response()->json(['error' => true, 'message' => 'Không tìm thấy ghi chú'], 404); } return response()->json(['error' => false, 'message' => 'Đã xoá ghi chú']); } private function cycleToMonths(?string $cycle): int { return match ($cycle) { 'monthly' => 1, 'quarterly' => 3, 'semi_annually' => 6, 'annually' => 12, 'biennially' => 24, 'triennially' => 36, default => 1, }; } }