diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 363d0cb..4aba957 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -49,7 +49,9 @@ "Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' portal/index.html)", "Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' index.html)", "Bash(xargs -I{} basename {})", - "Bash(uniq -c -w7)" + "Bash(uniq -c -w7)", + "Bash(xargs -I{} echo {})", + "Bash(xargs -I{} sh -c 'echo ==={}===; head -30 {}')" ] } } diff --git a/app/Enums/Product/ProductTypeEnums.php b/app/Enums/Product/ProductTypeEnums.php index 6d8292b..0a97c69 100644 --- a/app/Enums/Product/ProductTypeEnums.php +++ b/app/Enums/Product/ProductTypeEnums.php @@ -12,6 +12,9 @@ enum ProductTypeEnums: string case DOMAIN = 'domain'; case WEB_DESIGN = 'web design'; case ADDON = 'addon'; + case HARDWARE = 'hardware'; + case IT_SUPPORT = 'it_support'; + case SERVER_RENTAL = 'server_rental'; public function label(): string { @@ -24,6 +27,9 @@ enum ProductTypeEnums: string self::DOMAIN => 'Domain', self::WEB_DESIGN => 'Web Design', self::ADDON => 'Addon', + self::HARDWARE => 'Hardware', + self::IT_SUPPORT => 'IT Support', + self::SERVER_RENTAL => 'Thuê máy chủ', }; } @@ -38,6 +44,9 @@ enum ProductTypeEnums: string self::DOMAIN => 'purple', self::WEB_DESIGN => 'pink', self::ADDON => 'orange', + self::HARDWARE => 'volcano', + self::IT_SUPPORT => 'geekblue', + self::SERVER_RENTAL => 'magenta', }; } } \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 0ac1687..b88c895 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -5,122 +5,65 @@ namespace App\Http\Controllers\Api\Admin\Customer; use App\Http\Controllers\Controller; use App\Http\Requests\CreateCustomerRequest; use App\Http\Resources\Api\Admin\CustomerResource; -use App\Mail\Customer\ManualCustomerEmail; use App\Models\Customer; -use App\Models\Invoice; -use App\Models\Note; -use App\Models\Order; -use App\Models\Template; -use App\Models\Transaction; -use App\Models\Log; +use App\Models\DnsDomain; +use App\Models\DnsRecord; +use App\Models\Service; use App\Repositories\Customer\CustomerRepositoryInterface; -use App\Repositories\Invoice\InvoiceRepositoryInterface; -use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Module\Domain\DomainRepositoryInterface; -use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface; -use App\Repositories\Module\Vmware\VmwareRepositoryInterface; -use App\Repositories\Order\OrderRepositoryInterface; -use App\Repositories\Service\ServiceRepositoryInterface; -use App\Repositories\Transaction\TransactionRepositoryInterface; +use App\Services\Customer\Concerns\ResolvesCustomer; +use App\Services\Customer\CustomerAccountService; +use App\Services\Customer\CustomerBillingService; +use App\Services\Customer\CustomerImpersonationService; +use App\Services\Customer\CustomerMailService; +use App\Services\Customer\CustomerNoteService; +use App\Services\Customer\CustomerServiceProvisioningService; +use App\Services\Customer\CustomerWalletService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Str; -use App\Enums\Invoice\InvoiceStatus; -use App\Enums\Order\OrderStatus; -use App\Enums\TransactionStatus; -use App\Helpers\OrderHelper; +use Illuminate\Support\Facades\DB; class CustomerController extends Controller { + use ResolvesCustomer; + public function __construct( private readonly CustomerRepositoryInterface $customerRepository, - private readonly InvoiceRepositoryInterface $invoiceRepository, - private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, - private readonly ModuleHostingRepositoryInterface $moduleHostingRepository, - private readonly VmwareRepositoryInterface $vmwareRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly TransactionRepositoryInterface $transactionRepository, private readonly DomainRepositoryInterface $moduleDomainRepository, - private readonly ServiceRepositoryInterface $serviceRepository, + private readonly CustomerAccountService $accountService, + private readonly CustomerWalletService $walletService, + private readonly CustomerMailService $mailService, + private readonly CustomerImpersonationService $impersonationService, + private readonly CustomerBillingService $billingService, + private readonly CustomerServiceProvisioningService $serviceProvisioning, + private readonly CustomerNoteService $noteService, ) {} public function index(Request $request): JsonResponse { - $perPage = min((int) $request->get('per_page', 15), 500); - $query = $this->customerRepository->filter( - $request->all(), - [], - ['hosting', 'vps', 'domain'], - ['*'], - true - ); - $paginator = $query->paginate($perPage); + $perPage = min((int) $request->get('per_page', 15), 500); + $paginator = $this->customerRepository + ->filter($request->all(), [], ['hosting', 'vps', 'domain'], ['*'], true) + ->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(), - ], - ]); + return $this->paginatedResponse($paginator); } 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); - } + $customer = $this->accountService->create($request->validated()); - 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); - } + return response()->json([ + 'error' => false, + 'message' => 'Customer created successfully', + 'data' => new CustomerResource($customer), + ], 201); } 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->resolveCustomer($uuid); $customer = $this->customerRepository->findWithRelationships( $customer->id, @@ -128,371 +71,106 @@ class CustomerController extends Controller [ 'invoice' => [ 'invoices_status_count_1' => ['key' => 'status', 'condition' => '=', 'value' => 1], - 'invoices_status_count_0' => ['key' => 'status', 'condition' => '=', 'value' => 0] + 'invoices_status_count_0' => ['key' => 'status', 'condition' => '=', 'value' => 0], ], 'hosting' => 'hosting_count', 'vps' => 'vps_count', 'domain' => 'domain_count', - 'ticket' => 'ticket_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'] - ] - ] + '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); - } - + abort_if(!$customer, 404, "Can't find customer by UUID #{$uuid}"); $customer->refresh(); - return response()->json([ - 'error' => false, - 'data' => new CustomerResource($customer) - ]); + 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); + $data = $request->validated(); + if (!empty($request->password)) { + $data['password_raw'] = $request->password; } - try { - DB::beginTransaction(); - $data = $request->validated(); - unset($data['password']); + $customer = $this->accountService->update($uuid, $data); - 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); - } + return response()->json([ + 'error' => false, + 'message' => 'Customer updated successfully', + 'data' => new CustomerResource($customer), + ]); } public function destroy(string $uuid): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); + $this->accountService->destroy($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); - } + return response()->json(['error' => false, 'message' => 'Customer deleted successfully']); } public function trash(Request $request): JsonResponse { $perPage = min((int) $request->get('per_page', 15), 500); - $query = Customer::onlyTrashed() - ->with(['hosting', 'vps', 'domain']); + $query = Customer::onlyTrashed()->with(['hosting', 'vps', 'domain']); if ($filter = $request->get('filter')) { + $filter = trim($filter); $query->where(function ($q) use ($filter) { - $filter = trim($filter); $q->where('fullname', 'LIKE', "%{$filter}%") - ->orWhere('email', 'LIKE', "%{$filter}%") - ->orWhere('phone', '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(), - ], - ]); + return $this->paginatedResponse($query->paginate($perPage)); } public function restore(string $uuid): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); - if (!$customer) { - $customer = $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); - } + $this->accountService->restore($uuid); - 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); - } + return response()->json(['error' => false, 'message' => 'Customer restored successfully']); } public function forceDelete(string $uuid): JsonResponse { - $customer = $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); + $this->accountService->forceDelete($uuid); - 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); - } + return response()->json(['error' => false, 'message' => 'Customer permanently deleted']); } - /** - * 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); + $code = $this->impersonationService->createLoginCode($uuid, Auth::guard('admin')->user(), $request); - 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); - } + return response()->json(['error' => false, 'data' => ['code' => $code]]); } - public function resetPassword(Request $request, string $uuid): JsonResponse + public function resetPassword(string $uuid): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); + $this->mailService->resetPassword($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); - } + return response()->json([ + 'error' => false, + 'message' => 'Password reset successfully, new password has been sent to customer email', + ]); } public function sendPasswordEmail(Request $request, string $uuid): JsonResponse { - $request->validate([ - 'password' => 'required|string|min:8|max:64', - ]); + $data = $request->validate(['password' => 'required|string|min:8|max:64']); - $customer = $this->customerRepository->findBy('uuid', $uuid); + $this->mailService->sendPasswordEmail($uuid, $data['password'], $request->ip()); - 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); - } + return response()->json(['error' => false, 'message' => 'Đã gửi mật khẩu mới cho khách hàng.']); } public function sendEmail(Request $request, string $uuid): JsonResponse @@ -506,1033 +184,297 @@ class CustomerController extends Controller 'bcc' => 'nullable|string|max:1000', ]); - $customer = $this->customerRepository->findBy('uuid', $uuid); + $this->mailService->sendCustomEmail($uuid, $data, Auth::guard('admin')->user(), $request->ip()); - 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); - } + return response()->json(['error' => false, 'message' => 'Đã gửi email cho khách hàng.']); } public function addCredit(Request $request, string $uuid): JsonResponse { - $request->validate([ - 'amount' => 'required|numeric|min:1', - 'action' => 'required|in:+,-' - ]); + $request->validate(['amount' => 'required|numeric|min:1', 'action' => 'required|in:+,-']); - $customer = $this->customerRepository->findBy('uuid', $uuid); + $this->walletService->adjustCredit( + $uuid, + Auth::guard('admin')->user(), + (int) $request->amount, + $request->action === '+', + ); - 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(); + return response()->json(['error' => false, 'message' => 'Credit updated successfully']); } 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); - } + $data = $request->validate(['amount' => 'required|numeric|min:1', 'note' => 'required|string|max:1000']); $admin = Auth::guard('admin')->user(); - if (!$admin) { - return response()->json([ - 'error' => true, - 'message' => 'Unauthorized', - ], 401); - } + abort_if(!$admin, 401, 'Unauthorized'); - try { - DB::beginTransaction(); + $transaction = $this->walletService->addTotal($uuid, $admin, (int) $data['amount'], trim($data['note']), $request->ip()); - $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, - ], + 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() - ]); + return response()->json(['error' => false, 'data' => $this->serviceProvisioning->getProducts($uuid)]); } - public function getInvoices(string $uuid, Request $request): JsonResponse + public function getInvoices(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); - } - - $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 - ]); + return response()->json(['error' => false, 'data' => $this->billingService->getInvoices($uuid)]); } - /** - * 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', + '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' => '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']); - } + $invoice = $this->billingService->updateInvoice($uuid, $invoiceId, $data); - // Đ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'), - ]); + return response()->json(['error' => false, 'message' => 'Đã cập nhật hóa đơn', 'data' => $invoice]); } - public function getTransactions(string $uuid, Request $request): JsonResponse + public function getTransactions(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); - } - - $transactions = $this->transactionRepository->filter( - ['customer_id' => $customer->id], - ['user'] - ); - - return response()->json([ - 'error' => false, - 'data' => $transactions - ]); + return response()->json(['error' => false, 'data' => $this->billingService->getTransactions($uuid)]); } - /** - * 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', + '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); + $transaction = $this->billingService->updateTransaction($uuid, $transactionId, $data); - return response()->json([ - 'error' => false, - 'message' => 'Đã cập nhật giao dịch', - 'data' => $transaction->refresh()->load('user'), - ]); + return response()->json(['error' => false, 'message' => 'Đã cập nhật giao dịch', 'data' => $transaction]); } - public function getDomains(string $uuid, Request $request): JsonResponse + public function getDomains(string $uuid): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); + $customer = $this->resolveCustomer($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']); - $domains = $this->moduleDomainRepository->filter( - ['customer_id' => $customer->id], - ['customer'] - ); - - return response()->json([ - 'error' => false, - 'data' => $domains - ]); + return response()->json(['error' => false, 'data' => $domains]); } public function getDnsDomains(string $uuid): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); + $customer = $this->resolveCustomer($uuid); - if (!$customer) { - return response()->json([ - 'error' => true, - 'message' => "Can't find customer by UUID #" . $uuid, - ], 404); - } - - $domains = \App\Models\DnsDomain::query() + $domains = DnsDomain::query() ->with('server:id,name,ip_address') ->withCount(['ns', 'record']) - ->whereHas('customer', fn ($q) => $q->where('customers.id', $customer->id)) + ->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, - ]; - }); + ->map(fn($d) => [ + '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, - ]); + return response()->json(['error' => false, 'data' => $domains]); } public function getDnsDomainRecords(string $uuid, int $domainId): JsonResponse { - $customer = $this->customerRepository->findBy('uuid', $uuid); + $customer = $this->resolveCustomer($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') + $owns = 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); - } + abort_if(!$owns, 403, 'Domain không thuộc về khách hàng này'); - $records = \App\Models\DnsRecord::query() + $records = DnsRecord::query() ->where('dns_manager_domain_id', $domainId) ->orderBy('type') ->orderBy('name') ->get(); - return response()->json([ - 'error' => false, - 'data' => $records, - ]); + return response()->json(['error' => false, 'data' => $records]); } - public function getServices(string $uuid, Request $request): JsonResponse + public function getServices(string $uuid): JsonResponse { - $services = $this->serviceRepository->filter( - ['customer_uuid' => $uuid], - ['product', 'orderItem', 'invoice', 'vmware.os.osGroup'] - ); - - return response()->json([ - 'error' => false, - 'data' => $services - ]); + return response()->json(['error' => false, 'data' => $this->serviceProvisioning->getServices($uuid)]); } - /** - * 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']); - $data = $request->validate([ - 'ids' => 'required|array|min:1', - 'ids.*' => 'integer', - ]); + $deleted = $this->serviceProvisioning->bulkDeleteServices($uuid, $data['ids']); - $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], - ]); + 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', + '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', + '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', + 'vm_port' => 'nullable|integer|min:1|max:65535', ]); - try { - DB::beginTransaction(); + $service = $this->serviceProvisioning->updateService($uuid, $serviceId, $data); - $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); - } + return response()->json(['error' => false, 'message' => 'Cập nhật dịch vụ thành công', 'data' => $service]); + } + + public function changeServiceIp(string $uuid, int $serviceId, Request $request): JsonResponse + { + $data = $request->validate(['ip' => 'required|ip']); + + abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.'); + + $service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_ip' => $data['ip']]); + + return response()->json(['error' => false, 'message' => 'Đã đổi IP thành công', 'data' => $service]); + } + + public function changeServicePort(string $uuid, int $serviceId, Request $request): JsonResponse + { + $data = $request->validate(['port' => 'required|integer|min:1|max:65535']); + + abort_if(!Service::where('id', $serviceId)->value('vm_id'), 422, 'Dịch vụ chưa gắn VM.'); + + $service = $this->serviceProvisioning->updateService($uuid, $serviceId, ['vm_port' => $data['port']]); + + return response()->json(['error' => false, 'message' => 'Đã đổi port thành công', 'data' => $service]); + } + + public function notifyServiceRenewal(string $uuid, int $serviceId): JsonResponse + { + $this->serviceProvisioning->notifyRenewal($uuid, $serviceId); + + return response()->json(['error' => false, 'message' => 'Đã gửi thông báo gia hạn đến khách hàng.']); } - /** - * 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', + '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', + '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', + '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', + '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); + // Upload proof images (chuyển khoản) — I/O ở tầng HTTP, service chỉ nhận đường dẫn đã lưu. + if ($data['payment_method'] === 'transfer' && $request->hasFile('proof_images')) { + $data['_proof_paths'] = collect($request->file('proof_images')) + ->map(fn($file) => $file->store('payment-proofs', 'public')) + ->all(); } - $billingCycle = $data['billing_cycle'] ?? 'monthly'; - $months = $this->cycleToMonths($billingCycle); + $service = $this->serviceProvisioning->createService($uuid, $data, $request->ip()); - $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); - } + return response()->json(['error' => false, 'message' => 'Service created', 'data' => $service], 201); } - /** - * 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; - }); + $vms = $this->serviceProvisioning->listAvailableVms(trim((string) $request->get('q', ''))); return response()->json(['error' => false, 'data' => $vms]); } - public function getOrders(string $uuid, Request $request): JsonResponse + public function getOrders(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); - } - - $orders = $this->orderRepository->filter( - ['client' => $customer->id], - ['customer', 'payment', 'invoice'] - ); - - return response()->json([ - 'error' => false, - 'data' => $orders - ]); + return response()->json(['error' => false, 'data' => $this->billingService->getOrders($uuid)]); } - /** - * 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', + '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); + $order = $this->billingService->updateOrder($uuid, $orderId, $data); - return response()->json([ - 'error' => false, - 'message' => 'Đã cập nhật đơn hàng', - 'data' => $order->refresh()->load(['payment', 'invoice']), - ]); + return response()->json(['error' => false, 'message' => 'Đã cập nhật đơn hàng', 'data' => $order]); } 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(); + $this->accountService->updateStatus($uuid, (int) $request->input('status')); return response()->json(['error' => false, 'message' => 'Cập nhật trạng thái thành công']); } @@ -1541,201 +483,95 @@ class CustomerController extends Controller { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); - $uuids = $request->input('uuids'); - $customers = Customer::whereIn('uuid', $uuids)->get(); + $count = $this->accountService->bulkDelete($request->input('uuids')); - 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); - } + return response()->json(['error' => false, 'message' => "Đã xoá {$count} khách hàng"]); } public function bulkRestore(Request $request): JsonResponse { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); - $customers = Customer::onlyTrashed()->whereIn('uuid', $request->input('uuids'))->get(); + $count = $this->accountService->bulkRestore($request->input('uuids')); - 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); - } + return response()->json(['error' => false, 'message' => "Đã khôi phục {$count} khách hàng"]); } public function bulkForceDelete(Request $request): JsonResponse { $request->validate(['uuids' => ['required', 'array', 'min:1'], 'uuids.*' => ['string']]); - $customers = Customer::onlyTrashed()->whereIn('uuid', $request->input('uuids'))->get(); + $count = $this->accountService->bulkForceDelete($request->input('uuids')); - 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); - } + return response()->json(['error' => false, 'message' => "Đã xoá vĩnh viễn {$count} khách hàng"]); } public function bulkStatus(Request $request): JsonResponse { - $request->validate([ - 'uuids' => ['required', 'array', 'min:1'], + $data = $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')]); + $count = $this->accountService->bulkStatus($data['uuids'], $data['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]); + return response()->json(['error' => false, 'data' => $this->noteService->getCustomerNotes($uuid)]); } 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(), - ]); + $note = $this->noteService->storeCustomerNote($uuid, $data['note'], Auth::id()); - return response()->json([ - 'error' => false, - 'message' => 'Đã thêm ghi chú', - 'data' => $note->load('user:id,username'), - ]); + return response()->json(['error' => false, 'message' => 'Đã thêm ghi chú', 'data' => $note]); } 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); - } + $this->noteService->destroyNote($uuid, $noteId); return response()->json(['error' => false, 'message' => 'Đã xoá ghi chú']); } - private function cycleToMonths(?string $cycle): int + public function getServiceNotes(string $uuid, int $serviceId): JsonResponse { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; + return response()->json(['error' => false, 'data' => $this->noteService->getServiceNotes($uuid, $serviceId)]); + } + + public function storeServiceNote(string $uuid, int $serviceId, Request $request): JsonResponse + { + $data = $request->validate(['note' => 'required|string']); + + $note = $this->noteService->storeServiceNote($uuid, $serviceId, $data['note'], Auth::id()); + + return response()->json(['error' => false, 'message' => 'Đã thêm ghi chú', 'data' => $note]); + } + + public function destroyServiceNote(string $uuid, int $serviceId, int $noteId): JsonResponse + { + $this->noteService->destroyNote($uuid, $noteId); + + return response()->json(['error' => false, 'message' => 'Đã xoá ghi chú']); + } + + private function paginatedResponse(\Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator): JsonResponse + { + 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(), + ], + ]); } } diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php index d9f00c6..d6259ca 100644 --- a/app/Http/Controllers/Api/Admin/Order/OrderController.php +++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php @@ -6,37 +6,17 @@ use App\Http\Controllers\Controller; use App\Http\Requests\OrderRequest; use App\Http\Requests\UpdateOrderRequest; use App\Http\Resources\Api\Admin\OrderResource; -use App\Jobs\Hosting\CreateHostingJobs; -use App\Jobs\VirtualMachine\Create\CreateVMJobs; -use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine; use App\Models\Order; -use App\Repositories\Addon\AddonRepositoryInterface; -use App\Repositories\Invoice\InvoiceRepositoryInterface; -use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; -use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface; -use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface; -use App\Repositories\Module\IpAddress\IpAddressRepositoryInterface; -use App\Repositories\Module\Vmware\VmwareRepositoryInterface; use App\Repositories\Order\OrderRepositoryInterface; -use App\Repositories\Os\OsRepositoryInterface; use App\Services\OrderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; class OrderController extends Controller { public function __construct( private readonly OrderRepositoryInterface $orderRepository, - private readonly InvoiceRepositoryInterface $invoiceRepository, - private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, - private readonly ModuleHostingRepositoryInterface $moduleHostingRepository, - private readonly VmwareRepositoryInterface $moduleVmwareRepository, - private readonly HostingServerRepositoryInterface $moduleHostingServerRepository, - private readonly OsRepositoryInterface $osRepository, - private readonly IpAddressRepositoryInterface $ipAddressRepository, - private readonly AddonRepositoryInterface $addonRepository, private readonly OrderService $orderService, ) {} @@ -83,12 +63,7 @@ class OrderController extends Controller { $order = $this->orderRepository->findByOrderNum($order_num); - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => "Can't find order by ID #" . $order_num, - ], 404); - } + abort_if(!$order, 404, "Can't find order by ID #{$order_num}"); return response()->json([ 'error' => false, @@ -100,173 +75,48 @@ class OrderController extends Controller { $order = $this->orderRepository->findWithRelationships($id, ['invoice', 'orderItem']); - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => "Can't find order by ID #" . $id, - ], 404); - } + abort_if(!$order, 404, "Can't find order by ID #{$id}"); - $status = match ($request->action) { - "accept" => 1, - "cancel" => 2, - default => 0 + match ($request->action) { + 'accept' => $this->orderService->confirmOrder($order, (bool) $request->module), + 'cancel' => $this->orderService->setStatus($order, 2), + default => $this->orderService->setStatus($order, 0), }; - try { - DB::beginTransaction(); - - $this->orderRepository->update($id, ['status' => $status]); - - if ($status === 1 && $request->module == 1) { - $jobs = []; - $addon = []; - $product = 0; - - foreach ($order->orderItem as $key => $item) { - switch ((int) $item->type_id) { - case 1: - $itemData = $this->addonRepository->find($item->relid); - if ($itemData) { - $price = match ((int) $item->billing) { - 2 => $item->amount / 1, - 3 => $item->amount / 3, - 4 => $item->amount / 6, - 5 => $item->amount / 12, - default => 0 - }; - $addon[strtolower($itemData->name)] = (int) $price / $itemData->price; - $product = $item->parent_id; - } - break; - case 3: - $hostingServer = $this->moduleHostingServerRepository->get_active(); - if ($hostingServer) { - $this->moduleHostingRepository->update($item->relid, ['module_server_id' => $hostingServer->first()->id]); - $jobs[] = new CreateHostingJobs($item->relid); - } - break; - case 4: - $product = $this->moduleVmwareRepository->find($item->relid); - - if ($product && $product->os_id) { - $os = $this->osRepository->findWithRelationships($product->os_id, ['osGroup']); - if (!$os || !$os->osGroup->isNotEmpty()) break; - - $ip_address = $this->ipAddressRepository->filter([ - 'module_host_id' => $os->osGroup->module_host_id, - 'status' => 0, - ]); - - if (count($ip_address) > 0) { - $this->moduleVmwareRepository->update($item->relid, [ - 'module_ipaddress_id' => $ip_address->first()->id, - ]); - $this->ipAddressRepository->update($ip_address->first()->id, ['status' => 1]); - $jobs[] = new CreateVMJobs($product->id); - } - } - break; - } - } - - if (count($order->orderItem) === count($addon)) { - dispatch(new UpgradeVirtualMachine($product, $addon, $order->id))->delay(now()->addSeconds(10)); - } - - foreach ($jobs as $item) { - dispatch($item)->delay(now()->addSeconds(10)); - } - } - - DB::commit(); - - return response()->json(['error' => false, 'message' => 'Order updated successfully']); - } catch (\Exception $e) { - DB::rollBack(); - return response()->json(['error' => true, 'message' => $e->getMessage()], 500); - } + return response()->json(['error' => false, 'message' => 'Order updated successfully']); } public function destroy(int $id): JsonResponse { - $order = Order::with('Invoice', 'Invoice.InvoiceItem')->find($id); + $order = Order::with('invoice', 'invoice.invoiceItem')->find($id); - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => "Can't find order by ID #" . $id, - ], 404); - } + abort_if(!$order, 404, "Can't find order by ID #{$id}"); - try { - DB::beginTransaction(); + $this->orderService->deleteOrder($order); - if ($order->Invoice) { - $order->Invoice->delete(); - - if ($order->Invoice->InvoiceItem->count()) { - foreach ($order->Invoice->InvoiceItem->all() as $item) { - $item->delete(); - } - } - } - - $order->delete(); - - DB::commit(); - - return response()->json(['error' => false, 'message' => 'Order deleted successfully']); - } catch (\Exception $e) { - DB::rollBack(); - return response()->json(['error' => true, 'message' => $e->getMessage()], 500); - } + return response()->json(['error' => false, 'message' => 'Order deleted successfully']); } public function accept(int $id): JsonResponse { - $order = $this->orderRepository->findWithRelationships($id, ['invoice', 'orderItem']); + $order = $this->orderRepository->find($id); - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => "Can't find order by ID #" . $id, - ], 404); - } + abort_if(!$order, 404, "Can't find order by ID #{$id}"); - try { - DB::beginTransaction(); - $this->orderRepository->update($id, ['status' => 1]); - DB::commit(); + $this->orderService->setStatus($order, 1); - return response()->json(['error' => false, 'message' => 'Order accepted successfully']); - } catch (\Exception $e) { - DB::rollBack(); - return response()->json(['error' => true, 'message' => $e->getMessage()], 500); - } + return response()->json(['error' => false, 'message' => 'Order accepted successfully']); } public function cancel(int $id): JsonResponse { $order = $this->orderRepository->find($id); - if (!$order) { - return response()->json([ - 'error' => true, - 'message' => "Can't find order by ID #" . $id, - ], 404); - } + abort_if(!$order, 404, "Can't find order by ID #{$id}"); - try { - DB::beginTransaction(); - $this->orderRepository->update($id, ['status' => 2]); - DB::commit(); + $this->orderService->setStatus($order, 2); - return response()->json(['error' => false, 'message' => 'Order cancelled successfully']); - } catch (\Exception $e) { - DB::rollBack(); - return response()->json(['error' => true, 'message' => $e->getMessage()], 500); - } + return response()->json(['error' => false, 'message' => 'Order cancelled successfully']); } public function uploadProof(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 1630d58..9dfc68b 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -72,6 +72,7 @@ class ProductController extends Controller 'ram' => 'nullable|numeric|min:0', 'disk' => 'nullable|numeric|min:0', 'bandwidth' => 'nullable|numeric|min:0', + 'domain_count' => 'nullable|integer|min:0', 'billing_type' => 'nullable|string|in:recurring,onetime', 'currency' => 'nullable|string|max:10', 'price_monthly' => 'nullable|numeric|min:0', @@ -81,6 +82,7 @@ class ProductController extends Controller 'price_biennially' => 'nullable|numeric|min:0', 'price_triennially' => 'nullable|numeric|min:0', 'price_onetime' => 'nullable|numeric|min:0', + 'features' => 'nullable|array', ]); $productType = $validated['product_type'] ?? null; @@ -103,6 +105,8 @@ class ProductController extends Controller 'ram' => $validated['ram'] ?? null, 'disk' => $validated['disk'] ?? null, 'bandwidth' => $validated['bandwidth'] ?? null, + 'domain_count' => $validated['domain_count'] ?? null, + 'features' => $validated['features'] ?? null, ]); $this->savePricings($product, $validated); @@ -135,6 +139,7 @@ class ProductController extends Controller 'ram' => 'nullable|numeric|min:0', 'disk' => 'nullable|numeric|min:0', 'bandwidth' => 'nullable|numeric|min:0', + 'domain_count' => 'nullable|integer|min:0', 'os_ids' => 'nullable|array', 'os_ids.*' => 'integer|exists:os,id', 'module_host_id' => 'nullable|integer|exists:module_host,id', @@ -148,6 +153,7 @@ class ProductController extends Controller 'price_biennially' => 'nullable|numeric|min:0', 'price_triennially' => 'nullable|numeric|min:0', 'price_onetime' => 'nullable|numeric|min:0', + 'features' => 'nullable|array', ]); if (isset($validated['name']) && $validated['name'] !== $product->name) { diff --git a/app/Http/Controllers/Api/Admin/Product/ProductTypeFieldController.php b/app/Http/Controllers/Api/Admin/Product/ProductTypeFieldController.php new file mode 100644 index 0000000..9d2ceed --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Product/ProductTypeFieldController.php @@ -0,0 +1,71 @@ +when($request->get('product_type'), fn ($q, $type) => $q->where('product_type', $type)) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + return response()->json([ + 'error' => false, + 'data' => ProductTypeFieldResource::collection($items), + ]); + } + + public function store(ProductTypeFieldRequest $request): JsonResponse + { + $field = ProductTypeField::create($request->validated()); + + return response()->json([ + 'error' => false, + 'message' => 'Tạo field thành công.', + 'data' => new ProductTypeFieldResource($field), + ], 201); + } + + public function update(ProductTypeFieldRequest $request, int $id): JsonResponse + { + $field = ProductTypeField::find($id); + + if (!$field) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy field.'], 404); + } + + $field->update($request->validated()); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật field thành công.', + 'data' => new ProductTypeFieldResource($field), + ]); + } + + public function destroy(int $id): JsonResponse + { + $field = ProductTypeField::find($id); + + if (!$field) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy field.'], 404); + } + + $field->delete(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá field thành công.', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index ed3c555..758df87 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Api\Admin\Service; use App\Http\Controllers\Controller; +use App\Jobs\VirtualMachine\Provision\ResendVmCredentialsEmailJob; +use App\Models\ModuleVmware; use App\Models\Service; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -34,9 +36,11 @@ class ServiceController extends Controller public function show(int $id): JsonResponse { - $service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware', 'addons']) + $service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware.os.osGroup', 'addons']) ->findOrFail($id); + ModuleVmware::resolveVirtualMachineIds([$service->vmware]); + return response()->json(['error' => false, 'data' => $service]); } @@ -47,4 +51,15 @@ class ServiceController extends Controller return response()->json(['error' => false, 'message' => 'Đã gỡ khách hàng khỏi dịch vụ.']); } + + public function resendCredentials(int $id): JsonResponse + { + $service = Service::findOrFail($id); + + abort_if(!$service->vm_id, 422, 'Dịch vụ chưa gắn VM.'); + + ResendVmCredentialsEmailJob::dispatch($service->vm_id); + + return response()->json(['error' => false, 'message' => 'Đã xếp hàng gửi lại thông tin VPS cho khách hàng.']); + } } diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 9a0718f..e98889c 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -12,10 +12,7 @@ use App\Http\Resources\Api\Client\ClientResource; use App\Services\ActivityLogger; use App\Services\Api\Client\ClientAuthService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; -use Tymon\JWTAuth\Facades\JWTAuth; -use Tymon\JWTAuth\Exceptions\JWTException; /** * @OA\Tag( @@ -390,80 +387,37 @@ class ClientController extends Controller 'email' => ['required', 'email', 'max:255'], ]); - $email = strtolower(trim((string) $request->input('email'))); - - $customer = \App\Models\Customer::whereRaw('LOWER(email) = ?', [$email])->first(); - - if (! $customer) { - // Do not disclose whether the email exists. - return response()->json([ - 'error' => false, - 'message' => 'Nếu email tồn tại, mật khẩu mới đã được gửi đến hộp thư của bạn.', - ]); - } - - $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'; - $newPassword = ''; - $max = strlen($alphabet) - 1; - for ($i = 0; $i < 16; $i++) { - $newPassword .= $alphabet[random_int(0, $max)]; - } - - try { - \Illuminate\Support\Facades\DB::beginTransaction(); - - $customer->password = Hash::make($newPassword); - $customer->save(); - - \Illuminate\Support\Facades\Mail::to($customer->email)->later( - now()->addSeconds(5), - new \App\Mail\Customer\PasswordResetByAdmin([ - 'fullname' => $customer->fullname, - 'email' => $customer->email, - 'password' => $newPassword, - ]) - ); + $customer = $this->authService->resetForgottenPassword($request->input('email')); + if ($customer) { $this->activityLogger->log( event: 'password_forgot', logName: 'Customer', description: "Customer {$customer->email} yêu cầu cấp lại mật khẩu", subject: $customer, ); - - \Illuminate\Support\Facades\DB::commit(); - } catch (\Throwable) { - \Illuminate\Support\Facades\DB::rollBack(); - - return response()->json([ - 'error' => true, - 'message' => 'Không thể gửi email, vui lòng thử lại sau.', - ], 500); } + // Do not disclose whether the email exists. return response()->json([ 'error' => false, - 'message' => 'Mật khẩu mới đã được gửi đến email của bạn.', + 'message' => 'Nếu email tồn tại, mật khẩu mới đã được gửi đến hộp thư của bạn.', ]); } public function update(ClientUpdateRequest $request) { - $user = auth('client')->user(); + $result = $this->authService->updateProfile(auth('client')->user(), $request->validated()); + $user = $result['client']; - $oldValues = array_intersect_key($user->toArray(), $request->validated()); - $user->fill($request->validated()); - $dirty = $user->getDirty(); - $user->save(); - - if (!empty($dirty)) { + if (!empty($result['dirty'])) { $this->activityLogger->log( event: 'profile_update', logName: 'Customer', - description: "Customer {$user->email} cập nhật thông tin cá nhân: " . implode(', ', array_keys($dirty)), + description: "Customer {$user->email} cập nhật thông tin cá nhân: " . implode(', ', array_keys($result['dirty'])), subject: $user, - oldValues: array_intersect_key($oldValues, $dirty), - newValues: array_diff_key($dirty, array_flip(['password', 'remember_token'])), + oldValues: $result['old'], + newValues: array_diff_key($result['dirty'], array_flip(['password', 'remember_token'])), ); } @@ -478,29 +432,25 @@ class ClientController extends Controller public function updateCccd(ClientUpdateCccdRequest $request) { - $user = auth('client')->user(); + $frontUrl = Storage::disk('public')->url($request->file('cmnd_before')->store('cccd', 'public')); + $backUrl = Storage::disk('public')->url($request->file('cmnd_after')->store('cccd', 'public')); - $oldValues = array_intersect_key($user->toArray(), $request->validated()); - - $user->identity_number = $request->validated('cmnd'); - $user->front_identity_number = Storage::disk('public')->url( - $request->file('cmnd_before')->store('cccd', 'public') - ); - $user->back_identity_number = Storage::disk('public')->url( - $request->file('cmnd_after')->store('cccd', 'public') + $result = $this->authService->updateCccd( + auth('client')->user(), + $request->validated('cmnd'), + $frontUrl, + $backUrl, ); + $user = $result['client']; - $dirty = $user->getDirty(); - $user->save(); - - if (!empty($dirty)) { + if (!empty($result['dirty'])) { $this->activityLogger->log( event: 'cccd_update', logName: 'Customer', description: "Customer {$user->email} cập nhật chứng minh thư", subject: $user, - oldValues: array_intersect_key($oldValues, $dirty), - newValues: $dirty, + oldValues: $result['old'], + newValues: $result['dirty'], ); } @@ -517,16 +467,19 @@ class ClientController extends Controller { $user = auth('client')->user(); - if (!Hash::check($request->input('current_password'), $user->password)) { + try { + $this->authService->changePassword( + $user, + $request->input('current_password'), + $request->input('password'), + ); + } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'error' => true, - 'message' => 'Mật khẩu hiện tại không chính xác', + 'message' => collect($e->errors())->flatten()->first(), ], 422); } - $user->password = bcrypt($request->input('password')); - $user->save(); - $this->activityLogger->log( event: 'password_change', logName: 'Customer', diff --git a/app/Http/Controllers/Api/QueueMonitorController.php b/app/Http/Controllers/Api/QueueMonitorController.php index 3b2036c..8993dfb 100644 --- a/app/Http/Controllers/Api/QueueMonitorController.php +++ b/app/Http/Controllers/Api/QueueMonitorController.php @@ -7,6 +7,7 @@ use Illuminate\Http\JsonResponse; use Laravel\Horizon\Contracts\JobRepository; use Laravel\Horizon\Contracts\MetricsRepository; use Laravel\Horizon\Contracts\WorkloadRepository; +use Laravel\Horizon\Jobs\RetryFailedJob; class QueueMonitorController extends Controller { @@ -70,6 +71,19 @@ class QueueMonitorController extends Controller ]); } + /** + * POST /api/queue/jobs/failed/{id}/retry + * Re-queue a failed job by its Horizon job ID. + */ + public function retry(string $id): JsonResponse + { + abort_if(!$this->jobs->findFailed($id), 404, "Can't find failed job #{$id}"); + + dispatch(new RetryFailedJob($id)); + + return response()->json(['success' => true, 'message' => 'Job đã được đưa lại vào hàng đợi']); + } + /** * GET /api/queue/jobs/metrics * Queue throughput, runtime, and per-queue/per-job breakdown. diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index 9949629..86c2409 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -442,6 +442,19 @@ class VCenterManagerController extends Controller } } + public function powerState(Request $request) + { + $vmId = (int) $request->input('vm_id'); + + $vm = VirtualMachines::find($vmId); + + if (!$vm) { + return response()->json(['error' => true, 'message' => 'VM not found'], 404); + } + + return response()->json(['error' => false, 'data' => ['power_state' => $vm->power_state]]); + } + public function powerOn(Request $request) { $vmId = (int) $request->input('vm_id'); diff --git a/app/Http/Requests/ProductTypeField/ProductTypeFieldRequest.php b/app/Http/Requests/ProductTypeField/ProductTypeFieldRequest.php new file mode 100644 index 0000000..047f5cb --- /dev/null +++ b/app/Http/Requests/ProductTypeField/ProductTypeFieldRequest.php @@ -0,0 +1,28 @@ +isMethod('put') || $this->isMethod('patch') ? 'sometimes|' : ''; + + return [ + 'product_type' => $sometimes . 'required|string|max:50', + 'group_label' => 'nullable|string|max:255', + 'key' => $sometimes . 'required|string|max:255', + 'label' => $sometimes . 'required|string|max:255', + 'field_type' => $sometimes . 'required|string|in:text,number,textarea,repeater', + 'sort_order' => 'nullable|integer', + 'is_required' => 'boolean', + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index 58ca8cd..e1437f4 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -32,6 +32,8 @@ class ProductResource extends JsonResource 'ram' => $this->ram, 'disk' => $this->disk, 'bandwidth' => $this->bandwidth, + 'domain_count' => $this->domain_count, + 'features' => $this->features, 'setup_fee' => $this->setup_fee, 'min_price' => $minPrice, 'billing_type' => $this->whenLoaded('pricings', function () { diff --git a/app/Http/Resources/Api/Admin/ProductTypeFieldResource.php b/app/Http/Resources/Api/Admin/ProductTypeFieldResource.php new file mode 100644 index 0000000..ab4f04d --- /dev/null +++ b/app/Http/Resources/Api/Admin/ProductTypeFieldResource.php @@ -0,0 +1,24 @@ + $this->id, + 'product_type' => $this->product_type, + 'group_label' => $this->group_label, + 'key' => $this->key, + 'label' => $this->label, + 'field_type' => $this->field_type, + 'sort_order' => $this->sort_order, + 'is_required' => $this->is_required, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Jobs/Mail/SendInformationVirtualMachine.php b/app/Jobs/Mail/SendInformationVirtualMachine.php index b1bdf6a..60d2401 100644 --- a/app/Jobs/Mail/SendInformationVirtualMachine.php +++ b/app/Jobs/Mail/SendInformationVirtualMachine.php @@ -3,11 +3,8 @@ namespace App\Jobs\Mail; use App\Http\Services\Telegram; -use App\Mail\CreateVirtualMachine; -use App\Mail\VmCredentialsMail; -use App\Models\EmailNotificationTemplate; +use App\Mail\VmInfoMail; use App\Models\Invoice; -use App\Services\EmailNotificationTemplateRenderer; use App\Services\InvoicePdfService; use Carbon\Carbon; use Illuminate\Bus\Queueable; @@ -59,17 +56,7 @@ class SendInformationVirtualMachine implements ShouldQueue } } - // Try block-based DB template first - $dbTemplate = EmailNotificationTemplate::where('type', 'vm_credentials') - ->where('is_active', true) - ->first(); - - if ($dbTemplate) { - $email = new VmCredentialsMail($dbTemplate, $this->data, $invoice, $invoicePdf, $invoicePdfFileName); - } else { - // Fallback: legacy Template model (ID=2) - $email = new CreateVirtualMachine(2, $this->data, $invoicePdf, $invoicePdfFileName); - } + $email = new VmInfoMail($this->data, $invoicePdf, $invoicePdfFileName); Mail::to($this->customer->email)->send($email); diff --git a/app/Jobs/VirtualMachine/Provision/ResendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/ResendVmCredentialsEmailJob.php new file mode 100644 index 0000000..8391595 --- /dev/null +++ b/app/Jobs/VirtualMachine/Provision/ResendVmCredentialsEmailJob.php @@ -0,0 +1,79 @@ +find($this->moduleVmwareId); + + if (!$vm) { + $msg = "ResendVmCredentialsEmailJob: ModuleVmware #{$this->moduleVmwareId} not found"; + Log::error($msg); + $telegram->SendErrorMessage($msg); + return; + } + + $customer = $vm->customer; + + if (!$customer?->email) { + $msg = "ResendVmCredentialsEmailJob: customer or email missing for ModuleVmware #{$this->moduleVmwareId}"; + Log::error($msg, ['module_vmware_id' => $this->moduleVmwareId]); + $telegram->SendErrorMessage($msg); + return; + } + + $orderItem = $vm->order?->orderItems?->first(); + $emailData = app(VmCredentialsEmailDataBuilder::class)->build($vm, $orderItem); + + $invoice = Invoice::where('order_id', $vm->order_id)->first(); + + SendInformationVirtualMachine::dispatch($emailData, $customer, $invoice?->id); + + Log::info('ResendVmCredentialsEmailJob: email dispatched', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'customer_email' => $customer->email, + ]); + } + + public function failed(\Throwable $e): void + { + Log::error('ResendVmCredentialsEmailJob: failed', [ + 'module_vmware_id' => $this->moduleVmwareId, + 'error' => $e->getMessage(), + ]); + } +} diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index 2db7e2d..bb464c5 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -10,7 +10,7 @@ use App\Jobs\Mail\SendInformationVirtualMachine; use App\Models\Invoice; use App\Models\ModuleVmware; use App\Models\Service; -use App\Models\Setting; +use App\Services\VirtualMachine\VmCredentialsEmailDataBuilder; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -72,30 +72,8 @@ class SendVmCredentialsEmailJob implements ShouldQueue // ── [3] Build email payload ─────────────────────────────────────────── - $setting = Setting::where('key', 'setting')->first(); - $settingValue = $setting ? json_decode((string) $setting->value) : null; - - $months = (int) ($orderItem?->months ?? 0); - $osGroupName = $vm->os?->osGroup?->name ?? ''; - $isWindows = str_contains(strtolower($osGroupName), 'windows'); - $osUsername = $isWindows ? 'Administrator' : 'root'; - - $emailData = $this->safeUtf8([ - 'company_name' => $settingValue->company ?? 'Thế Giới Số', - 'company_logo' => $settingValue->logo ?? '', - 'client_name' => $customer->fullname ?? '', - 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', - 'product_billing' => $this->billingLabel($months), - 'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A', - 'service_host' => $vm->ip ?? 'N/A', - 'service_username' => $osUsername, - 'service_port' => (string) ($vm->port ?? '22'), - 'service_password' => $vm->plain_password ?? '(xem tai khoan quan ly)', - 'os_name' => $vm->os ? ($osGroupName . ' ' . $vm->os->version) : 'Unknown OS', - 'service_due_exp' => $vm->next_due_date - ? date('d/m/Y', (int) $vm->next_due_date) - : 'N/A', - ]); + $months = (int) ($orderItem?->months ?? 0); + $emailData = app(VmCredentialsEmailDataBuilder::class)->build($vm, $orderItem); // PDF is generated inside the mail job — never pass binary blobs through queue payload $invoice = Invoice::where('order_id', $vm->order_id)->first(); @@ -123,7 +101,7 @@ class SendVmCredentialsEmailJob implements ShouldQueue 'total' => $orderItem?->price ?? 0, 'created_at' => $order?->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), 'ip' => $vm->ip ?? '', - 'username' => $osUsername, + 'username' => $emailData['service_username'], 'password' => $vm->plain_password ?? '', ]); @@ -204,26 +182,4 @@ class SendVmCredentialsEmailJob implements ShouldQueue default => ($months ?? 0) . ' Thang', }; } - - /** - * Sanitize tất cả string trong array thành UTF-8 hợp lệ. - * Loại bỏ ký tự binary/invalid trước khi đẩy vào queue (tránh JSON encode lỗi). - */ - private function safeUtf8(array $data): array - { - return array_map(function ($value) { - if (!is_string($value)) return $value; - - // Nếu đã là UTF-8 hợp lệ → giữ nguyên - if (mb_check_encoding($value, 'UTF-8')) return $value; - - // Thử convert từ encoding khác sang UTF-8 - $converted = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'ISO-8859-1'); - - // Nếu vẫn invalid → strip ký tự lỗi - return mb_check_encoding($converted, 'UTF-8') - ? $converted - : preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|[^\x09\x0A\x0D\x20-\x{10FFFF}]/u', '', $value); - }, $data); - } } diff --git a/app/Mail/ServiceRenewalReminderMail.php b/app/Mail/ServiceRenewalReminderMail.php new file mode 100644 index 0000000..288c79b --- /dev/null +++ b/app/Mail/ServiceRenewalReminderMail.php @@ -0,0 +1,46 @@ +service->server_name ?? $this->service->product?->name ?? 'dịch vụ'; + + return new Envelope(subject: "Nhắc gia hạn dịch vụ {$name}"); + } + + public function content(): Content + { + return new Content( + view: 'emails.customer.service-renewal-reminder', + with: [ + 'clientName' => $this->customer->fullname ?? '', + 'productName' => $this->service->product?->name ?? 'Dịch vụ', + 'serverName' => $this->service->server_name ?? '', + 'expiresAt' => $this->service->expires_at + ? \Carbon\Carbon::parse($this->service->expires_at)->format('d/m/Y') + : 'N/A', + 'price' => $this->service->orderItem?->price + ? number_format((float) $this->service->orderItem->price, 0, ',', '.') + : null, + ], + ); + } +} diff --git a/app/Mail/VmInfoMail.php b/app/Mail/VmInfoMail.php new file mode 100644 index 0000000..b260e9a --- /dev/null +++ b/app/Mail/VmInfoMail.php @@ -0,0 +1,71 @@ +data['os_name'] ?? ''; + + return new Envelope( + subject: "VPS của bạn đã sẵn sàng" . ($osName ? " — {$osName}" : ''), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.customer.send-info-vps', + with: [ + 'companyName' => $this->data['company_name'] ?? '', + 'companyLogo' => $this->data['company_logo'] ?? '', + 'clientName' => $this->data['client_name'] ?? '', + 'productName' => $this->data['product_name'] ?? '', + 'productBilling' => $this->data['product_billing'] ?? '', + 'productPrice' => $this->data['product_price'] ?? '', + 'serviceHost' => $this->data['service_host'] ?? '', + 'serviceUsername' => $this->data['service_username'] ?? '', + 'servicePort' => $this->data['service_port'] ?? '', + 'servicePassword' => $this->data['service_password'] ?? '', + 'osName' => $this->data['os_name'] ?? '', + 'serviceDueExp' => $this->data['service_due_exp'] ?? '', + ], + ); + } + + public function attachments(): array + { + if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { + return [ + Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName) + ->withMime('application/pdf'), + ]; + } + + return []; + } +} diff --git a/app/Models/ModuleVmware.php b/app/Models/ModuleVmware.php index aa82429..afa81a8 100644 --- a/app/Models/ModuleVmware.php +++ b/app/Models/ModuleVmware.php @@ -94,4 +94,40 @@ class ModuleVmware extends Model default => 'Pending', }; } + + /** + * `virtual_machine_id`/`cpu`/`ram_mb` are only written when a service is + * created directly against a known vCenter inventory row (admin "create + * service" flow) or after a manual config change (UpdateVmConfigJob) — + * the normal auto-provisioning pipeline never sets them, since the VM + * isn't synced into `virtual_machines` yet at creation time. Backfill + * them here from the live `vmware_id` ↔ `virtual_machines.vm_id` match + * so API responses always expose the real link/spec (needed by the + * admin UI to show CPU/RAM and enable power on/off/restart/console). + * + * `disk_gb` has no source anywhere in the app (vCenter sync doesn't + * track disk size) — it stays null until captured some other way. + */ + public static function resolveVirtualMachineIds(iterable $vmwares): void + { + $candidates = collect($vmwares)->filter(fn ($vm) => $vm && $vm->vmware_id + && (!$vm->virtual_machine_id || !$vm->cpu || !$vm->ram_mb)); + if ($candidates->isEmpty()) { + return; + } + + $inventory = \App\Models\VirtualMachines::whereIn('vm_id', $candidates->pluck('vmware_id')->unique()) + ->get(['id', 'vm_id', 'cpu', 'ram_mb']) + ->keyBy('vm_id'); + + foreach ($candidates as $vm) { + $match = $inventory->get($vm->vmware_id); + if (!$match) { + continue; + } + $vm->virtual_machine_id ??= $match->id; + $vm->cpu ??= $match->cpu; + $vm->ram_mb ??= $match->ram_mb; + } + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 8622daa..d720c31 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -29,6 +29,7 @@ class Product extends Model 'gpu', 'disk', 'bandwidth', + 'domain_count', 'stock', 'is_unlimited', 'is_active', @@ -39,6 +40,7 @@ class Product extends Model 'module_host_id', 'module_datastore_id', 'published_at', + 'features', ]; protected $casts = [ @@ -47,6 +49,7 @@ class Product extends Model 'published_at' => 'date', 'gallery' => 'array', 'product_type' => 'string', + 'features' => 'array', ]; protected static function boot() diff --git a/app/Models/ProductTypeField.php b/app/Models/ProductTypeField.php new file mode 100644 index 0000000..f8530e4 --- /dev/null +++ b/app/Models/ProductTypeField.php @@ -0,0 +1,23 @@ + 'boolean', + 'sort_order' => 'integer', + ]; +} diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 9f0b903..fd92fde 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -4,12 +4,16 @@ namespace App\Services\Api\Client; use App\Http\Services\Telegram; use App\Mail\Admin\TrialRegistrationRequest as TrialRegistrationRequestMail; +use App\Mail\Customer\PasswordResetByAdmin; use App\Mail\Customer\VerifyAccount as VerifyAccountMail; use App\Mail\Customer\Welcome as WelcomeMail; +use App\Models\Customer; use App\Models\RefreshToken; use App\Models\Setting; use App\Repositories\Api\Client\ClientRepositoryInterface; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; @@ -299,4 +303,77 @@ class ClientAuthService $hash = hash('sha256', $rawToken); RefreshToken::where('token', $hash)->delete(); } + + /** + * Cấp mật khẩu mới ngẫu nhiên khi khách hàng quên mật khẩu. Không tiết lộ + * email có tồn tại hay không — trả về null nếu không tìm thấy tài khoản. + */ + public function resetForgottenPassword(string $email): ?Customer + { + $customer = Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first(); + + if (!$customer) { + return null; + } + + $newPassword = Str::password(16); + + DB::transaction(function () use ($customer, $newPassword) { + $customer->password = Hash::make($newPassword); + $customer->save(); + + Mail::to($customer->email)->later(now()->addSeconds(5), new PasswordResetByAdmin([ + 'fullname' => $customer->fullname, + 'email' => $customer->email, + 'password' => $newPassword, + ])); + }); + + return $customer; + } + + /** + * Cập nhật thông tin cá nhân. Trả về old/dirty values để caller ghi activity log. + */ + public function updateProfile(Customer $client, array $data): array + { + $oldValues = array_intersect_key($client->toArray(), $data); + $client->fill($data); + $dirty = $client->getDirty(); + $client->save(); + + return ['client' => $client, 'old' => array_intersect_key($oldValues, $dirty), 'dirty' => $dirty]; + } + + /** + * Cập nhật CMT/CCCD (số + 2 ảnh đã được lưu sẵn trên storage bởi caller). + */ + public function updateCccd(Customer $client, string $number, string $frontUrl, string $backUrl): array + { + $oldValues = $client->only(['identity_number', 'front_identity_number', 'back_identity_number']); + + $client->identity_number = $number; + $client->front_identity_number = $frontUrl; + $client->back_identity_number = $backUrl; + + $dirty = $client->getDirty(); + $client->save(); + + return ['client' => $client, 'old' => array_intersect_key($oldValues, $dirty), 'dirty' => $dirty]; + } + + /** + * Đổi mật khẩu. Ném ValidationException nếu mật khẩu hiện tại không đúng. + */ + public function changePassword(Customer $client, string $currentPassword, string $newPassword): void + { + if (!Hash::check($currentPassword, $client->password)) { + throw ValidationException::withMessages([ + 'current_password' => ['Mật khẩu hiện tại không chính xác'], + ]); + } + + $client->password = Hash::make($newPassword); + $client->save(); + } } diff --git a/app/Services/Customer/Concerns/ResolvesCustomer.php b/app/Services/Customer/Concerns/ResolvesCustomer.php new file mode 100644 index 0000000..13a643b --- /dev/null +++ b/app/Services/Customer/Concerns/ResolvesCustomer.php @@ -0,0 +1,21 @@ +customerRepository->findBy('uuid', $uuid); + + abort_if(!$customer, 404, "Can't find customer by UUID #{$uuid}"); + + return $customer; + } +} diff --git a/app/Services/Customer/CustomerAccountService.php b/app/Services/Customer/CustomerAccountService.php new file mode 100644 index 0000000..a443c59 --- /dev/null +++ b/app/Services/Customer/CustomerAccountService.php @@ -0,0 +1,176 @@ +customerRepository->findBy('email', $data['email'] ?? null)) { + abort(422, 'Email already exists on the system!'); + } + + return DB::transaction(function () use ($data) { + $note = $data['note'] ?? null; + unset($data['note']); + + $data['password'] = Hash::make($data['password']); + $data['email_marketing'] = ($data['email_marketing'] ?? null) === 'on' ? 1 : 0; + + $customer = $this->customerRepository->create($data); + + if ($note !== null) { + $customer->update(['note' => $note]); + } + + return $customer->fresh(); + }); + } + + public function update(string $uuid, array $data): Customer + { + $customer = $this->resolveCustomer($uuid); + + return DB::transaction(function () use ($customer, $data) { + $note = $data['note'] ?? null; + unset($data['note'], $data['password']); + + if (!empty($data['password_raw'])) { + $data['password'] = Hash::make($data['password_raw']); + unset($data['password_raw']); + } + + $this->customerRepository->update($customer->id, $data); + + if ($note !== null) { + $customer->update(['note' => $note]); + } + + return $customer->fresh(); + }); + } + + public function destroy(string $uuid): void + { + $customer = $this->resolveCustomer($uuid); + + DB::transaction(function () use ($customer) { + $this->deleteRelatedRecords($customer->id); + $customer->delete(); + }); + } + + public function restore(string $uuid): void + { + $customer = $this->customerRepository->findBy('uuid', $uuid) + ?? $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); + + abort_if(!$customer, 404, "Can't find customer by UUID #{$uuid}"); + + DB::transaction(function () use ($customer) { + $customer->restore(); + $this->restoreRelatedRecords($customer->id); + }); + } + + public function forceDelete(string $uuid): void + { + $customer = $this->customerRepository->onlyTrashed()->where('uuid', $uuid)->first(); + + abort_if(!$customer, 404, "Can't find customer by UUID #{$uuid}"); + + DB::transaction(function () use ($customer) { + $this->forceDeleteRelatedRecords($customer->id); + $customer->forceDelete(); + }); + } + + public function updateStatus(string $uuid, int $status): void + { + $customer = $this->resolveCustomer($uuid); + $customer->update(['status' => $status]); + } + + public function bulkDelete(array $uuids): int + { + $customers = Customer::whereIn('uuid', $uuids)->get(); + + DB::transaction(function () use ($customers) { + foreach ($customers as $customer) { + $this->deleteRelatedRecords($customer->id); + $customer->delete(); + } + }); + + return $customers->count(); + } + + public function bulkRestore(array $uuids): int + { + $customers = Customer::onlyTrashed()->whereIn('uuid', $uuids)->get(); + + DB::transaction(function () use ($customers) { + foreach ($customers as $customer) { + $customer->restore(); + $this->restoreRelatedRecords($customer->id); + } + }); + + return $customers->count(); + } + + public function bulkForceDelete(array $uuids): int + { + $customers = Customer::onlyTrashed()->whereIn('uuid', $uuids)->get(); + + DB::transaction(function () use ($customers) { + foreach ($customers as $customer) { + $this->forceDeleteRelatedRecords($customer->id); + $customer->forceDelete(); + } + }); + + return $customers->count(); + } + + public function bulkStatus(array $uuids, int $status): int + { + return Customer::whereIn('uuid', $uuids)->update(['status' => $status]); + } + + private function deleteRelatedRecords(int $customerId): void + { + Invoice::where('customer_id', $customerId)->delete(); + Order::where('customer_id', $customerId)->delete(); + Transaction::where('customer_id', $customerId)->delete(); + } + + private function restoreRelatedRecords(int $customerId): void + { + Invoice::withTrashed()->where('customer_id', $customerId)->restore(); + Order::withTrashed()->where('customer_id', $customerId)->restore(); + Transaction::withTrashed()->where('customer_id', $customerId)->restore(); + } + + private function forceDeleteRelatedRecords(int $customerId): void + { + Invoice::withTrashed()->where('customer_id', $customerId)->forceDelete(); + Order::withTrashed()->where('customer_id', $customerId)->forceDelete(); + Transaction::withTrashed()->where('customer_id', $customerId)->forceDelete(); + } +} diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php new file mode 100644 index 0000000..b998939 --- /dev/null +++ b/app/Services/Customer/CustomerBillingService.php @@ -0,0 +1,119 @@ +resolveCustomer($uuid); + + $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 $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, array $data): Invoice + { + $customer = $this->resolveCustomer($uuid); + + $invoice = Invoice::where('customer_id', $customer->id)->where('id', $invoiceId)->first(); + abort_if(!$invoice, 404, 'Không tìm thấy hóa đơn'); + + // `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 $invoice->load('payment'); + } + + public function getTransactions(string $uuid): Collection + { + $customer = $this->resolveCustomer($uuid); + + return $this->transactionRepository->filter(['customer_id' => $customer->id], ['user']); + } + + /** + * 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, array $data): Transaction + { + $customer = $this->resolveCustomer($uuid); + + $transaction = Transaction::where('customer_id', $customer->id)->where('id', $transactionId)->first(); + abort_if(!$transaction, 404, 'Không tìm thấy giao dịch'); + + // Đi qua repository để cache của transactionRepository->filter() + // (dùng ở getTransactions) được xoá theo — xem ghi chú ở updateInvoice. + $this->transactionRepository->update($transactionId, $data); + + return $transaction->refresh()->load('user'); + } + + public function getOrders(string $uuid): Collection + { + $customer = $this->resolveCustomer($uuid); + + return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoice']); + } + + /** + * 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, array $data): Order + { + $customer = $this->resolveCustomer($uuid); + + $order = Order::where('customer_id', $customer->id)->where('id', $orderId)->first(); + abort_if(!$order, 404, 'Không tìm thấy đơn hàng'); + + // Đi qua repository để cache của orderRepository->filter() + // (dùng ở getOrders) được xoá theo — xem ghi chú ở updateInvoice. + $this->orderRepository->update($orderId, $data); + + return $order->refresh()->load(['payment', 'invoice']); + } +} diff --git a/app/Services/Customer/CustomerImpersonationService.php b/app/Services/Customer/CustomerImpersonationService.php new file mode 100644 index 0000000..9f88f5e --- /dev/null +++ b/app/Services/Customer/CustomerImpersonationService.php @@ -0,0 +1,61 @@ +resolveCustomer($uuid); + + abort_if((int) $customer->status !== 1, 422, 'Khách hàng không hoạt động, không thể đăng nhập hộ.'); + + $token = JWTAuth::fromUser($customer); + $refreshToken = $this->clientAuthService->createRefreshToken($customer->id); + $clientResource = (new ClientResource($customer->loadCount(['vps', 'hosting', 'domain'])))->resolve($request); + + $code = Str::random(48); + Cache::put("impersonate:{$code}", [ + 'token' => $token, + 'refresh_token' => $refreshToken, + 'client' => $clientResource, + ], self::TTL_SECONDS); + + 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 $code; + } +} diff --git a/app/Services/Customer/CustomerMailService.php b/app/Services/Customer/CustomerMailService.php new file mode 100644 index 0000000..d40bc07 --- /dev/null +++ b/app/Services/Customer/CustomerMailService.php @@ -0,0 +1,146 @@ +resolveCustomer($uuid); + + $this->setPasswordAndNotify($customer, str()->random(10)); + } + + /** + * Đặt mật khẩu do admin nhập và gửi mail cho khách hàng, có ghi log. + */ + public function sendPasswordEmail(string $uuid, string $password, string $clientIp): void + { + $customer = $this->resolveCustomer($uuid); + + $this->setPasswordAndNotify($customer, $password); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $clientIp, + 'description' => sprintf('Admin gửi lại mật khẩu mới cho customer %s', $customer->email), + 'customer_id' => $customer->id, + ]); + } + + private function setPasswordAndNotify(Customer $customer, string $password): void + { + DB::transaction(function () use ($customer, $password) { + $this->customerRepository->update($customer->id, ['password' => Hash::make($password)]); + + $mail = new PasswordResetByAdmin([ + 'fullname' => $customer->fullname, + 'email' => $customer->email, + 'password' => $password, + ]); + + Mail::to($customer->email)->send($mail); + + MailLog::create([ + 'subject' => $mail->envelope()->subject, + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'message' => $mail->render(), + ]); + }); + } + + /** + * Gửi email tự do (hoặc theo template) cho khách hàng, có ghi log. + */ + public function sendCustomEmail(string $uuid, array $data, User $admin, string $clientIp): void + { + $customer = $this->resolveCustomer($uuid); + + $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(); + abort_if(!$template, 422, 'Template không tồn tại hoặc đang bị tắt.'); + + $subject = trim((string) $template->subject); + $message = trim((string) $template->message); + } + + abort_if($subject === '' || $message === '', 422, 'Subject và content không được để trống.'); + + $to = $this->parseEmailRecipients($data['to'] ?? null) ?: [$customer->email]; + $cc = $this->parseEmailRecipients($data['cc'] ?? null); + $bcc = $this->parseEmailRecipients($data['bcc'] ?? null); + + abort_if($to === [], 422, 'Không có địa chỉ email nhận hợp lệ.'); + + $mailer = Mail::to($to); + if ($cc !== []) { + $mailer->cc($cc); + } + if ($bcc !== []) { + $mailer->bcc($bcc); + } + $mailer->send(new ManualCustomerEmail($subject, $message)); + + MailLog::create([ + 'template_id' => $template->id ?? 0, + 'subject' => $subject, + 'to' => implode(', ', $to), + 'cc' => implode(', ', $cc), + 'customer_id' => $customer->id, + 'message' => $message, + ]); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $clientIp, + '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, + ]); + } + + 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(); + } +} diff --git a/app/Services/Customer/CustomerNoteService.php b/app/Services/Customer/CustomerNoteService.php new file mode 100644 index 0000000..eb5a4c5 --- /dev/null +++ b/app/Services/Customer/CustomerNoteService.php @@ -0,0 +1,76 @@ +resolveCustomer($uuid); + + return Note::with('user:id,username') + ->where('customer_id', $customer->id) + ->whereNull('service_id') + ->latest() + ->get(); + } + + public function storeCustomerNote(string $uuid, string $note, int $userId): Note + { + return $this->storeNote($uuid, ['note' => $note], $userId); + } + + /** + * 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): Collection + { + $customer = $this->resolveCustomer($uuid); + + return Note::with('user:id,username') + ->where('customer_id', $customer->id) + ->where('service_id', $serviceId) + ->latest() + ->get(); + } + + public function storeServiceNote(string $uuid, int $serviceId, string $note, int $userId): Note + { + return $this->storeNote($uuid, ['note' => $note, 'service_id' => $serviceId], $userId); + } + + public function destroyNote(string $uuid, int $noteId): void + { + $customer = $this->resolveCustomer($uuid); + + $deleted = Note::where('id', $noteId)->where('customer_id', $customer->id)->delete(); + abort_if(!$deleted, 404, 'Không tìm thấy ghi chú'); + } + + private function storeNote(string $uuid, array $attributes, int $userId): Note + { + $customer = $this->resolveCustomer($uuid); + + $note = Note::create([ + ...$attributes, + 'customer_id' => $customer->id, + 'user_id' => $userId, + ]); + + return $note->load('user:id,username'); + } +} diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php new file mode 100644 index 0000000..09ec995 --- /dev/null +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -0,0 +1,407 @@ +resolveCustomer($uuid); + + $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 $allService->values(); + } + + public function getServices(string $uuid): Collection + { + $services = $this->serviceRepository->filter( + ['customer_uuid' => $uuid], + ['product', 'orderItem', 'invoice', 'vmware.os.osGroup'], + ); + + ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware')); + + return $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, array $ids): int + { + $customer = $this->resolveCustomer($uuid); + + return Service::where('customer_id', $customer->id)->whereIn('id', $ids)->delete(); + } + + /** + * 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 ($service, $data) { + $vmFields = ['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', ...$vmFields])->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) { + ModuleVmware::findOrFail($service->vm_id)->update($vmData); + } + } + + $service->load(['product', 'orderItem', 'invoice', 'vmware.os.osGroup']); + ModuleVmware::resolveVirtualMachineIds([$service->vmware]); + + return $service; + }); + } + + /** + * Gửi email nhắc gia hạn dịch vụ cho khách hàng. + */ + public function notifyRenewal(string $uuid, int $serviceId): void + { + $customer = $this->resolveCustomer($uuid); + + $service = Service::where('customer_id', $customer->id)->where('id', $serviceId) + ->with(['product', 'orderItem']) + ->first(); + abort_if(!$service, 404, 'Không tìm thấy dịch vụ'); + abort_if(!$customer->email, 422, 'Khách hàng chưa có email'); + + Mail::to($customer->email)->send(new ServiceRenewalReminderMail($customer, $service)); + } + + /** + * 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); + + $product = Product::with('pricings')->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 = $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 + abort_if( + $paymentMethod === 'balance' && (int) $customer->total < $basePrice, + 422, + sprintf( + 'Số dư khách không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format((int) $customer->total), + number_format($basePrice), + ), + ); + + return DB::transaction(function () use ($customer, $product, $data, $billingCycle, $months, $basePrice, $paymentMethod, $clientIp) { + // 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' => $clientIp, + ]); + + $orderItem = 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::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 = $this->createModuleVmwareIfNeeded($data, $customer, $order, $basePrice, $expiresAt); + + // 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(), + ]); + + 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(); + } + + $proofPaths = $data['_proof_paths'] ?? []; + + 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 = 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) { + ModuleVmware::where('id', $vmId)->update(['service_id' => $service->id]); + } + + return $service->load(['product', '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): Collection + { + $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'); + + return 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; + }); + } + + private function cycleToMonths(?string $cycle): int + { + return match ($cycle) { + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + default => 1, + }; + } +} diff --git a/app/Services/Customer/CustomerWalletService.php b/app/Services/Customer/CustomerWalletService.php new file mode 100644 index 0000000..99ab329 --- /dev/null +++ b/app/Services/Customer/CustomerWalletService.php @@ -0,0 +1,95 @@ +resolveCustomer($uuid); + + return DB::transaction(function () use ($customer, $admin, $amount, $isAdd) { + $this->customerRepository->update($customer->id, [ + 'credit' => (int) $customer->credit + ($isAdd ? $amount : -$amount), + 'total' => (int) $customer->total + ($isAdd ? $amount : -$amount), + ]); + + $this->recordTransaction( + $customer, + $admin, + $amount, + $isAdd ? 'admin_add_credit' : 'admin_deduct_credit', + 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), + ), + ); + + return $customer->fresh(); + }); + } + + public function addTotal(string $uuid, User $admin, int $amount, string $note, string $clientIp): Transaction + { + $customer = $this->resolveCustomer($uuid); + + return DB::transaction(function () use ($customer, $admin, $amount, $note, $clientIp) { + $transaction = $this->recordTransaction( + $customer, + $admin, + $amount, + 'admin_add_total', + $note, + ); + + $customer->update(['total' => (int) $customer->total + $amount]); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $clientIp, + '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), + $note, + ), + 'customer_id' => $customer->id, + ]); + + return $transaction; + }); + } + + private function recordTransaction(Customer $customer, User $admin, int $amount, string $type, string $note): Transaction + { + return 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' => $type, + 'note' => $note, + ]); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index df2d9a6..4910eb3 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -11,14 +11,17 @@ use App\Http\Helpers\Helper; use App\Jobs\Hosting\CreateHostingJobs; use App\Jobs\Mail\Customer\OrderCreatedJob; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; +use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine; use App\Models\Customer; use App\Models\Order; use App\Models\OrderItem; use App\Models\Payment; use App\Models\Transaction; +use App\Repositories\Addon\AddonRepositoryInterface; use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface; +use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface; use App\Repositories\Module\Vmware\VmwareRepositoryInterface; use App\Repositories\Order\OrderRepositoryInterface; use Carbon\Carbon; @@ -34,8 +37,88 @@ class OrderService private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, private readonly ModuleHostingRepositoryInterface $moduleHostingRepository, private readonly VmwareRepositoryInterface $moduleVmwareRepository, + private readonly HostingServerRepositoryInterface $moduleHostingServerRepository, + private readonly AddonRepositoryInterface $addonRepository, ) {} + /** + * Xác nhận đơn hàng (status=1). Nếu $provisionModules, chạy thêm logic + * cấp phát hosting/VM/addon dựa trên các order item (module=1 từ FE). + */ + public function confirmOrder(Order $order, bool $provisionModules): void + { + DB::transaction(function () use ($order, $provisionModules) { + $this->orderRepository->update($order->id, ['status' => 1]); + + if ($provisionModules) { + $this->provisionOrderItems($order); + } + }); + } + + public function setStatus(Order $order, int $status): void + { + $this->orderRepository->update($order->id, ['status' => $status]); + } + + /** + * Xoá đơn hàng cùng hóa đơn và các dòng hóa đơn liên quan. + */ + public function deleteOrder(Order $order): void + { + DB::transaction(function () use ($order) { + if ($order->invoice) { + $order->invoice->invoiceItem()->delete(); + $order->invoice->delete(); + } + + $order->delete(); + }); + } + + private function provisionOrderItems(Order $order): void + { + $jobs = []; + $addon = []; + $product = 0; + + foreach ($order->orderItem as $item) { + switch ((int) $item->type_id) { + case 1: + $itemData = $this->addonRepository->find($item->relid); + if ($itemData) { + $price = match ((int) $item->billing) { + 2 => $item->amount / 1, + 3 => $item->amount / 3, + 4 => $item->amount / 6, + 5 => $item->amount / 12, + default => 0 + }; + $addon[strtolower($itemData->name)] = (int) $price / $itemData->price; + $product = $item->parent_id; + } + break; + case 3: + $hostingServer = $this->moduleHostingServerRepository->get_active(); + if ($hostingServer) { + $this->moduleHostingRepository->update($item->relid, ['module_server_id' => $hostingServer->first()->id]); + $jobs[] = new CreateHostingJobs($item->relid); + } + break; + // case 4 (VPS/Cloud): xử lý bởi CreateVirtualMachineJob, đã dispatch + // ngay lúc tạo đơn trong createOrder() — không provision lại ở đây. + } + } + + if (count($order->orderItem) === count($addon)) { + dispatch(new UpgradeVirtualMachine($product, $addon, $order->id))->delay(now()->addSeconds(10)); + } + + foreach ($jobs as $item) { + dispatch($item)->delay(now()->addSeconds(10)); + } + } + public function createOrder(array $data): Order { $customer = Customer::find($data['customer_id']); diff --git a/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php b/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php index 66dc921..2503357 100644 --- a/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php +++ b/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php @@ -82,7 +82,7 @@ final class VmRebuildPasswordService $encrypted = SignRSA::EncryptFunc($newPassword); - ModuleVmware::where('id', $ctx->moduleVmwareId)->update([ + ModuleVmware::findOrFail($ctx->moduleVmwareId)->update([ 'password' => $encrypted, 'plain_password' => $newPassword, ]); diff --git a/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php b/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php new file mode 100644 index 0000000..85a0ab6 --- /dev/null +++ b/app/Services/VirtualMachine/VmCredentialsEmailDataBuilder.php @@ -0,0 +1,76 @@ +first(); + $settingValue = $setting ? json_decode((string) $setting->value) : null; + + $months = (int) ($orderItem?->months ?? 0); + $osGroupName = $vm->os?->osGroup?->name ?? ''; + $isWindows = str_contains(strtolower($osGroupName), 'windows'); + $osUsername = $isWindows ? 'Administrator' : 'root'; + + return $this->safeUtf8([ + 'company_name' => $settingValue->company ?? 'Thế Giới Số', + 'company_logo' => $settingValue->logo ?? '', + 'client_name' => $vm->customer->fullname ?? '', + 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', + 'product_billing' => $this->billingLabel($months), + 'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A', + 'service_host' => $vm->ip ?? 'N/A', + 'service_username' => $osUsername, + 'service_port' => (string) ($vm->port ?? '22'), + 'service_password' => $vm->plain_password ?? '(xem tai khoan quan ly)', + 'os_name' => $vm->os ? ($osGroupName . ' ' . $vm->os->version) : 'Unknown OS', + 'service_due_exp' => $vm->next_due_date + ? date('d/m/Y', (int) $vm->next_due_date) + : 'N/A', + ]); + } + + private function billingLabel(?int $months): string + { + return match ($months) { + 1 => 'Hang thang', + 3 => '3 Thang', + 6 => '6 Thang', + 12 => 'Hang nam', + 24 => '2 Nam', + default => ($months ?? 0) . ' Thang', + }; + } + + /** + * Sanitize tất cả string trong array thành UTF-8 hợp lệ. + * Loại bỏ ký tự binary/invalid trước khi đẩy vào queue (tránh JSON encode lỗi). + */ + private function safeUtf8(array $data): array + { + return array_map(function ($value) { + if (!is_string($value)) return $value; + + if (mb_check_encoding($value, 'UTF-8')) return $value; + + $converted = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'ISO-8859-1'); + + return mb_check_encoding($converted, 'UTF-8') + ? $converted + : preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|[^\x09\x0A\x0D\x20-\x{10FFFF}]/u', '', $value); + }, $data); + } +} diff --git a/app/Services/VmProvisionService.php b/app/Services/VmProvisionService.php index 1b91e07..5c8a47e 100644 --- a/app/Services/VmProvisionService.php +++ b/app/Services/VmProvisionService.php @@ -115,7 +115,7 @@ final class VmProvisionService */ public function updatePassword(int $moduleVmwareId, string $plainPassword): void { - ModuleVmware::where('id', $moduleVmwareId)->update([ + ModuleVmware::findOrFail($moduleVmwareId)->update([ 'password' => $this->encryptPassword($plainPassword), 'plain_password' => $plainPassword, ]); @@ -130,7 +130,7 @@ final class VmProvisionService */ public function savePassword(int $moduleVmwareId, string $ip, string $plainPassword): void { - ModuleVmware::where('id', $moduleVmwareId)->update([ + ModuleVmware::findOrFail($moduleVmwareId)->update([ 'ip' => $ip, 'password' => $this->encryptPassword($plainPassword), 'plain_password' => $plainPassword, diff --git a/database/migrations/2026_08_25_100000_add_new_types_to_product_categories_table.php b/database/migrations/2026_08_25_100000_add_new_types_to_product_categories_table.php new file mode 100644 index 0000000..6aa8ef4 --- /dev/null +++ b/database/migrations/2026_08_25_100000_add_new_types_to_product_categories_table.php @@ -0,0 +1,20 @@ +id(); + $table->string('product_type', 50)->index(); + $table->string('key'); + $table->string('label'); + $table->string('field_type', 20)->default('text') + ->comment('text | number | textarea | repeater'); + $table->integer('sort_order')->default(0); + $table->boolean('is_required')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_type_fields'); + } +}; diff --git a/database/migrations/2026_08_25_110000_add_domain_count_to_products_table.php b/database/migrations/2026_08_25_110000_add_domain_count_to_products_table.php new file mode 100644 index 0000000..fccc274 --- /dev/null +++ b/database/migrations/2026_08_25_110000_add_domain_count_to_products_table.php @@ -0,0 +1,23 @@ +integer('domain_count')->nullable()->after('bandwidth') + ->comment('Số lượng domain đi kèm gói hosting'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('domain_count'); + }); + } +}; diff --git a/database/migrations/2026_08_25_120000_add_group_label_to_product_type_fields_table.php b/database/migrations/2026_08_25_120000_add_group_label_to_product_type_fields_table.php new file mode 100644 index 0000000..e9c4997 --- /dev/null +++ b/database/migrations/2026_08_25_120000_add_group_label_to_product_type_fields_table.php @@ -0,0 +1,23 @@ +string('group_label')->nullable()->after('product_type') + ->comment('Tên nhóm để gom các field liên quan khi hiển thị, vd "Bảo mật"'); + }); + } + + public function down(): void + { + Schema::table('product_type_fields', function (Blueprint $table) { + $table->dropColumn('group_label'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f42fa54..43720d6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -23,6 +23,10 @@ class DatabaseSeeder extends Seeder $user->assignRole('Full Administrator'); $this->call(TemplateSeeder::class); $this->call(Setting::class); + $this->call(ProductCategorySeeder::class); + $this->call(ProductTypeFieldSeeder::class); + $this->call(ProductMockupSeeder::class); + $this->call(ItSupportCatalogSeeder::class); } } diff --git a/database/seeders/ItSupportCatalogSeeder.php b/database/seeders/ItSupportCatalogSeeder.php new file mode 100644 index 0000000..169692d --- /dev/null +++ b/database/seeders/ItSupportCatalogSeeder.php @@ -0,0 +1,227 @@ +whereIn('key', ['packages', 'response_time_hours', 'coverage']) + ->delete(); + + $groups = [ + 'Nhóm dịch vụ phần mềm & thiết bị' => [ + ['device_check_frequency', 'Kiểm tra thiết bị định kỳ', 'text'], + ['device_cleaning_frequency', 'Vệ sinh thiết bị định kỳ', 'text'], + ['software_install_on_request', 'Cài đặt phần mềm theo yêu cầu', 'text'], + ['virus_scan_frequency', 'Kiểm tra và quét virus định kỳ', 'text'], + ['hw_sw_troubleshooting', 'Xử lý các lỗi phần cứng, phần mềm', 'text'], + ['loaner_device', 'Cho mượn thiết bị thay thế khi cần', 'text'], + ], + 'Nhóm hạ tầng & hỗ trợ kỹ thuật' => [ + ['operations_staff', 'Nhân sự vận hành hệ thống', 'text'], + ['response_time', 'Thời gian tiếp nhận và hỗ trợ', 'text'], + ['support_channels', 'Kênh hỗ trợ', 'text'], + ['monitoring_24_7', 'Hệ thống giám sát hạ tầng 24/7', 'text'], + ['infra_report_frequency', 'Báo cáo hệ thống hạ tầng', 'text'], + ['backup_restore', 'Hệ thống Backup - Restore Data', 'text'], + ], + 'Camera/IP Phone & thiết bị ngoại vi' => [ + ['camera_ip_phone_support', 'Hỗ trợ kiểm tra thiết bị (Camera/IP Phone)', 'text'], + ], + 'Tư vấn & bảo mật' => [ + ['upgrade_consulting', 'Tư vấn & báo giá nâng cấp thiết bị văn phòng', 'text'], + ['security_alert_notice', 'Thông báo lỗ hổng bảo mật đến người dùng', 'text'], + ['issue_consulting', 'Tư vấn lỗi thường gặp và đề xuất hướng xử lý', 'text'], + ], + 'Tường lửa & bảo vệ truy cập' => [ + ['firewall_support_level', 'Hỗ trợ và xử lý yêu cầu tường lửa', 'text'], + ['website_download_block', 'Chặn truy cập website, tải xuống theo yêu cầu', 'text'], + ['network_security_incident', 'Xử lý sự cố kết nối mạng & bảo mật', 'text'], + ['remote_access_setup', 'Thiết lập truy xuất dữ liệu từ xa', 'text'], + ], + ]; + + $sortOrder = 0; + foreach ($groups as $groupLabel => $rows) { + foreach ($rows as [$key, $label, $fieldType]) { + $sortOrder++; + ProductTypeField::updateOrCreate( + ['product_type' => 'it_support', 'key' => $key], + [ + 'group_label' => $groupLabel, + 'label' => $label, + 'field_type' => $fieldType, + 'sort_order' => $sortOrder, + 'is_required' => false, + ] + ); + } + } + + // ── 4 tiers (Star / Silver / Gold / Diamond) ────────────────────── + $category = ProductCategories::where('slug', 'it-support')->first(); + if (!$category) { + return; + } + + $tiers = [ + [ + 'name' => 'IT Helpdesk Star', + 'short_description' => 'Dưới 10 PC — 200.000đ / máy / tháng', + 'price_monthly' => 200000, + 'values' => [ + 'device_check_frequency' => '2 tháng/lần', + 'device_cleaning_frequency' => '2 tháng/lần', + 'software_install_on_request' => 'Có', + 'virus_scan_frequency' => '3 tháng/lần', + 'hw_sw_troubleshooting' => 'Có', + 'loaner_device' => 'Có', + 'operations_staff' => '1 IT Manager · 5 IT Hỗ trợ', + 'response_time' => "Online 15' · Onsite 120'", + 'support_channels' => 'Phone, Chat, Webform, Email', + 'monitoring_24_7' => 'Có', + 'infra_report_frequency' => 'Hàng tháng', + 'backup_restore' => 'Có', + 'camera_ip_phone_support' => 'Có', + 'upgrade_consulting' => 'Có', + 'security_alert_notice' => 'Có', + 'issue_consulting' => 'Có', + 'firewall_support_level' => 'Cơ bản', + 'website_download_block' => 'Có', + 'network_security_incident' => 'Có', + 'remote_access_setup' => 'Có', + ], + ], + [ + 'name' => 'IT Helpdesk Silver', + 'short_description' => '10 - 30 PC — 180.000đ / máy / tháng', + 'price_monthly' => 180000, + 'values' => [ + 'device_check_frequency' => '1 tháng/lần', + 'device_cleaning_frequency' => '2 tháng/lần', + 'software_install_on_request' => 'Có', + 'virus_scan_frequency' => '2 tháng/lần', + 'hw_sw_troubleshooting' => 'Có', + 'loaner_device' => 'Có', + 'operations_staff' => '1 IT Manager · 5 IT Hỗ trợ', + 'response_time' => "Online 10' · Onsite 90'", + 'support_channels' => 'Phone, Chat, Webform, Email', + 'monitoring_24_7' => 'Có', + 'infra_report_frequency' => 'Hàng tuần, tháng', + 'backup_restore' => 'Có', + 'camera_ip_phone_support' => 'Có', + 'upgrade_consulting' => 'Có', + 'security_alert_notice' => 'Có', + 'issue_consulting' => 'Có', + 'firewall_support_level' => 'Tiêu chuẩn', + 'website_download_block' => 'Có', + 'network_security_incident' => 'Có', + 'remote_access_setup' => 'Có', + ], + ], + [ + 'name' => 'IT Helpdesk Gold', + 'short_description' => '31 - 49 PC — 150.000đ / máy / tháng', + 'price_monthly' => 150000, + 'values' => [ + 'device_check_frequency' => '1 tháng/lần', + 'device_cleaning_frequency' => '1 tháng/lần', + 'software_install_on_request' => 'Có', + 'virus_scan_frequency' => '1 tháng/lần', + 'hw_sw_troubleshooting' => 'Có', + 'loaner_device' => 'Có', + 'operations_staff' => '1 IT Manager · 5 IT Hỗ trợ', + 'response_time' => "Online 10' · Onsite 60'", + 'support_channels' => 'Phone, Chat, Webform, Email', + 'monitoring_24_7' => 'Có', + 'infra_report_frequency' => 'Hàng tuần, tháng', + 'backup_restore' => 'Có', + 'camera_ip_phone_support' => 'Có', + 'upgrade_consulting' => 'Có', + 'security_alert_notice' => 'Có', + 'issue_consulting' => 'Có', + 'firewall_support_level' => 'Nâng cao', + 'website_download_block' => 'Có', + 'network_security_incident' => 'Có', + 'remote_access_setup' => 'Có', + ], + ], + [ + 'name' => 'IT Helpdesk Diamond', + 'short_description' => 'Kinh doanh đặc biệt — Liên hệ báo giá', + 'price_monthly' => null, + 'values' => [ + 'device_check_frequency' => '1 tháng/lần', + 'device_cleaning_frequency' => '1 tháng/lần', + 'software_install_on_request' => 'Có', + 'virus_scan_frequency' => '1 tháng/lần', + 'hw_sw_troubleshooting' => 'Có', + 'loaner_device' => 'Có', + 'operations_staff' => '1 IT Manager · 5 IT Hỗ trợ', + 'response_time' => "Online 10' · Onsite 60'", + 'support_channels' => 'Phone, Chat, Webform, Email', + 'monitoring_24_7' => 'Có', + 'infra_report_frequency' => 'Hàng tuần, tháng', + 'backup_restore' => 'Có', + 'camera_ip_phone_support' => 'Có', + 'upgrade_consulting' => 'Có', + 'security_alert_notice' => 'Có', + 'issue_consulting' => 'Có', + 'firewall_support_level' => 'Cao cấp', + 'website_download_block' => 'Có', + 'network_security_incident' => 'Có', + 'remote_access_setup' => 'Có', + ], + ], + ]; + + // Remove the earlier single generic mockup — superseded by the 4 real tiers. + Product::withTrashed()->where('slug', 'it-support-goi-doanh-nghiep')->first()?->forceDelete(); + + foreach ($tiers as $tier) { + $slug = Str::slug($tier['name']); + $product = Product::withTrashed()->where('slug', $slug)->first(); + + if (!$product) { + $product = Product::create([ + 'name' => $tier['name'], + 'slug' => $slug, + 'short_description' => $tier['short_description'], + 'product_categories_id' => $category->id, + 'setup_fee' => 0, + 'features' => $tier['values'], + 'is_active' => true, + 'is_unlimited' => true, + 'published_at' => now(), + ]); + } else { + $product->update([ + 'short_description' => $tier['short_description'], + 'features' => $tier['values'], + ]); + } + + if ($tier['price_monthly'] !== null) { + ProductPricings::updateOrCreate( + ['product_id' => $product->id, 'billing_cycle' => 'monthly'], + ['price' => $tier['price_monthly'], 'currency' => 'VND', 'is_active' => true] + ); + } + } + } +} diff --git a/database/seeders/ProductCategorySeeder.php b/database/seeders/ProductCategorySeeder.php new file mode 100644 index 0000000..4508223 --- /dev/null +++ b/database/seeders/ProductCategorySeeder.php @@ -0,0 +1,27 @@ + 'Thiết kế website', 'slug' => 'thiet-ke-website', 'type' => 'web_design', 'sort_order' => 10], + ['name' => 'Email', 'slug' => 'email', 'type' => 'email', 'sort_order' => 20], + ['name' => 'Hardware', 'slug' => 'hardware', 'type' => 'hardware', 'sort_order' => 30], + ['name' => 'IT Support', 'slug' => 'it-support', 'type' => 'it_support', 'sort_order' => 40], + ['name' => 'Thuê máy chủ', 'slug' => 'thue-may-chu', 'type' => 'server_rental', 'sort_order' => 50], + ]; + + foreach ($categories as $category) { + ProductCategories::firstOrCreate( + ['slug' => $category['slug']], + $category + ['is_active' => true] + ); + } + } +} diff --git a/database/seeders/ProductMockupSeeder.php b/database/seeders/ProductMockupSeeder.php new file mode 100644 index 0000000..4741312 --- /dev/null +++ b/database/seeders/ProductMockupSeeder.php @@ -0,0 +1,119 @@ + [ + 'name' => 'Website Landing Page - Cơ bản', + 'short_description' => 'Thiết kế landing page 1 trang, chuẩn responsive, tối ưu SEO cơ bản.', + 'setup_fee' => 500000, + 'features' => null, + 'price_monthly' => 0, + 'price_onetime' => 3500000, + ], + 'email' => [ + 'name' => 'Email Doanh Nghiệp - Gói 10 tài khoản', + 'short_description' => 'Email theo tên miền riêng, chống spam, đồng bộ đa thiết bị.', + 'setup_fee' => 0, + 'features' => [ + 'mailbox_quota' => 10, + 'account_count' => 10, + 'domain' => 'yourcompany.vn', + ], + 'price_monthly' => 250000, + ], + 'hardware' => [ + 'name' => 'Switch Cisco Catalyst 24-port', + 'short_description' => 'Switch mạng 24 cổng Gigabit, hàng chính hãng, bảo hành nhà phân phối.', + 'setup_fee' => 200000, + 'features' => [ + 'device_type' => 'Switch mạng', + 'model' => 'Catalyst 2960-24TT-L', + 'serial_number' => 'SN-CISCO-2960-000123', + 'warranty_months' => 24, + ], + 'price_onetime' => 12500000, + ], + 'it-support' => [ + 'name' => 'IT Support - Gói Doanh Nghiệp', + 'short_description' => 'Hỗ trợ kỹ thuật trọn gói cho văn phòng, ưu tiên xử lý sự cố 24/7.', + 'setup_fee' => 0, + 'features' => [ + 'packages' => [ + ['label' => 'Gói Cơ bản', 'value' => '2.000.000đ/tháng - Hỗ trợ giờ hành chính'], + ['label' => 'Gói Nâng cao', 'value' => '4.500.000đ/tháng - Hỗ trợ 24/7'], + ['label' => 'Gói Doanh nghiệp', 'value' => '8.000.000đ/tháng - Có kỹ thuật viên thường trực'], + ], + 'response_time_hours' => 2, + 'coverage' => 'Xử lý sự cố phần cứng, phần mềm, mạng nội bộ, sao lưu dữ liệu định kỳ.', + ], + 'price_monthly' => 4500000, + ], + 'thue-may-chu' => [ + 'name' => 'Thuê Máy Chủ Vật Lý - Dell R740', + 'short_description' => 'Thuê nguyên máy chủ vật lý đặt tại data center Tier III, băng thông riêng.', + 'setup_fee' => 1000000, + 'features' => [ + 'location' => 'Data Center FPT Telecom, Q.9, TP.HCM', + 'specs' => 'Dell PowerEdge R740, 2x Xeon Silver 4210, 64GB RAM, 2x 960GB SSD RAID1', + 'rental_months' => 12, + ], + 'price_monthly' => 6500000, + ], + ]; + + foreach ($mockups as $categorySlug => $data) { + $category = ProductCategories::where('slug', $categorySlug)->first(); + if (!$category) { + continue; + } + + $slug = Str::slug($data['name']); + if (Product::withTrashed()->where('slug', $slug)->exists()) { + continue; + } + + $product = Product::create([ + 'name' => $data['name'], + 'slug' => $slug, + 'short_description' => $data['short_description'], + 'product_categories_id' => $category->id, + 'setup_fee' => $data['setup_fee'], + 'features' => $data['features'], + 'is_active' => true, + 'is_unlimited' => true, + 'published_at' => now(), + ]); + + if (!empty($data['price_monthly'])) { + ProductPricings::create([ + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'price' => $data['price_monthly'], + 'currency' => 'VND', + 'is_active' => true, + ]); + } + + if (!empty($data['price_onetime'])) { + ProductPricings::create([ + 'product_id' => $product->id, + 'billing_cycle' => 'onetime', + 'price' => $data['price_onetime'], + 'currency' => 'VND', + 'is_active' => true, + ]); + } + } + } +} diff --git a/database/seeders/ProductTypeFieldSeeder.php b/database/seeders/ProductTypeFieldSeeder.php new file mode 100644 index 0000000..e8434c1 --- /dev/null +++ b/database/seeders/ProductTypeFieldSeeder.php @@ -0,0 +1,54 @@ + 'vps', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1], + ['product_type' => 'vps', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2], + ['product_type' => 'vps', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3], + ['product_type' => 'vps', 'key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'field_type' => 'number', 'sort_order' => 4], + + // Hosting + ['product_type' => 'hosting', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1], + ['product_type' => 'hosting', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2], + ['product_type' => 'hosting', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3], + ['product_type' => 'hosting', 'key' => 'domain_count', 'label' => 'Số lượng domain', 'field_type' => 'number', 'sort_order' => 4], + + // Email + ['product_type' => 'email', 'key' => 'mailbox_quota', 'label' => 'Dung lượng hộp thư (GB)', 'field_type' => 'number', 'sort_order' => 1], + ['product_type' => 'email', 'key' => 'account_count', 'label' => 'Số lượng tài khoản email', 'field_type' => 'number', 'sort_order' => 2], + ['product_type' => 'email', 'key' => 'domain', 'label' => 'Tên miền sử dụng', 'field_type' => 'text', 'sort_order' => 3], + + // Hardware + ['product_type' => 'hardware', 'key' => 'device_type', 'label' => 'Loại thiết bị', 'field_type' => 'text', 'sort_order' => 1], + ['product_type' => 'hardware', 'key' => 'model', 'label' => 'Model / Model number', 'field_type' => 'text', 'sort_order' => 2], + ['product_type' => 'hardware', 'key' => 'serial_number', 'label' => 'Số serial (Serial Number)', 'field_type' => 'text', 'sort_order' => 3], + ['product_type' => 'hardware', 'key' => 'warranty_months', 'label' => 'Thời gian bảo hành (tháng)', 'field_type' => 'number', 'sort_order' => 4], + + // IT Support + ['product_type' => 'it_support', 'key' => 'packages', 'label' => 'Các gói hỗ trợ', 'field_type' => 'repeater', 'sort_order' => 1], + ['product_type' => 'it_support', 'key' => 'response_time_hours', 'label' => 'Thời gian phản hồi (giờ)', 'field_type' => 'number', 'sort_order' => 2], + ['product_type' => 'it_support', 'key' => 'coverage', 'label' => 'Phạm vi hỗ trợ', 'field_type' => 'textarea', 'sort_order' => 3], + + // Server rental (thuê máy chủ) + ['product_type' => 'server_rental', 'key' => 'location', 'label' => 'Vị trí đặt máy chủ', 'field_type' => 'text', 'sort_order' => 1], + ['product_type' => 'server_rental', 'key' => 'specs', 'label' => 'Cấu hình máy chủ', 'field_type' => 'textarea', 'sort_order' => 2], + ['product_type' => 'server_rental', 'key' => 'rental_months', 'label' => 'Thời hạn thuê (tháng)', 'field_type' => 'number', 'sort_order' => 3], + ]; + + foreach ($fields as $field) { + ProductTypeField::firstOrCreate( + ['product_type' => $field['product_type'], 'key' => $field['key']], + $field + ); + } + } +} diff --git a/resources/views/emails/customer/send-info-vps.blade.php b/resources/views/emails/customer/send-info-vps.blade.php new file mode 100644 index 0000000..6cf3d00 --- /dev/null +++ b/resources/views/emails/customer/send-info-vps.blade.php @@ -0,0 +1,218 @@ +@php + $companyName = $companyName ?? config('app.name', 'Thế Giới Số'); + $companyLogo = $companyLogo ?? ''; + $clientName = $clientName ?? 'Quý khách'; + $productName = $productName ?? 'Cloud VPS'; + $productBilling = $productBilling ?? ''; + $productPrice = $productPrice ?? ''; + $serviceHost = $serviceHost ?? ''; + $serviceUsername = $serviceUsername ?? ''; + $servicePort = $servicePort ?? ''; + $servicePassword = $servicePassword ?? ''; + $osName = $osName ?? ''; + $serviceDueExp = $serviceDueExp ?? ''; + $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '(028) 7309. 7379'; +@endphp + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + +
+
+

