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
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ 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:
+
+
+
+
+
+ | Gói dịch vụ: |
+ {{ $productName }} |
+
+ @if($productBilling)
+
+ | Chu kỳ thanh toán: |
+ {{ $productBilling }} |
+
+ @endif
+ @if($productPrice)
+
+ | Giá dịch vụ: |
+ {{ $productPrice }} VNĐ |
+
+ @endif
+ @if($osName)
+
+ | Hệ điều hành: |
+ {{ $osName }} |
+
+ @endif
+
+ | Địa chỉ IP: |
+ {{ $serviceHost }} |
+
+
+ | Tài khoản đăng nhập: |
+ {{ $serviceUsername }} |
+
+
+ | Mật khẩu: |
+ {{ $servicePassword }} |
+
+ @if($servicePort)
+
+ | Cổng kết nối: |
+ {{ $servicePort }} |
+
+ @endif
+ @if($serviceDueExp)
+
+ | Hạn sử dụng: |
+ {{ $serviceDueExp }} |
+
+ @endif
+
+ | 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.
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+ |
+
+ 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ụ.
+
+
+
+
+
+ | Dịch vụ: |
+ {{ $productName }} |
+
+
+ | Ngày hết hạn: |
+ {{ $expiresAt }} |
+
+ @if($price)
+
+ | Số tiền gia hạn: |
+ {{ $price }} VNĐ |
+
+ @endif
+
+
+
+
+ Bạn có thể đăng nhập vào cổng quản lý để gia hạn dịch vụ.
+
+
+
+
+
+ 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');
});