Refactor VM resource specs resolution and enhance order payment success email with addon summary; add UpdateVmConfigJob for CPU/RAM updates

This commit is contained in:
baodev0
2026-07-17 14:00:42 +07:00
parent d9da3b5bb3
commit ad20cd5bdc
12 changed files with 458 additions and 33 deletions
@@ -12,6 +12,25 @@ class ServiceClientDetailResource extends JsonResource
*
* @return array<string, mixed>
*/
private function resolveSpecs($product): ?array
{
$addons = $this->addons ?? collect();
if ($addons->isNotEmpty()) {
return [
'cpu' => (int) $addons->where('addon_type', 'cpu')->sum('addon_value'),
'ram' => (int) $addons->where('addon_type', 'ram')->sum('addon_value'),
'disk' => (int) $addons->where('addon_type', 'disk')->sum('addon_value'),
'bandwidth' => (int) $addons->where('addon_type', 'bandwidth')->sum('addon_value'),
];
}
return $product ? [
'cpu' => (int) ($product->cpu ?? 0),
'ram' => (int) ($product->ram ?? 0),
'disk' => (int) ($product->disk ?? 0),
'bandwidth' => (int) ($product->bandwidth ?? 0),
] : null;
}
public function toArray(Request $request): array
{
if (!$this->resource) {
@@ -35,12 +54,7 @@ class ServiceClientDetailResource extends JsonResource
'version' => $orderItem?->os?->version,
'group' => $orderItem?->os?->osGroup?->name,
],
'specs' => $product ? [
'cpu' => (int) ($product->cpu ?? 0),
'ram' => (int) ($product->ram ?? 0),
'disk' => (int) ($product->disk ?? 0),
'bandwidth' => (int) ($product->bandwidth ?? 0),
] : null,
'specs' => $this->resolveSpecs($product),
'order' => $order ? [
'total' => (float) $order->total,
'months' => (int) $orderItem->months,
@@ -12,6 +12,25 @@ class VpsServiceResource extends JsonResource
*
* @return array<string, mixed>
*/
private function resolveSpecs($product): ?array
{
$addons = $this->addons ?? collect();
if ($addons->isNotEmpty()) {
return [
'cpu' => (int) $addons->where('addon_type', 'cpu')->sum('addon_value'),
'ram' => (int) $addons->where('addon_type', 'ram')->sum('addon_value'),
'disk' => (int) $addons->where('addon_type', 'disk')->sum('addon_value'),
'bandwidth' => (int) $addons->where('addon_type', 'bandwidth')->sum('addon_value'),
];
}
return $product ? [
'cpu' => (int) ($product->cpu ?? 0),
'ram' => (int) ($product->ram ?? 0),
'disk' => (int) ($product->disk ?? 0),
'bandwidth' => (int) ($product->bandwidth ?? 0),
] : null;
}
public function toArray(Request $request): array
{
$orderItem = $this->orderItem;
@@ -43,13 +62,7 @@ class VpsServiceResource extends JsonResource
'product_type' => $product->product_type,
'page_type' => 'service_vps',
] : null,
'specs' => $product ? [
'cpu' => (int) ($product->cpu ?? 0),
'ram' => (int) ($product->ram ?? 0),
'disk' => (int) ($product->disk ?? 0),
'bandwidth' => (int) ($product->bandwidth ?? 0),
'gpu' => $product->gpu,
] : null,
'specs' => $this->resolveSpecs($product),
];
}
}
@@ -265,21 +265,51 @@ class ProcessOrderPaymentJob implements ShouldQueue
{
try {
$customer = $this->order->customer;
// Gửi email thông báo thành công
\App\Jobs\Mail\Customer\OrderPaymentSuccessJob::dispatch([
'customer_email' => $customer->email,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'order_id' => $this->order->id,
'order_num' => $this->order->order_num,
'amount' => $this->order->amount,
'vm_results' => $vmResults
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'order_id' => $this->order->id,
'order_num' => $this->order->order_num,
'amount' => $this->order->amount,
'vm_results' => $vmResults,
'addons_summary' => $this->resolveAddonsSummary(),
]);
} catch (\Throwable $e) {
Log::error('Không thể gửi email thông báo thành công: ' . $e->getMessage());
}
}
/**
* Resolve addon display rows from all order items' addons_meta.
* Returns array of [{name, qty, subtotal}] for email rendering.
*
* @return array<int, array{name:string, qty:int, subtotal:int}>
*/
private function resolveAddonsSummary(): array
{
$rows = [];
$this->order->load('orderItems');
foreach ($this->order->orderItems as $item) {
$meta = is_array($item->addons_meta) ? $item->addons_meta : [];
foreach ($meta as $entry) {
$addon = \App\Models\Addon::find($entry['addon_id'] ?? null);
if (!$addon) {
continue;
}
$qty = (int) ($entry['qty'] ?? 0);
$rows[] = [
'name' => $addon->name,
'qty' => $qty,
'subtotal' => (int) ($entry['subtotal'] ?? $addon->price * $qty),
];
}
}
return $rows;
}
/**
* Check if the error is a timeout error
*/
@@ -31,7 +31,7 @@ use Illuminate\Support\Facades\Log;
*
* Assigns a real static IP to the VM guest OS and verifies it is active.
* Only persists the IP to DB (module_vmware + virtual_machines) after
* successful guest-side verification, then dispatches ChangePasswordViaVsphereJob.
* successful guest-side verification, then dispatches UpdateVmConfigJob → ChangePasswordViaVsphereJob.
*
* Flow:
* [1] Resolve network config from order item → product → ipAddress template
@@ -41,7 +41,7 @@ use Illuminate\Support\Facades\Log;
* [5] On FAIL → release IP, try next
* [6] Call verifyIPLinux.ps1 / verifyIPWindows.ps1 via SSH
* [7] On FAIL → release IP, try next
* [8] SUCCESS → persistIp() → dispatch ChangePasswordViaVsphereJob
* [8] SUCCESS → persistIp() → dispatch UpdateVmConfigJob → ChangePasswordViaVsphereJob
* [9] All attempts exhausted → mark VM inactive (status=0)
*/
class AssignIpToGuestJob implements ShouldQueue
@@ -184,7 +184,7 @@ class AssignIpToGuestJob implements ShouldQueue
'attempt' => $attempt,
]);
ChangePasswordViaVsphereJob::dispatch(
UpdateVmConfigJob::dispatch(
$this->moduleVmwareId,
$this->vmId,
$this->osFamily,
@@ -226,8 +226,8 @@ class AssignIpToGuestJob implements ShouldQueue
'attempt' => $attempt,
]);
// Dispatch password change now that IP is confirmed
ChangePasswordViaVsphereJob::dispatch(
// Dispatch addon config update → then password change
UpdateVmConfigJob::dispatch(
$this->moduleVmwareId,
$this->vmId,
$this->osFamily,
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Jobs\VirtualMachine\Provision;
use App\Http\Facade\SignRSA;
use App\Http\Services\Telegram;
use App\Models\Addon;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\vCenter;
use App\Services\VirtualMachine\VmProvisionLogger;
use App\Services\VsphereHostSshService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* UpdateVmConfigJob
*
* Applies customer-selected CPU/RAM addons (from addons_meta on the OrderItem)
* to the live vSphere VM via updateVMConfig.ps1.
*
* Skipped if the order has no cpu/ram addon → goes straight to ChangePasswordViaVsphereJob.
*
* PS1 on vSphere host must accept:
* $server_name, $cpu, $memoryGB, $vcenter_host, $vcenter_user, $vcenter_password
* and call Connect-VIServer before Get-VM.
*
* Flow:
* [1] Load ModuleVmware → order → orderItems.first() → product
* [2] Resolve addon cpu/ram from addons_meta (code field saved by OrderService)
* [3] If no cpu/ram addon → skip, dispatch ChangePasswordViaVsphereJob
* [4] Load vCenter credentials (same pattern as ChangePasswordViaVsphereJob)
* [5] Compute total: base (product.cpu/ram) + addon qty
* [6] SSH → vSphere → updateVMConfig.ps1 with vCenter creds
* [7] Assert SUCCESS → dispatch ChangePasswordViaVsphereJob
*/
class UpdateVmConfigJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 180;
public int $backoff = 30;
public function __construct(
public readonly int $moduleVmwareId,
public readonly string $vmId,
public readonly string $osFamily,
) {}
public function handle(VsphereHostSshService $ssh, VmProvisionLogger $logger, Telegram $telegram): void
{
$logger->for('UpdateVmConfigJob');
$logger->step('UpdateVmConfigJob started', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$moduleVmware = ModuleVmware::with(['order.orderItems.product', 'vcenter'])->find($this->moduleVmwareId);
if (!$moduleVmware) {
Log::error('UpdateVmConfigJob: ModuleVmware not found', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$this->nextStep();
return;
}
$orderItem = $moduleVmware->order?->orderItems?->first();
$product = $orderItem?->product;
$addonsMeta = is_array($orderItem?->addons_meta) ? $orderItem->addons_meta : [];
[$addonCpu, $addonRamGb] = $this->resolveAddonCpuRam($addonsMeta);
if ($addonCpu === 0 && $addonRamGb === 0) {
$logger->step('no cpu/ram addon — skipping UpdateVmConfigJob', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
]);
$this->nextStep();
return;
}
// Load vCenter credentials — same pattern as ChangePasswordViaVsphereJob
$vcenter = $moduleVmware->vcenter ?? vCenter::first();
if (!$vcenter) {
$msg = "UpdateVmConfigJob: no vCenter record found for server [{$moduleVmware->name}]";
Log::error($msg, ['module_vmware_id' => $this->moduleVmwareId, 'server_name' => $moduleVmware->name]);
$telegram->SendMessage("🚨 <b>UpdateVmConfigJob: Lỗi</b>\n⚠️ Không tìm thấy vCenter\n🖥️ Server: <code>{$moduleVmware->name}</code>\n🕒 " . Carbon::now()->format('d/m/Y H:i:s'));
throw new \RuntimeException($msg);
}
$vcenterHost = $vcenter->host;
$vcenterUser = $vcenter->user;
$vcenterPass = SignRSA::DecryptFunc($vcenter->password);
$serverName = (string) $moduleVmware->name;
$totalCpu = ((int) ($product?->cpu ?? 0)) + $addonCpu;
$totalRam = ((int) ($product?->ram ?? 0)) + $addonRamGb;
$logger->step('calling updateVMConfig.ps1', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'vcenter_host' => $vcenterHost,
'addon_cpu' => $addonCpu,
'addon_ram_gb' => $addonRamGb,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
]);
$output = $ssh->updateVmConfig(
$serverName,
$totalCpu,
$totalRam,
$vcenterHost,
$vcenterUser,
$vcenterPass,
);
Log::info('UpdateVmConfigJob: PS1 output', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'output' => trim($output),
]);
if (!str_contains(strtoupper(trim($output)), 'SUCCESS')) {
$msg = "updateVMConfig.ps1 failed for server [{$serverName}]. Output: " . trim($output);
Log::error('UpdateVmConfigJob: script returned FAIL', [
'module_vmware_id' => $this->moduleVmwareId,
'server_name' => $serverName,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
'output' => trim($output),
'attempt' => $this->attempts(),
'max_tries' => $this->tries,
]);
$telegram->SendMessage(
"⚠️ <b>UpdateVmConfigJob thất bại</b>\n" .
"🖥️ Server: <code>{$serverName}</code>\n" .
"🔁 Lần thử: {$this->attempts()}/{$this->tries}\n" .
"💻 CPU: {$totalCpu} | RAM: {$totalRam} GB\n" .
"📋 Output: <code>" . htmlspecialchars(trim($output) ?: '(trống)') . "</code>\n" .
"🕒 " . Carbon::now()->format('d/m/Y H:i:s')
);
throw new \RuntimeException($msg);
}
$logger->success('VM config updated via addon script', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'total_cpu' => $totalCpu,
'total_ram_gb' => $totalRam,
'output' => trim($output),
]);
$this->nextStep();
}
public function failed(\Throwable $e): void
{
Log::error('UpdateVmConfigJob: all retries exhausted', [
'module_vmware_id' => $this->moduleVmwareId,
'vm_id' => $this->vmId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]);
Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']);
try {
$telegram = app(Telegram::class);
$vm = ModuleVmware::with(['order.customer'])->find($this->moduleVmwareId);
$customer = $vm?->order?->customer;
$telegram->SendMessage(
"🚨 <b>UpdateVmConfigJob: Thất bại hoàn toàn</b>\n" .
"━━━━━━━━━━━━━━━━━━━━\n" .
"🖥️ VM ID: <code>{$this->vmId}</code>\n" .
"📦 Server: <code>" . ($vm?->name ?? 'N/A') . "</code>\n" .
"👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" .
"📧 Email: " . ($customer?->email ?? 'N/A') . "\n" .
"❌ Lỗi: <code>" . htmlspecialchars($e->getMessage()) . "</code>\n" .
"⚡ Trạng thái: VM đã bị đánh dấu inactive\n" .
"🕒 " . Carbon::now()->format('d/m/Y H:i:s') . "\n" .
"━━━━━━━━━━━━━━━━━━━━\n" .
"⚠️ Vui lòng kiểm tra và xử lý thủ công!"
);
} catch (\Throwable $te) {
Log::warning('UpdateVmConfigJob: could not send Telegram failure notification', [
'error' => $te->getMessage(),
]);
}
}
// ─────────────────────────────────────────────────────────────────────────
/**
* Resolve cpu/ram qty from addons_meta.
* Uses 'code' field saved by OrderService::resolveAddons() — no extra DB query needed.
* Falls back to Addon::find() if 'code' is missing (legacy meta without code).
*
* @param array<int, array{addon_id?:int, qty?:int, code?:string}> $addonsMeta
* @return array{int, int} [cpu_count, ram_gb]
*/
private function resolveAddonCpuRam(array $addonsMeta): array
{
$cpu = 0;
$ram = 0;
foreach ($addonsMeta as $meta) {
$qty = (int) ($meta['qty'] ?? 0);
$code = strtolower((string) ($meta['code'] ?? ''));
// Fallback: look up code from DB if not in meta (old orders)
if ($code === '' && isset($meta['addon_id'])) {
$addon = Addon::find($meta['addon_id']);
$code = strtolower((string) ($addon?->code ?? ''));
}
if ($code === 'cpu') {
$cpu += $qty;
} elseif ($code === 'ram') {
$ram += $qty;
}
}
return [$cpu, $ram];
}
private function nextStep(): void
{
ChangePasswordViaVsphereJob::dispatch(
$this->moduleVmwareId,
$this->vmId,
$this->osFamily,
);
}
}
+8 -7
View File
@@ -41,13 +41,14 @@ class OrderPaymentSuccess extends Mailable
return new Content(
view: 'emails.customer.order-payment-success',
with: [
'customerName' => $this->data['customer_name'] ?? 'Khách hàng',
'orderId' => $this->data['order_id'] ?? 'N/A',
'orderNum' => $this->data['order_num'] ?? 'N/A',
'amount' => $this->data['amount'] ?? 0,
'vmResults' => $this->data['vm_results'] ?? [],
'supportEmail' => config('mail.support_email', 'support@example.com'),
'supportPhone' => config('mail.support_phone', '1900-xxxx'),
'customerName' => $this->data['customer_name'] ?? 'Khách hàng',
'orderId' => $this->data['order_id'] ?? 'N/A',
'orderNum' => $this->data['order_num'] ?? 'N/A',
'amount' => $this->data['amount'] ?? 0,
'vmResults' => $this->data['vm_results'] ?? [],
'addonsSummary' => $this->data['addons_summary'] ?? [],
'supportEmail' => config('mail.support_email', 'support@example.com'),
'supportPhone' => config('mail.support_phone', '1900-xxxx'),
],
);
}
@@ -50,7 +50,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn
'due_date' => now()->addDays(7)->toDateString(),
]);
// Create one invoice_item per order_item line
// Create one invoice_item per order_item line + addon sub-lines
foreach ($order->orderItems as $item) {
InvoiceItem::create([
'invoice_id' => $invoice->id,
@@ -62,6 +62,27 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn
'unit_price' => $item->price,
'total_price' => $item->price * ($item->quantity ?? 1),
]);
$addonsMeta = is_array($item->addons_meta) ? $item->addons_meta : [];
foreach ($addonsMeta as $meta) {
$qty = (int) ($meta['qty'] ?? 1);
$price = (int) ($meta['price'] ?? 0);
$subtotal = (int) ($meta['subtotal'] ?? $price * $qty);
$name = $meta['name'] ?? ('Addon #' . ($meta['addon_id'] ?? '?'));
$code = strtoupper($meta['code'] ?? '');
$label = $code ? "[{$code}] {$name}" : $name;
InvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $item->product_id,
'type' => 'addon',
'description' => $label,
'months' => $this->cycleToMonths($item->billing_cycle),
'quantity' => $qty,
'unit_price' => $price,
'total_price' => $subtotal,
]);
}
}
return $invoice;
@@ -20,6 +20,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
$query = $this->model
->with([
'product',
'addons',
'invoice.order',
'orderItem' => fn($q) => $q->with(['product', 'os.osGroup', 'order']),
])
@@ -60,6 +61,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
{
return $this->model
->with([
'addons',
'orderItem.os.osGroup',
'orderItem.product',
'orderItem.order',
+2
View File
@@ -308,6 +308,8 @@ class OrderService
$qty = max(1, (int) ($entry['qty'] ?? 1));
$resolved[] = [
'addon_id' => $addon->id,
'code' => strtolower((string) $addon->code),
'name' => $addon->name,
'qty' => $qty,
'price' => $addon->price,
'subtotal' => $addon->price * $qty,
+18
View File
@@ -7,6 +7,7 @@ use App\Enums\Order\OrderStatus;
use App\Enums\TransactionStatus;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Models\Addon;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Log as CustomerActivityLog;
@@ -210,6 +211,23 @@ class InvoicePaymentService
}
}
}
$addonsMeta = is_array($item->addons_meta)
? $item->addons_meta
: json_decode($item->addons_meta ?? '[]', true);
foreach ((array) $addonsMeta as $meta) {
$addon = Addon::find($meta['addon_id'] ?? null);
if (!$addon) {
continue;
}
ServiceAddon::create([
'service_id' => $service->id,
'addon_type' => strtolower((string) $addon->code),
'addon_value' => (string) ($meta['qty'] ?? 1),
'price' => (int) ($meta['price'] ?? $addon->price),
]);
}
}
private function cycleToMonths(?string $cycle): int
+46
View File
@@ -36,6 +36,9 @@ final class VsphereHostSshService
private const SCRIPT_CHANGE_PWD_LINUX = 'changePwdLinux.ps1';
private const SCRIPT_CHANGE_PWD_WINDOWS = 'changePwdWindows.ps1';
private const ADDON_DIR = 'C:\\CLISave\\Addon';
private const SCRIPT_UPDATE_VM_CONFIG = 'updateVMConfig.ps1';
private const CONNECT_TIMEOUT = 20;
// ─────────────────────────────────────────────────────────────────────────
@@ -257,6 +260,49 @@ final class VsphereHostSshService
];
}
/**
* Update VM CPU and memory via updateVMConfig.ps1.
*
* Script path: C:\CLISave\Addon\updateVMConfig.ps1
* Params: $server_name $cpu $memoryGB $vcenter_host $vcenter_user $vcenter_password
* Output: "SUCCESS" or "FAIL"
*
* PS1 must call Connect-VIServer before Get-VM — see updateVMConfig.ps1 on vSphere host.
*/
public function updateVmConfig(
string $serverName,
int $cpu,
int $memoryGB,
string $vcenterHost,
string $vcenterUser,
string $vcenterPassword,
): string {
$remoteCmd = $this->windowsPowerShellCommand(self::ADDON_DIR, self::SCRIPT_UPDATE_VM_CONFIG, [
$serverName,
(string) $cpu,
(string) $memoryGB,
$vcenterHost,
$vcenterUser,
$vcenterPassword,
]);
Log::info('VsphereHostSshService: executing updateVmConfig', [
'server_name' => $serverName,
'cpu' => $cpu,
'memoryGB' => $memoryGB,
'vcenter_host' => $vcenterHost,
]);
$output = $this->sshExec($remoteCmd);
Log::info('VsphereHostSshService: updateVmConfig output', [
'server_name' => $serverName,
'output' => trim($output),
]);
return $output;
}
/**
* Customer-initiated VM password change.
*
@@ -26,6 +26,11 @@
.vm-box .vm-row:last-child { border-bottom: none; }
.vm-box .vm-label { color: #15803d; font-size: 13px; width: 120px; flex-shrink: 0; }
.vm-box .vm-value { font-family: 'Courier New', monospace; font-size: 14px; font-weight: 700; color: #111827; word-break: break-all; }
.addon-table { width: 100%; border-collapse: collapse; margin-top: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
.addon-table th { padding: 8px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; background: #f1f5f9; text-align: left; border-bottom: 1px solid #e2e8f0; }
.addon-table td { padding: 9px 14px; font-size: 13px; color: #374151; border-bottom: 1px solid #f1f5f9; }
.addon-table tr:last-child td { border-bottom: none; }
.addon-table .td-num { font-weight: 600; color: #111827; text-align: right; }
.amount-box { background: #fffbeb; border: 1.5px solid #fbbf24; border-radius: 8px; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
.amount-box span { font-size: 13px; color: #92400e; }
.amount-box strong { font-size: 20px; font-weight: 800; color: #b45309; }
@@ -86,6 +91,28 @@
@endforeach
@endif
@if(!empty($addonsSummary))
<div class="section-label">Gói nâng cấp cấu hình (Addon)</div>
<table class="addon-table">
<thead>
<tr>
<th>Dịch vụ addon</th>
<th style="text-align:right">Số lượng</th>
<th style="text-align:right">Thành tiền</th>
</tr>
</thead>
<tbody>
@foreach($addonsSummary as $addon)
<tr>
<td>{{ $addon['name'] }}</td>
<td class="td-num">{{ $addon['qty'] }}</td>
<td class="td-num">{{ number_format($addon['subtotal'], 0, ',', '.') }} VNĐ</td>
</tr>
@endforeach
</tbody>
</table>
@endif
<div class="notice">
⚠️ Vui lòng <strong>đổi mật khẩu ngay</strong> sau khi đăng nhập lần đầu để bảo mật tài khoản.<br>
Nếu cần hỗ trợ, liên hệ: <strong>{{ $supportEmail ?? '' }}</strong> hoặc <strong>{{ $supportPhone ?? '' }}</strong>