+ VPS của bạn đã sẵn sàng +

+ +

+ VPS của bạn đã sẵn sàng +

+ +

+ Kính chào {{ $clientName }}, +

+ +

+ Dịch vụ {{ $productName }} tại {{ $companyName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập: +

+ + + + + + + + @if($productBilling) + + + + + @endif + @if($productPrice) + + + + + @endif + @if($osName) + + + + + @endif + + + + + + + + + + + + + @if($servicePort) + + + + + @endif + @if($serviceDueExp) + + + + + @endif + + + + + +
Gói dịch vụ:{{ $productName }}
Chu kỳ thanh toán:{{ $productBilling }}
Giá dịch vụ:{{ $productPrice }} VNĐ
Hệ điều hành:{{ $osName }}
Địa chỉ IP:{{ $serviceHost }}
Tài khoản đăng nhập:{{ $serviceUsername }}
Mật khẩu:{{ $servicePassword }}
Cổng kết nối:{{ $servicePort }}
Hạn sử dụng:{{ $serviceDueExp }}
Trạng thái:Đang hoạt động ✓
+ +

+ Lưu ý: Vì lý do bảo mật, mật khẩu chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn. +

+ +

+ Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình. +

+ + + + + + + +
+ Đăng nhập ngay +
+ +

+ Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. +

+ +

+ Email này được gửi tự động. Vui lòng không trả lời email này. +

+ +

---
+ Hotline: {{ $supportPhone }}
+ Email: {{ $supportEmail }} +

+
+
diff --git a/resources/views/emails/customer/service-renewal-reminder.blade.php b/resources/views/emails/customer/service-renewal-reminder.blade.php new file mode 100644 index 0000000..a5f0d95 --- /dev/null +++ b/resources/views/emails/customer/service-renewal-reminder.blade.php @@ -0,0 +1,113 @@ +@php + $clientName = $clientName ?? 'Quý khách'; + $productName = $productName ?? 'Dịch vụ'; + $serverName = $serverName ?? ''; + $expiresAt = $expiresAt ?? 'N/A'; + $price = $price ?? null; + $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '(028) 7309. 7379'; +@endphp + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số + +
+
+

+ Nhắc gia hạn dịch vụ +

+ +

+ Kính chào {{ $clientName }}, +

+ +

+ Dịch vụ {{ $productName }}{{ $serverName ? " ({$serverName})" : '' }} của bạn sắp đến hạn thanh toán. Vui lòng gia hạn sớm để tránh gián đoạn dịch vụ. +

+ + + + + + + + + + + + @if($price) + + + + + @endif + +
Dịch vụ:{{ $productName }}
Ngày hết hạn:{{ $expiresAt }}
Số tiền gia hạn:{{ $price }} VNĐ
+ +

+ Bạn có thể đăng nhập vào cổng quản lý để gia hạn dịch vụ. +

+ + + + + + + +
+ Đăng nhập ngay +
+ +

+ Email này được gửi tự động. Vui lòng không trả lời email này. +

+ +

---
+ Hotline: {{ $supportPhone }}
+ Email: {{ $supportEmail }} +

+
+
diff --git a/routes/api.php b/routes/api.php index bd83a2f..ac5ab3c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\Admin\Payment\PaymentController; use App\Http\Controllers\Api\Admin\Addon\AddonController; use App\Http\Controllers\Api\Admin\WebDesign\WebDesignQuoteController; use App\Http\Controllers\Api\Admin\Product\ProductCategoryController; +use App\Http\Controllers\Api\Admin\Product\ProductTypeFieldController; use App\Http\Controllers\Api\Admin\Product\ProductController; use App\Http\Controllers\Api\Admin\Service\ServiceController; use App\Http\Controllers\Api\Admin\VPS\VPSController; @@ -127,6 +128,9 @@ Route::prefix('v1') 'perm:customer.edit', ); Route::put('{uuid}/services/{serviceId}', 'updateService')->middleware('perm:customer.edit'); + Route::post('{uuid}/services/{serviceId}/change-ip', 'changeServiceIp')->middleware('perm:customer.edit'); + Route::post('{uuid}/services/{serviceId}/change-port', 'changeServicePort')->middleware('perm:customer.edit'); + Route::post('{uuid}/services/{serviceId}/notify-renew', 'notifyServiceRenewal')->middleware('perm:customer.edit'); Route::get('{uuid}/notes', 'getCustomerNotes')->middleware('perm:customer.view'); Route::post('{uuid}/notes', 'storeCustomerNote')->middleware('perm:customer.edit'); Route::delete('{uuid}/notes/{noteId}', 'destroyCustomerNote')->middleware('perm:customer.edit'); @@ -164,6 +168,7 @@ Route::prefix('v1') Route::get('/', 'index')->middleware('perm:customer.view'); Route::get('{id}', 'show')->middleware('perm:customer.view'); Route::patch('{id}/detach-customer', 'detachCustomer')->middleware('perm:customer.edit'); + Route::post('{id}/resend-credentials', 'resendCredentials')->middleware('perm:customer.edit'); }); // Products (module: product-service) @@ -221,6 +226,15 @@ Route::prefix('v1') ->name('force-delete'); }); + Route::prefix('product-type-fields') + ->controller(ProductTypeFieldController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:product-service.view'); + Route::post('/', 'store')->middleware('perm:product-service.create'); + Route::put('{id}', 'update')->middleware('perm:product-service.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy'); + }); + // Addons (module: product-service) Route::prefix('addons') ->controller(AddonController::class) @@ -580,6 +594,7 @@ Route::prefix('v1') Route::get('jobs/recent', 'recent'); Route::get('jobs/pending', 'pending'); Route::get('jobs/failed', 'failed'); + Route::post('jobs/failed/{id}/retry', 'retry'); Route::get('jobs/metrics', 'metrics'); }); @@ -663,6 +678,7 @@ Route::prefix('v1') Route::post('vm/update-config', 'updateConfig')->middleware('perm:module-vcenter.edit'); Route::post('vm/delete', 'deleteVM')->middleware('perm:module-vcenter.edit'); Route::get('vm/stats', 'stats')->middleware('perm:module-vcenter.view'); + Route::get('vm/power-state', 'powerState')->middleware('perm:module-vcenter.view'); Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); }); diff --git a/routes/client.php b/routes/client.php index 7b90a89..fbd98a9 100644 --- a/routes/client.php +++ b/routes/client.php @@ -221,10 +221,10 @@ Route::prefix('v1') ->as('inet-domain.') ->controller(InetDomainController::class) ->group(function () { - Route::get('whois/{domain}', 'whois')->name('whois'); - Route::get('check-available', 'checkAvailable')->name('check-available'); - Route::post('bulk-check-available', 'bulkCheckAvailable')->name('bulk-check-available'); - Route::post('ai-suggest', 'aiSuggest')->name('ai-suggest'); + Route::get('whois/{domain}', 'whois')->name('whois')->middleware('throttle:20,1'); + Route::get('check-available', 'checkAvailable')->name('check-available')->middleware('throttle:30,1'); + Route::post('bulk-check-available', 'bulkCheckAvailable')->name('bulk-check-available')->middleware('throttle:10,1'); + Route::post('ai-suggest', 'aiSuggest')->name('ai-suggest')->middleware('throttle:5,1'); Route::get('suffixes', 'suffixes')->name('suffixes'); });