diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fe39b1a..f8723f2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,8 @@ "Bash(grep -n -A 40 \"CREATE TABLE \\\\`services\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", "Bash(grep -n -A 30 \"CREATE TABLE \\\\`products\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", "Bash(grep -n -A 30 \"CREATE TABLE \\\\`vmware\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)", - "Bash(pnpm tsc *)" + "Bash(pnpm tsc *)", + "Bash(grep *)" ] } } diff --git a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php index f68c497..39b67da 100644 --- a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php +++ b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php @@ -2,28 +2,115 @@ namespace App\Http\Controllers\Api\Admin\Dashboard; +use App\Enums\Invoice\InvoiceStatus; +use App\Enums\Order\OrderStatus; use App\Enums\TransactionStatus; use App\Http\Controllers\Controller; use App\Models\Customer; +use App\Models\Invoice; +use App\Models\Order; +use App\Models\Service; use App\Models\Transaction; +use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class DashboardController extends Controller { + // Các type được tính là "nạp tiền vào ví" + private const DEPOSIT_TYPES = ['manual', 'vnpay', 'deposit']; + + /** Map Order status int → string FE */ + private function orderStatusLabel(int $status): string + { + return match ($status) { + OrderStatus::PENDING->value => 'pending', + OrderStatus::ACTIVE->value => 'active', + OrderStatus::CANCELLED->value => 'cancelled', + OrderStatus::PROCESSING->value => 'processing', + default => (string) $status, + }; + } + + /** Map Invoice status int → FE payment status (paid/pending/failed) */ + private function paymentStatusFromInvoice(?int $status): string + { + return match ($status) { + InvoiceStatus::PAID->value => 'paid', + InvoiceStatus::UNPAID->value => 'pending', + InvoiceStatus::CANCELLED->value => 'failed', + default => 'pending', + }; + } + + /** % delta giữa current vs previous (an toàn chia 0) */ + private function deltaPct(float $current, float $previous): float + { + if ($previous == 0.0) { + return $current > 0 ? 100.0 : 0.0; + } + return round((($current - $previous) / $previous) * 100, 2); + } + public function stats(): JsonResponse { - $total_customers = Customer::count(); + $now = now(); + $startOfMonth = $now->copy()->startOfMonth(); + $startOfPrevMonth = $now->copy()->subMonthNoOverflow()->startOfMonth(); + $endOfPrevMonth = $now->copy()->subMonthNoOverflow()->endOfMonth(); - $total_deposits = (float) Transaction::where('type', 'manual') - ->where('status', TransactionStatus::COMPLETED->value) + // Customers + $totalCustomers = Customer::count(); + $newThisMonth = Customer::where('created_at', '>=', $startOfMonth)->count(); + $newPrevMonth = Customer::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count(); + + // Deposits (transactions completed, type ∈ DEPOSIT_TYPES) + $depositBase = Transaction::whereIn('type', self::DEPOSIT_TYPES) + ->where('status', TransactionStatus::COMPLETED->value); + + $totalDeposits = (float) (clone $depositBase)->sum('amount'); + $depositsThisMonth = (float) (clone $depositBase) + ->where('created_at', '>=', $startOfMonth) ->sum('amount'); + $depositsPrevMonth = (float) (clone $depositBase) + ->whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth]) + ->sum('amount'); + + // Số dư ví khách hàng (cột customers.total) + $totalBalance = (float) Customer::sum('total'); + + // Services + $totalActiveServices = Service::where('status', 'active')->count(); + $expiring7d = Service::where('status', 'active') + ->whereNotNull('expires_at') + ->whereBetween('expires_at', [$now, $now->copy()->addDays(7)]) + ->count(); + + // Orders + $totalOrdersThisMonth = Order::where('created_at', '>=', $startOfMonth)->count(); + $totalOrdersPrevMonth = Order::whereBetween('created_at', [$startOfPrevMonth, $endOfPrevMonth])->count(); + $pendingOrders = Order::where('status', OrderStatus::PENDING->value)->count(); return response()->json([ 'error' => false, - 'data' => [ - 'total_customers' => $total_customers, - 'total_deposits' => $total_deposits, + 'data' => [ + 'total_customers' => $totalCustomers, + 'new_customers_this_month' => $newThisMonth, + 'new_customers_delta_pct' => $this->deltaPct($newThisMonth, $newPrevMonth), + + 'total_deposits' => $totalDeposits, + 'deposits_this_month' => $depositsThisMonth, + 'deposits_delta_pct' => $this->deltaPct($depositsThisMonth, $depositsPrevMonth), + + 'total_balance' => $totalBalance, + + 'total_active_services' => $totalActiveServices, + 'expiring_services_7d' => $expiring7d, + + 'total_orders_this_month' => $totalOrdersThisMonth, + 'pending_orders' => $pendingOrders, + 'orders_delta_pct' => $this->deltaPct($totalOrdersThisMonth, $totalOrdersPrevMonth), ], ]); } @@ -32,34 +119,322 @@ class DashboardController extends Controller { $year = $request->integer('year', now()->year); - $deposits = Transaction::where('type', 'manual') + $deposits = Transaction::whereIn('type', self::DEPOSIT_TYPES) ->where('status', TransactionStatus::COMPLETED->value) ->whereYear('created_at', $year) - ->selectRaw('MONTH(created_at) as month, SUM(amount) as total_deposits') - ->groupBy('month') - ->pluck('total_deposits', 'month'); + ->selectRaw('MONTH(created_at) as m, SUM(amount) as total') + ->groupBy('m') + ->pluck('total', 'm'); $newCustomers = Customer::whereYear('created_at', $year) - ->selectRaw('MONTH(created_at) as month, COUNT(*) as new_customers') - ->groupBy('month') - ->pluck('new_customers', 'month'); + ->selectRaw('MONTH(created_at) as m, COUNT(*) as c') + ->groupBy('m') + ->pluck('c', 'm'); - $months = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12']; + $orders = Order::whereYear('created_at', $year) + ->selectRaw('MONTH(created_at) as m, COUNT(*) as c') + ->groupBy('m') + ->pluck('c', 'm'); + + // Doanh thu dịch vụ = tổng invoice đã PAID trong tháng + $serviceRevenue = Invoice::where('status', InvoiceStatus::PAID->value) + ->whereYear('paid_at', $year) + ->selectRaw('MONTH(paid_at) as m, SUM(total) as total') + ->groupBy('m') + ->pluck('total', 'm'); + + $data = collect(range(1, 12))->map(fn ($m) => [ + 'month' => 'T' . $m, + 'deposits' => (float) ($deposits[$m] ?? 0), + 'new_customers' => (int) ($newCustomers[$m] ?? 0), + 'orders' => (int) ($orders[$m] ?? 0), + 'service_revenue' => (float) ($serviceRevenue[$m] ?? 0), + ])->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + + public function dailyStats(Request $request): JsonResponse + { + $month = $request->integer('month', now()->month); + $year = $request->integer('year', now()->year); + + $start = Carbon::create($year, $month, 1)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + $daysInMonth = $start->daysInMonth; + + $orders = Order::whereBetween('created_at', [$start, $end]) + ->selectRaw('DAY(created_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c', 'd'); + + $depositsAgg = Transaction::whereIn('type', self::DEPOSIT_TYPES) + ->where('status', TransactionStatus::COMPLETED->value) + ->whereBetween('created_at', [$start, $end]) + ->selectRaw('DAY(created_at) as d, COUNT(*) as c, SUM(amount) as total') + ->groupBy('d') + ->get() + ->keyBy('d'); + + $data = collect(range(1, $daysInMonth))->map(fn ($d) => [ + 'day' => str_pad((string) $d, 2, '0', STR_PAD_LEFT), + 'orders' => (int) ($orders[$d] ?? 0), + 'deposits_count' => (int) ($depositsAgg[$d]->c ?? 0), + 'deposits_amount' => (float) ($depositsAgg[$d]->total ?? 0), + ])->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + + public function recentOrders(Request $request): JsonResponse + { + $limit = (int) $request->integer('limit', 8); + $limit = max(1, min(50, $limit)); + + $orders = Order::with([ + 'customer:id,fullname,email', + 'orderItems:id,order_id,product_id', + 'orderItems.product:id,name', + 'invoice:id,order_id,status', + ]) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + + $items = $orders->map(function (Order $o) { + $firstItem = $o->orderItems->first(); + $serviceName = $firstItem?->product?->name ?? '—'; + $invoiceStatus = $o->invoice?->status ?? null; - $data = collect(range(1, 12))->map(function ($m) use ($months, $deposits, $newCustomers) { return [ - 'month' => $months[$m - 1], - 'deposits' => (float) ($deposits[$m] ?? 0), - 'new_customers' => (int) ($newCustomers[$m] ?? 0), + 'id' => $o->id, + 'code' => $o->order_num, + 'customer_id' => $o->customer_id, + 'customer_name' => $o->customer->fullname ?? '—', + 'customer_email' => $o->customer->email ?? '', + 'service_name' => $serviceName, + 'amount' => (float) $o->total, + 'payment_status' => $this->paymentStatusFromInvoice($invoiceStatus), + 'status' => $this->orderStatusLabel((int) $o->status), + 'created_at' => $o->created_at?->toISOString(), ]; })->values(); return response()->json([ 'error' => false, - 'data' => $data, + 'data' => ['items' => $items, 'total' => Order::count()], ]); } + public function recentDeposits(Request $request): JsonResponse + { + $limit = (int) $request->integer('limit', 6); + $limit = max(1, min(50, $limit)); + + $txs = Transaction::with('customer:id,fullname') + ->whereIn('type', self::DEPOSIT_TYPES) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + + $items = $txs->map(function (Transaction $t) { + $status = match ((int) $t->status) { + TransactionStatus::COMPLETED->value => 'success', + TransactionStatus::PENDING->value => 'pending', + TransactionStatus::FAILED->value => 'failed', + default => 'pending', + }; + + return [ + 'id' => $t->id, + 'code' => $t->public_code ?? $t->trans ?? ('TX-' . $t->id), + 'customer_id' => $t->customer_id, + 'customer_name' => $t->customer->fullname ?? '—', + 'amount' => (float) $t->amount, + 'method' => (string) ($t->type ?? 'manual'), + 'status' => $status, + 'created_at' => $t->created_at?->toISOString(), + ]; + })->values(); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $items, + 'total' => Transaction::whereIn('type', self::DEPOSIT_TYPES)->count(), + ], + ]); + } + + public function topServices(Request $request): JsonResponse + { + $limit = (int) $request->integer('limit', 5); + $limit = max(1, min(20, $limit)); + + $startOfMonth = now()->startOfMonth(); + $startOfPrevMonth = now()->copy()->subMonthNoOverflow()->startOfMonth(); + $endOfPrevMonth = now()->copy()->subMonthNoOverflow()->endOfMonth(); + + // Top theo số service đang active của từng product + $rows = Service::query() + ->select( + 'services.product_id', + 'products.name as product_name', + 'products.slug as product_slug', + DB::raw('COUNT(*) as active_count') + ) + ->join('products', 'products.id', '=', 'services.product_id') + ->where('services.status', 'active') + ->whereNotNull('services.product_id') + ->groupBy('services.product_id', 'products.name', 'products.slug') + ->orderByDesc('active_count') + ->limit($limit) + ->get(); + + $productIds = $rows->pluck('product_id')->all(); + + // Đã bán & doanh thu kỳ hiện tại / kỳ trước (theo paid invoice item) + $soldCurrent = DB::table('order_items') + ->join('orders', 'orders.id', '=', 'order_items.order_id') + ->whereIn('order_items.product_id', $productIds) + ->where('orders.created_at', '>=', $startOfMonth) + ->selectRaw('order_items.product_id, COUNT(*) as sold, SUM(order_items.price * order_items.quantity) as revenue') + ->groupBy('order_items.product_id') + ->get() + ->keyBy('product_id'); + + $soldPrev = DB::table('order_items') + ->join('orders', 'orders.id', '=', 'order_items.order_id') + ->whereIn('order_items.product_id', $productIds) + ->whereBetween('orders.created_at', [$startOfPrevMonth, $endOfPrevMonth]) + ->selectRaw('order_items.product_id, SUM(order_items.price * order_items.quantity) as revenue') + ->groupBy('order_items.product_id') + ->get() + ->keyBy('product_id'); + + $data = $rows->map(function ($r) use ($soldCurrent, $soldPrev) { + $cur = (float) ($soldCurrent[$r->product_id]->revenue ?? 0); + $prev = (float) ($soldPrev[$r->product_id]->revenue ?? 0); + + return [ + 'id' => (int) $r->product_id, + 'name' => $r->product_name, + 'code' => $r->product_slug, + 'active_count' => (int) $r->active_count, + 'sold_count' => (int) ($soldCurrent[$r->product_id]->sold ?? 0), + 'revenue' => $cur, + 'trend_pct' => $this->deltaPct($cur, $prev), + ]; + })->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + + public function expiringServices(Request $request): JsonResponse + { + $withinDays = (int) $request->integer('within_days', 7); + $withinDays = max(1, min(60, $withinDays)); + + $now = now(); + $until = $now->copy()->addDays($withinDays); + + $services = Service::with(['customer:id,fullname', 'product:id,name']) + ->where('status', 'active') + ->whereNotNull('expires_at') + ->whereBetween('expires_at', [$now, $until]) + ->orderBy('expires_at') + ->limit(20) + ->get(); + + $data = $services->map(function (Service $s) use ($now) { + $expires = $s->expires_at instanceof Carbon + ? $s->expires_at + : Carbon::parse((string) $s->expires_at); + + return [ + 'id' => $s->id, + 'service_name' => $s->product->name ?? ($s->server_name ?? 'Dịch vụ'), + 'customer_id' => $s->customer_id, + 'customer_name' => $s->customer->fullname ?? '—', + 'expired_at' => $expires->toISOString(), + 'days_left' => max(0, (int) $now->diffInDays($expires, false)), + 'amount' => 0.0, + ]; + })->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + + public function activities(Request $request): JsonResponse + { + $limit = (int) $request->integer('limit', 8); + $limit = max(1, min(30, $limit)); + + // Tổng hợp 3 nguồn rồi sort theo thời gian + $orders = Order::with('customer:id,fullname') + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->map(function (Order $o) { + $isCancelled = (int) $o->status === OrderStatus::CANCELLED->value; + $isActive = (int) $o->status === OrderStatus::ACTIVE->value; + $type = $isCancelled ? 'order_cancelled' : ($isActive ? 'order_completed' : 'order_created'); + + return [ + 'id' => 'order-' . $o->id, + 'type' => $type, + 'title' => 'Đơn ' . $o->order_num . ' ' . match ($type) { + 'order_cancelled' => 'đã huỷ', + 'order_completed' => 'đã kích hoạt', + default => 'được tạo', + }, + 'description' => ($o->customer->fullname ?? '—') . ' · ' . number_format((float) $o->total, 0, ',', '.') . 'đ', + 'created_at' => $o->created_at?->toISOString(), + '_ts' => $o->created_at?->timestamp ?? 0, + ]; + }); + + $deposits = Transaction::with('customer:id,fullname') + ->whereIn('type', self::DEPOSIT_TYPES) + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->map(function (Transaction $t) { + $ok = (int) $t->status === TransactionStatus::COMPLETED->value; + return [ + 'id' => 'tx-' . $t->id, + 'type' => $ok ? 'deposit_success' : 'deposit_failed', + 'title' => $ok ? 'Nạp tiền thành công' : 'Giao dịch nạp thất bại', + 'description' => ($t->customer->fullname ?? '—') . ' · ' . number_format((float) $t->amount, 0, ',', '.') . 'đ', + 'created_at' => $t->created_at?->toISOString(), + '_ts' => $t->created_at?->timestamp ?? 0, + ]; + }); + + $customers = Customer::orderByDesc('created_at') + ->limit($limit) + ->get(['id', 'fullname', 'email', 'created_at']) + ->map(fn (Customer $c) => [ + 'id' => 'customer-' . $c->id, + 'type' => 'customer_registered', + 'title' => 'Khách hàng mới đăng ký', + 'description' => ($c->fullname ?: $c->email) ?? '—', + 'created_at' => $c->created_at?->toISOString(), + '_ts' => $c->created_at?->timestamp ?? 0, + ]); + + $data = $orders->concat($deposits)->concat($customers) + ->sortByDesc('_ts') + ->take($limit) + ->map(function ($a) { + unset($a['_ts']); + return $a; + }) + ->values(); + + return response()->json(['error' => false, 'data' => $data]); + } + public function latestCustomers(): JsonResponse { $customers = Customer::orderByDesc('created_at') @@ -68,13 +443,13 @@ class DashboardController extends Controller return response()->json([ 'error' => false, - 'data' => [ - 'items' => $customers->map(fn(Customer $c) => [ - 'id' => $c->id, - 'uuid' => $c->uuid, - 'fullname' => $c->fullname, - 'email' => $c->email, - 'total' => (float) $c->total, + 'data' => [ + 'items' => $customers->map(fn (Customer $c) => [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'fullname' => $c->fullname, + 'email' => $c->email, + 'total' => (float) $c->total, 'created_at' => $c->created_at?->toISOString(), ])->values(), 'total' => Customer::count(), diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php index 6976eee..2d9b9a7 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php @@ -20,9 +20,10 @@ class DomainController extends Controller public function index(Request $request): JsonResponse { - $perPage = min((int) $request->get('per_page', 15), 500); - $filter = $request->get('filter'); - $status = $request->get('status'); + $perPage = min((int) $request->get('per_page', 15), 500); + $filter = $request->get('filter'); + $status = $request->get('status'); + $customerName = trim((string) $request->get('customer_name', '')); $query = $this->domainRepository->model ->with(['server', 'customer']) @@ -36,6 +37,12 @@ class DomainController extends Controller $query->where('is_created', (int) $status); } + if ($customerName !== '') { + $query->whereHas('customer', function ($q) use ($customerName) { + $q->where('fullname', 'like', "%{$customerName}%"); + }); + } + $paginator = $query->orderByDesc('id')->paginate($perPage); $items = $paginator->getCollection()->map(fn($domain) => $this->formatDomain($domain)); diff --git a/app/Http/Controllers/Api/Admin/Provider/ActiveInfraController.php b/app/Http/Controllers/Api/Admin/Provider/ActiveInfraController.php new file mode 100644 index 0000000..784b602 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Provider/ActiveInfraController.php @@ -0,0 +1,79 @@ +service->resolveChain(); + } catch (Throwable $e) { + return response()->json([ + 'error' => true, + 'message' => $e->getMessage(), + ], 422); + } + + return response()->json([ + 'error' => false, + 'data' => [ + 'vcenter' => $chain['vcenter']->only(['id', 'host', 'user', 'status']), + 'datacenter' => $chain['datacenter']->only(['id', 'name', 'datacenter_id', 'status']), + 'host' => $chain['host']->only(['id', 'name', 'host_id', 'status']), + 'datastore' => $chain['datastore']->only(['id', 'name', 'datastore', 'status']), + 'network' => $chain['network']->only(['id', 'name', 'network', 'status']), + ], + ]); + } + + /** POST /administrator/provider/{type}/{id}/activate */ + public function activate(string $type, int $id): JsonResponse + { + try { + $row = $this->service->activate($type, $id); + } catch (Throwable $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } + + return response()->json([ + 'error' => false, + 'data' => $row, + ]); + } + + /** POST /administrator/provider/{type}/{id}/deactivate */ + public function deactivate(string $type, int $id): JsonResponse + { + try { + $row = $this->service->deactivate($type, $id); + } catch (Throwable $e) { + return response()->json(['error' => true, 'message' => $e->getMessage()], 422); + } + + return response()->json([ + 'error' => false, + 'data' => $row, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Provider/ProviderSettingController.php b/app/Http/Controllers/Api/Admin/Provider/ProviderSettingController.php new file mode 100644 index 0000000..079c5d0 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Provider/ProviderSettingController.php @@ -0,0 +1,73 @@ +service->all(); + + // Mirror current truth: active vCenter id from module_vcenter + $data['active_vcenter_id'] = vCenter::where('status', 1)->value('id'); + + return response()->json([ + 'error' => false, + 'data' => $data, + ]); + } + + /** PUT /administrator/provider/settings */ + public function update(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'default_folder' => 'sometimes|string|max:100', + 'default_resource_pool' => 'sometimes|string|max:100', + 'default_datastore' => 'sometimes|string|max:100', + 'name' => 'sometimes|nullable|string|max:120', + 'region' => 'sometimes|nullable|string|max:60', + 'tags' => 'sometimes|array', + 'tags.*' => 'string|max:40', + ]); + + if ($validator->fails()) { + return response()->json([ + 'error' => true, + 'message' => $validator->errors()->first(), + ], 422); + } + + $patch = $validator->validated(); + + // active_vcenter_id is read-only — strip if client sent it + unset($patch['active_vcenter_id']); + + $merged = $this->service->merge($patch); + $merged['active_vcenter_id'] = vCenter::where('status', 1)->value('id'); + + return response()->json([ + 'error' => false, + 'data' => $merged, + ]); + } +} diff --git a/app/Http/Services/vCenter/VirtualMachineService.php b/app/Http/Services/vCenter/VirtualMachineService.php index 5df5d71..6ff6eca 100644 --- a/app/Http/Services/vCenter/VirtualMachineService.php +++ b/app/Http/Services/vCenter/VirtualMachineService.php @@ -8,6 +8,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Str; use App\Http\Services\Discord; use App\Models\ModuleDatastore; +use App\Services\VirtualMachine\VmProviderSettingsService; use Exception; class VirtualMachineService @@ -77,11 +78,13 @@ class VirtualMachineService 'addon' => $addon ]); - $datastores = ModuleDatastore::where('module_host_id', $host->id)->get(); + /** @var VmProviderSettingsService $providerSettings */ + $providerSettings = app(VmProviderSettingsService::class); - $datastoreId = $datastores->isNotEmpty() - ? $datastores->first()->datastore - : 'datastore-3004'; + $activeDatastore = ModuleDatastore::where('module_host_id', $host->id) + ->where('status', 1) + ->first(); + $datastoreId = $activeDatastore?->datastore ?? $providerSettings->defaultDatastore(); $body = [ 'spec' => [ @@ -89,9 +92,9 @@ class VirtualMachineService 'source' => $template, 'placement' => [ 'datacenter' => $datacenter->datacenter_id, - 'folder' => 'group-v1008', + 'folder' => $providerSettings->defaultFolder(), 'host' => $host->host_id, - 'resource_pool' => 'resgroup-3002', + 'resource_pool' => $providerSettings->defaultResourcePool(), 'datastore' => $datastoreId, ], 'power_on' => false, diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index ac63b5b..72b04cb 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -9,6 +9,7 @@ use App\Jobs\VirtualMachine\Provision\AssignIpToGuestJob; use App\Models\OrderItem; use App\Services\VirtualMachine\VmAddonResolver; use App\Services\VirtualMachine\VmInfrastructureResolver; +use App\Services\VirtualMachine\VmIpReservationService; use App\Services\VirtualMachine\VmModuleService; use App\Services\VirtualMachine\VmNotificationService; use App\Services\VirtualMachine\VmPowerService; @@ -50,7 +51,11 @@ class CreateVirtualMachineJob implements ShouldQueue VmServiceSyncService $serviceSync, VmNotificationService $notification, VmVcenterErrorTranslator $errorTranslator, + VmIpReservationService $ipReservation, ): bool { + $reservation = null; + $ipPoolId = null; + try { $vmLogger->step('Create VM job started', [ 'order_item_id' => $this->item->id, @@ -66,6 +71,39 @@ class CreateVirtualMachineJob implements ShouldQueue $infra = $infraResolver->resolve($this->item, $product); $addonData = $addonResolver->resolve($this->item); + // Reserve IP BEFORE clone so VM name on vCenter, ModuleVmware.name + // and services.server_name all carry the format {ip}_{server_name}. + // Network comes from the active chain (active host → active network); + // product->ipAddress is no longer consulted for network selection. + if (!$infra->network) { + throw new RuntimeException("Active host id={$infra->host->id} chưa có network active."); + } + $networkId = $infra->network->id; + $dns = $product->ipAddress->dns ?? '8.8.8.8'; + + $picked = $ipReservation->reserve($networkId, $infra->host->id, $dns); + + if (!$picked) { + throw new RuntimeException( + "Không còn IP free trong pool (network_id={$networkId}) để tạo VM." + ); + } + + $reservation = $picked['reservation']; + $ipPoolId = $picked['ip_pool_id']; + $reservedIp = $reservation->ip; + $originalName = $this->item->server_name ?? ('VM-' . $this->item->id); + $combinedName = "{$reservedIp}_{$originalName}"; + + $this->item->server_name = $combinedName; + + $vmLogger->step('IP reserved for VM', [ + 'order_item_id' => $this->item->id, + 'ip' => $reservedIp, + 'ip_pool_id' => $ipPoolId, + 'server_name' => $combinedName, + ]); + if (!$vCenter->loginServer($infra->vcenter)) { throw new RuntimeException("Login vào vCenter thất bại với host: {$infra->vcenter->host}"); } @@ -142,6 +180,8 @@ class CreateVirtualMachineJob implements ShouldQueue (string) $vmId, $infra->isWindows ? VmProvisionService::OS_WINDOWS : VmProvisionService::OS_LINUX, VmProvisionService::TEMPLATE_PASSWORD, + $reservation->id, + $ipPoolId, )->onQueue('default'); $vmLogger->success('AssignIpToGuestJob dispatched', [ @@ -160,6 +200,10 @@ class CreateVirtualMachineJob implements ShouldQueue return true; } catch (Throwable $e) { + if ($reservation) { + $ipReservation->release($reservation, $ipPoolId); + } + $vmLogger->exception('Create VM job failed', $e, [ 'order_item_id' => $this->item->id, 'is_retry' => $this->isRetry, diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 4b06c92..51387aa 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -58,6 +58,8 @@ class AssignIpToGuestJob implements ShouldQueue public readonly string $vmId, public readonly string $osFamily, // VmProvisionService::OS_LINUX | OS_WINDOWS public readonly string $guestPassword = VmProvisionService::TEMPLATE_PASSWORD, + public readonly ?int $reservationId = null, + public readonly ?int $reservedIpPoolId = null, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -103,16 +105,22 @@ class AssignIpToGuestJob implements ShouldQueue $guestUser = strtoupper($this->osFamily) === 'WINDOWS' ? 'Administrator' : 'root'; // ─── Attempt loop ──────────────────────────────────────────────────── + // If reservationId is supplied, the IP was reserved upstream (CreateVirtualMachineJob) + // and baked into the VM name — we can't swap IPs on retry, so we run a single attempt. + $maxAttempts = $this->reservationId ? 1 : self::MAX_ATTEMPTS; - for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) { + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { Log::info('AssignIpToGuestJob: attempt', [ 'attempt' => $attempt, 'module_vmware_id' => $this->moduleVmwareId, 'network_id' => $networkId, + 'preserved' => (bool) $this->reservationId, ]); - // [3] Reserve a free IP with transaction + row lock - $picked = $this->reserveIp($networkId, $hostId, $dns); + // [3] Use upstream reservation if provided, else reserve a fresh IP. + $picked = $this->reservationId + ? $this->loadReservation($this->reservationId, $this->reservedIpPoolId) + : $this->reserveIp($networkId, $hostId, $dns); if (!$picked) { Log::error('AssignIpToGuestJob: no free IPs in pool', [ @@ -220,7 +228,7 @@ class AssignIpToGuestJob implements ShouldQueue Log::error('AssignIpToGuestJob: all attempts failed — marking VM inactive', [ 'module_vmware_id' => $this->moduleVmwareId, 'vm_id' => $this->vmId, - 'attempts' => self::MAX_ATTEMPTS, + 'attempts' => $maxAttempts, ]); ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); @@ -336,6 +344,34 @@ class AssignIpToGuestJob implements ShouldQueue }); } + /** + * Load a reservation that was created upstream (e.g. by CreateVirtualMachineJob). + * Returns the same shape as reserveIp() so the rest of the flow is identical. + * + * @return array{reservation:ModuleIpAddress,ip_pool_id:int,gateway:string,cidr:int}|null + */ + private function loadReservation(int $reservationId, ?int $ipPoolId): ?array + { + $reservation = ModuleIpAddress::find($reservationId); + if (!$reservation) { + Log::error('AssignIpToGuestJob: upstream reservation missing', [ + 'reservation_id' => $reservationId, + ]); + return null; + } + + $ipPool = $ipPoolId ? IpPool::find($ipPoolId) : null; + $cidr = (int) ($ipPool->cidr ?? 24); + $gateway = (string) ($reservation->gateway ?: ($ipPool->gateway ?? '')); + + return [ + 'reservation' => $reservation, + 'ip_pool_id' => $ipPoolId ?? 0, + 'gateway' => $gateway, + 'cidr' => $cidr ?: 24, + ]; + } + /** * Release a failed reservation by deleting the pending ModuleIpAddress row. * This returns the IP to the available pool. diff --git a/app/Mail/Customer/Welcome.php b/app/Mail/Customer/Welcome.php new file mode 100644 index 0000000..75f00de --- /dev/null +++ b/app/Mail/Customer/Welcome.php @@ -0,0 +1,64 @@ +payload()['company_name'] ?? config('app.name', 'The Gioi So'); + + return new Envelope( + subject: 'Chào mừng bạn đến với ' . $appName, + ); + } + + public function content(): Content + { + $data = $this->payload(); + + return new Content( + view: 'emails.customer.welcome', + with: [ + 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'fullName' => $data['fullname'] ?? 'Quý khách', + 'loginUrl' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', + 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function payload(): array + { + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + } catch (\Throwable) { + $settingValue = []; + } + + return array_merge([ + 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'), + 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')), + 'login_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', + ], $this->data); + } +} diff --git a/app/Models/ModuleDatacenter.php b/app/Models/ModuleDatacenter.php index 06b7e34..029a3e1 100644 --- a/app/Models/ModuleDatacenter.php +++ b/app/Models/ModuleDatacenter.php @@ -14,11 +14,17 @@ class ModuleDatacenter extends Model protected $fillable = [ 'name', 'datacenter_id', - 'vcenter_id' + 'vcenter_id', + 'status', ]; public function vCenter() { return $this->belongsTo(\App\Models\vCenter::class,'vcenter_id','id'); } + + public function hosts() + { + return $this->hasMany(\App\Models\ModuleHost::class, 'module_datacenter_id', 'id'); + } } diff --git a/app/Models/ModuleHost.php b/app/Models/ModuleHost.php index b5f40b6..a0f38eb 100644 --- a/app/Models/ModuleHost.php +++ b/app/Models/ModuleHost.php @@ -17,7 +17,8 @@ class ModuleHost extends Model 'template', 'module_datacenter_id', 'console', - 'iso_path' + 'iso_path', + 'status', ]; public function datacenter() diff --git a/app/Models/ModuleNetwork.php b/app/Models/ModuleNetwork.php index 43fec7c..c177a70 100644 --- a/app/Models/ModuleNetwork.php +++ b/app/Models/ModuleNetwork.php @@ -15,7 +15,8 @@ class ModuleNetwork extends Model 'name', 'type', 'network', - 'module_host_id' + 'module_host_id', + 'status', ]; public function host() diff --git a/app/Repositories/Setting/SettingCachedRepository.php b/app/Repositories/Setting/SettingCachedRepository.php index 35f1370..59fee72 100644 --- a/app/Repositories/Setting/SettingCachedRepository.php +++ b/app/Repositories/Setting/SettingCachedRepository.php @@ -29,7 +29,7 @@ class SettingCachedRepository extends BaseCachedRepository implements SettingRep return $this->settingRepository->updateOrCreateByKey($key, $data); } - public function clearCacheByKey(string $key) + public function clearCacheByKey(string $key): void { $cacheKey = $this->getCacheKey("setting_getByKey:{$key}"); Cache::forget($cacheKey); diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 72145ab..7ca466e 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -2,9 +2,12 @@ namespace App\Services\Api\Client; +use App\Mail\Customer\Welcome as WelcomeMail; use App\Models\RefreshToken; use App\Repositories\Api\Client\ClientRepositoryInterface; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Tymon\JWTAuth\Facades\JWTAuth; @@ -71,6 +74,8 @@ class ClientAuthService $token = JWTAuth::fromUser($client); $refreshToken = $this->createRefreshToken($client->id); + $this->sendWelcomeEmail($client); + return [ 'client' => $client, 'token' => $token, @@ -78,6 +83,24 @@ class ClientAuthService ]; } + /** + * Gửi email chào mừng cho khách hàng mới đăng ký. + */ + protected function sendWelcomeEmail($client): void + { + try { + Mail::to($client->email)->send(new WelcomeMail([ + 'fullname' => $client->fullname, + 'email' => $client->email, + ])); + } catch (\Throwable $e) { + Log::warning('Send welcome email failed: ' . $e->getMessage(), [ + 'customer_id' => $client->id, + 'email' => $client->email, + ]); + } + } + /** * Tạo refresh token mới, lưu hash vào DB, trả về raw token. */ diff --git a/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php index 360f78e..c0dbb3c 100644 --- a/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php +++ b/app/Services/VirtualMachine/Dto/VmInfrastructureContext.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Services\VirtualMachine\Dto; +use App\Models\ModuleDatastore; use App\Models\ModuleHost; +use App\Models\ModuleNetwork; use App\Models\Os; use App\Models\vCenter; @@ -19,6 +21,8 @@ class VmInfrastructureContext public readonly bool $isWindows, public readonly string $vmUser, public readonly int $vmPort, + public readonly ?ModuleDatastore $datastore = null, + public readonly ?ModuleNetwork $network = null, ) { } } diff --git a/app/Services/VirtualMachine/VmActiveProviderService.php b/app/Services/VirtualMachine/VmActiveProviderService.php new file mode 100644 index 0000000..cfdfc06 --- /dev/null +++ b/app/Services/VirtualMachine/VmActiveProviderService.php @@ -0,0 +1,226 @@ + [vCenter::class, null, [self::TYPE_DATACENTER]], + self::TYPE_DATACENTER => [ModuleDatacenter::class, 'vcenter_id', [self::TYPE_HOST]], + self::TYPE_HOST => [ModuleHost::class, 'module_datacenter_id', [self::TYPE_DATASTORE, self::TYPE_NETWORK]], + self::TYPE_DATASTORE => [ModuleDatastore::class, 'module_host_id', []], + self::TYPE_NETWORK => [ModuleNetwork::class, 'module_host_id', []], + ]; + + public function __construct(private readonly VmProviderSettingsService $providerSettings) + { + } + + // ───────────────────────────────────────────────────────────────────────── + // Resolve helpers + // ───────────────────────────────────────────────────────────────────────── + + public function activeVcenter(): ?vCenter + { + return vCenter::where('status', 1)->first(); + } + + public function activeDatacenter(int $vcenterId): ?ModuleDatacenter + { + return ModuleDatacenter::where('vcenter_id', $vcenterId) + ->where('status', 1) + ->first(); + } + + public function activeHost(int $datacenterId): ?ModuleHost + { + return ModuleHost::where('module_datacenter_id', $datacenterId) + ->where('status', 1) + ->first(); + } + + public function activeDatastore(int $hostId): ?ModuleDatastore + { + return ModuleDatastore::where('module_host_id', $hostId) + ->where('status', 1) + ->first(); + } + + public function activeNetwork(int $hostId): ?ModuleNetwork + { + return ModuleNetwork::where('module_host_id', $hostId) + ->where('status', 1) + ->first(); + } + + /** + * Return the full active chain. Throws if any layer has no active row. + * + * @return array{vcenter:vCenter,datacenter:ModuleDatacenter,host:ModuleHost,datastore:ModuleDatastore,network:ModuleNetwork} + */ + public function resolveChain(): array + { + $vcenter = $this->activeVcenter(); + if (!$vcenter) { + throw new RuntimeException('Không có vCenter nào đang active. Vui lòng kích hoạt 1 vCenter trong admin.'); + } + + $datacenter = $this->activeDatacenter($vcenter->id); + if (!$datacenter) { + throw new RuntimeException("vCenter active id={$vcenter->id} chưa có Datacenter nào active."); + } + + $host = $this->activeHost($datacenter->id); + if (!$host) { + throw new RuntimeException("Datacenter active id={$datacenter->id} chưa có Host nào active."); + } + + $datastore = $this->activeDatastore($host->id); + if (!$datastore) { + throw new RuntimeException("Host active id={$host->id} chưa có Datastore nào active."); + } + + $network = $this->activeNetwork($host->id); + if (!$network) { + throw new RuntimeException("Host active id={$host->id} chưa có Network nào active."); + } + + return compact('vcenter', 'datacenter', 'host', 'datastore', 'network'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Mutators + // ───────────────────────────────────────────────────────────────────────── + + /** + * Set this row active, deactivating all siblings in the same parent scope. + */ + public function activate(string $type, int $id): Model + { + [$modelClass, $parentColumn] = $this->mapOrFail($type); + + return DB::transaction(function () use ($modelClass, $parentColumn, $type, $id) { + /** @var Model $row */ + $row = $modelClass::query()->lockForUpdate()->findOrFail($id); + + // Deactivate siblings under same parent (or all rows if global scope) + $siblings = $modelClass::query()->where('id', '!=', $row->getKey()); + if ($parentColumn) { + $siblings->where($parentColumn, $row->{$parentColumn}); + } + $siblings->update(['status' => 0]); + + $row->status = 1; + $row->save(); + + if ($type === self::TYPE_VCENTER) { + $this->providerSettings->setActiveVcenterId((int) $row->getKey()); + } + + return $row->refresh(); + }); + } + + /** + * Set this row inactive AND cascade-deactivate every descendant beneath it. + */ + public function deactivate(string $type, int $id): Model + { + [$modelClass] = $this->mapOrFail($type); + + return DB::transaction(function () use ($modelClass, $type, $id) { + /** @var Model $row */ + $row = $modelClass::query()->lockForUpdate()->findOrFail($id); + + $row->status = 0; + $row->save(); + + $this->cascadeDeactivate($type, (int) $row->getKey()); + + if ($type === self::TYPE_VCENTER) { + $this->providerSettings->setActiveVcenterId(null); + } + + return $row->refresh(); + }); + } + + /** + * Recursively set status=0 on every descendant of $parentId under $type. + */ + private function cascadeDeactivate(string $type, int $parentId): void + { + $childTypes = self::MAP[$type][2] ?? []; + + foreach ($childTypes as $childType) { + [$childModel, $childParentColumn] = $this->mapOrFail($childType); + if (!$childParentColumn) { + continue; + } + + $childIds = $childModel::query() + ->where($childParentColumn, $parentId) + ->pluck('id') + ->all(); + + if (!$childIds) { + continue; + } + + $childModel::query()->whereIn('id', $childIds)->update(['status' => 0]); + + foreach ($childIds as $childId) { + $this->cascadeDeactivate($childType, (int) $childId); + } + } + } + + /** + * @return array{0:class-string,1:?string,2:string[]} + */ + private function mapOrFail(string $type): array + { + if (!isset(self::MAP[$type])) { + throw new RuntimeException("Unknown infra type: {$type}"); + } + return self::MAP[$type]; + } +} diff --git a/app/Services/VirtualMachine/VmInfrastructureResolver.php b/app/Services/VirtualMachine/VmInfrastructureResolver.php index a7040b8..7c8731d 100644 --- a/app/Services/VirtualMachine/VmInfrastructureResolver.php +++ b/app/Services/VirtualMachine/VmInfrastructureResolver.php @@ -4,22 +4,31 @@ declare(strict_types=1); namespace App\Services\VirtualMachine; -use App\Models\ModuleHost; use App\Models\OrderItem; use App\Models\Os; use App\Services\VirtualMachine\Dto\VmInfrastructureContext; use RuntimeException; +/** + * Resolves the provisioning target by walking the ACTIVE chain only: + * + * active vCenter → active Datacenter → active Host → active Datastore + Network + * + * Product / OS bindings are now informational only — the host/datacenter/vcenter + * configured per-product no longer drive VM creation. The whole platform clones + * onto whatever single chain is currently marked status=1. + */ class VmInfrastructureResolver { - public function __construct(private readonly VmProvisionLogger $logger) - { + public function __construct( + private readonly VmProvisionLogger $logger, + private readonly VmActiveProviderService $activeProvider, + ) { } public function resolve(OrderItem $item, $product): VmInfrastructureContext { - $os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter']) - ->find($item->os_id); + $os = Os::with('osGroup')->find($item->os_id); if (!$os) { throw new RuntimeException( @@ -27,63 +36,31 @@ class VmInfrastructureResolver ); } - // Priority 1: host attached directly to OS - $host = $os->host; - $datacenter = $host?->datacenter; - $vcenter = $datacenter?->vCenter; + $chain = $this->activeProvider->resolveChain(); - // Priority 2: host from OS group fallback - if (!$host || !$datacenter || !$vcenter) { - $groupHost = $os->osGroup?->host; - $host = $host ?? $groupHost; - $datacenter = $datacenter ?? $groupHost?->datacenter; - $vcenter = $vcenter ?? $groupHost?->datacenter?->vCenter; - - if ($groupHost) { - $this->logger->step('Infrastructure fallback: osGroup host', [ - 'os_id' => $os->id, - 'os_group_id' => $os->os_group_id, - 'host_id' => $groupHost->id, - ]); - } - } - - // Priority 3: host from product (Provisioning tab) - if ((!$host || !$datacenter || !$vcenter) && $product && $product->module_host_id) { - $productHost = ModuleHost::with('datacenter.vCenter')->find($product->module_host_id); - if ($productHost) { - $host = $host ?? $productHost; - $datacenter = $datacenter ?? $productHost->datacenter; - $vcenter = $vcenter ?? $productHost->datacenter?->vCenter; - - $this->logger->step('Infrastructure fallback: product host', [ - 'product_id' => $product->id, - 'host_id' => $productHost->id, - 'datacenter_id' => $productHost->datacenter?->id, - ]); - } - } - - if (!$vcenter || !$host || !$datacenter) { - throw new RuntimeException( - "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #" . ($product->id ?? 'unknown') . ". " - . "Vui lòng cấu hình vCenter trong tab Provisioning của sản phẩm, " - . "hoặc gán host cho OS group của OS được chọn." - ); - } + $this->logger->step('Infrastructure resolved from active chain', [ + 'order_item_id' => $item->id, + 'vcenter_id' => $chain['vcenter']->id, + 'datacenter_id' => $chain['datacenter']->id, + 'host_id' => $chain['host']->id, + 'datastore_id' => $chain['datastore']->id, + 'network_id' => $chain['network']->id, + ]); $osGroupName = $os->osGroup?->name ?? ''; $isWindows = str_contains(strtolower($osGroupName), 'windows'); return new VmInfrastructureContext( os: $os, - host: $host, - datacenter: $datacenter, - vcenter: $vcenter, + host: $chain['host'], + datacenter: $chain['datacenter'], + vcenter: $chain['vcenter'], template: $os->template_id, isWindows: $isWindows, vmUser: $isWindows ? 'administrator' : 'root', vmPort: $isWindows ? 3389 : 22, + datastore: $chain['datastore'], + network: $chain['network'], ); } } diff --git a/app/Services/VirtualMachine/VmIpReservationService.php b/app/Services/VirtualMachine/VmIpReservationService.php new file mode 100644 index 0000000..bf50fd3 --- /dev/null +++ b/app/Services/VirtualMachine/VmIpReservationService.php @@ -0,0 +1,92 @@ +lockForUpdate() + ->pluck('ip') + ->toArray(); + + $ipPool = IpPool::where('module_network_id', $networkId) + ->where('status', 'free') + ->whereNotNull('gateway') + ->where('gateway', '!=', '') + ->whereNotIn('ip_address', $usedIps) + ->lockForUpdate() + ->inRandomOrder() + ->first(); + + if (!$ipPool) { + return null; + } + + $ipPool->update(['status' => 'reserved']); + + $cidr = (int) ($ipPool->cidr ?: 24); + $gateway = (string) ($ipPool->gateway ?? ''); + $netmask = $this->cidrToNetmask($cidr); + + $reservation = ModuleIpAddress::create([ + 'vm_name' => '__pending__', + 'module_host_id' => $hostId, + 'module_network_id' => $networkId, + 'ip' => $ipPool->ip_address, + 'gateway' => $gateway, + 'netmask' => $netmask, + 'dns' => $dns, + 'status' => 0, + ]); + + return [ + 'reservation' => $reservation, + 'ip_pool_id' => $ipPool->id, + 'gateway' => $gateway, + 'cidr' => $cidr, + ]; + }); + } + + public function release(ModuleIpAddress $reservation, ?int $ipPoolId): void + { + try { + $reservation->delete(); + if ($ipPoolId) { + IpPool::where('id', $ipPoolId)->update(['status' => 'free']); + } + Log::info('VmIpReservationService: IP released', ['ip' => $reservation->ip]); + } catch (\Throwable $e) { + Log::warning('VmIpReservationService: release failed', [ + 'ip' => $reservation->ip, + 'error' => $e->getMessage(), + ]); + } + } + + public function cidrToNetmask(int $cidr): string + { + $cidr = max(0, min(32, $cidr)); + return long2ip(-1 << (32 - $cidr)); + } +} diff --git a/app/Services/VirtualMachine/VmProviderSettingsService.php b/app/Services/VirtualMachine/VmProviderSettingsService.php new file mode 100644 index 0000000..2a97fb4 --- /dev/null +++ b/app/Services/VirtualMachine/VmProviderSettingsService.php @@ -0,0 +1,87 @@ + null, + 'default_folder' => 'group-v1008', + 'default_resource_pool' => 'resgroup-3002', + 'default_datastore' => 'datastore-3004', + 'name' => null, + 'region' => null, + 'tags' => [], + ]; + + public function __construct(private readonly SettingRepositoryInterface $settings) + { + } + + /** @return array */ + public function all(): array + { + $raw = $this->settings->getByKey(self::KEY); + $data = is_string($raw) ? json_decode($raw, true) : []; + return array_merge(self::DEFAULTS, is_array($data) ? $data : []); + } + + public function get(string $field, mixed $default = null): mixed + { + return $this->all()[$field] ?? $default; + } + + /** Merge-write a subset of fields. */ + public function merge(array $patch): array + { + $merged = array_merge($this->all(), $patch); + $this->settings->updateOrCreateByKey(self::KEY, [ + 'value' => json_encode($merged, JSON_UNESCAPED_UNICODE), + ]); + return $merged; + } + + public function setActiveVcenterId(?int $id): void + { + $this->merge(['active_vcenter_id' => $id]); + } + + public function defaultFolder(): string + { + return (string) $this->get('default_folder', self::DEFAULTS['default_folder']); + } + + public function defaultResourcePool(): string + { + return (string) $this->get('default_resource_pool', self::DEFAULTS['default_resource_pool']); + } + + public function defaultDatastore(): string + { + return (string) $this->get('default_datastore', self::DEFAULTS['default_datastore']); + } +} diff --git a/database/migrations/2026_06_15_103255_add_active_status_to_infra_tables.php b/database/migrations/2026_06_15_103255_add_active_status_to_infra_tables.php new file mode 100644 index 0000000..929ddb7 --- /dev/null +++ b/database/migrations/2026_06_15_103255_add_active_status_to_infra_tables.php @@ -0,0 +1,104 @@ +tinyInteger('status')->default(0)->after('iso_path'); + }); + } + + if (!Schema::hasColumn('module_network', 'status')) { + Schema::table('module_network', function (Blueprint $table) { + $table->tinyInteger('status')->default(0)->after('module_host_id'); + }); + } + + $this->backfillOneActiveGlobal('module_vcenter'); + $this->backfillOneActivePerParent('module_datacenter', 'vcenter_id'); + $this->backfillOneActivePerParent('module_host', 'module_datacenter_id'); + $this->backfillOneActivePerParent('module_datastore', 'module_host_id'); + $this->backfillOneActivePerParent('module_network', 'module_host_id'); + } + + public function down(): void + { + if (Schema::hasColumn('module_network', 'status')) { + Schema::table('module_network', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } + if (Schema::hasColumn('module_host', 'status')) { + Schema::table('module_host', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } + } + + /** Keep one global active row (smallest id wins), zero out the rest. */ + private function backfillOneActiveGlobal(string $table): void + { + $activeId = DB::table($table)->where('status', 1)->orderBy('id')->value('id') + ?? DB::table($table)->orderBy('id')->value('id'); + + if (!$activeId) { + return; + } + + DB::table($table)->where('id', '!=', $activeId)->update(['status' => 0]); + DB::table($table)->where('id', $activeId)->update(['status' => 1]); + } + + /** For each parent value, keep one child active (smallest id wins). */ + private function backfillOneActivePerParent(string $table, string $parentColumn): void + { + $parents = DB::table($table)->select($parentColumn)->distinct()->pluck($parentColumn); + + foreach ($parents as $parentId) { + if ($parentId === null) { + continue; + } + + $activeId = DB::table($table) + ->where($parentColumn, $parentId) + ->where('status', 1) + ->orderBy('id') + ->value('id'); + + $activeId = $activeId ?? DB::table($table) + ->where($parentColumn, $parentId) + ->orderBy('id') + ->value('id'); + + if (!$activeId) { + continue; + } + + DB::table($table) + ->where($parentColumn, $parentId) + ->where('id', '!=', $activeId) + ->update(['status' => 0]); + + DB::table($table)->where('id', $activeId)->update(['status' => 1]); + } + } +}; diff --git a/resources/views/emails/customer/welcome.blade.php b/resources/views/emails/customer/welcome.blade.php new file mode 100644 index 0000000..0792dfa --- /dev/null +++ b/resources/views/emails/customer/welcome.blade.php @@ -0,0 +1,67 @@ + + + + + + Chào mừng đến với {{ $appName }} + + + +
+
+
+
🎉
+

Chào mừng bạn đến với {{ $appName }}

+

Tài khoản của bạn đã được tạo thành công

+
+ +
+

+ Xin chào {{ $fullName }}, +

+

+ Chào mừng bạn đến với {{ $appName }}! + Tài khoản của bạn đã được khởi tạo thành công và sẵn sàng sử dụng. +

+

+ Tại {{ $appName }}, bạn có thể: +

+
    +
  • Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền
  • +
  • Đặt hàng nhanh chóng, thanh toán an toàn
  • +
  • Theo dõi lịch sử đơn hàng và quản lý dịch vụ dễ dàng
  • +
  • Nhận ưu đãi và khuyến mãi độc quyền dành cho khách hàng
  • +
+ + + +

+ Cảm ơn bạn đã tin tưởng và đồng hành cùng {{ $appName }}. + Chúc bạn có những trải nghiệm tuyệt vời! +

+
+ + +
+
+ + diff --git a/routes/api.php b/routes/api.php index d4706a6..114c673 100644 --- a/routes/api.php +++ b/routes/api.php @@ -382,12 +382,36 @@ Route::prefix('v1') Route::post('media/upload', [AdminSettingController::class, 'uploadMedia']); + // Provider (active vCenter chain + provider settings — module: module-vcenter) + Route::prefix('provider') + ->group(function () { + Route::get('active-chain', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'chain']) + ->middleware('perm:module-vcenter.view'); + Route::post('{type}/{id}/activate', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'activate']) + ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) + ->middleware('perm:module-vcenter.edit'); + Route::post('{type}/{id}/deactivate', [\App\Http\Controllers\Api\Admin\Provider\ActiveInfraController::class, 'deactivate']) + ->whereIn('type', ['vcenter', 'datacenter', 'host', 'datastore', 'network']) + ->middleware('perm:module-vcenter.edit'); + + Route::get('settings', [\App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, 'index']) + ->middleware('perm:module-vcenter.view'); + Route::put('settings', [\App\Http\Controllers\Api\Admin\Provider\ProviderSettingController::class, 'update']) + ->middleware('perm:module-vcenter.edit'); + }); + // Dashboard Stats Route::prefix('dashboard') ->controller(\App\Http\Controllers\Api\Admin\Dashboard\DashboardController::class) ->group(function () { Route::get('stats', 'stats'); Route::get('monthly-stats', 'monthlyStats'); + Route::get('daily-stats', 'dailyStats'); + Route::get('recent-orders', 'recentOrders'); + Route::get('recent-deposits', 'recentDeposits'); + Route::get('top-services', 'topServices'); + Route::get('expiring-services', 'expiringServices'); + Route::get('activities', 'activities'); Route::get('latest-customers', 'latestCustomers'); });