This commit is contained in:
baodev0
2026-09-23 15:17:18 +07:00
parent cd38c5d6d0
commit 82a71244ca
107 changed files with 3332 additions and 3306 deletions
+3 -2
View File
@@ -18,8 +18,9 @@ class UserFactory extends Factory
public function definition()
{
return [
'firstname' => "Sysmtem",
'lastname' => "Admin",
// Bảng `user` dùng một cột `fullname`; firstname/lastname không tồn tại nên mọi
// test cần tài khoản admin đều chết ở đây với "Unknown column 'firstname'".
'fullname' => 'System Admin',
'username' => "admin",
'email' => 'admin@tgs.com.vn',
'email_verified_at' => now(),
@@ -0,0 +1,81 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (DB::table('template')->where('scenario', 'vm_credentials')->exists()) {
return;
}
$variables = [
['key' => 'app_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Quý khách'],
['key' => 'product_name', 'label' => 'Tên dịch vụ', 'type' => 'string', 'example' => 'Cloud VPS Pro'],
['key' => 'product_billing_row', 'label' => '[Tự động] Dòng chu kỳ thanh toán', 'type' => 'html', 'example' => ''],
['key' => 'product_price_row', 'label' => '[Tự động] Dòng giá dịch vụ', 'type' => 'html', 'example' => ''],
['key' => 'os_name_row', 'label' => '[Tự động] Dòng hệ điều hành', 'type' => 'html', 'example' => ''],
['key' => 'service_host', 'label' => 'Địa chỉ IP', 'type' => 'string', 'example' => '1.2.3.4'],
['key' => 'service_username', 'label' => 'Tài khoản đăng nhập', 'type' => 'string', 'example' => 'root'],
['key' => 'service_password', 'label' => 'Mật khẩu', 'type' => 'string', 'example' => 'P@ssw0rd123'],
['key' => 'service_port_row', 'label' => '[Tự động] Dòng cổng kết nối', 'type' => 'html', 'example' => ''],
['key' => 'service_due_exp_row', 'label' => '[Tự động] Dòng hạn sử dụng', 'type' => 'html', 'example' => ''],
['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'],
];
$message = <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr class="Header"><td style="width: 700px;" valign="top"><table align="center" width="700" bgcolor="#ffffff" style="border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 0;"><a href="https://tgs.com.vn" style="display: block;" target="_blank" rel="noopener"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số" style="width: 100%; display: block; border: none;" width="700"></a></td></tr></tbody></table></td></tr>
<tr class="NoiDungKM"><td style="width: 700px; padding: 25px; background-color: #ffffff;" valign="top">
<p style="text-align: center;"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786503590/email_banner_cloud_vps.png" alt="VPS của bạn đã sẵn sàng" width="100%"></p>
<p style="font-size: 21px; font-weight: bold; text-align: center;"><strong>VPS của bạn đã sẵn sàng</strong></p>
<p style="font-weight: bold;"><strong>Kính chào <span style="color: #f16723;">{{full_name}},</span></strong></p>
<p style="text-align: justify;">Dịch vụ <strong>{{product_name}}</strong> tại <strong>{{app_name}}</strong> đã được khởi tạo thành công. Dưới đây là thông tin truy cập:</p>
<table cellpadding="0" cellspacing="0" border="0" style="width: 100%; table-layout: fixed; border: 1px solid #e8e8e8; border-right: 0;"><tbody>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8; width: 200px;">Gói dịch vụ:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>{{product_name}}</strong></td></tr>
{{product_billing_row}}
{{product_price_row}}
{{os_name_row}}
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Địa chỉ IP:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>{{service_host}}</strong></td></tr>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tài khoản đăng nhập:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>{{service_username}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Mật khẩu:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>{{service_password}}</strong></td></tr>
{{service_port_row}}
{{service_due_exp_row}}
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Trạng thái:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong style="color: #16a34a;">Đang hoạt động ✓</strong></td></tr>
</tbody></table>
<p style="text-align: justify; color: #b45309; font-size: 13px;">Lưu ý: Vì lý do bảo mật, mật khẩu chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn.</p>
<p style="text-align: justify;">Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình.</p>
<table cellspacing="0" cellpadding="0" style="margin: 16px auto 16px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 10px 20px; border-radius: 4px;"><a href="{{login_url}}" target="_blank" rel="noopener" style="text-decoration: none;"><strong style="color: #ffffff;">Đăng nhập ngay</strong></a></td></tr></tbody></table>
<p style="text-align: justify; color: #6b7280; font-size: 13px;">Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua <strong>{{support_email}}</strong> hoặc <strong>{{support_phone}}</strong>.</p>
<p style="text-align: justify; color: #666666; font-size: 13px;">Email này được gửi tự động. Vui lòng không trả lời email này.</p>
<p style="text-align: justify;">---<br>Hotline: {{support_phone}}<br>Email: {{support_email}}</p>
</td></tr>
<tr class="Footer"><td align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="700" align="center" background="https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png" bgcolor="#F16723" style="border-collapse: collapse; width: 700px; background-image: url(&quot;https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png&quot;); background-size: cover; background-position: center; background-repeat: no-repeat;"><tbody><tr><td style="padding: 40px 30px 20px 30px; text-align: center;"><strong style="font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong></td></tr><tr><td style="padding: 6px 30px 16px 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Tel: (028) 7309.7379 &nbsp;-&nbsp; Email: <a href="mailto:support@tgs.com.vn" style="color: #ffffff; text-decoration: none;">support@tgs.com.vn</a></span></td></tr></tbody></table></td></tr>
</tbody></table></td></tr></tbody></table>
HTML;
DB::table('template')->insert([
'type_id' => 0,
'scenario' => 'vm_credentials',
'key' => 'vm_credentials',
'format' => 'html',
'name' => 'Khởi tạo dịch vụ (thông tin truy cập VPS)',
'subject' => 'VPS của bạn đã sẵn sàng — {{product_name}}',
'message' => $message,
'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('template')->where('scenario', 'vm_credentials')->delete();
}
};
@@ -0,0 +1,174 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Dựng lại 3 template còn thiếu nội dung theo bố cục 700px chuẩn
* (Header + NoiDungKM + Footer), đồng thời gỡ template holiday_notice.
*
* Header/Footer giữ nguyên bản chuẩn — footer hardcode thông tin công ty vì
* EmailTemplateRenderer chỉ thay placeholder dạng {{key}} (không hiểu {{ $var }}).
*/
return new class extends Migration
{
private const SCENARIOS = ['domain_order_approved', 'domain_order_rejected', 'renewal_reminder'];
public function up(): void
{
foreach ($this->templates() as $scenario => $item) {
$row = [
'scenario' => $scenario,
'key' => $scenario,
'format' => 'html',
'type_id' => 0,
'status' => 1,
'name' => $item['name'],
'subject' => $item['subject'],
'message' => $this->header() . $item['body'] . $this->footer(),
'metadata' => json_encode(['variables' => $item['variables']], JSON_UNESCAPED_UNICODE),
];
$existing = DB::table('template')->where('scenario', $scenario)->first();
if ($existing) {
DB::table('template')->where('id', $existing->id)->update($row + ['updated_at' => now()]);
} else {
DB::table('template')->insert($row + ['created_at' => now(), 'updated_at' => now()]);
}
}
DB::table('template')->where('scenario', 'holiday_notice')->delete();
}
public function down(): void
{
DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete();
}
private function templates(): array
{
return [
'domain_order_approved' => [
'name' => 'Tên miền được duyệt',
'subject' => 'Tên miền {{domain_name}} đã được duyệt đăng ký',
'variables' => [
['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'],
['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'],
],
'body' => <<<'HTML'
<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #16a34a;">Tên miền đã được duyệt đăng ký</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{client_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Đơn đăng ký tên miền của Quý khách tại <strong>{{company_name}}</strong> đã được duyệt và kích hoạt thành công. Tên miền hiện đã sẵn sàng để Quý khách trỏ về dịch vụ của mình.</p>
<table style="border-collapse: collapse; width: 100%; border: 1px solid #e8e8e8; font-size: 14px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tên miền:</td><td style="padding: 7px 11px;"><strong>{{domain_name}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Mã đơn hàng:</td><td style="padding: 7px 11px;"><strong>{{order_num}}</strong></td></tr>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Trạng thái:</td><td style="padding: 7px 11px;"><strong style="color: #16a34a;">Đã kích hoạt &#10003;</strong></td></tr>
</tbody></table>
<p style="margin: 20px 0 24px 0; text-align: justify;">Quý khách có thể quản lý tên miền, cấu hình DNS và theo dõi hạn sử dụng ngay trong mục Đơn hàng trên cổng khách hàng.</p>
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Đây là email được gửi tự động. Vui lòng không trả lời email này.</td>
</tr></tbody></table>
</td></tr>
HTML,
],
'domain_order_rejected' => [
'name' => 'Tên miền bị từ chối',
'subject' => 'Tên miền {{domain_name}} đã bị từ chối đăng ký',
'variables' => [
['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'],
['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'],
['key' => 'reason', 'label' => 'Lý do từ chối', 'type' => 'string', 'example' => 'Tên miền đã có chủ sở hữu'],
],
'body' => <<<'HTML'
<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #dc2626;">Đơn đăng ký tên miền bị từ chối</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{client_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Rất tiếc, đơn đăng ký tên miền của Quý khách tại <strong>{{company_name}}</strong> không thể tiếp tục xử lý. Số tiền của đơn hàng đã được hoàn lại vào số dư tài khoản của Quý khách.</p>
<table style="border-collapse: collapse; width: 100%; border: 1px solid #e8e8e8; font-size: 14px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tên miền:</td><td style="padding: 7px 11px;"><strong>{{domain_name}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Mã đơn hàng:</td><td style="padding: 7px 11px;"><strong>{{order_num}}</strong></td></tr>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Trạng thái:</td><td style="padding: 7px 11px;"><strong style="color: #dc2626;">Bị từ chối</strong></td></tr>
</tbody></table>
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #dc2626; background-color: #fef2f2; font-size: 14px; line-height: 21px; color: #626262;"><strong style="color: #374151;">Lý do:</strong> {{reason}}</td>
</tr></tbody></table>
<p style="margin: 20px 0 24px 0; text-align: justify;">Quý khách có thể đăng ký lại với tên miền khác, hoặc liên hệ bộ phận hỗ trợ để được tư vấn thêm.</p>
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Đây là email được gửi tự động. Vui lòng không trả lời email này.</td>
</tr></tbody></table>
</td></tr>
HTML,
],
'renewal_reminder' => [
'name' => 'Nhắc gia hạn',
'subject' => 'Nhắc gia hạn dịch vụ {{product_name}}',
'variables' => [
['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'product_name', 'label' => 'Tên dịch vụ', 'type' => 'string', 'example' => 'Cloud VPS Pro'],
['key' => 'server_name_suffix', 'label' => '[Tự động] Tên server trong ngoặc', 'type' => 'html', 'example' => ''],
['key' => 'expires_at', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '31/12/2026'],
['key' => 'price_row', 'label' => '[Tự động] Dòng số tiền gia hạn', 'type' => 'html', 'example' => ''],
['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309. 7379'],
],
'body' => <<<'HTML'
<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #f16723;">Nhắc gia hạn dịch vụ</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{client_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Dịch vụ <strong>{{product_name}}</strong>{{server_name_suffix}} của Quý khách sắp đến hạn. Vui lòng gia hạn trước ngày hết hạn để dịch vụ không bị gián đoạn.</p>
<table style="border-collapse: collapse; width: 100%; border: 1px solid #e8e8e8; font-size: 14px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Dịch vụ:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong>{{product_name}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Ngày hết hạn:</td><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;"><strong style="color: #dc2626;">{{expires_at}}</strong></td></tr>
{{price_row}}
</tbody></table>
<table cellspacing="0" cellpadding="0" style="margin: 24px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 12px 24px; border-radius: 4px;"><a href="{{login_url}}" target="_blank" rel="noopener" style="text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;">GIA HẠN NGAY</a></td></tr></tbody></table>
<p style="margin: 0 0 24px 0; text-align: justify;">Nếu Quý khách đã thanh toán gia hạn, vui lòng bỏ qua email này. Mọi thắc mắc xin liên hệ <strong>{{support_email}}</strong> hoặc <strong>{{support_phone}}</strong>.</p>
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Đây là email được gửi tự động. Vui lòng không trả lời email này.</td>
</tr></tbody></table>
</td></tr>
HTML,
],
];
}
private function header(): string
{
return <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr class="Header"><td style="width: 700px;" valign="top"><table align="center" width="700" bgcolor="#ffffff" style="border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 0;"><a href="https://tgs.com.vn" style="display: block;" target="_blank" rel="noopener"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ" style="width: 100%; display: block; border: none;" width="700"></a></td></tr></tbody></table></td></tr>
HTML;
}
private function footer(): string
{
return <<<'HTML'
<tr class="Footer"><td align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="700" align="center" background="https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png" bgcolor="#F16723" style="border-collapse: collapse; width: 700px; background-image: url(&quot;https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png&quot;); background-size: cover; background-position: center; background-repeat: no-repeat;"><tbody>
<tr><td style="padding: 40px 30px 20px 30px; text-align: center;"><table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate; border-spacing: 8px 0; width: 100%;"><tbody><tr>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/premium-hosting/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Premium Hosting</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/cloud-server/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Cloud Server</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://cloudemail.vn/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Email doanh nghiệp</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/thiet-ke-website/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Thiết kế website</a></td>
</tr></tbody></table></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><strong style="font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Tel: (028) 7309. 7379 &nbsp;-&nbsp; MST: 0318443116</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Email: <a href="mailto:support@tgs.com.vn" style="color: #ffffff; text-decoration: none;">support@tgs.com.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;"><a href="https://www.tgs.com.vn" style="color: #ffffff; text-decoration: underline;">www.TGS.com.vn</a> &nbsp;&amp;&nbsp; <a href="https://www.thegioiso.cloud" style="color: #ffffff; text-decoration: underline;">www.thegioiso.CLOUD</a> &nbsp;&amp;&nbsp; <a href="https://www.thegioiserver.com" style="color: #ffffff; text-decoration: underline;">www.thegioiSERVER.com</a> &nbsp;&amp;&nbsp; <a href="https://www.cloudemail.vn" style="color: #ffffff; text-decoration: underline;">www.CloudEmail.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 16px 30px; text-align: center;"><span style="font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">Thế Giới Số - Premium HOSTING &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự</span></td></tr>
</tbody></table></td></tr>
</tbody></table></td></tr></tbody></table>
HTML;
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* `amount` là số tiền khách KHAI khi tạo yêu cầu nạp. `received_amount` là số THỰC NHẬN
* trên sao kê do admin nhập khi duyệt — số dư được cộng theo cột này. Giữ cả hai để kế toán
* đối chiếu được phần chênh thay vì mất dấu như trước.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->bigInteger('received_amount')->nullable()->after('amount')
->comment('Số tiền thực nhận trên sao kê; null = chưa duyệt, khi duyệt mặc định bằng amount');
});
// Giao dịch đã duyệt trước đây được cộng đúng bằng `amount`, nên backfill để
// báo cáo đối soát không thấy một khoảng trống giả.
DB::table('transactions')
->where('status', \App\Enums\TransactionStatus::COMPLETED->value)
->whereNull('received_amount')
->update(['received_amount' => DB::raw('amount')]);
}
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('received_amount');
});
}
};
@@ -0,0 +1,103 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Template cho chuỗi nhắc thanh toán (InvoiceReminderService). Dùng lại khung 700px chuẩn:
* header/footer giữ nguyên, chỉ phần NoiDungKM là riêng của kịch bản này.
*/
return new class extends Migration
{
private const SCENARIO = 'invoice_payment_reminder';
public function up(): void
{
if (DB::table('template')->where('scenario', self::SCENARIO)->exists()) {
return;
}
$variables = [
['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'invoice_number', 'label' => 'Số hoá đơn', 'type' => 'string', 'example' => 'INV-20260920-00001'],
['key' => 'total', 'label' => 'Tổng tiền hoá đơn', 'type' => 'string', 'example' => '1.200.000'],
['key' => 'outstanding', 'label' => 'Số tiền còn phải trả', 'type' => 'string', 'example' => '1.200.000'],
['key' => 'due_date', 'label' => 'Hạn thanh toán', 'type' => 'string', 'example' => '20/10/2026'],
['key' => 'status_line', 'label' => 'Câu mô tả tình trạng hạn', 'type' => 'string', 'example' => 'Hoá đơn sẽ đến hạn sau 7 ngày.'],
['key' => 'paid_row', 'label' => '[Tự động] Dòng đã thanh toán một phần', 'type' => 'html', 'example' => ''],
['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309. 7379'],
];
DB::table('template')->insert([
'type_id' => 0,
'scenario' => self::SCENARIO,
'key' => self::SCENARIO,
'format' => 'html',
'name' => 'Nhắc thanh toán hoá đơn',
'subject' => 'Nhắc thanh toán hoá đơn {{invoice_number}}',
'message' => $this->header() . $this->body() . $this->footer(),
'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('template')->where('scenario', self::SCENARIO)->delete();
}
private function body(): string
{
return <<<'HTML'
<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #f16723;">Nhắc thanh toán hoá đơn</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{client_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">{{status_line}} Quý khách vui lòng hoàn tất thanh toán để dịch vụ không bị gián đoạn.</p>
<table style="border-collapse: collapse; width: 100%; border: 1px solid #e8e8e8; font-size: 14px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Số hoá đơn:</td><td style="padding: 7px 11px;"><strong>{{invoice_number}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tổng tiền:</td><td style="padding: 7px 11px;"><strong>{{total}} VNĐ</strong></td></tr>
{{paid_row}}
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Còn phải thanh toán:</td><td style="padding: 7px 11px;"><strong style="color: #dc2626;">{{outstanding}} VNĐ</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Hạn thanh toán:</td><td style="padding: 7px 11px;"><strong>{{due_date}}</strong></td></tr>
</tbody></table>
<table cellspacing="0" cellpadding="0" style="margin: 24px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 12px 24px; border-radius: 4px;"><a href="{{login_url}}" target="_blank" rel="noopener" style="text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;">THANH TOÁN NGAY</a></td></tr></tbody></table>
<p style="margin: 0 0 24px 0; text-align: justify;">Nếu Quý khách đã thanh toán, vui lòng bỏ qua email này. Mọi thắc mắc xin liên hệ <strong>{{support_email}}</strong> hoặc <strong>{{support_phone}}</strong>.</p>
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Đây là email được gửi tự động. Vui lòng không trả lời email này.</td>
</tr></tbody></table>
</td></tr>
HTML;
}
private function header(): string
{
return <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr class="Header"><td style="width: 700px;" valign="top"><table align="center" width="700" bgcolor="#ffffff" style="border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 0;"><a href="https://tgs.com.vn" style="display: block;" target="_blank" rel="noopener"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ" style="width: 100%; display: block; border: none;" width="700"></a></td></tr></tbody></table></td></tr>
HTML;
}
private function footer(): string
{
return <<<'HTML'
<tr class="Footer"><td align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="700" align="center" background="https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png" bgcolor="#F16723" style="border-collapse: collapse; width: 700px; background-image: url(&quot;https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png&quot;); background-size: cover; background-position: center; background-repeat: no-repeat;"><tbody>
<tr><td style="padding: 40px 30px 20px 30px; text-align: center;"><table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate; border-spacing: 8px 0; width: 100%;"><tbody><tr>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/premium-hosting/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Premium Hosting</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/cloud-server/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Cloud Server</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://cloudemail.vn/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Email doanh nghiệp</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/thiet-ke-website/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Thiết kế website</a></td>
</tr></tbody></table></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><strong style="font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Tel: (028) 7309. 7379 &nbsp;-&nbsp; MST: 0318443116</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Email: <a href="mailto:support@tgs.com.vn" style="color: #ffffff; text-decoration: none;">support@tgs.com.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 16px 30px; text-align: center;"><span style="font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">Thế Giới Số - Premium HOSTING &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự</span></td></tr>
</tbody></table></td></tr>
</tbody></table></td></tr></tbody></table>
HTML;
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Trước đây hoá đơn chỉ có 5 trạng thái rời rạc, không chỗ nào ghi được "đã thu bao nhiêu",
* nên mọi khoản trả thiếu/trả dư đều biến mất khỏi hoá đơn và chỉ còn dấu vết mờ ở số dư ví.
* amount_paid cộng dồn số đã thu; hoá đơn chỉ chuyển PAID khi amount_paid >= total.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('amount_paid')->default(0)->after('total')
->comment('Số tiền đã thu được cộng dồn, VND. PAID khi >= total');
});
// Hoá đơn đã PAID trước đây luôn được thu đủ (luồng cũ all-or-nothing), nên backfill
// để báo cáo công nợ không thấy toàn bộ lịch sử là "chưa thu đồng nào".
DB::table('invoices')
->where('status', \App\Enums\Invoice\InvoiceStatus::PAID->value)
->update(['amount_paid' => DB::raw('total')]);
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('amount_paid');
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* customers.total/credit/total_pending là INT signed (trần ~2,14 tỷ) trong khi invoices.total
* là unsignedBigInteger. Khách doanh nghiệp nạp quá ngưỡng sẽ tràn số. Đưa về bigInteger cho
* đồng bộ — vẫn để signed vì Transaction hoàn tiền ghi số âm.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->bigInteger('total')->default(0)->change();
$table->bigInteger('credit')->default(0)->change();
});
if (Schema::hasColumn('customers', 'total_pending')) {
Schema::table('customers', function (Blueprint $table) {
$table->bigInteger('total_pending')->default(0)->change();
});
}
Schema::table('transactions', function (Blueprint $table) {
$table->bigInteger('amount')->nullable()->change();
});
}
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->integer('total')->default(0)->change();
$table->integer('credit')->default(0)->change();
});
Schema::table('transactions', function (Blueprint $table) {
$table->integer('amount')->nullable()->change();
});
}
};
@@ -0,0 +1,113 @@
<?php
use App\Support\EmailTemplateShell;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Hai template cuối còn thiếu trong tbl.template:
*
* - customer_payment: App\Mail\Customer\CustomerPayment trỏ tới scenario này từ lâu nhưng
* chưa từng có row, gọi tới là ném ModelNotFoundException.
* - invoice_notification: thay cho blade emails/invoice-notification.blade.php, để không còn
* email nào render qua Blade.
*/
return new class extends Migration
{
private const SCENARIOS = ['customer_payment', 'invoice_notification'];
public function up(): void
{
foreach ($this->templates() as $scenario => $item) {
if (DB::table('template')->where('scenario', $scenario)->exists()) {
continue;
}
DB::table('template')->insert([
'type_id' => 0,
'scenario' => $scenario,
'key' => $scenario,
'format' => 'html',
'name' => $item['name'],
'subject' => $item['subject'],
'message' => EmailTemplateShell::wrap(EmailTemplateShell::body($item['body'])),
'metadata' => json_encode(['variables' => $item['variables']], JSON_UNESCAPED_UNICODE),
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete();
}
private function templates(): array
{
$notice = EmailTemplateShell::autoNotice();
return [
'customer_payment' => [
'name' => 'Thông báo thanh toán khách hàng',
'subject' => 'Thông báo thanh toán - {{company_name}}',
'variables' => [
['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
],
'body' => <<<HTML
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #f16723;">Thông báo thanh toán</p>
<p style="margin: 24px 0 16px 0;">Kính chào Quý khách,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của <strong>{{company_name}}</strong>. Chúng tôi gửi email này để thông báo về tình trạng thanh toán trên tài khoản của Quý khách.</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Quý khách vui lòng đăng nhập vào cổng khách hàng để xem chi tiết hoá đơn và thực hiện thanh toán.</p>
<p style="margin: 0 0 24px 0; text-align: justify;">Nếu Quý khách cần hỗ trợ, vui lòng liên hệ với chúng tôi để được giải đáp.</p>
{$notice}
HTML,
],
'invoice_notification' => [
'name' => 'Thông báo hoá đơn / gia hạn cước',
'subject' => '{{title}} #{{invoice_number}} - {{company_name}}',
'variables' => [
['key' => 'title', 'label' => 'Tiêu đề (hoá đơn / gia hạn cước)', 'type' => 'string', 'example' => 'Thông báo hóa đơn'],
['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'body_message', 'label' => 'Lời nhắn của admin', 'type' => 'string', 'example' => 'Quý khách vui lòng thanh toán hoá đơn đính kèm.'],
['key' => 'invoice_number', 'label' => 'Số hoá đơn', 'type' => 'string', 'example' => 'INV-20260920-00001'],
['key' => 'created_date', 'label' => 'Ngày tạo hoá đơn', 'type' => 'string', 'example' => '20/09/2026'],
['key' => 'due_date', 'label' => 'Hạn thanh toán', 'type' => 'string', 'example' => '20/10/2026'],
['key' => 'total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '1.200.000'],
['key' => 'status_label', 'label' => '[Tự động] Trạng thái hoá đơn', 'type' => 'html', 'example' => '<strong style="color:#dc2626;">Chưa thanh toán</strong>'],
['key' => 'outstanding_row', 'label' => '[Tự động] Dòng còn thiếu khi trả một phần', 'type' => 'html', 'example' => ''],
['key' => 'intro_note', 'label' => 'Ghi chú về file PDF đính kèm', 'type' => 'string', 'example' => 'File PDF hóa đơn đã được đính kèm trong email này.'],
['key' => 'pay_button', 'label' => '[Tự động] Nút thanh toán', 'type' => 'html', 'example' => ''],
['key' => 'attachments_block', 'label' => '[Tự động] Danh sách file đính kèm', 'type' => 'html', 'example' => ''],
['key' => 'note_block', 'label' => '[Tự động] Ghi chú trên hoá đơn', 'type' => 'html', 'example' => ''],
],
'body' => <<<HTML
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #f16723;">{{title}}</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{customer_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">{{body_message}}</p>
<table style="border-collapse: collapse; width: 100%; border: 1px solid #e8e8e8; font-size: 14px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Số hóa đơn:</td><td style="padding: 7px 11px;"><strong>{{invoice_number}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Ngày tạo:</td><td style="padding: 7px 11px;"><strong>{{created_date}}</strong></td></tr>
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Hạn thanh toán:</td><td style="padding: 7px 11px;"><strong>{{due_date}}</strong></td></tr>
<tr><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Tổng tiền:</td><td style="padding: 7px 11px;"><strong>{{total}} VNĐ</strong></td></tr>
{{outstanding_row}}
<tr bgcolor="#FFF1E6"><td style="padding: 7px 11px; border-right: 1px solid #e8e8e8;">Trạng thái:</td><td style="padding: 7px 11px;">{{status_label}}</td></tr>
</tbody></table>
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #fff7ed; font-size: 13px; line-height: 20px; color: #626262;">{{intro_note}}</td>
</tr></tbody></table>
{{pay_button}}
{{attachments_block}}
{{note_block}}
<p style="margin: 20px 0 8px 0; text-align: justify;">Nếu Quý khách có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.</p>
<p style="margin: 0 0 24px 0;">Trân trọng,<br><strong>{{company_name}}</strong></p>
{$notice}
HTML,
],
];
}
};
@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 13 template trong tbl.template có footer chứa {{ $companyName }}, {{ $companyAddress }}…
* — cú pháp blade lọt vào qua Email Builder. EmailTemplateRenderer chỉ khớp {{key}} với
* [a-zA-Z_][a-zA-Z0-9_]* nên dấu $ làm nó KHÔNG khớp: các chuỗi này được gửi ra nguyên văn
* trong email khách nhận.
*
* Thay bằng giá trị thật lấy từ Setting (fallback đúng như blade emails/partials/email-footer
* trước đây), vì footer giống nhau ở mọi scenario nên không đáng khai báo thành biến động.
*/
return new class extends Migration
{
public function up(): void
{
$setting = $this->companyInfo();
$map = [
'companyName' => $setting['company'],
'companyAddress' => $setting['address'],
'companyTax' => $setting['tax'],
'supportPhone' => $setting['hotline'],
'supportEmail' => $setting['support'],
];
$templates = DB::table('template')->select('id', 'subject', 'message')->get();
$updated = 0;
foreach ($templates as $template) {
$subject = $this->replace((string) $template->subject, $map);
$message = $this->replace((string) $template->message, $map);
if ($subject === (string) $template->subject && $message === (string) $template->message) {
continue;
}
DB::table('template')->where('id', $template->id)->update([
'subject' => $template->subject === null ? null : $subject,
'message' => $message,
'updated_at' => now(),
]);
$updated++;
}
// Không echo khi chạy trong test (RefreshDatabase) — làm bẩn output PHPUnit.
if ($updated > 0 && app()->runningInConsole() && !app()->runningUnitTests()) {
echo " Đã thay placeholder blade trong {$updated} template.\n";
}
}
/**
* Không có down(): đây là sửa dữ liệu hỏng, khôi phục lại placeholder blade chỉ làm
* email hiển thị sai trở lại.
*/
public function down(): void
{
// no-op
}
private function replace(string $content, array $map): string
{
foreach ($map as $key => $value) {
$content = preg_replace('/\{\{\s*\$' . preg_quote($key, '/') . '\s*\}\}/', $value, $content) ?? $content;
}
return $content;
}
private function companyInfo(): array
{
$raw = DB::table('setting')->where('key', 'setting')->value('value');
$parsed = $raw ? (json_decode((string) $raw, true) ?: []) : [];
// DB test không có row setting nào → phải dùng ?? chứ không phải ?:, nếu không
// "Undefined array key" làm hỏng mọi test dùng RefreshDatabase.
return [
'company' => $parsed['company'] ?? null ?: 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ',
'address' => $parsed['address'] ?? null ?: '128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh',
'tax' => $parsed['tax'] ?? null ?: '0318443116',
'hotline' => $parsed['hotline'] ?? null ?: '(028) 7309. 7379',
'support' => $parsed['support'] ?? null ?: 'support@tgs.com.vn',
];
}
};
@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Footer không còn phụ thuộc biến: thông tin liên hệ được ghi thẳng vào template để admin
* sửa trực tiếp trong Email Builder, không phải chờ Mailable truyền vào.
*
* {{support_phone}} / {{support_email}} là hai biến cuối còn sót trong footer (và vài câu
* "liên hệ ..." ở thân bài). Thay bằng giá trị thật lấy từ Setting.
*/
return new class extends Migration
{
public function up(): void
{
$contact = $this->contactInfo();
$map = [
'support_phone' => $contact['hotline'],
'support_email' => $contact['support'],
];
$updated = 0;
foreach (DB::table('template')->select('id', 'subject', 'message')->get() as $template) {
$subject = $this->replace((string) $template->subject, $map);
$message = $this->replace((string) $template->message, $map);
if ($subject === (string) $template->subject && $message === (string) $template->message) {
continue;
}
DB::table('template')->where('id', $template->id)->update([
'subject' => $template->subject === null ? null : $subject,
'message' => $message,
'updated_at' => now(),
]);
$updated++;
}
if ($updated > 0 && app()->runningInConsole() && !app()->runningUnitTests()) {
echo " Đã ghi cứng thông tin liên hệ trong {$updated} template.\n";
}
}
/** Không khôi phục: đưa biến trở lại footer chỉ làm admin mất quyền sửa trực tiếp. */
public function down(): void
{
// no-op
}
private function replace(string $content, array $map): string
{
foreach ($map as $key => $value) {
$content = preg_replace('/\{\{\s*' . preg_quote($key, '/') . '\s*\}\}/', $value, $content) ?? $content;
}
return $content;
}
private function contactInfo(): array
{
$raw = DB::table('setting')->where('key', 'setting')->value('value');
$parsed = $raw ? (json_decode((string) $raw, true) ?: []) : [];
return [
'hotline' => $parsed['hotline'] ?? null ?: '(028) 7309. 7379',
'support' => $parsed['support'] ?? null ?: 'support@tgs.com.vn',
];
}
};
@@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Template gửi link đặt lại mật khẩu cho khách (thay cho việc cấp mật khẩu mới qua email).
* Dùng lại khung 700px chuẩn như invoice_payment_reminder.
*/
return new class extends Migration
{
private const SCENARIO = 'customer_password_reset_link';
public function up(): void
{
if (DB::table('template')->where('scenario', self::SCENARIO)->exists()) {
return;
}
$variables = [
['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
['key' => 'reset_url', 'label' => 'Link đặt lại mật khẩu', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dat-lai-mat-khau?token=abc&email=a%40b.com'],
['key' => 'expire_minutes', 'label' => 'Thời hạn link (phút)', 'type' => 'string', 'example' => '60'],
];
DB::table('template')->insert([
'type_id' => 0,
'scenario' => self::SCENARIO,
'key' => self::SCENARIO,
'format' => 'html',
'name' => 'Link đặt lại mật khẩu khách hàng',
'subject' => 'Yêu cầu đặt lại mật khẩu',
'message' => $this->header() . $this->body() . $this->footer(),
'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('template')->where('scenario', self::SCENARIO)->delete();
}
private function body(): string
{
return <<<'HTML'
<tr class="NoiDungKM"><td style="width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top">
<p style="margin: 0 0 6px 0; font-size: 21px; font-weight: bold; text-align: center; color: #f16723;">Đặt lại mật khẩu</p>
<p style="margin: 24px 0 16px 0;">Kính chào <strong style="color: #f16723;">{{full_name}}</strong>,</p>
<p style="margin: 0 0 20px 0; text-align: justify;">Chúng tôi nhận được yêu cầu đặt lại mật khẩu cho tài khoản của Quý khách. Vui lòng bấm nút bên dưới để tạo mật khẩu mới. Link có hiệu lực trong <strong>{{expire_minutes}} phút</strong> và chỉ dùng được một lần.</p>
<table cellspacing="0" cellpadding="0" style="margin: 24px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 12px 24px; border-radius: 4px;"><a href="{{reset_url}}" target="_blank" rel="noopener" style="text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;">ĐẶT LẠI MẬT KHẨU</a></td></tr></tbody></table>
<p style="margin: 0 0 24px 0; text-align: justify;">Nếu Quý khách không yêu cầu, vui lòng bỏ qua email này — mật khẩu hiện tại vẫn giữ nguyên. Mọi thắc mắc xin liên hệ <strong>support@tgs.com.vn</strong> hoặc <strong>(028) 7309. 7379</strong>.</p>
<table style="width: 100%; border-collapse: collapse;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>
<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;"><strong style="color: #374151;">Lưu ý:</strong> Không chia sẻ link này cho bất kỳ ai, kể cả nhân viên Thế Giới Số.</td>
</tr></tbody></table>
</td></tr>
HTML;
}
private function header(): string
{
return <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody>
<tr class="Header"><td style="width: 700px;" valign="top"><table align="center" width="700" bgcolor="#ffffff" style="border-collapse: collapse; width: 700px; max-width: 100%; text-align: center; background-color: #ffffff; border-top: 5px solid #f16723;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 0;"><a href="https://tgs.com.vn" style="display: block;" target="_blank" rel="noopener"><img src="https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png" alt="Thư gửi từ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ" style="width: 100%; display: block; border: none;" width="700"></a></td></tr></tbody></table></td></tr>
HTML;
}
private function footer(): string
{
return <<<'HTML'
<tr class="Footer"><td align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="700" align="center" background="https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png" bgcolor="#F16723" style="border-collapse: collapse; width: 700px; background-image: url(&quot;https://res.cloudinary.com/izpast9i/image/upload/v1786500298/email_footer.png&quot;); background-size: cover; background-position: center; background-repeat: no-repeat;"><tbody>
<tr><td style="padding: 40px 30px 20px 30px; text-align: center;"><table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate; border-spacing: 8px 0; width: 100%;"><tbody><tr>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/premium-hosting/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Premium Hosting</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/cloud-server/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Cloud Server</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://cloudemail.vn/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Email doanh nghiệp</a></td>
<td width="25%" style="border: 1px solid rgba(255,255,255,0.35); border-radius: 8px; padding: 6px 4px; font-size: 12px; line-height: 16px; color: #ffffff; text-align: center; background-color: rgba(255,255,255,0.18);"><a href="https://tgs.com.vn/thiet-ke-website/" target="_blank" rel="noopener" style="color: #ffffff; text-decoration: none;">Thiết kế website</a></td>
</tr></tbody></table></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><strong style="font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Tel: (028) 7309. 7379 &nbsp;-&nbsp; MST: 0318443116</span></td></tr>
<tr><td style="padding: 6px 30px 0 30px; text-align: center;"><span style="font-size: 12px; line-height: 18px; color: #ffffff;">Email: <a href="mailto:support@tgs.com.vn" style="color: #ffffff; text-decoration: none;">support@tgs.com.vn</a></span></td></tr>
<tr><td style="padding: 6px 30px 16px 30px; text-align: center;"><span style="font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">Thế Giới Số - Premium HOSTING &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự</span></td></tr>
</tbody></table></td></tr>
</tbody></table></td></tr></tbody></table>
HTML;
}
};
@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Ảnh CCCD trước đây lưu trên disk `public` (ai có URL cũng xem được). Chuyển sang disk
* `local` (private) và lưu path tương đối `cccd/...`; ClientResource cấp URL ký để xem.
*/
return new class extends Migration
{
private const COLUMNS = ['front_identity_number', 'back_identity_number'];
public function up(): void
{
DB::table('customers')
->where(fn ($q) => $q->where('front_identity_number', 'like', '%/storage/cccd/%')
->orWhere('back_identity_number', 'like', '%/storage/cccd/%'))
->orderBy('id')
->each(function ($row) {
$updates = [];
foreach (self::COLUMNS as $column) {
$value = (string) $row->{$column};
if (!str_contains($value, '/storage/cccd/')) {
continue;
}
$path = 'cccd/' . Str::afterLast($value, '/storage/cccd/');
if (Storage::disk('public')->exists($path)) {
Storage::disk('local')->put($path, Storage::disk('public')->get($path));
Storage::disk('public')->delete($path);
}
$updates[$column] = $path;
}
DB::table('customers')->where('id', $row->id)->update($updates);
});
// Ảnh mồ côi (khách đã upload lại, DB không còn trỏ tới) vẫn là CCCD thật → không để public.
foreach (Storage::disk('public')->files('cccd') as $path) {
Storage::disk('local')->put($path, Storage::disk('public')->get($path));
Storage::disk('public')->delete($path);
}
}
public function down(): void
{
DB::table('customers')
->where(fn ($q) => $q->where('front_identity_number', 'like', 'cccd/%')
->orWhere('back_identity_number', 'like', 'cccd/%'))
->orderBy('id')
->each(function ($row) {
$updates = [];
foreach (self::COLUMNS as $column) {
$path = (string) $row->{$column};
if (!str_starts_with($path, 'cccd/')) {
continue;
}
if (Storage::disk('local')->exists($path)) {
Storage::disk('public')->put($path, Storage::disk('local')->get($path));
Storage::disk('local')->delete($path);
}
$updates[$column] = Storage::disk('public')->url($path);
}
DB::table('customers')->where('id', $row->id)->update($updates);
});
}
};
-11
View File
@@ -263,17 +263,6 @@ HTML
'type_id' => 0,
'status' => 1,
],
[
'name' => 'Thông báo nghỉ lễ',
'scenario' => 'holiday_notice',
'format' => 'html',
'subject' => 'Thông báo lịch nghỉ lễ từ {$holiday_name}',
'message' => <<<'HTML'
<table style="border-collapse: collapse; width: 100%; background-color: #efefef;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="padding: 20px;" align="center" valign="top"><table style="padding-top: 20px; padding-bottom: 20px; border-collapse: collapse; background-color: #ffffff; width: 700px;" border="0" cellspacing="0" cellpadding="0"><tbody><tr><td style="width: 700px;" valign="top"><img style="display: block; border: 0;" src="{$hero_image}" alt="" width="700" height="350"><div style="padding: 0 30px; font-family: Arial, Helvetica, sans-serif; font-size: 14px; line-height: 1.6; color: #000000;"><div>&nbsp;</div><div><strong>Kính gửi: Quý Khách hàng, Quý Đối tác</strong></div><div>&nbsp;</div><div>Nhân dịp kỷ niệm <strong>{$holiday_name}</strong>, Thế Giới Số xin trân trọng gửi tới Quý Khách hàng và Quý Đối tác lời chúc sức khỏe, hạnh phúc và thành công.</div><div>&nbsp;</div><div><strong>Thế Giới Số</strong> xin thông báo lịch nghỉ lễ như sau:</div><div>&nbsp;&nbsp;&nbsp;&nbsp;<strong>Thời gian nghỉ lễ:</strong> Từ <strong>{$holiday_from}</strong> đến hết <strong>{$holiday_to}</strong></div><div>&nbsp;&nbsp;&nbsp;&nbsp;<strong>Thời gian làm việc trở lại:</strong> <strong>{$work_again}</strong></div><div>&nbsp;</div><div>Trong thời gian nghỉ lễ, nếu có vấn đề cần hỗ trợ kỹ thuật vui lòng liên hệ:</div><div>&nbsp;&nbsp;&nbsp;&nbsp;+ Gửi ticket qua: <a href="{$ticket_url}" target="_blank" rel="noopener">{$ticket_url}</a></div><div>&nbsp;&nbsp;&nbsp;&nbsp;+ Email: <a href="mailto:{$support_email}">{$support_email}</a></div><div>&nbsp;&nbsp;&nbsp;&nbsp;+ Hotline: <strong>{$hotline}</strong></div><div>&nbsp;</div><div>Kính chúc Quý Khách hàng và Quý Đối tác có một kỳ nghỉ lễ vui vẻ, an toàn và nhiều sức khỏe.</div><div>&nbsp;</div></div></td></tr></tbody></table></td></tr></tbody></table>
HTML,
'type_id' => 0,
'status' => 1,
],
];
foreach ($newTemplates as $item) {
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Database\Seeders;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Tài khoản dùng để kiểm thử luồng order/invoice sau đợt sửa.
* Chạy: php artisan db:seed --class=TestAccountSeeder
*
* Idempotent — chạy lại chỉ đặt lại mật khẩu, không tạo trùng.
*/
class TestAccountSeeder extends Seeder
{
private const CUSTOMER_EMAIL = 'pbao.dev@gmail.com';
private const CUSTOMER_PASSWORD = 'password123';
private const ADMIN_USERNAME = 'admin';
private const ADMIN_PASSWORD = '!admin@123!@#';
public function run(): void
{
$this->seedAdmin();
$this->seedCustomer();
}
private function seedAdmin(): void
{
$admin = User::where('username', self::ADMIN_USERNAME)->first();
if ($admin) {
$admin->update(['password' => Hash::make(self::ADMIN_PASSWORD)]);
$this->command?->info('Admin đã có — đặt lại mật khẩu cho "' . self::ADMIN_USERNAME . '".');
} else {
$admin = User::create([
'fullname' => 'System Admin',
'username' => self::ADMIN_USERNAME,
'email' => 'admin@tgs.com.vn',
'email_verified_at' => now(),
'password' => Hash::make(self::ADMIN_PASSWORD),
]);
$this->command?->info('Đã tạo admin "' . self::ADMIN_USERNAME . '".');
}
// status nằm ngoài $fillable nên phải gán thẳng.
$admin->forceFill(['status' => 1])->save();
if (method_exists($admin, 'syncRoles') && \Spatie\Permission\Models\Role::where('name', 'Full Administrator')->exists()) {
$admin->syncRoles(['Full Administrator']);
$this->command?->info(' → gán role Full Administrator.');
}
}
private function seedCustomer(): void
{
$customer = Customer::where('email', self::CUSTOMER_EMAIL)->first();
if ($customer) {
$customer->update(['password' => Hash::make(self::CUSTOMER_PASSWORD)]);
$this->command?->info('Khách hàng đã có — đặt lại mật khẩu cho ' . self::CUSTOMER_EMAIL . '.');
} else {
$customer = Customer::create([
'fullname' => 'Phạm Bảo (test)',
'username' => 'pbao.dev',
'email' => self::CUSTOMER_EMAIL,
'phone' => '0900000000',
'password' => Hash::make(self::CUSTOMER_PASSWORD),
'email_verified_at' => now(),
'email_verify' => 1,
'address' => '128/38 Tân Hương, Phú Thọ Hòa, TP. Hồ Chí Minh',
]);
$this->command?->info('Đã tạo khách hàng ' . self::CUSTOMER_EMAIL . '.');
}
// Nạp sẵn số dư để đặt và thanh toán đơn thử, kể cả các ca trả từng phần.
$customer->forceFill([
'status' => 1,
'total' => max((int) $customer->total, 50_000_000),
])->save();
if (empty($customer->uuid)) {
$customer->forceFill(['uuid' => (string) Str::uuid()])->save();
}
$this->command?->info(' → số dư: ' . number_format((int) $customer->fresh()->total) . ' VNĐ');
}
}