feat: Reorganize database migrations for module snapshots, VMware, and OS groups, and implement customer order creation email notifications.

This commit is contained in:
pbao9
2026-03-12 12:31:52 +07:00
parent 1934be9a19
commit 508d5186db
19 changed files with 350 additions and 195 deletions
+21 -3
View File
@@ -43,17 +43,28 @@ class OrderResource extends JsonResource
'status' => $this->invoice->status,
'date' => $this->invoice->date,
'due_date' => $this->invoice->due_date,
'date_paid' => $this->invoice->date_paid,
'paid_at' => $this->invoice->paid_at,
]),
'amount' => $this->amount,
'total' => $this->total,
'payment_method' => $this->whenLoaded('payment', fn() => $this->payment->name),
'payment_proof' => $this->payment_proof,
'note' => $this->note,
'client_ip' => $this->client_ip,
'status' => $this->status,
'date' => $this->date,
'paid_at' => $this->paid_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'vmwares' => $this->whenLoaded('moduleVmwares', fn() => $this->moduleVmwares->map(fn($vm) => [
'id' => $vm->id,
'vmware_id' => $vm->vmware_id,
'name' => $vm->name,
'hostname' => $vm->hostname,
'user' => $vm->user,
'ip' => $vm->ipAddress?->ip,
'os_id' => $vm->os_id,
'status' => $vm->status,
'next_due_date' => $vm->next_due_date,
])),
'order_items' => $this->whenLoaded('orderItems', fn() => $this->orderItems->map(function ($item) {
return [
'id' => $item->id,
@@ -73,6 +84,13 @@ class OrderResource extends JsonResource
'quantity' => $item->quantity,
'server_name' => $item->server_name,
'price' => $item->price,
'service' => $item->service ? [
'id' => $item->service->id,
'vm_id' => $item->service->vm_id,
'status' => $item->service->status,
'started_at' => $item->service->started_at,
'expires_at' => $item->service->expires_at,
] : null,
];
})),
];
@@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Mail\Customer;
use App\Mail\Customer\OrderCreated;
use App\Models\Template;
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;
use Illuminate\Support\Facades\Mail;
class OrderCreatedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function handle(): void
{
$template = Template::where('name', 'Order Created')->where('status', 1)->first();
if (!$template) {
Log::warning('OrderCreatedJob: Template "Order Created" not found or inactive.');
return;
}
try {
Mail::to($this->data['customer_email'])->send(new OrderCreated($template, $this->data));
} catch (\Throwable $e) {
Log::error('OrderCreatedJob: Failed to send email. ' . $e->getMessage(), [
'customer_email' => $this->data['customer_email'],
'order_num' => $this->data['order_num'] ?? null,
]);
}
}
}
@@ -14,6 +14,7 @@ use App\Models\ModuleVmware;
use App\Models\ModuleIpAddress;
use App\Models\IpPool;
use App\Models\Log as CustomerLog;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -255,6 +256,22 @@ class CreateVirtualMachineJob implements ShouldQueue
AssignIPJob::dispatch($vmId, $moduleVmware->id, $moduleIpAddress->ip)
->delay(now()->addMinutes(5));
// Lưu Service record để track dịch vụ của khách hàng
Service::create([
'customer_id' => $customer->id,
'product_id' => $this->item->product_id,
'order_item_id' => $this->item->id,
'invoice_id' => $invoice?->id,
'vm_id' => $vmId,
'server_name' => $serverName,
'status' => 'active',
'auto_renew' => false,
'started_at' => now(),
'expires_at' => $this->item->months > 0
? now()->addMonths($this->item->months)
: null,
]);
} catch (\Exception $e) {
Log::error('❌ Lỗi khi lưu dữ liệu VM và IP address: ' . $e->getMessage(), [
'vmware_id' => $vmId,
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Mail\Customer;
use App\Models\Template;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OrderCreated extends Mailable
{
use Queueable, SerializesModels;
protected Template $template;
protected array $data;
public function __construct(Template $template, array $data)
{
$this->template = $template;
$this->data = $data;
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->template->subject,
);
}
public function content(): Content
{
$message = str_replace(
[
'{\$company_name}',
'{\$company_logo}',
'{\$client_name}',
'{\$order_num}',
'{\$order_total}',
'{\$invoice_num}',
],
[
$this->data['company_name'],
$this->data['company_logo'],
$this->data['client_name'],
$this->data['order_num'],
$this->data['order_total'],
$this->data['invoice_num'],
],
$this->template->message
);
return new Content(
view: 'emails.template',
with: ['template' => $message],
);
}
public function attachments(): array
{
return [];
}
}
+6
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\ModuleVmware;
class Order extends Model
{
@@ -59,4 +60,9 @@ class Order extends Model
{
return $this->belongsTo(Payment::class);
}
public function moduleVmwares()
{
return $this->hasMany(ModuleVmware::class, 'order_id');
}
}
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
@@ -31,6 +32,15 @@ class OrderItem extends Model
return $this->belongsTo(Product::class, 'product_id');
}
public function getProductAttribute(): ?Product
{
if ($this->relationLoaded('product')) {
return $this->getRelation('product');
}
return $this->product()->first();
}
public function pricing()
{
return $this->belongsTo(ProductPricings::class, 'product_pricing_id');
@@ -139,6 +139,8 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface
return Order::with([
'orderItems.product',
'orderItems.os',
'orderItems.service',
'moduleVmwares.ipAddress',
'user',
'customer',
'payment',
+28 -9
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Enums\Product\ProductBillingCycle;
use App\Http\Helpers\Helper;
use App\Jobs\Hosting\CreateHostingJobs;
use App\Jobs\Mail\Customer\OrderCreatedJob;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Models\Customer;
use App\Models\Order;
@@ -37,20 +38,25 @@ class OrderService
}
$paymentRecord = Payment::find((int) $data['payment_method']);
$isBankTransfer = $paymentRecord && (int) $paymentRecord->type === 1;
$isBalancePayment = $paymentRecord && (int) $paymentRecord->type === 0;
// Balance payment → auto-confirm order & invoice
$effectiveStatus = $isBalancePayment ? 1 : (int) $data['status'];
[$productItems, $relIdData, $total] = $this->buildProductItems($data);
$orderTotal = (int) ($total + ($total * (10 / 100)));
$invoiceData = [
'invoice_number' => Helper::generateUniqueCode(),
'due_date' => Carbon::today()->toDateString(),
'paid_at' => (int) $data['status'] === 1 ? Carbon::today()->toDateString() : null,
'paid_at' => $effectiveStatus === 1 ? Carbon::now() : null,
'sub_total' => $total,
'tax' => $total * (10 / 100),
'total' => $total + ($total * (10 / 100)),
'total' => $orderTotal,
'payment_method' => $paymentRecord?->id,
'customer_id' => $data['customer_id'],
'status' => $data['status'],
'status' => $effectiveStatus,
];
$orderData = [
@@ -58,15 +64,15 @@ class OrderService
'customer_id' => $data['customer_id'],
'discount_id' => $data['discount_id'] ?? null,
'sub_total' => $total,
'total' => $total + ($total * (10 / 100)),
'total' => $orderTotal,
'payment_method' => $paymentRecord?->code ?? null,
'payment_proof' => $data['payment_proof'] ?? null,
'user_id' => Auth::guard('admin')->id(),
'status' => $data['status'],
'status' => $effectiveStatus,
'client_ip' => request()->getClientIp(),
];
return DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $total, $isBankTransfer) {
$order = DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $orderTotal, $isBalancePayment) {
$invoice = $this->invoiceRepository->create($invoiceData);
$productItems = array_map(fn ($item) => array_merge($item, ['invoice_id' => $invoice->id]), $productItems);
@@ -77,6 +83,8 @@ class OrderService
$order = $this->orderRepository->create($orderData);
$invoice->update(['order_id' => $order->id]);
$relIdData = array_map(function ($item) use ($order) {
$item['data']['order_id'] = $order->id;
return $item;
@@ -116,8 +124,7 @@ class OrderService
}
}
if (!$isBankTransfer) {
$orderTotal = (int) ($total + ($total * (10 / 100)));
if ($isBalancePayment) {
$customer = Customer::lockForUpdate()->find($data['customer_id']);
if ($customer->total < $orderTotal) {
@@ -129,6 +136,18 @@ class OrderService
return $order;
});
OrderCreatedJob::dispatch([
'customer_email' => $customer->email,
'client_name' => $customer->fullname,
'order_num' => $orderData['order_num'],
'invoice_num' => $invoiceData['invoice_number'],
'order_total' => number_format($orderTotal, 0, ',', '.'),
'company_name' => setting('general.company_name') ?: config('app.name'),
'company_logo' => setting('general.company_logo') ?: '',
]);
return $order;
}
private function buildProductItems(array $data): array
@@ -1,59 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('module_vmware', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable();
$table->foreignId('module_ip_address')
->constrained('module_ip_address')->nullable();
$table->foreignId('vcenter_id')
->constrained('module_vcenter')->nullable();
$table->integer('port')->nullable();
$table->integer('order_id')->nullable();
$table->integer('first_payment')->nullable();
$table->integer('next_payment')->nullable();
$table->integer('next_due_date')->nullable();
$table->integer('next_invoice')->nullable();
$table->string('user')->nullable();
$table->text('password')->nullable();
$table->string('hostname')->nullable();
$table->string('vmware_id')->nullable();
$table->bigInteger('os_id')->default(0)->index();
$table->bigInteger('customer_id')->default(0)->index();
$table->tinyInteger('status')
->default(1)
->comment('1: Running;0:Off;2:Suspend');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('module_vmwares');
}
};
@@ -1,34 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('module_snapshot', function (Blueprint $table) {
$table->id();
$table->string('name', 255)->nullable();
$table->bigInteger('customer_id')->default(0)->index();
$table->bigInteger('module_vmware_id')->default(0)->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('module_snapshots');
}
};
@@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('module_snapshot', function (Blueprint $table) {
$table->text('description')->nullable()->after('name');
// Async snapshot status
$table->enum('status', [
'pending',
'processing',
'success',
'failed'
])->default('pending')->index()->after('module_vmware_id');
// vCenter mapping
$table->string('snapshot_ref')->nullable()->comment('Snapshot ID / moRef from vCenter');
$table->string('vcenter_task_id')->nullable()->comment('Task ID from vCenter');
// Error handling
$table->text('error_message')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('module_snapshot', function (Blueprint $table) {
$table->dropColumn([
'description',
'status',
'snapshot_ref',
'vcenter_task_id',
'error_message',
]);
});
}
};
@@ -1,39 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('module_snapshot', function (Blueprint $table) {
//
$table->unsignedBigInteger('size_bytes')
->nullable()
->comment('Snapshot size in bytes');
$table->decimal('size_gb', 10, 2)
->nullable()
->comment('Snapshot size in GB');
$table->timestamp('size_last_synced_at')
->nullable()
->comment('Last time snapshot size was synced');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('module_snapshot', function (Blueprint $table) {
//
});
}
};
@@ -43,7 +43,6 @@ return new class extends Migration
$table->string('payment_method', 100)->nullable();
$table->boolean('auto_renew')->default(false);
$table->date('due_date')->nullable();
$table->timestamp('paid_at')->nullable();
@@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('module_vmware', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable();
$table->unsignedBigInteger('module_ip_address')->nullable()->index();
$table->unsignedBigInteger('vcenter_id')->nullable()->index();
$table->integer('port')->nullable();
$table->unsignedBigInteger('order_id')->nullable()->index();
$table->integer('first_payment')->nullable();
$table->integer('next_payment')->nullable();
$table->integer('next_due_date')->nullable();
$table->integer('next_invoice')->nullable();
$table->string('user')->nullable();
$table->text('password')->nullable();
$table->string('hostname')->nullable();
$table->string('vmware_id')->nullable();
$table->unsignedBigInteger('os_id')->nullable()->index();
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->tinyInteger('status')
->default(1)
->comment('1: Running; 0: Off; 2: Suspend');
$table->timestamps();
// Foreign Keys
$table->foreign('module_ip_address')
->references('id')
->on('module_ip_address')
->nullOnDelete();
$table->foreign('vcenter_id')
->references('id')
->on('module_vcenter')
->nullOnDelete();
$table->foreign('order_id')
->references('id')
->on('orders')
->nullOnDelete();
$table->foreign('os_id')
->references('id')
->on('os')
->nullOnDelete();
$table->foreign('customer_id')
->references('id')
->on('customers')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('module_vmware');
}
};
@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('module_snapshot', function (Blueprint $table) {
$table->id();
$table->string('name', 255)->nullable();
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->unsignedBigInteger('module_vmware_id')->nullable()->index();
$table->text('description')->nullable();
// Async snapshot status
$table->enum('status', [
'pending',
'processing',
'success',
'failed'
])->default('pending')->index();
// vCenter mapping
$table->string('snapshot_ref')->nullable()->comment('Snapshot ID / moRef from vCenter');
$table->string('vcenter_task_id')->nullable()->comment('Task ID from vCenter');
$table->unsignedBigInteger('size_bytes')
->nullable()
->comment('Snapshot size in bytes');
$table->decimal('size_gb', 10, 2)
->nullable()
->comment('Snapshot size in GB');
$table->timestamp('size_last_synced_at')
->nullable()
->comment('Last time snapshot size was synced');
// Error handling
$table->text('error_message')->nullable();
$table->timestamps();
$table->foreign('customer_id')
->references('id')
->on('customers')
->nullOnDelete();
$table->foreign('module_vmware_id')
->references('id')
->on('module_vmware')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('module_snapshots');
}
};
+1 -1
View File
@@ -23,6 +23,6 @@ class DatabaseSeeder extends Seeder
$this->call(TemplateSeeder::class);
$this->call(Setting::class);
$this->call(ControlPanelSeeder::class);
// $this->call(VPSProductSeeder::class);
$this->call(VPSProductSeeder::class);
}
}
+7
View File
@@ -44,6 +44,13 @@ class TemplateSeeder extends Seeder
'type_id' => 0,
'status' => 1
],
[
'name' => 'Order Created',
'subject' => 'Xác nhận đơn hàng #{$order_num}',
'message' => '<table cellpadding=0 cellspacing=0 role=presentation style=width:100%;font-family:Montserrat,-apple-system><tr><td style=background-color:#eceff1;font-family:Montserrat,-apple-system,sans-serif align=center><table cellpadding=0 cellspacing=0 role=presentation style=width:600px><tr><td align=center><table cellpadding=0 cellspacing=0 role=presentation style=width:100%><tr><td style=padding:48px;text-align:center> <tr style=background-color:#fff><td style="text-align:center;padding:20px;border-bottom:.5px solid #ececec"><img alt={$company_name} src={$company_logo} style=max-height:100px><tr><td style=border-radius:4px;background-color:#fff;padding:48px;text-align:left;font-family:Montserrat,-apple-system,sans-serif;font-size:16px;line-height:24px;color:#626262><p style=margin-bottom:10px;font-size:20px;font-weight:600>Kính chào {$client_name},<p>Cảm ơn bạn đã đặt hàng tại <strong>{$company_name}</strong>! Đơn hàng của bạn đã được tiếp nhận thành công.<p><strong>Thông tin đơn hàng:</strong><ul><li>Mã đơn hàng: <strong>{$order_num}</strong><li>Mã hóa đơn: <strong>{$invoice_num}</strong><li>Tổng tiền: <strong>{$order_total} đ</strong></ul><p>Chúng tôi sẽ xử lý đơn hàng của bạn trong thời gian sớm nhất và thông báo khi dịch vụ được kích hoạt.<p>Nếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.<p>Lưu ý:<ul><li>Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này!</ul><p> </table><tr><td style=padding:48px;text-align:center> </table></table>',
'type_id' => 0,
'status' => 1
],
[
'name' => 'Service Expiry',
'subject' => 'Thông báo dịch vụ sắp hết hạn',
@@ -0,0 +1 @@
{!! $template !!}