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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user