feat: add GeneratesUniqueSlug class for unique slug generation

feat: create PostCategoryFactory for generating post category data

feat: create PostFactory for generating post data

feat: add retry_requested_at column to services table

feat: add vcenter provisioning fields to products table

feat: add module_host_id to os table

feat: create email_notification_templates table for email templates

feat: add scenario and format fields to template table

feat: add admin fields to transactions table

fix: update ticket table columns and types

feat: add uuid to ticket table and backfill existing records

fix: make ticket_reply user_id nullable

feat: create notifications table for user notifications

fix: make ticket uuid nullable

feat: create post_categories table for blog categories

feat: create posts table for blog posts

feat: add invoice email template for PDF generation
This commit is contained in:
baodev0
2026-05-25 15:39:42 +07:00
parent a31d238135
commit 520f21cb11
73 changed files with 4682 additions and 440 deletions
@@ -7,6 +7,7 @@ use App\Http\Services\vCenter;
use App\Jobs\Mail\Customer\VMCreationFailedJob;
use App\Jobs\VirtualMachine\Provision\ChangePasswordViaVsphereJob;
use App\Services\VmProvisionService;
use App\Models\ModuleHost;
use App\Models\OrderItem;
use App\Models\Os;
use App\Models\ModuleVmware;
@@ -62,7 +63,7 @@ class CreateVirtualMachineJob implements ShouldQueue
return false;
}
$osModel = Os::with('osGroup.host.datacenter.vcenter')->find($this->item->os_id);
$osModel = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($this->item->os_id);
if (!$osModel) {
$errorMessage = "Không tìm thấy OS id={$this->item->os_id} trong OrderItem #{$this->item->id}. os_id không hợp lệ.";
@@ -81,20 +82,59 @@ class CreateVirtualMachineJob implements ShouldQueue
}
$template = $osModel->template_id;
$host = $osModel->osGroup?->host; // từ os_group.module_host_id
// Ưu tiên 1: host gán trực tiếp trên OS
$host = $osModel->host;
$datacenter = $host?->datacenter;
$vcenter = $datacenter?->vcenter;
$vcenter = $datacenter?->vCenter;
// Ưu tiên 2: host từ osGroup (cũ)
if (!$host || !$datacenter || !$vcenter) {
$groupHost = $osModel->osGroup?->host;
$host = $host ?? $groupHost;
$datacenter = $datacenter ?? $groupHost?->datacenter;
$vcenter = $vcenter ?? $groupHost?->datacenter?->vCenter;
if ($groupHost) {
Log::info('CreateVirtualMachineJob: dùng host từ osGroup làm fallback', [
'os_id' => $osModel->id,
'os_group_id' => $osModel->os_group_id,
'host_id' => $groupHost->id,
]);
}
}
// Ưu tiên 3: host từ sản phẩm (cấu hình tab Provisioning)
if ((!$host || !$datacenter || !$vcenter) && $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;
Log::info('CreateVirtualMachineJob: dùng host từ sản phẩm làm fallback', [
'product_id' => $product->id,
'host_id' => $productHost->id,
'datacenter_id' => $productHost->datacenter?->id,
]);
}
}
if (!$vcenter || !$host || !$datacenter) {
$errorMessage = "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #{$product->id}";
$errorMessage = "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #{$product->id}. "
. "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.";
Log::error($errorMessage);
$telegram->SendErrorMessage([
'error' => $errorMessage,
'product_id' => $product->id,
'order_item_id' => $this->item->id,
'os_id' => $this->item->os_id,
'os_group_name' => $osModel->osGroup?->name,
'product_module_host_id' => $product->module_host_id,
'host_id' => $host?->id,
'datacenter_id' => $datacenter?->id,
'vcenter_id' => $vcenter?->id
'vcenter_id' => $vcenter?->id,
], 'CreateVirtualMachineJob - Missing Infrastructure');
if (!$this->isRetry) {
@@ -171,7 +211,7 @@ class CreateVirtualMachineJob implements ShouldQueue
]);
Log::info('CreateVirtualMachineJob - Result: ' . json_encode($result));
if (is_array($result) && ($result['error'] ?? false)) {
$errorMessage = 'Tạo máy ảo thất bại: ' . json_encode($result);
$errorMessage = $this->translateVcenterError($result);
Log::warning($errorMessage);
$telegram->SendErrorMessage([
'error' => $result['error'] ?? 'Unknown error',
@@ -490,6 +530,32 @@ class CreateVirtualMachineJob implements ShouldQueue
* Gửi email thông báo VM đã khởi tạo thành công cho khách hàng.
* Gọi ngay sau khi tạo module_vmware — cho cả Linux lẫn Windows.
*/
/**
* Dịch error từ vCenter API sang tiếng Việt dễ hiểu.
*/
private function translateVcenterError(array $result): string
{
$data = $result['data'] ?? [];
$errorType = is_array($data) ? ($data['error_type'] ?? '') : '';
$serverName = $this->item->server_name ?? '';
$map = [
'ALREADY_EXISTS' => "Tên máy chủ \"{$serverName}\" đã tồn tại trong hệ thống. Vui lòng liên hệ hỗ trợ để được xử lý hoặc chọn tên máy chủ khác.",
'NOT_FOUND' => 'Không tìm thấy template hoặc tài nguyên trên vCenter. Vui lòng liên hệ hỗ trợ.',
'RESOURCE_INACCESSIBLE' => 'Tài nguyên vCenter không khả dụng. Vui lòng thử lại sau hoặc liên hệ hỗ trợ.',
'INVALID_ARGUMENT' => 'Thông tin cấu hình máy chủ không hợp lệ. Vui lòng liên hệ hỗ trợ.',
'UNABLE_TO_ALLOCATE_RESOURCE' => 'Không đủ tài nguyên để khởi tạo máy chủ. Vui lòng liên hệ hỗ trợ.',
'TIMED_OUT' => 'Yêu cầu khởi tạo máy chủ hết thời gian chờ. Vui lòng thử lại sau.',
];
if ($errorType && isset($map[$errorType])) {
return $map[$errorType];
}
// Fallback: không để lộ JSON thô ra email khách hàng
return "Không thể khởi tạo máy chủ ảo lúc này. Chúng tôi đã ghi nhận và sẽ xử lý trong thời gian sớm nhất. Vui lòng liên hệ hỗ trợ nếu cần.";
}
private function getBillingCycleLabel(?int $months): string
{
return match ($months) {
@@ -205,17 +205,28 @@ class RetryProvisioningOrchestrator implements ShouldQueue
/**
* Resolve vcenter from DB chain:
* Path 1: order_items.os_id → os → osGroup → host → datacenter → vcenter
* Path 2: invoices → invoice_items → product_id → os_product → os → osGroup → host → datacenter → vcenter
* Path 3: module_vmware with same order_id (partial provisioning)
* Path 1a: order_items.os_id → os.host → datacenter → vcenter (ưu tiên: host gán trực tiếp trên OS)
* Path 1b: order_items.os_id → os → osGroup → host → datacenter → vcenter
* Path 2a: invoices → product → os_product → os.host → datacenter → vcenter
* Path 2b: invoices → product → os_product → os → osGroup → host → datacenter → vcenter
* Path 3: module_vmware with same order_id (partial provisioning)
*/
private function resolveVcenter(OrderItem $item, Telegram $telegram): ?VcenterModel
{
if ($item->os_id) {
$os = Os::with('osGroup.host.datacenter.vCenter')->find($item->os_id);
$os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($item->os_id);
// Path 1a: host gán trực tiếp trên OS
$vcenter = $os?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via os.host chain', ['os_id' => $item->os_id, 'vcenter_id' => $vcenter->id]);
return $vcenter;
}
// Path 1b: host qua osGroup (cũ)
$vcenter = $os?->osGroup?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via os → osGroup chain', ['vcenter_id' => $vcenter->id]);
Log::info('resolveVcenter: found via os → osGroup chain', ['os_id' => $item->os_id, 'vcenter_id' => $vcenter->id]);
return $vcenter;
}
}
@@ -229,10 +240,19 @@ class RetryProvisioningOrchestrator implements ShouldQueue
->first();
if ($osRecord) {
$os = Os::with('osGroup.host.datacenter.vCenter')->find($osRecord->os_id);
$os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($osRecord->os_id);
// Path 2a: host gán trực tiếp trên OS
$vcenter = $os?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via invoice → os.host chain', ['vcenter_id' => $vcenter->id]);
return $vcenter;
}
// Path 2b: host qua osGroup
$vcenter = $os?->osGroup?->host?->datacenter?->vCenter;
if ($vcenter) {
Log::info('resolveVcenter: found via invoice_items → os_product chain', ['vcenter_id' => $vcenter->id]);
Log::info('resolveVcenter: found via invoice → os → osGroup chain', ['vcenter_id' => $vcenter->id]);
return $vcenter;
}
}
@@ -80,24 +80,22 @@ class SendVmCredentialsEmailJob implements ShouldQueue
$isWindows = str_contains(strtolower($osGroupName), 'windows');
$osUsername = $isWindows ? 'Administrator' : 'root';
$emailData = [
$emailData = $this->safeUtf8([
'company_name' => $settingValue->company ?? 'Thế Giới Số',
'company_logo' => $settingValue->logo ?? '',
'client_name' => $customer->fullname ?? '',
'client_name' => $customer->fullname ?? '',
'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS',
'product_billing' => $this->billingLabel($months),
'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A',
'service_host' => $vm->ip ?? 'N/A',
'service_username' => $osUsername,
'service_port' => $vm->port,
'service_password' => $vm->plain_password ?? '(xem tài khoản quản lý)',
'os_name' => $vm->os
? ($osGroupName . ' ' . $vm->os->version)
: 'Unknown OS',
'service_port' => (string) ($vm->port ?? '22'),
'service_password' => $vm->plain_password ?? '(xem tai khoan quan ly)',
'os_name' => $vm->os ? ($osGroupName . ' ' . $vm->os->version) : 'Unknown OS',
'service_due_exp' => $vm->next_due_date
? date('d/m/Y', (int) $vm->next_due_date)
: 'N/A',
];
]);
// PDF is generated inside the mail job — never pass binary blobs through queue payload
$invoice = Invoice::where('order_id', $vm->order_id)->first();
@@ -198,12 +196,34 @@ class SendVmCredentialsEmailJob implements ShouldQueue
private function billingLabel(?int $months): string
{
return match ($months) {
1 => 'Hàng tháng',
3 => '3 Tháng',
6 => '6 Tháng',
12 => 'Hàng năm',
24 => '2 Năm',
default => ($months ?? 0) . ' Tháng',
1 => 'Hang thang',
3 => '3 Thang',
6 => '6 Thang',
12 => 'Hang nam',
24 => '2 Nam',
default => ($months ?? 0) . ' Thang',
};
}
/**
* Sanitize tất cả string trong array thành UTF-8 hợp lệ.
* Loại bỏ ký tự binary/invalid trước khi đẩy vào queue (tránh JSON encode lỗi).
*/
private function safeUtf8(array $data): array
{
return array_map(function ($value) {
if (!is_string($value)) return $value;
// Nếu đã là UTF-8 hợp lệ → giữ nguyên
if (mb_check_encoding($value, 'UTF-8')) return $value;
// Thử convert từ encoding khác sang UTF-8
$converted = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'ISO-8859-1');
// Nếu vẫn invalid → strip ký tự lỗi
return mb_check_encoding($converted, 'UTF-8')
? $converted
: preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|[^\x09\x0A\x0D\x20-\x{10FFFF}]/u', '', $value);
}, $data);
}
}