feat: email gửi tay/nhắc gia hạn, gói hosting, sửa dữ liệu sản phẩm

- Gửi email theo template cho 1 khách, tab lịch sử email, nhắc gia hạn dịch vụ hàng loạt
- Mail lưu from/bcc/sent_by; mẫu MARKETING và mẫu cũ trong TemplateSeeder
- Gói hosting: giới hạn, trạng thái, lấy plan Plesk qua CLI gateway
- Backfill payment_method và ngày đơn hàng còn thiếu
- Bổ sung field CPU/RAM/Disk/Băng thông cho loại sản phẩm vps và cloud

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-09 11:32:05 +07:00
co-authored by Claude Opus 5.5
parent 8651dde6b1
commit 30898ea46f
59 changed files with 1771 additions and 108 deletions
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('mail', function (Blueprint $table) {
$table->string('from_address', 255)->nullable()->after('subject');
$table->string('bcc', 1000)->nullable()->after('cc');
$table->unsignedBigInteger('sent_by')->nullable()->index()->after('customer_id');
});
}
public function down(): void
{
Schema::table('mail', function (Blueprint $table) {
$table->dropIndex(['sent_by']);
$table->dropColumn(['from_address', 'bcc', 'sent_by']);
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_plans', function (Blueprint $table) {
$table->text('description')->nullable()->after('title');
$table->unsignedInteger('bandwidth')->nullable()->after('domain');
$table->unsignedInteger('databases')->nullable()->after('bandwidth');
$table->unsignedInteger('mailboxes')->nullable()->after('databases');
$table->unsignedInteger('mailbox_quota')->nullable()->after('mailboxes');
$table->unsignedInteger('ftp_accounts')->nullable()->after('mailbox_quota');
$table->unsignedInteger('subdomains')->nullable()->after('ftp_accounts');
$table->unsignedInteger('domain_aliases')->nullable()->after('subdomains');
$table->json('features')->nullable()->after('domain_aliases');
$table->boolean('is_active')->default(true)->after('features');
$table->unsignedInteger('sort_order')->default(0)->after('is_active');
});
}
public function down(): void
{
Schema::table('service_plans', function (Blueprint $table) {
$table->dropColumn([
'description', 'bandwidth', 'databases', 'mailboxes', 'mailbox_quota',
'ftp_accounts', 'subdomains', 'domain_aliases', 'features', 'is_active', 'sort_order',
]);
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('orders')
->whereNull('payment_method')
->whereExists(function ($query) {
$query->selectRaw('1')
->from('invoices')
->whereColumn('invoices.order_id', 'orders.id')
->whereNotNull('invoices.payment_method');
})
->update([
'payment_method' => DB::raw('(SELECT invoices.payment_method FROM invoices WHERE invoices.order_id = orders.id AND invoices.payment_method IS NOT NULL ORDER BY invoices.id ASC LIMIT 1)'),
]);
}
public function down(): void
{
// Preserve recovered payment data. The application remains compatible
// because orders.payment_method is still the original nullable string.
}
};
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('orders')
->whereNull('date')
->whereNotNull('created_at')
->update(['date' => DB::raw('DATE(created_at)')]);
}
public function down(): void
{
// Preserve recovered order dates. The date column remains nullable and
// compatible with older application versions after a code rollback.
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const TYPES = ['vps', 'cloud'];
private const FIELDS = [
['key' => 'cpu', 'label' => 'CPU (vCPU)', 'sort_order' => 1],
['key' => 'ram', 'label' => 'RAM (GB)', 'sort_order' => 2],
['key' => 'disk', 'label' => 'Disk (GB)', 'sort_order' => 3],
['key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'sort_order' => 4],
];
public function up(): void
{
$now = now();
foreach (self::TYPES as $type) {
foreach (self::FIELDS as $field) {
$exists = DB::table('product_type_fields')
->where('product_type', $type)
->where('key', $field['key'])
->exists();
if ($exists) {
continue;
}
DB::table('product_type_fields')->insert($field + [
'product_type' => $type,
'field_type' => 'number',
'is_required' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
}
};